diff options
Diffstat (limited to 'client/src/app')
345 files changed, 8017 insertions, 2796 deletions
diff --git a/client/src/app/+about/about-instance/about-instance.component.html b/client/src/app/+about/about-instance/about-instance.component.html index fc5736aba..1df1ef2ad 100644 --- a/client/src/app/+about/about-instance/about-instance.component.html +++ b/client/src/app/+about/about-instance/about-instance.component.html | |||
@@ -20,7 +20,7 @@ | |||
20 | </div> | 20 | </div> |
21 | 21 | ||
22 | <div i18n class="middle-title" *ngIf="html.administrator || maintenanceLifetime || businessModel"> | 22 | <div i18n class="middle-title" *ngIf="html.administrator || maintenanceLifetime || businessModel"> |
23 | Administrators & sustainability | 23 | ADMINISTRATORS & SUSTAINABILITY |
24 | </div> | 24 | </div> |
25 | 25 | ||
26 | <div class="block administrator" *ngIf="html.administrator"> | 26 | <div class="block administrator" *ngIf="html.administrator"> |
@@ -48,7 +48,7 @@ | |||
48 | </div> | 48 | </div> |
49 | 49 | ||
50 | <div i18n class="middle-title" *ngIf="html.description"> | 50 | <div i18n class="middle-title" *ngIf="html.description"> |
51 | Information | 51 | INFORMATION |
52 | </div> | 52 | </div> |
53 | 53 | ||
54 | <div class="block description"> | 54 | <div class="block description"> |
@@ -58,7 +58,7 @@ | |||
58 | </div> | 58 | </div> |
59 | 59 | ||
60 | <div i18n class="middle-title" *ngIf="html.moderationInformation || html.codeOfConduct || html.terms"> | 60 | <div i18n class="middle-title" *ngIf="html.moderationInformation || html.codeOfConduct || html.terms"> |
61 | Moderation | 61 | MODERATION |
62 | </div> | 62 | </div> |
63 | 63 | ||
64 | <div class="block moderation-information" *ngIf="html.moderationInformation"> | 64 | <div class="block moderation-information" *ngIf="html.moderationInformation"> |
@@ -80,7 +80,7 @@ | |||
80 | </div> | 80 | </div> |
81 | 81 | ||
82 | <div i18n class="middle-title" *ngIf="html.hardwareInformation"> | 82 | <div i18n class="middle-title" *ngIf="html.hardwareInformation"> |
83 | Other information | 83 | OTHER INFORMATION |
84 | </div> | 84 | </div> |
85 | 85 | ||
86 | <div class="block hardware-information" *ngIf="html.hardwareInformation"> | 86 | <div class="block hardware-information" *ngIf="html.hardwareInformation"> |
@@ -96,9 +96,8 @@ | |||
96 | </div> | 96 | </div> |
97 | 97 | ||
98 | <div class="col"> | 98 | <div class="col"> |
99 | <div i18n class="middle-title"> | 99 | <div class="anchor" id="statistics"></div> |
100 | Statistics | 100 | <div i18n class="middle-title">STATISTICS</div> |
101 | </div> | ||
102 | <my-instance-statistics></my-instance-statistics> | 101 | <my-instance-statistics></my-instance-statistics> |
103 | </div> | 102 | </div> |
104 | </div> | 103 | </div> |
diff --git a/client/src/app/+about/about-instance/about-instance.component.ts b/client/src/app/+about/about-instance/about-instance.component.ts index 87beb13da..e1809d7b7 100644 --- a/client/src/app/+about/about-instance/about-instance.component.ts +++ b/client/src/app/+about/about-instance/about-instance.component.ts | |||
@@ -1,19 +1,18 @@ | |||
1 | import { Component, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, OnInit, ViewChild, AfterViewChecked } from '@angular/core' |
2 | import { Notifier, ServerService } from '@app/core' | 2 | import { Notifier, ServerService } from '@app/core' |
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' | 3 | import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' |
5 | import { InstanceService } from '@app/shared/instance/instance.service' | 4 | import { InstanceService } from '@app/shared/instance/instance.service' |
6 | import { MarkdownService } from '@app/shared/renderer' | ||
7 | import { forkJoin } from 'rxjs' | ||
8 | import { map, switchMap } from 'rxjs/operators' | ||
9 | import { ServerConfig } from '@shared/models' | 5 | import { ServerConfig } from '@shared/models' |
6 | import { ActivatedRoute } from '@angular/router' | ||
7 | import { ResolverData } from './about-instance.resolver' | ||
8 | import { ViewportScroller } from '@angular/common' | ||
10 | 9 | ||
11 | @Component({ | 10 | @Component({ |
12 | selector: 'my-about-instance', | 11 | selector: 'my-about-instance', |
13 | templateUrl: './about-instance.component.html', | 12 | templateUrl: './about-instance.component.html', |
14 | styleUrls: [ './about-instance.component.scss' ] | 13 | styleUrls: [ './about-instance.component.scss' ] |
15 | }) | 14 | }) |
16 | export class AboutInstanceComponent implements OnInit { | 15 | export class AboutInstanceComponent implements OnInit, AfterViewChecked { |
17 | @ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent | 16 | @ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent |
18 | 17 | ||
19 | shortDescription = '' | 18 | shortDescription = '' |
@@ -37,11 +36,10 @@ export class AboutInstanceComponent implements OnInit { | |||
37 | serverConfig: ServerConfig | 36 | serverConfig: ServerConfig |
38 | 37 | ||
39 | constructor ( | 38 | constructor ( |
40 | private notifier: Notifier, | 39 | private viewportScroller: ViewportScroller, |
40 | private route: ActivatedRoute, | ||
41 | private serverService: ServerService, | 41 | private serverService: ServerService, |
42 | private instanceService: InstanceService, | 42 | private instanceService: InstanceService |
43 | private markdownService: MarkdownService, | ||
44 | private i18n: I18n | ||
45 | ) {} | 43 | ) {} |
46 | 44 | ||
47 | get instanceName () { | 45 | get instanceName () { |
@@ -56,35 +54,27 @@ export class AboutInstanceComponent implements OnInit { | |||
56 | return this.serverConfig.instance.isNSFW | 54 | return this.serverConfig.instance.isNSFW |
57 | } | 55 | } |
58 | 56 | ||
59 | ngOnInit () { | 57 | async ngOnInit () { |
60 | this.serverConfig = this.serverService.getTmpConfig() | 58 | this.serverConfig = this.serverService.getTmpConfig() |
61 | this.serverService.getConfig() | 59 | this.serverService.getConfig() |
62 | .subscribe(config => this.serverConfig = config) | 60 | .subscribe(config => this.serverConfig = config) |
63 | 61 | ||
64 | this.instanceService.getAbout() | 62 | const { about, languages, categories }: ResolverData = this.route.snapshot.data.instanceData |
65 | .pipe( | 63 | |
66 | switchMap(about => { | 64 | this.languages = languages |
67 | return forkJoin([ | 65 | this.categories = categories |
68 | this.instanceService.buildTranslatedLanguages(about), | 66 | |
69 | this.instanceService.buildTranslatedCategories(about) | 67 | this.shortDescription = about.instance.shortDescription |
70 | ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories }))) | 68 | |
71 | }) | 69 | this.creationReason = about.instance.creationReason |
72 | ).subscribe( | 70 | this.maintenanceLifetime = about.instance.maintenanceLifetime |
73 | async ({ about, languages, categories }) => { | 71 | this.businessModel = about.instance.businessModel |
74 | this.languages = languages | 72 | |
75 | this.categories = categories | 73 | this.html = await this.instanceService.buildHtml(about) |
76 | 74 | } | |
77 | this.shortDescription = about.instance.shortDescription | 75 | |
78 | 76 | ngAfterViewChecked () { | |
79 | this.creationReason = about.instance.creationReason | 77 | if (window.location.hash) this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', '')) |
80 | this.maintenanceLifetime = about.instance.maintenanceLifetime | ||
81 | this.businessModel = about.instance.businessModel | ||
82 | |||
83 | this.html = await this.instanceService.buildHtml(about) | ||
84 | }, | ||
85 | |||
86 | () => this.notifier.error(this.i18n('Cannot get about information from server')) | ||
87 | ) | ||
88 | } | 78 | } |
89 | 79 | ||
90 | openContactModal () { | 80 | openContactModal () { |
diff --git a/client/src/app/+about/about-instance/about-instance.resolver.ts b/client/src/app/+about/about-instance/about-instance.resolver.ts new file mode 100644 index 000000000..94c6abe5a --- /dev/null +++ b/client/src/app/+about/about-instance/about-instance.resolver.ts | |||
@@ -0,0 +1,27 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router' | ||
3 | import { map, switchMap } from 'rxjs/operators' | ||
4 | import { forkJoin } from 'rxjs' | ||
5 | import { InstanceService } from '@app/shared/instance/instance.service' | ||
6 | import { About } from '@shared/models/server' | ||
7 | |||
8 | export type ResolverData = { about: About, languages: string[], categories: string[] } | ||
9 | |||
10 | @Injectable() | ||
11 | export class AboutInstanceResolver implements Resolve<any> { | ||
12 | constructor ( | ||
13 | private instanceService: InstanceService | ||
14 | ) {} | ||
15 | |||
16 | resolve (route: ActivatedRouteSnapshot) { | ||
17 | return this.instanceService.getAbout() | ||
18 | .pipe( | ||
19 | switchMap(about => { | ||
20 | return forkJoin([ | ||
21 | this.instanceService.buildTranslatedLanguages(about), | ||
22 | this.instanceService.buildTranslatedCategories(about) | ||
23 | ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories }))) | ||
24 | }) | ||
25 | ) | ||
26 | } | ||
27 | } | ||
diff --git a/client/src/app/+about/about-instance/contact-admin-modal.component.html b/client/src/app/+about/about-instance/contact-admin-modal.component.html index c3c71bdee..7d93796ec 100644 --- a/client/src/app/+about/about-instance/contact-admin-modal.component.html +++ b/client/src/app/+about/about-instance/contact-admin-modal.component.html | |||
@@ -10,7 +10,7 @@ | |||
10 | <div class="form-group"> | 10 | <div class="form-group"> |
11 | <label i18n for="fromName">Your name</label> | 11 | <label i18n for="fromName">Your name</label> |
12 | <input | 12 | <input |
13 | type="text" id="fromName" | 13 | type="text" id="fromName" class="form-control" |
14 | formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }" | 14 | formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }" |
15 | > | 15 | > |
16 | <div *ngIf="formErrors.fromName" class="form-error">{{ formErrors.fromName }}</div> | 16 | <div *ngIf="formErrors.fromName" class="form-error">{{ formErrors.fromName }}</div> |
@@ -19,7 +19,7 @@ | |||
19 | <div class="form-group"> | 19 | <div class="form-group"> |
20 | <label i18n for="fromEmail">Your email</label> | 20 | <label i18n for="fromEmail">Your email</label> |
21 | <input | 21 | <input |
22 | type="text" id="fromEmail" | 22 | type="text" id="fromEmail" class="form-control" |
23 | formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }" | 23 | formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }" |
24 | > | 24 | > |
25 | <div *ngIf="formErrors.fromEmail" class="form-error">{{ formErrors.fromEmail }}</div> | 25 | <div *ngIf="formErrors.fromEmail" class="form-error">{{ formErrors.fromEmail }}</div> |
@@ -28,7 +28,7 @@ | |||
28 | <div class="form-group"> | 28 | <div class="form-group"> |
29 | <label i18n for="subject">Subject</label> | 29 | <label i18n for="subject">Subject</label> |
30 | <input | 30 | <input |
31 | type="text" id="subject" | 31 | type="text" id="subject" class="form-control" |
32 | formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }" | 32 | formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }" |
33 | > | 33 | > |
34 | <div *ngIf="formErrors.subject" class="form-error">{{ formErrors.subject }}</div> | 34 | <div *ngIf="formErrors.subject" class="form-error">{{ formErrors.subject }}</div> |
@@ -36,7 +36,7 @@ | |||
36 | 36 | ||
37 | <div class="form-group"> | 37 | <div class="form-group"> |
38 | <label i18n for="body">Your message</label> | 38 | <label i18n for="body">Your message</label> |
39 | <textarea id="body" formControlName="body" [ngClass]="{ 'input-error': formErrors['body'] }"> | 39 | <textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }"> |
40 | </textarea> | 40 | </textarea> |
41 | <div *ngIf="formErrors.body" class="form-error">{{ formErrors.body }}</div> | 41 | <div *ngIf="formErrors.body" class="form-error">{{ formErrors.body }}</div> |
42 | </div> | 42 | </div> |
@@ -44,9 +44,10 @@ | |||
44 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | 44 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> |
45 | 45 | ||
46 | <div class="form-group inputs"> | 46 | <div class="form-group inputs"> |
47 | <span i18n class="action-button action-button-cancel" (click)="hide()"> | 47 | <input |
48 | Cancel | 48 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" |
49 | </span> | 49 | (click)="hide()" (key.enter)="hide()" |
50 | > | ||
50 | 51 | ||
51 | <input | 52 | <input |
52 | type="submit" i18n-value value="Submit" class="action-button-submit" | 53 | type="submit" i18n-value value="Submit" class="action-button-submit" |
diff --git a/client/src/app/+about/about-instance/contact-admin-modal.component.ts b/client/src/app/+about/about-instance/contact-admin-modal.component.ts index 2ed41e741..d5e146b82 100644 --- a/client/src/app/+about/about-instance/contact-admin-modal.component.ts +++ b/client/src/app/+about/about-instance/contact-admin-modal.component.ts | |||
@@ -51,7 +51,7 @@ export class ContactAdminModalComponent extends FormReactive implements OnInit { | |||
51 | } | 51 | } |
52 | 52 | ||
53 | show () { | 53 | show () { |
54 | this.openedModal = this.modalService.open(this.modal, { keyboard: false }) | 54 | this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) |
55 | } | 55 | } |
56 | 56 | ||
57 | hide () { | 57 | hide () { |
diff --git a/client/src/app/+about/about-peertube/about-peertube.component.html b/client/src/app/+about/about-peertube/about-peertube.component.html index 26a3d4554..e5a8b2097 100644 --- a/client/src/app/+about/about-peertube/about-peertube.component.html +++ b/client/src/app/+about/about-peertube/about-peertube.component.html | |||
@@ -65,8 +65,11 @@ | |||
65 | <div class="privacy-contributors"> | 65 | <div class="privacy-contributors"> |
66 | <my-about-peertube-contributors></my-about-peertube-contributors> | 66 | <my-about-peertube-contributors></my-about-peertube-contributors> |
67 | 67 | ||
68 | <div class="p2p-privacy"> | 68 | <div class="p2p-privacy"> |
69 | <h3 i18n class="section-title">P2P & Privacy</h3> | 69 | <h3 class="section-title"> |
70 | <div class="anchor" id="privacy"></div> <!-- privacy anchor --> | ||
71 | <ng-container i18n>P2P & Privacy</ng-container> | ||
72 | </h3> | ||
70 | 73 | ||
71 | <p i18n> | 74 | <p i18n> |
72 | PeerTube uses the BitTorrent protocol to share bandwidth between users by default to help lower the load on the server, | 75 | PeerTube uses the BitTorrent protocol to share bandwidth between users by default to help lower the load on the server, |
@@ -95,7 +98,7 @@ | |||
95 | <li i18n> | 98 | <li i18n> |
96 | For each request sent, the tracker returns random peers at a limited number. | 99 | For each request sent, the tracker returns random peers at a limited number. |
97 | For instance, if there are 1000 peers in the swarm and the tracker sends only 20 peers for each request, there must be at least 50 | 100 | For instance, if there are 1000 peers in the swarm and the tracker sends only 20 peers for each request, there must be at least 50 |
98 | requests sent to know every peers in the swarm | 101 | requests sent to know every peer in the swarm |
99 | </li> | 102 | </li> |
100 | 103 | ||
101 | <li i18n> | 104 | <li i18n> |
diff --git a/client/src/app/+about/about-peertube/about-peertube.component.ts b/client/src/app/+about/about-peertube/about-peertube.component.ts index 64fd30837..98c5f93c3 100644 --- a/client/src/app/+about/about-peertube/about-peertube.component.ts +++ b/client/src/app/+about/about-peertube/about-peertube.component.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | import { Component } from '@angular/core' | 1 | import { Component, AfterViewChecked } from '@angular/core' |
2 | import { ViewportScroller } from '@angular/common' | ||
2 | 3 | ||
3 | @Component({ | 4 | @Component({ |
4 | selector: 'my-about-peertube', | 5 | selector: 'my-about-peertube', |
@@ -6,5 +7,12 @@ import { Component } from '@angular/core' | |||
6 | styleUrls: [ './about-peertube.component.scss' ] | 7 | styleUrls: [ './about-peertube.component.scss' ] |
7 | }) | 8 | }) |
8 | 9 | ||
9 | export class AboutPeertubeComponent { | 10 | export class AboutPeertubeComponent implements AfterViewChecked { |
11 | constructor ( | ||
12 | private viewportScroller: ViewportScroller | ||
13 | ) {} | ||
14 | |||
15 | ngAfterViewChecked () { | ||
16 | if (window.location.hash) this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', '')) | ||
17 | } | ||
10 | } | 18 | } |
diff --git a/client/src/app/+about/about-routing.module.ts b/client/src/app/+about/about-routing.module.ts index 33e5070cb..91ccb846f 100644 --- a/client/src/app/+about/about-routing.module.ts +++ b/client/src/app/+about/about-routing.module.ts | |||
@@ -5,6 +5,7 @@ import { AboutComponent } from './about.component' | |||
5 | import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component' | 5 | import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component' |
6 | import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component' | 6 | import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component' |
7 | import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component' | 7 | import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component' |
8 | import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver' | ||
8 | 9 | ||
9 | const aboutRoutes: Routes = [ | 10 | const aboutRoutes: Routes = [ |
10 | { | 11 | { |
@@ -24,6 +25,9 @@ const aboutRoutes: Routes = [ | |||
24 | meta: { | 25 | meta: { |
25 | title: 'About this instance' | 26 | title: 'About this instance' |
26 | } | 27 | } |
28 | }, | ||
29 | resolve: { | ||
30 | instanceData: AboutInstanceResolver | ||
27 | } | 31 | } |
28 | }, | 32 | }, |
29 | { | 33 | { |
diff --git a/client/src/app/+about/about.component.html b/client/src/app/+about/about.component.html index 0c4a5156d..da16b933d 100644 --- a/client/src/app/+about/about.component.html +++ b/client/src/app/+about/about.component.html | |||
@@ -2,11 +2,11 @@ | |||
2 | <div class="sub-menu"> | 2 | <div class="sub-menu"> |
3 | 3 | ||
4 | <div class="links"> | 4 | <div class="links"> |
5 | <a i18n routerLink="instance" routerLinkActive="active" class="title-page">Instance</a> | 5 | <a i18n routerLink="instance" routerLinkActive="active" class="title-page title-page-about">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 title-page-about">PeerTube</a> |
8 | 8 | ||
9 | <a i18n routerLink="follows" routerLinkActive="active" class="title-page">Follows</a> | 9 | <a i18n routerLink="follows" routerLinkActive="active" class="title-page title-page-about">Follows</a> |
10 | </div> | 10 | </div> |
11 | </div> | 11 | </div> |
12 | 12 | ||
diff --git a/client/src/app/+about/about.module.ts b/client/src/app/+about/about.module.ts index 14bf76e55..84d697540 100644 --- a/client/src/app/+about/about.module.ts +++ b/client/src/app/+about/about.module.ts | |||
@@ -7,6 +7,7 @@ import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertub | |||
7 | import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' | 7 | import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component' |
8 | import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component' | 8 | import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component' |
9 | import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/about-peertube-contributors.component' | 9 | import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/about-peertube-contributors.component' |
10 | import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver' | ||
10 | 11 | ||
11 | @NgModule({ | 12 | @NgModule({ |
12 | imports: [ | 13 | imports: [ |
@@ -28,6 +29,7 @@ import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/a | |||
28 | ], | 29 | ], |
29 | 30 | ||
30 | providers: [ | 31 | providers: [ |
32 | AboutInstanceResolver | ||
31 | ] | 33 | ] |
32 | }) | 34 | }) |
33 | export class AboutModule { } | 35 | export class AboutModule { } |
diff --git a/client/src/app/+accounts/account-about/account-about.component.html b/client/src/app/+accounts/account-about/account-about.component.html index f857e5a52..3ae11b49c 100644 --- a/client/src/app/+accounts/account-about/account-about.component.html +++ b/client/src/app/+accounts/account-about/account-about.component.html | |||
@@ -1,11 +1,11 @@ | |||
1 | <div *ngIf="account" class="row"> | 1 | <div *ngIf="account" class="row"> |
2 | <div class="block col-md-6 col-sm-12"> | 2 | <div class="block col-md-6 col-sm-12"> |
3 | <div i18n class="small-title">Description</div> | 3 | <div i18n class="small-title">DESCRIPTION</div> |
4 | <div class="content" [innerHtml]="getAccountDescription()"></div> | 4 | <div class="content" [innerHtml]="getAccountDescription()"></div> |
5 | </div> | 5 | </div> |
6 | 6 | ||
7 | <div class="block col-md-6 col-sm-12"> | 7 | <div class="block col-md-6 col-sm-12"> |
8 | <div i18n class="small-title">Stats</div> | 8 | <div i18n class="small-title">STATS</div> |
9 | 9 | ||
10 | <div i18n class="content">Joined {{ account.createdAt | date }}</div> | 10 | <div i18n class="content">Joined {{ account.createdAt | date }}</div> |
11 | </div> | 11 | </div> |
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 781156840..8f1ff21a5 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 | |||
@@ -25,7 +25,7 @@ | |||
25 | </div> | 25 | </div> |
26 | 26 | ||
27 | <a *ngIf="getVideosOf(videoChannel).length !== 0" class="show-more" i18n [routerLink]="getVideoChannelLink(videoChannel)"> | 27 | <a *ngIf="getVideosOf(videoChannel).length !== 0" class="show-more" i18n [routerLink]="getVideoChannelLink(videoChannel)"> |
28 | Show this channel | 28 | SHOW THIS CHANNEL |
29 | </a> | 29 | </a> |
30 | </div> | 30 | </div> |
31 | </div> | 31 | </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 a258c7b86..042290809 100644 --- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss +++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.scss | |||
@@ -29,4 +29,11 @@ | |||
29 | } | 29 | } |
30 | } | 30 | } |
31 | 31 | ||
32 | 32 | @media screen and (max-width: $mobile-view) { | |
33 | .section { | ||
34 | .section-title { | ||
35 | flex-direction: column; | ||
36 | align-items: normal; | ||
37 | } | ||
38 | } | ||
39 | } | ||
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 ac4477c18..41b27b541 100644 --- a/client/src/app/+accounts/account-videos/account-videos.component.ts +++ b/client/src/app/+accounts/account-videos/account-videos.component.ts | |||
@@ -12,6 +12,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
12 | import { Subscription } from 'rxjs' | 12 | import { Subscription } from 'rxjs' |
13 | import { ScreenService } from '@app/shared/misc/screen.service' | 13 | import { ScreenService } from '@app/shared/misc/screen.service' |
14 | import { Notifier, ServerService } from '@app/core' | 14 | import { Notifier, ServerService } from '@app/core' |
15 | import { UserService } from '@app/shared' | ||
16 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
15 | 17 | ||
16 | @Component({ | 18 | @Component({ |
17 | selector: 'my-account-videos', | 19 | selector: 'my-account-videos', |
@@ -34,9 +36,11 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit, | |||
34 | protected serverService: ServerService, | 36 | protected serverService: ServerService, |
35 | protected route: ActivatedRoute, | 37 | protected route: ActivatedRoute, |
36 | protected authService: AuthService, | 38 | protected authService: AuthService, |
39 | protected userService: UserService, | ||
37 | protected notifier: Notifier, | 40 | protected notifier: Notifier, |
38 | protected confirmService: ConfirmService, | 41 | protected confirmService: ConfirmService, |
39 | protected screenService: ScreenService, | 42 | protected screenService: ScreenService, |
43 | protected storageService: LocalStorageService, | ||
40 | private accountService: AccountService, | 44 | private accountService: AccountService, |
41 | private videoService: VideoService | 45 | private videoService: VideoService |
42 | ) { | 46 | ) { |
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html index 85f7dd30c..af80337ce 100644 --- a/client/src/app/+accounts/accounts.component.html +++ b/client/src/app/+accounts/accounts.component.html | |||
@@ -7,14 +7,13 @@ | |||
7 | <div class="actor-info"> | 7 | <div class="actor-info"> |
8 | <div class="actor-names"> | 8 | <div class="actor-names"> |
9 | <div class="actor-display-name">{{ account.displayName }}</div> | 9 | <div class="actor-display-name">{{ account.displayName }}</div> |
10 | <div class="actor-name">{{ account.nameWithHost }} | 10 | <div class="actor-name"> |
11 | 11 | <span>{{ account.nameWithHost }}</span> | |
12 | <button ngxClipboard [cbContent]="account.nameWithHostForced" (click)="activateCopiedMessage()" | 12 | <button [cdkCopyToClipboard]="account.nameWithHostForced" (click)="activateCopiedMessage()" |
13 | class="btn btn-outline-secondary btn-sm copy-button" | 13 | class="btn btn-outline-secondary btn-sm copy-button" |
14 | > | 14 | > |
15 | <span class="glyphicon glyphicon-copy"></span> | 15 | <span class="glyphicon glyphicon-copy"></span> |
16 | </button> | 16 | </button> |
17 | |||
18 | </div> | 17 | </div> |
19 | <span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="badge badge-danger" i18n>Banned</span> | 18 | <span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="badge badge-danger" i18n>Banned</span> |
20 | <span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span> | 19 | <span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span> |
@@ -33,17 +32,17 @@ | |||
33 | </div> | 32 | </div> |
34 | 33 | ||
35 | <div class="right-buttons"> | 34 | <div class="right-buttons"> |
36 | <a *ngIf="isAccountManageable" routerLink="/my-account" class="btn btn-outline-tertiary mr-2" i18n>Manage</a> | 35 | <a *ngIf="isAccountManageable && !isInSmallView" routerLink="/my-account" class="btn btn-outline-tertiary mr-2" i18n>Manage account</a> |
37 | <my-subscribe-button *ngIf="videoChannels" [account]="account" [videoChannels]="videoChannels"></my-subscribe-button> | 36 | <my-subscribe-button *ngIf="videoChannels" [account]="account" [videoChannels]="videoChannels"></my-subscribe-button> |
38 | </div> | 37 | </div> |
39 | </div> | 38 | </div> |
40 | 39 | ||
41 | <div class="links"> | 40 | <div class="links w-100"> |
42 | <a i18n routerLink="video-channels" routerLinkActive="active" class="title-page">Video channels</a> | 41 | <ng-template #linkTemplate let-item="item"> |
43 | 42 | <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a> | |
44 | <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a> | 43 | </ng-template> |
45 | 44 | ||
46 | <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> | 45 | <list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow> |
47 | </div> | 46 | </div> |
48 | </div> | 47 | </div> |
49 | 48 | ||
diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss index 96484c9d3..12170e371 100644 --- a/client/src/app/+accounts/accounts.component.scss +++ b/client/src/app/+accounts/accounts.component.scss | |||
@@ -1,3 +1,9 @@ | |||
1 | // Bootstrap grid utilities require functions, variables and mixins | ||
2 | @import 'node_modules/bootstrap/scss/functions'; | ||
3 | @import 'node_modules/bootstrap/scss/variables'; | ||
4 | @import 'node_modules/bootstrap/scss/mixins'; | ||
5 | @import 'node_modules/bootstrap/scss/grid'; | ||
6 | |||
1 | @import '_variables'; | 7 | @import '_variables'; |
2 | @import '_mixins'; | 8 | @import '_mixins'; |
3 | 9 | ||
@@ -13,7 +19,16 @@ | |||
13 | display: flex; | 19 | display: flex; |
14 | height: max-content; | 20 | height: max-content; |
15 | margin-left: auto; | 21 | margin-left: auto; |
16 | margin-top: 20px; | 22 | margin-top: 10px; |
23 | |||
24 | @include media-breakpoint-down(lg) { | ||
25 | flex-flow: column-reverse; | ||
26 | |||
27 | a { | ||
28 | margin-top: 0.25rem; | ||
29 | margin-right: 0 !important; | ||
30 | } | ||
31 | } | ||
17 | 32 | ||
18 | a { | 33 | a { |
19 | @include peertube-button-outline; | 34 | @include peertube-button-outline; |
@@ -41,3 +56,32 @@ my-user-moderation-dropdown, | |||
41 | padding: 5px; | 56 | padding: 5px; |
42 | margin-top: -2px; | 57 | margin-top: -2px; |
43 | } | 58 | } |
59 | |||
60 | @media screen and (max-width: $mobile-view) { | ||
61 | .sub-menu { | ||
62 | .actor { | ||
63 | flex-direction: column; | ||
64 | align-items: center; | ||
65 | |||
66 | img, | ||
67 | .actor-info .actor-names .actor-display-name { | ||
68 | margin-right: 0; | ||
69 | } | ||
70 | |||
71 | .actor-info { | ||
72 | .actor-names { | ||
73 | flex-direction: column; | ||
74 | align-items: center; | ||
75 | } | ||
76 | |||
77 | my-user-moderation-dropdown { | ||
78 | margin-left: 0; | ||
79 | } | ||
80 | |||
81 | .actor-followers { | ||
82 | text-align: center; | ||
83 | } | ||
84 | } | ||
85 | } | ||
86 | } | ||
87 | } | ||
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index ad611f221..bf71179f3 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts | |||
@@ -3,13 +3,15 @@ import { ActivatedRoute } from '@angular/router' | |||
3 | import { AccountService } from '@app/shared/account/account.service' | 3 | import { AccountService } from '@app/shared/account/account.service' |
4 | import { Account } from '@app/shared/account/account.model' | 4 | import { Account } from '@app/shared/account/account.model' |
5 | import { RestExtractor, UserService } from '@app/shared' | 5 | import { RestExtractor, UserService } from '@app/shared' |
6 | import { catchError, distinctUntilChanged, first, map, switchMap, tap } from 'rxjs/operators' | 6 | import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators' |
7 | import { forkJoin, Subscription } from 'rxjs' | 7 | import { Subscription } from 'rxjs' |
8 | import { AuthService, Notifier, RedirectService } from '@app/core' | 8 | import { AuthService, Notifier, RedirectService } from '@app/core' |
9 | import { User, UserRight } from '../../../../shared' | 9 | import { User, UserRight } from '../../../../shared' |
10 | import { I18n } from '@ngx-translate/i18n-polyfill' | 10 | import { I18n } from '@ngx-translate/i18n-polyfill' |
11 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 11 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
12 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 12 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
13 | import { ListOverflowItem } from '@app/shared/misc/list-overflow.component' | ||
14 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
13 | 15 | ||
14 | @Component({ | 16 | @Component({ |
15 | templateUrl: './accounts.component.html', | 17 | templateUrl: './accounts.component.html', |
@@ -19,6 +21,7 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
19 | account: Account | 21 | account: Account |
20 | accountUser: User | 22 | accountUser: User |
21 | videoChannels: VideoChannel[] = [] | 23 | videoChannels: VideoChannel[] = [] |
24 | links: ListOverflowItem[] = [] | ||
22 | 25 | ||
23 | isAccountManageable = false | 26 | isAccountManageable = false |
24 | accountFollowerTitle = '' | 27 | accountFollowerTitle = '' |
@@ -34,6 +37,7 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
34 | private restExtractor: RestExtractor, | 37 | private restExtractor: RestExtractor, |
35 | private redirectService: RedirectService, | 38 | private redirectService: RedirectService, |
36 | private authService: AuthService, | 39 | private authService: AuthService, |
40 | private screenService: ScreenService, | ||
37 | private i18n: I18n | 41 | private i18n: I18n |
38 | ) { | 42 | ) { |
39 | } | 43 | } |
@@ -70,6 +74,12 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
70 | 74 | ||
71 | err => this.notifier.error(err.message) | 75 | err => this.notifier.error(err.message) |
72 | ) | 76 | ) |
77 | |||
78 | this.links = [ | ||
79 | { label: this.i18n('VIDEO CHANNELS'), routerLink: 'video-channels' }, | ||
80 | { label: this.i18n('VIDEOS'), routerLink: 'videos' }, | ||
81 | { label: this.i18n('ABOUT'), routerLink: 'about' } | ||
82 | ] | ||
73 | } | 83 | } |
74 | 84 | ||
75 | ngOnDestroy () { | 85 | ngOnDestroy () { |
@@ -83,6 +93,10 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
83 | ) | 93 | ) |
84 | } | 94 | } |
85 | 95 | ||
96 | get isInSmallView () { | ||
97 | return this.screenService.isInSmallView() | ||
98 | } | ||
99 | |||
86 | onUserChanged () { | 100 | onUserChanged () { |
87 | this.getUserIfNeeded(this.account) | 101 | this.getUserIfNeeded(this.account) |
88 | } | 102 | } |
@@ -96,7 +110,7 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
96 | } | 110 | } |
97 | 111 | ||
98 | subscribersDisplayFor (count: number) { | 112 | subscribersDisplayFor (count: number) { |
99 | return this.i18n(`{count, plural, =1 {1 subscriber} other {${count} subscribers}}`, { count }) | 113 | return this.i18n('{count, plural, =1 {1 subscriber} other {{{count}} subscribers}}', { count }) |
100 | } | 114 | } |
101 | 115 | ||
102 | private getUserIfNeeded (account: Account) { | 116 | private getUserIfNeeded (account: Account) { |
diff --git a/client/src/app/+admin/admin.component.html b/client/src/app/+admin/admin.component.html index 9a3d90c18..76d297c52 100644 --- a/client/src/app/+admin/admin.component.html +++ b/client/src/app/+admin/admin.component.html | |||
@@ -1,28 +1,10 @@ | |||
1 | <div class="row"> | 1 | <div class="row"> |
2 | <div class="sub-menu"> | 2 | <div class="sub-menu"> |
3 | <a i18n *ngIf="hasUsersRight()" routerLink="/admin/users" routerLinkActive="active" class="title-page"> | 3 | <ng-template #linkTemplate let-item="item"> |
4 | Users | 4 | <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page title-page-settings">{{ item.label }}</a> |
5 | </a> | 5 | </ng-template> |
6 | 6 | ||
7 | <a i18n *ngIf="hasServerFollowRight()" routerLink="/admin/follows" routerLinkActive="active" class="title-page"> | 7 | <list-overflow [items]="items" [itemTemplate]="linkTemplate"></list-overflow> |
8 | Manage follows | ||
9 | </a> | ||
10 | |||
11 | <a i18n *ngIf="hasVideoAbusesRight() || hasVideoBlacklistRight()" routerLink="/admin/moderation" routerLinkActive="active" class="title-page"> | ||
12 | Moderation | ||
13 | </a> | ||
14 | |||
15 | <a i18n *ngIf="hasConfigRight()" routerLink="/admin/config" routerLinkActive="active" class="title-page"> | ||
16 | Configuration | ||
17 | </a> | ||
18 | |||
19 | <a i18n *ngIf="hasPluginsRight()" routerLink="/admin/plugins" routerLinkActive="active" class="title-page"> | ||
20 | Plugins/Themes | ||
21 | </a> | ||
22 | |||
23 | <a i18n *ngIf="hasJobsRight() || hasLogsRight() || hasDebugRight()" routerLink="/admin/system" routerLinkActive="active" class="title-page"> | ||
24 | System | ||
25 | </a> | ||
26 | </div> | 8 | </div> |
27 | 9 | ||
28 | <div class="margin-content"> | 10 | <div class="margin-content"> |
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts index b23999d40..9662522dc 100644 --- a/client/src/app/+admin/admin.component.ts +++ b/client/src/app/+admin/admin.component.ts | |||
@@ -1,12 +1,28 @@ | |||
1 | import { Component } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
2 | import { UserRight } from '../../../../shared' | 2 | import { UserRight } from '../../../../shared' |
3 | import { AuthService } from '../core/auth/auth.service' | 3 | import { AuthService } from '../core/auth/auth.service' |
4 | import { ListOverflowItem } from '@app/shared/misc/list-overflow.component' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | 6 | ||
5 | @Component({ | 7 | @Component({ |
6 | templateUrl: './admin.component.html' | 8 | templateUrl: './admin.component.html' |
7 | }) | 9 | }) |
8 | export class AdminComponent { | 10 | export class AdminComponent implements OnInit { |
9 | constructor (private auth: AuthService) {} | 11 | items: ListOverflowItem[] = [] |
12 | |||
13 | constructor ( | ||
14 | private auth: AuthService, | ||
15 | private i18n: I18n | ||
16 | ) {} | ||
17 | |||
18 | ngOnInit () { | ||
19 | if (this.hasUsersRight()) this.items.push({ label: this.i18n('Users'), routerLink: '/admin/users' }) | ||
20 | if (this.hasServerFollowRight()) this.items.push({ label: this.i18n('Follows & redundancies'), routerLink: '/admin/follows' }) | ||
21 | if (this.hasVideoAbusesRight() || this.hasVideoBlacklistRight()) this.items.push({ label: this.i18n('Moderation'), routerLink: '/admin/moderation' }) | ||
22 | if (this.hasConfigRight()) this.items.push({ label: this.i18n('Configuration'), routerLink: '/admin/config' }) | ||
23 | if (this.hasPluginsRight()) this.items.push({ label: this.i18n('Plugins/Themes'), routerLink: '/admin/plugins' }) | ||
24 | if (this.hasJobsRight() || this.hasLogsRight() || this.hasDebugRight()) this.items.push({ label: this.i18n('System'), routerLink: '/admin/system' }) | ||
25 | } | ||
10 | 26 | ||
11 | hasUsersRight () { | 27 | hasUsersRight () { |
12 | return this.auth.getUser().hasRight(UserRight.MANAGE_USERS) | 28 | return this.auth.getUser().hasRight(UserRight.MANAGE_USERS) |
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts index 9c56b5750..d04313c0a 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' | |||
5 | import { SharedModule } from '../shared' | 5 | import { SharedModule } from '../shared' |
6 | import { AdminRoutingModule } from './admin-routing.module' | 6 | import { AdminRoutingModule } from './admin-routing.module' |
7 | import { AdminComponent } from './admin.component' | 7 | import { AdminComponent } from './admin.component' |
8 | import { FollowersListComponent, FollowingAddComponent, FollowsComponent } from './follows' | 8 | import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows' |
9 | import { FollowingListComponent } from './follows/following-list/following-list.component' | 9 | import { FollowingListComponent } from './follows/following-list/following-list.component' |
10 | import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users' | 10 | import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users' |
11 | import { | 11 | import { |
@@ -16,7 +16,6 @@ import { | |||
16 | } from './moderation' | 16 | } from './moderation' |
17 | import { ModerationComponent } from '@app/+admin/moderation/moderation.component' | 17 | import { ModerationComponent } from '@app/+admin/moderation/moderation.component' |
18 | import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' | 18 | import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' |
19 | import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' | ||
20 | import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' | 19 | import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' |
21 | import { JobsComponent } from '@app/+admin/system/jobs/jobs.component' | 20 | import { JobsComponent } from '@app/+admin/system/jobs/jobs.component' |
22 | import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system' | 21 | import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system' |
@@ -27,23 +26,31 @@ import { PluginSearchComponent } from '@app/+admin/plugins/plugin-search/plugin- | |||
27 | import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component' | 26 | import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component' |
28 | import { SelectButtonModule } from 'primeng/selectbutton' | 27 | import { SelectButtonModule } from 'primeng/selectbutton' |
29 | import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' | 28 | import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' |
29 | import { VideoRedundancyInformationComponent } from '@app/+admin/follows/video-redundancies-list/video-redundancy-information.component' | ||
30 | import { ChartModule } from 'primeng/chart' | ||
31 | import { BatchDomainsModalComponent } from './config/shared/batch-domains-modal.component' | ||
32 | import { VideoAbuseDetailsComponent } from './moderation/video-abuse-list/video-abuse-details.component' | ||
30 | 33 | ||
31 | @NgModule({ | 34 | @NgModule({ |
32 | imports: [ | 35 | imports: [ |
33 | AdminRoutingModule, | 36 | AdminRoutingModule, |
37 | |||
38 | SharedModule, | ||
39 | |||
34 | TableModule, | 40 | TableModule, |
35 | SelectButtonModule, | 41 | SelectButtonModule, |
36 | SharedModule | 42 | ChartModule |
37 | ], | 43 | ], |
38 | 44 | ||
39 | declarations: [ | 45 | declarations: [ |
40 | AdminComponent, | 46 | AdminComponent, |
41 | 47 | ||
42 | FollowsComponent, | 48 | FollowsComponent, |
43 | FollowingAddComponent, | ||
44 | FollowersListComponent, | 49 | FollowersListComponent, |
45 | FollowingListComponent, | 50 | FollowingListComponent, |
46 | RedundancyCheckboxComponent, | 51 | RedundancyCheckboxComponent, |
52 | VideoRedundanciesListComponent, | ||
53 | VideoRedundancyInformationComponent, | ||
47 | 54 | ||
48 | UsersComponent, | 55 | UsersComponent, |
49 | UserCreateComponent, | 56 | UserCreateComponent, |
@@ -54,6 +61,7 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' | |||
54 | ModerationComponent, | 61 | ModerationComponent, |
55 | VideoBlacklistListComponent, | 62 | VideoBlacklistListComponent, |
56 | VideoAbuseListComponent, | 63 | VideoAbuseListComponent, |
64 | VideoAbuseDetailsComponent, | ||
57 | VideoAutoBlacklistListComponent, | 65 | VideoAutoBlacklistListComponent, |
58 | ModerationCommentModalComponent, | 66 | ModerationCommentModalComponent, |
59 | InstanceServerBlocklistComponent, | 67 | InstanceServerBlocklistComponent, |
@@ -70,7 +78,9 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' | |||
70 | DebugComponent, | 78 | DebugComponent, |
71 | 79 | ||
72 | ConfigComponent, | 80 | ConfigComponent, |
73 | EditCustomConfigComponent | 81 | EditCustomConfigComponent, |
82 | |||
83 | BatchDomainsModalComponent | ||
74 | ], | 84 | ], |
75 | 85 | ||
76 | exports: [ | 86 | exports: [ |
@@ -78,7 +88,6 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service' | |||
78 | ], | 88 | ], |
79 | 89 | ||
80 | providers: [ | 90 | providers: [ |
81 | RedundancyService, | ||
82 | JobService, | 91 | JobService, |
83 | LogsService, | 92 | LogsService, |
84 | DebugService, | 93 | DebugService, |
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index 915d60090..5703d5a2e 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 | |||
@@ -1,671 +1,808 @@ | |||
1 | <form role="form" [formGroup]="form"> | 1 | <form role="form" [formGroup]="form"> |
2 | 2 | ||
3 | <ngb-tabset class="root-tabset bootstrap"> | 3 | <div ngbNav #nav="ngbNav" class="nav-tabs"> |
4 | 4 | ||
5 | <ngb-tab i18n-title title="Instance information"> | 5 | <ng-container ngbNavItem="instance-information"> |
6 | <ng-template ngbTabContent> | 6 | <a ngbNavLink i18n>Instance information</a> |
7 | |||
8 | <ng-template ngbNavContent> | ||
7 | 9 | ||
8 | <ng-container formGroupName="instance"> | 10 | <ng-container formGroupName="instance"> |
9 | 11 | ||
10 | <div i18n class="inner-form-title">Instance</div> | 12 | <div class="form-row mt-5"> <!-- instance grid --> |
13 | <div class="form-group col-12 col-lg-4 col-xl-3"> | ||
14 | <div i18n class="inner-form-title">INSTANCE</div> | ||
15 | </div> | ||
11 | 16 | ||
12 | <div class="form-group"> | 17 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
13 | <label i18n for="instanceName">Name</label> | ||
14 | <input | ||
15 | type="text" id="instanceName" | ||
16 | formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }" | ||
17 | > | ||
18 | <div *ngIf="formErrors.instance.name" class="form-error">{{ formErrors.instance.name }}</div> | ||
19 | </div> | ||
20 | 18 | ||
21 | <div class="form-group"> | 19 | <div class="form-group"> |
22 | <label i18n for="instanceShortDescription">Short description</label> | 20 | <label i18n for="instanceName">Name</label> |
23 | <textarea | 21 | <input |
24 | id="instanceShortDescription" formControlName="shortDescription" class="small" | 22 | type="text" id="instanceName" class="form-control" |
25 | [ngClass]="{ 'input-error': formErrors['instance.shortDescription'] }" | 23 | formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }" |
26 | ></textarea> | 24 | > |
27 | <div *ngIf="formErrors.instance.shortDescription" class="form-error">{{ formErrors.instance.shortDescription }}</div> | 25 | <div *ngIf="formErrors.instance.name" class="form-error">{{ formErrors.instance.name }}</div> |
28 | </div> | 26 | </div> |
29 | 27 | ||
30 | <div class="form-group"> | 28 | <div class="form-group"> |
31 | <label i18n for="instanceDescription">Description</label><my-help helpType="markdownText"></my-help> | 29 | <label i18n for="instanceShortDescription">Short description</label> |
32 | <my-markdown-textarea | 30 | <textarea |
33 | name="instanceDescription" formControlName="description" textareaWidth="500px" [previewColumn]="true" | 31 | id="instanceShortDescription" formControlName="shortDescription" class="form-control small" |
34 | [classes]="{ 'input-error': formErrors['instance.description'] }" | 32 | [ngClass]="{ 'input-error': formErrors['instance.shortDescription'] }" |
35 | ></my-markdown-textarea> | 33 | ></textarea> |
36 | <div *ngIf="formErrors.instance.description" class="form-error">{{ formErrors.instance.description }}</div> | 34 | <div *ngIf="formErrors.instance.shortDescription" class="form-error">{{ formErrors.instance.shortDescription }}</div> |
37 | </div> | 35 | </div> |
38 | 36 | ||
39 | <div class="form-group"> | 37 | <div class="form-group"> |
40 | <label i18n for="instanceCategories">Main instance categories</label> | 38 | <label i18n for="instanceDescription">Description</label><my-help helpType="markdownText"></my-help> |
39 | <my-markdown-textarea | ||
40 | name="instanceDescription" formControlName="description" textareaMaxWidth="500px" | ||
41 | [classes]="{ 'input-error': formErrors['instance.description'] }" | ||
42 | ></my-markdown-textarea> | ||
43 | <div *ngIf="formErrors.instance.description" class="form-error">{{ formErrors.instance.description }}</div> | ||
44 | </div> | ||
41 | 45 | ||
42 | <div> | 46 | <div class="form-group"> |
43 | <p-multiSelect | 47 | <label i18n for="instanceCategories">Main instance categories</label> |
44 | inputId="instanceCategories" [options]="categoryItems" formControlName="categories" showToggleAll="false" | 48 | |
45 | [defaultLabel]="getDefaultCategoryLabel()" [selectedItemsLabel]="getSelectedCategoryLabel()" | 49 | <div> |
46 | emptyFilterMessage="No results found" i18n-emptyFilterMessage | 50 | <p-multiSelect |
47 | ></p-multiSelect> | 51 | inputId="instanceCategories" [options]="categoryItems" formControlName="categories" [showToggleAll]="false" |
48 | </div> | 52 | [defaultLabel]="getDefaultCategoryLabel()" [selectedItemsLabel]="getSelectedCategoryLabel()" |
49 | </div> | 53 | emptyFilterMessage="No results found" i18n-emptyFilterMessage |
54 | ></p-multiSelect> | ||
55 | </div> | ||
56 | </div> | ||
50 | 57 | ||
51 | <div class="form-group"> | 58 | <div class="form-group"> |
52 | <label i18n for="instanceLanguages">Main languages you/your moderators speak</label> | 59 | <label i18n for="instanceLanguages">Main languages you/your moderators speak</label> |
60 | |||
61 | <div> | ||
62 | <p-multiSelect | ||
63 | inputId="instanceLanguages" [options]="languageItems" formControlName="languages" [showToggleAll]="false" | ||
64 | [defaultLabel]="getDefaultLanguageLabel()" [selectedItemsLabel]="getSelectedLanguageLabel()" | ||
65 | emptyFilterMessage="No results found" i18n-emptyFilterMessage | ||
66 | ></p-multiSelect> | ||
67 | </div> | ||
68 | </div> | ||
53 | 69 | ||
54 | <div> | ||
55 | <p-multiSelect | ||
56 | inputId="instanceLanguages" [options]="languageItems" formControlName="languages" showToggleAll="false" | ||
57 | [defaultLabel]="getDefaultLanguageLabel()" [selectedItemsLabel]="getSelectedLanguageLabel()" | ||
58 | emptyFilterMessage="No results found" i18n-emptyFilterMessage | ||
59 | ></p-multiSelect> | ||
60 | </div> | 70 | </div> |
61 | </div> | 71 | </div> |
62 | 72 | ||
63 | <div i18n class="inner-form-title">Moderation & NSFW</div> | 73 | <div class="form-row mt-4"> <!-- moderation & nsfw grid --> |
74 | <div class="form-group col-12 col-lg-4 col-xl-3"> | ||
75 | <div i18n class="inner-form-title">MODERATION & NSFW</div> | ||
76 | <div i18n class="inner-for-description"> | ||
77 | Manage <a routerLink="/admin/users">users</a> to build a moderation team. | ||
78 | </div> | ||
79 | </div> | ||
64 | 80 | ||
65 | <div class="form-group"> | 81 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
66 | <my-peertube-checkbox inputName="instanceIsNSFW" formControlName="isNSFW"> | ||
67 | <ng-template ptTemplate="label"> | ||
68 | <ng-container i18n>This instance is dedicated to sensitive or NSFW content</ng-container> | ||
69 | </ng-template> | ||
70 | 82 | ||
71 | <ng-template ptTemplate="help"> | 83 | <div class="form-group"> |
72 | <ng-container i18n> | 84 | <my-peertube-checkbox inputName="instanceIsNSFW" formControlName="isNSFW"> |
73 | Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br /><br /> | 85 | <ng-template ptTemplate="label"> |
74 | Moreover, the NSFW checkbox on video upload will be automatically checked by default. | 86 | <ng-container i18n>This instance is dedicated to sensitive or NSFW content</ng-container> |
75 | </ng-container> | 87 | </ng-template> |
76 | </ng-template> | ||
77 | </my-peertube-checkbox> | ||
78 | </div> | ||
79 | 88 | ||
80 | <div class="form-group"> | 89 | <ng-template ptTemplate="help"> |
81 | <label i18n for="instanceDefaultNSFWPolicy">Policy on videos containing sensitive content</label> | 90 | <ng-container i18n> |
91 | Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br /><br /> | ||
92 | Moreover, the NSFW checkbox on video upload will be automatically checked by default. | ||
93 | </ng-container> | ||
94 | </ng-template> | ||
95 | </my-peertube-checkbox> | ||
96 | </div> | ||
82 | 97 | ||
83 | <my-help> | 98 | <div class="form-group"> |
84 | <ng-template ptTemplate="customHtml"> | 99 | <label i18n for="instanceDefaultNSFWPolicy">Policy on videos containing sensitive content</label> |
85 | <ng-container i18n> | ||
86 | With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video. | ||
87 | </ng-container> | ||
88 | </ng-template> | ||
89 | </my-help> | ||
90 | |||
91 | <div class="peertube-select-container"> | ||
92 | <select id="instanceDefaultNSFWPolicy" formControlName="defaultNSFWPolicy"> | ||
93 | <option i18n value="do_not_list">Do not list</option> | ||
94 | <option i18n value="blur">Blur thumbnails</option> | ||
95 | <option i18n value="display">Display</option> | ||
96 | </select> | ||
97 | </div> | ||
98 | <div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error">{{ formErrors.instance.defaultNSFWPolicy }}</div> | ||
99 | </div> | ||
100 | 100 | ||
101 | <div class="form-group"> | 101 | <my-help> |
102 | <label i18n for="instanceTerms">Terms</label><my-help helpType="markdownText"></my-help> | 102 | <ng-template ptTemplate="customHtml"> |
103 | <my-markdown-textarea | 103 | <ng-container i18n> |
104 | name="instanceTerms" formControlName="terms" textareaWidth="500px" [previewColumn]="true" | 104 | With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video. |
105 | [ngClass]="{ 'input-error': formErrors['instance.terms'] }" | 105 | </ng-container> |
106 | ></my-markdown-textarea> | 106 | </ng-template> |
107 | <div *ngIf="formErrors.instance.terms" class="form-error">{{ formErrors.instance.terms }}</div> | 107 | </my-help> |
108 | </div> | 108 | |
109 | <div class="peertube-select-container"> | ||
110 | <select id="instanceDefaultNSFWPolicy" formControlName="defaultNSFWPolicy" class="form-control"> | ||
111 | <option i18n value="undefined" disabled>Policy for sensitive videos</option> | ||
112 | <option i18n value="do_not_list">Do not list</option> | ||
113 | <option i18n value="blur">Blur thumbnails</option> | ||
114 | <option i18n value="display">Display</option> | ||
115 | </select> | ||
116 | </div> | ||
117 | <div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error">{{ formErrors.instance.defaultNSFWPolicy }}</div> | ||
118 | </div> | ||
109 | 119 | ||
110 | <div class="form-group"> | 120 | <div class="form-group"> |
111 | <label i18n for="instanceCodeOfConduct">Code of conduct</label><my-help helpType="markdownText"></my-help> | 121 | <label i18n for="instanceTerms">Terms</label><my-help helpType="markdownText"></my-help> |
112 | <my-markdown-textarea | 122 | <my-markdown-textarea |
113 | name="instanceCodeOfConduct" formControlName="codeOfConduct" textareaWidth="500px" [previewColumn]="true" | 123 | name="instanceTerms" formControlName="terms" textareaMaxWidth="500px" |
114 | [ngClass]="{ 'input-error': formErrors['instance.codeOfConduct'] }" | 124 | [ngClass]="{ 'input-error': formErrors['instance.terms'] }" |
115 | ></my-markdown-textarea> | 125 | ></my-markdown-textarea> |
116 | <div *ngIf="formErrors.instance.codeOfConduct" class="form-error">{{ formErrors.instance.codeOfConduct }}</div> | 126 | <div *ngIf="formErrors.instance.terms" class="form-error">{{ formErrors.instance.terms }}</div> |
117 | </div> | 127 | </div> |
118 | 128 | ||
119 | <div class="form-group"> | 129 | <div class="form-group"> |
120 | <label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help> | 130 | <label i18n for="instanceCodeOfConduct">Code of conduct</label><my-help helpType="markdownText"></my-help> |
121 | <div i18n class="label-small-info">Who moderates the instance? What is the policy regarding NSFW videos? Political videos? etc</div> | 131 | <my-markdown-textarea |
132 | name="instanceCodeOfConduct" formControlName="codeOfConduct" textareaMaxWidth="500px" | ||
133 | [ngClass]="{ 'input-error': formErrors['instance.codeOfConduct'] }" | ||
134 | ></my-markdown-textarea> | ||
135 | <div *ngIf="formErrors.instance.codeOfConduct" class="form-error">{{ formErrors.instance.codeOfConduct }}</div> | ||
136 | </div> | ||
122 | 137 | ||
123 | <my-markdown-textarea | 138 | <div class="form-group"> |
124 | name="instanceModerationInformation" formControlName="moderationInformation" textareaWidth="500px" [previewColumn]="true" | 139 | <label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help> |
125 | [ngClass]="{ 'input-error': formErrors['instance.moderationInformation'] }" | 140 | <div i18n class="label-small-info">Who moderates the instance? What is the policy regarding NSFW videos? Political videos? etc</div> |
126 | ></my-markdown-textarea> | 141 | |
127 | <div *ngIf="formErrors.instance.moderationInformation" class="form-error">{{ formErrors.instance.moderationInformation }}</div> | 142 | <my-markdown-textarea |
143 | name="instanceModerationInformation" formControlName="moderationInformation" textareaMaxWidth="500px" | ||
144 | [ngClass]="{ 'input-error': formErrors['instance.moderationInformation'] }" | ||
145 | ></my-markdown-textarea> | ||
146 | <div *ngIf="formErrors.instance.moderationInformation" class="form-error">{{ formErrors.instance.moderationInformation }}</div> | ||
147 | </div> | ||
148 | |||
149 | </div> | ||
128 | </div> | 150 | </div> |
129 | 151 | ||
130 | <div i18n class="inner-form-title">You and your instance</div> | 152 | <div class="form-row mt-4"> <!-- you and your instance grid --> |
153 | <div class="form-group col-12 col-lg-4 col-xl-3"> | ||
154 | <div i18n class="inner-form-title">YOU AND YOUR INSTANCE</div> | ||
155 | </div> | ||
131 | 156 | ||
132 | <div class="form-group"> | 157 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
133 | <label i18n for="instanceAdministrator">Who is behind the instance?</label> | ||
134 | <div i18n class="label-small-info">A single person? A non-profit? A company?</div> | ||
135 | 158 | ||
136 | <my-markdown-textarea | 159 | <div class="form-group"> |
137 | name="instanceAdministrator" formControlName="administrator" textareaWidth="500px" textareaHeight="75px" [previewColumn]="true" | 160 | <label i18n for="instanceAdministrator">Who is behind the instance?</label> |
138 | [classes]="{ 'input-error': formErrors['instance.administrator'] }" | 161 | <div i18n class="label-small-info">A single person? A non-profit? A company?</div> |
139 | ></my-markdown-textarea> | ||
140 | 162 | ||
141 | <div *ngIf="formErrors.instance.administrator" class="form-error">{{ formErrors.instance.administrator }}</div> | 163 | <my-markdown-textarea |
142 | </div> | 164 | name="instanceAdministrator" formControlName="administrator" textareaMaxWidth="500px" textareaHeight="75px" |
165 | [classes]="{ 'input-error': formErrors['instance.administrator'] }" | ||
166 | ></my-markdown-textarea> | ||
143 | 167 | ||
144 | <div class="form-group"> | 168 | <div *ngIf="formErrors.instance.administrator" class="form-error">{{ formErrors.instance.administrator }}</div> |
145 | <label i18n for="instanceCreationReason">Why did you create this instance?</label> | 169 | </div> |
146 | <div i18n class="label-small-info">To share your personal videos? To open registrations and allow people to upload what they want?</div> | ||
147 | 170 | ||
148 | <textarea | 171 | <div class="form-group"> |
149 | id="instanceCreationReason" formControlName="creationReason" class="small" | 172 | <label i18n for="instanceCreationReason">Why did you create this instance?</label> |
150 | [ngClass]="{ 'input-error': formErrors['instance.creationReason'] }" | 173 | <div i18n class="label-small-info">To share your personal videos? To open registrations and allow people to upload what they want?</div> |
151 | ></textarea> | 174 | |
152 | <div *ngIf="formErrors.instance.creationReason" class="form-error">{{ formErrors.instance.creationReason }}</div> | 175 | <textarea |
153 | </div> | 176 | id="instanceCreationReason" formControlName="creationReason" class="small" class="form-control" |
177 | [ngClass]="{ 'input-error': formErrors['instance.creationReason'] }" | ||
178 | ></textarea> | ||
179 | <div *ngIf="formErrors.instance.creationReason" class="form-error">{{ formErrors.instance.creationReason }}</div> | ||
180 | </div> | ||
154 | 181 | ||
155 | <div class="form-group"> | 182 | <div class="form-group"> |
156 | <label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label> | 183 | <label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label> |
157 | <div i18n class="label-small-info">It's important to know for users who want to register on your instance</div> | 184 | <div i18n class="label-small-info">It's important to know for users who want to register on your instance</div> |
185 | |||
186 | <textarea | ||
187 | id="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" class="form-control small" | ||
188 | [ngClass]="{ 'input-error': formErrors['instance.maintenanceLifetime'] }" | ||
189 | ></textarea> | ||
190 | <div *ngIf="formErrors.instance.maintenanceLifetime" class="form-error">{{ formErrors.instance.maintenanceLifetime }}</div> | ||
191 | </div> | ||
158 | 192 | ||
159 | <textarea | 193 | <div class="form-group"> |
160 | id="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" class="small" | 194 | <label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label> |
161 | [ngClass]="{ 'input-error': formErrors['instance.maintenanceLifetime'] }" | 195 | <div i18n class="label-small-info">With your own funds? With user donations? Advertising?</div> |
162 | ></textarea> | 196 | |
163 | <div *ngIf="formErrors.instance.maintenanceLifetime" class="form-error">{{ formErrors.instance.maintenanceLifetime }}</div> | 197 | <textarea |
198 | id="instanceBusinessModel" formControlName="businessModel" class="form-control small" | ||
199 | [ngClass]="{ 'input-error': formErrors['instance.businessModel'] }" | ||
200 | ></textarea> | ||
201 | <div *ngIf="formErrors.instance.businessModel" class="form-error">{{ formErrors.instance.businessModel }}</div> | ||
202 | </div> | ||
203 | |||
204 | </div> | ||
164 | </div> | 205 | </div> |
165 | 206 | ||
166 | <div class="form-group"> | 207 | <div class="form-row mt-4"> <!-- other information grid --> |
167 | <label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label> | 208 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
168 | <div i18n class="label-small-info">With your own funds? With users donations? Advertising?</div> | 209 | <div i18n class="inner-form-title">OTHER INFORMATION</div> |
210 | </div> | ||
169 | 211 | ||
170 | <textarea | 212 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
171 | id="instanceBusinessModel" formControlName="businessModel" class="small" | ||
172 | [ngClass]="{ 'input-error': formErrors['instance.businessModel'] }" | ||
173 | ></textarea> | ||
174 | <div *ngIf="formErrors.instance.businessModel" class="form-error">{{ formErrors.instance.businessModel }}</div> | ||
175 | </div> | ||
176 | 213 | ||
177 | <div i18n class="inner-form-title">Other information</div> | 214 | <div class="form-group"> |
215 | <label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label> | ||
216 | <div i18n class="label-small-info">i.e. 2vCore 2GB RAM, a direct the link to the server you rent, etc.</div> | ||
178 | 217 | ||
179 | <div class="form-group"> | 218 | <my-markdown-textarea |
180 | <label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label> | 219 | name="instanceHardwareInformation" formControlName="hardwareInformation" textareaMaxWidth="500px" textareaHeight="75px" |
181 | <div i18n class="label-small-info">2vCore 2GB RAM/or directly the link to the server you rent etc</div> | 220 | [classes]="{ 'input-error': formErrors['instance.hardwareInformation'] }" |
221 | ></my-markdown-textarea> | ||
182 | 222 | ||
183 | <my-markdown-textarea | 223 | <div *ngIf="formErrors.instance.hardwareInformation" class="form-error">{{ formErrors.instance.hardwareInformation }}</div> |
184 | name="instanceHardwareInformation" formControlName="hardwareInformation" textareaWidth="500px" textareaHeight="75px" [previewColumn]="true" | 224 | </div> |
185 | [classes]="{ 'input-error': formErrors['instance.hardwareInformation'] }" | ||
186 | ></my-markdown-textarea> | ||
187 | 225 | ||
188 | <div *ngIf="formErrors.instance.hardwareInformation" class="form-error">{{ formErrors.instance.hardwareInformation }}</div> | 226 | </div> |
189 | </div> | 227 | </div> |
190 | 228 | ||
191 | </ng-container> | 229 | </ng-container> |
192 | </ng-template> | 230 | </ng-template> |
193 | </ngb-tab> | 231 | </ng-container> |
194 | |||
195 | <ngb-tab i18n-title title="Basic configuration"> | ||
196 | <ng-template ngbTabContent> | ||
197 | 232 | ||
198 | <div i18n class="inner-form-title">Theme & Default route</div> | 233 | <ng-container ngbNavItem="basic-configuration"> |
234 | <a ngbNavLink i18n>Basic configuration</a> | ||
199 | 235 | ||
200 | <ng-container formGroupName="theme"> | 236 | <ng-template ngbNavContent> |
201 | <div class="form-group"> | ||
202 | <label i18n for="themeDefault">Global theme</label> | ||
203 | 237 | ||
204 | <div class="peertube-select-container"> | 238 | <div class="form-row mt-5"> <!-- appearance grid --> |
205 | <select formControlName="default" id="themeDefault"> | 239 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
206 | <option i18n value="default">default</option> | 240 | <div i18n class="inner-form-title">APPEARANCE</div> |
207 | 241 | <div i18n class="inner-for-description"> | |
208 | <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option> | 242 | Use <a routerLink="/admin/plugins">plugins & themes</a> for more involved changes, or <a routerLink="/admin/config/edit-custom" fragment="customizations" (click)="gotoAnchor()">add slight customizations</a>. |
209 | </select> | ||
210 | </div> | 243 | </div> |
211 | </div> | 244 | </div> |
212 | </ng-container> | ||
213 | |||
214 | 245 | ||
215 | <div class="form-group" formGroupName="instance"> | 246 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
216 | <label i18n for="instanceDefaultClientRoute">Default client route</label> | ||
217 | <div class="peertube-select-container"> | ||
218 | <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute"> | ||
219 | <option i18n value="/videos/overview">Discover videos</option> | ||
220 | <option i18n value="/videos/trending">Trending videos</option> | ||
221 | <option i18n value="/videos/most-liked">Most liked videos</option> | ||
222 | <option i18n value="/videos/recently-added">Recently added videos</option> | ||
223 | <option i18n value="/videos/local">Local videos</option> | ||
224 | </select> | ||
225 | </div> | ||
226 | <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div> | ||
227 | </div> | ||
228 | 247 | ||
229 | <div i18n class="inner-form-title">Signup</div> | 248 | <ng-container formGroupName="theme"> |
230 | 249 | <div class="form-group"> | |
231 | <ng-container formGroupName="signup"> | 250 | <label i18n for="themeDefault">Theme</label> |
232 | <div class="form-group"> | ||
233 | <my-peertube-checkbox | ||
234 | inputName="signupEnabled" formControlName="enabled" | ||
235 | i18n-labelText labelText="Signup enabled" | ||
236 | > | ||
237 | <ng-container ngProjectAs="extra"> | ||
238 | <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }" | ||
239 | inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification" | ||
240 | i18n-labelText labelText="Signup requires email verification" | ||
241 | ></my-peertube-checkbox> | ||
242 | |||
243 | <div [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }" class="mt-3"> | ||
244 | <label i18n for="signupLimit">Signup limit</label> | ||
245 | <input | ||
246 | type="text" id="signupLimit" | ||
247 | formControlName="limit" [ngClass]="{ 'input-error': formErrors['signup.limit'] }" | ||
248 | > | ||
249 | <div *ngIf="formErrors.signup.limit" class="form-error">{{ formErrors.signup.limit }}</div> | ||
250 | </div> | ||
251 | </ng-container> | ||
252 | </my-peertube-checkbox> | ||
253 | </div> | ||
254 | </ng-container> | ||
255 | 251 | ||
252 | <div class="peertube-select-container"> | ||
253 | <select formControlName="default" id="themeDefault" class="form-control"> | ||
254 | <option i18n value="default">default</option> | ||
256 | 255 | ||
257 | <div i18n class="inner-form-title">Users</div> | 256 | <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option> |
257 | </select> | ||
258 | </div> | ||
259 | </div> | ||
260 | </ng-container> | ||
258 | 261 | ||
259 | <ng-container formGroupName="user"> | 262 | <div class="form-group" formGroupName="instance"> |
260 | <div class="form-group"> | 263 | <label i18n for="instanceDefaultClientRoute">Landing page</label> |
261 | <label i18n for="userVideoQuota">Default video quota per user</label> | 264 | <div class="peertube-select-container"> |
262 | <div class="peertube-select-container"> | 265 | <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute" class="form-control"> |
263 | <select id="userVideoQuota" formControlName="videoQuota"> | 266 | <option i18n value="/videos/overview">Discover videos</option> |
264 | <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value"> | 267 | <option i18n value="/videos/trending">Trending videos</option> |
265 | {{ videoQuotaOption.label }} | 268 | <option i18n value="/videos/most-liked">Most liked videos</option> |
266 | </option> | 269 | <option i18n value="/videos/recently-added">Recently added videos</option> |
267 | </select> | 270 | <option i18n value="/videos/local">Local videos</option> |
271 | </select> | ||
272 | </div> | ||
273 | <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div> | ||
268 | </div> | 274 | </div> |
269 | <div *ngIf="formErrors.user.videoQuota" class="form-error">{{ formErrors.user.videoQuota }}</div> | 275 | |
270 | </div> | 276 | </div> |
277 | </div> | ||
271 | 278 | ||
272 | <div class="form-group"> | 279 | <div class="form-row mt-4"> <!-- new users grid --> |
273 | <label i18n for="userVideoQuotaDaily">Default daily upload limit per user</label> | 280 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
274 | <div class="peertube-select-container"> | 281 | <div i18n class="inner-form-title">NEW USERS</div> |
275 | <select id="userVideoQuotaDaily" formControlName="videoQuotaDaily"> | 282 | <div i18n class="inner-for-description"> |
276 | <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value"> | 283 | Manage <a routerLink="/admin/users">users</a> to set their quota individually. |
277 | {{ videoQuotaDailyOption.label }} | ||
278 | </option> | ||
279 | </select> | ||
280 | </div> | 284 | </div> |
281 | <div *ngIf="formErrors.user.videoQuotaDaily" class="form-error">{{ formErrors.user.videoQuotaDaily }}</div> | ||
282 | </div> | 285 | </div> |
283 | </ng-container> | ||
284 | 286 | ||
287 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> | ||
285 | 288 | ||
286 | <div i18n class="inner-form-title">Import</div> | 289 | <ng-container formGroupName="signup"> |
290 | <div class="form-group"> | ||
291 | <my-peertube-checkbox | ||
292 | inputName="signupEnabled" formControlName="enabled" | ||
293 | i18n-labelText labelText="Signup enabled" | ||
294 | > | ||
295 | <ng-container ngProjectAs="description"> | ||
296 | <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span> | ||
297 | </ng-container> | ||
298 | <ng-container ngProjectAs="extra"> | ||
299 | <my-peertube-checkbox [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }" | ||
300 | inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification" | ||
301 | i18n-labelText labelText="Signup requires email verification" | ||
302 | ></my-peertube-checkbox> | ||
303 | |||
304 | <div [ngClass]="{ 'disabled-checkbox-extra': !isSignupEnabled() }" class="mt-3"> | ||
305 | <label i18n for="signupLimit">Signup limit</label> | ||
306 | <input | ||
307 | type="number" min="-1" id="signupLimit" class="form-control" | ||
308 | formControlName="limit" [ngClass]="{ 'input-error': formErrors['signup.limit'] }" | ||
309 | > | ||
310 | <div *ngIf="formErrors.signup.limit" class="form-error">{{ formErrors.signup.limit }}</div> | ||
311 | <small *ngIf="form.value['signup']['limit'] === -1" class="text-muted">Signup won't be limited to a fixed number of users.</small> | ||
312 | </div> | ||
313 | </ng-container> | ||
314 | </my-peertube-checkbox> | ||
315 | </div> | ||
316 | </ng-container> | ||
287 | 317 | ||
288 | <ng-container formGroupName="import"> | 318 | <ng-container formGroupName="user"> |
289 | <ng-container formGroupName="videos"> | 319 | <div class="form-group"> |
320 | <label i18n for="userVideoQuota">Default video quota per user</label> | ||
321 | <div class="peertube-select-container"> | ||
322 | <select id="userVideoQuota" formControlName="videoQuota" class="form-control"> | ||
323 | <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value" [disabled]="videoQuotaOption.disabled"> | ||
324 | {{ videoQuotaOption.label }} | ||
325 | </option> | ||
326 | </select> | ||
327 | </div> | ||
328 | <div *ngIf="formErrors.user.videoQuota" class="form-error">{{ formErrors.user.videoQuota }}</div> | ||
329 | </div> | ||
290 | 330 | ||
291 | <div class="form-group" formGroupName="http"> | 331 | <div class="form-group"> |
292 | <my-peertube-checkbox | 332 | <label i18n for="userVideoQuotaDaily">Default daily upload limit per user</label> |
293 | inputName="importVideosHttpEnabled" formControlName="enabled" | 333 | <div class="peertube-select-container"> |
294 | i18n-labelText labelText="Allow import with HTTP URL (i.e. YouTube)" | 334 | <select id="userVideoQuotaDaily" formControlName="videoQuotaDaily" class="form-control"> |
295 | ></my-peertube-checkbox> | 335 | <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value" [disabled]="videoQuotaDailyOption.disabled"> |
296 | </div> | 336 | {{ videoQuotaDailyOption.label }} |
337 | </option> | ||
338 | </select> | ||
339 | </div> | ||
340 | <div *ngIf="formErrors.user.videoQuotaDaily" class="form-error">{{ formErrors.user.videoQuotaDaily }}</div> | ||
341 | </div> | ||
342 | </ng-container> | ||
297 | 343 | ||
298 | <div class="form-group" formGroupName="torrent"> | 344 | </div> |
299 | <my-peertube-checkbox | 345 | </div> |
300 | inputName="importVideosTorrentEnabled" formControlName="enabled" | ||
301 | i18n-labelText labelText="Allow import with a torrent file or a magnet URI" | ||
302 | ></my-peertube-checkbox> | ||
303 | </div> | ||
304 | 346 | ||
305 | </ng-container> | 347 | <div class="form-row mt-4"> <!-- new videos grid --> |
306 | </ng-container> | 348 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
349 | <div i18n class="inner-form-title">NEW VIDEOS</div> | ||
350 | </div> | ||
307 | 351 | ||
352 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> | ||
308 | 353 | ||
309 | <div i18n class="inner-form-title">Auto-blacklist</div> | 354 | <ng-container formGroupName="import"> |
355 | <ng-container formGroupName="videos"> | ||
310 | 356 | ||
311 | <ng-container formGroupName="autoBlacklist"> | 357 | <div class="form-group" formGroupName="http"> |
312 | <ng-container formGroupName="videos"> | 358 | <my-peertube-checkbox |
313 | <ng-container formGroupName="ofUsers"> | 359 | inputName="importVideosHttpEnabled" formControlName="enabled" |
360 | i18n-labelText labelText="Allow import with HTTP URL (i.e. YouTube)" | ||
361 | ></my-peertube-checkbox> | ||
362 | </div> | ||
314 | 363 | ||
315 | <div class="form-group"> | 364 | <div class="form-group" formGroupName="torrent"> |
316 | <my-peertube-checkbox | 365 | <my-peertube-checkbox |
317 | inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled" | 366 | inputName="importVideosTorrentEnabled" formControlName="enabled" |
318 | i18n-labelText labelText="Blacklist new videos automatically" | 367 | i18n-labelText labelText="Allow import with a torrent file or a magnet URI" |
319 | > | 368 | ></my-peertube-checkbox> |
320 | <ng-container ngProjectAs="description"> | 369 | </div> |
321 | <span i18n>Videos of regular users will stay private until a moderator reviews them. Can be overriden per user.</span> | ||
322 | </ng-container> | ||
323 | </my-peertube-checkbox> | ||
324 | </div> | ||
325 | 370 | ||
371 | </ng-container> | ||
326 | </ng-container> | 372 | </ng-container> |
327 | </ng-container> | ||
328 | </ng-container> | ||
329 | 373 | ||
374 | <ng-container formGroupName="autoBlacklist"> | ||
375 | <ng-container formGroupName="videos"> | ||
376 | <ng-container formGroupName="ofUsers"> | ||
377 | |||
378 | <div class="form-group"> | ||
379 | <my-peertube-checkbox | ||
380 | inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled" | ||
381 | i18n-labelText labelText="Blacklist new videos automatically" | ||
382 | > | ||
383 | <ng-container ngProjectAs="description"> | ||
384 | <span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span> | ||
385 | </ng-container> | ||
386 | </my-peertube-checkbox> | ||
387 | </div> | ||
330 | 388 | ||
331 | <div i18n class="inner-form-title">Instance followers</div> | 389 | </ng-container> |
390 | </ng-container> | ||
391 | </ng-container> | ||
332 | 392 | ||
333 | <ng-container formGroupName="followers"> | 393 | </div> |
334 | <ng-container formGroupName="instance"> | 394 | </div> |
335 | 395 | ||
336 | <div class="form-group"> | 396 | <div class="form-row mt-4"> <!-- federation grid --> |
337 | <my-peertube-checkbox | 397 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
338 | inputName="followersInstanceEnabled" formControlName="enabled" | 398 | <div i18n class="inner-form-title">FEDERATION</div> |
339 | i18n-labelText labelText="Other instances can follow your instance" | 399 | <div i18n class="inner-form-description"> |
340 | ></my-peertube-checkbox> | 400 | Manage <a routerLink="/admin/follows">relations</a> with other instances. |
341 | </div> | 401 | </div> |
402 | </div> | ||
342 | 403 | ||
343 | <div class="form-group"> | 404 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
344 | <my-peertube-checkbox | ||
345 | inputName="followersInstanceManualApproval" formControlName="manualApproval" | ||
346 | i18n-labelText labelText="Manually approve new instance followers" | ||
347 | ></my-peertube-checkbox> | ||
348 | </div> | ||
349 | </ng-container> | ||
350 | </ng-container> | ||
351 | 405 | ||
352 | <div i18n class="inner-form-title">Instance followings</div> | 406 | <ng-container formGroupName="followers"> |
407 | <ng-container formGroupName="instance"> | ||
353 | 408 | ||
354 | <ng-container formGroupName="followings"> | 409 | <div class="form-group"> |
355 | <ng-container formGroupName="instance"> | 410 | <my-peertube-checkbox |
411 | inputName="followersInstanceEnabled" formControlName="enabled" | ||
412 | i18n-labelText labelText="Other instances can follow yours" | ||
413 | ></my-peertube-checkbox> | ||
414 | </div> | ||
356 | 415 | ||
357 | <ng-container formGroupName="autoFollowBack"> | 416 | <div class="form-group"> |
358 | <div class="form-group"> | 417 | <my-peertube-checkbox |
359 | <my-peertube-checkbox | 418 | inputName="followersInstanceManualApproval" formControlName="manualApproval" |
360 | inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled" | 419 | i18n-labelText labelText="Manually approve new instance followers" |
361 | i18n-labelText labelText="Automatically follow other instances that follow you" | 420 | ></my-peertube-checkbox> |
362 | > | 421 | </div> |
363 | <ng-container ngProjectAs="description"> | 422 | </ng-container> |
364 | <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span> | ||
365 | </ng-container> | ||
366 | </my-peertube-checkbox> | ||
367 | </div> | ||
368 | </ng-container> | 423 | </ng-container> |
369 | 424 | ||
370 | <ng-container formGroupName="autoFollowIndex"> | 425 | <ng-container formGroupName="followings"> |
371 | <div class="form-group"> | 426 | <ng-container formGroupName="instance"> |
372 | <my-peertube-checkbox | 427 | |
373 | inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled" | 428 | <ng-container formGroupName="autoFollowBack"> |
374 | i18n-labelText labelText="Automatically follow instances of the public index" | 429 | <div class="form-group"> |
375 | > | 430 | <my-peertube-checkbox |
376 | <ng-container ngProjectAs="description"> | 431 | inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled" |
377 | <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span> | 432 | i18n-labelText labelText="Automatically follow back instances" |
378 | </ng-container> | 433 | > |
434 | <ng-container ngProjectAs="description"> | ||
435 | <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span> | ||
436 | </ng-container> | ||
437 | </my-peertube-checkbox> | ||
438 | </div> | ||
439 | </ng-container> | ||
379 | 440 | ||
380 | <ng-container ngProjectAs="extra"> | 441 | <ng-container formGroupName="autoFollowIndex"> |
381 | <div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }"> | 442 | <div class="form-group"> |
382 | <label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label> | 443 | <my-peertube-checkbox |
383 | <input | 444 | inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled" |
384 | type="text" id="followingsInstanceAutoFollowIndexUrl" | 445 | i18n-labelText labelText="Automatically follow instances of a public index" |
385 | formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors['followings.instance.autoFollowIndex.indexUrl'] }" | 446 | > |
386 | > | 447 | <ng-container ngProjectAs="description"> |
387 | <div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div> | 448 | <p i18n>⚠️ This functionality requires a lot of attention and extra moderation.</p> |
388 | </div> | 449 | |
389 | </ng-container> | 450 | <span i18n> |
390 | </my-peertube-checkbox> | 451 | You should only follow indexes you trust, or <a href="https://framagit.org/framasoft/peertube/instances-peertube#peertube-auto-follow">host your own</a>. |
391 | </div> | 452 | </span> |
453 | </ng-container> | ||
454 | |||
455 | <ng-container ngProjectAs="extra"> | ||
456 | <div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }"> | ||
457 | <label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label> | ||
458 | <input | ||
459 | type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control" | ||
460 | formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors['followings.instance.autoFollowIndex.indexUrl'] }" | ||
461 | > | ||
462 | <div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div> | ||
463 | </div> | ||
464 | </ng-container> | ||
465 | </my-peertube-checkbox> | ||
466 | </div> | ||
392 | 467 | ||
468 | </ng-container> | ||
469 | </ng-container> | ||
393 | </ng-container> | 470 | </ng-container> |
394 | </ng-container> | ||
395 | </ng-container> | ||
396 | 471 | ||
472 | </div> | ||
473 | </div> | ||
474 | |||
475 | <div class="form-row mt-4"> <!-- administrators grid --> | ||
476 | <div class="form-group col-12 col-lg-4 col-xl-3"> | ||
477 | <div i18n class="inner-form-title">ADMINISTRATORS</div> | ||
478 | </div> | ||
397 | 479 | ||
398 | <div i18n class="inner-form-title">Administrator</div> | 480 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
399 | 481 | ||
400 | <div class="form-group" formGroupName="admin"> | 482 | <div class="form-group" formGroupName="admin"> |
401 | <label i18n for="adminEmail">Admin email</label> | 483 | <label i18n for="adminEmail">Admin email</label> |
402 | <input | 484 | <input |
403 | type="text" id="adminEmail" | 485 | type="text" id="adminEmail" class="form-control" |
404 | formControlName="email" [ngClass]="{ 'input-error': formErrors['admin.email'] }" | 486 | formControlName="email" [ngClass]="{ 'input-error': formErrors['admin.email'] }" |
405 | > | 487 | > |
406 | <div *ngIf="formErrors.admin.email" class="form-error">{{ formErrors.admin.email }}</div> | 488 | <div *ngIf="formErrors.admin.email" class="form-error">{{ formErrors.admin.email }}</div> |
407 | </div> | 489 | </div> |
490 | |||
491 | <div class="form-group" formGroupName="contactForm"> | ||
492 | <my-peertube-checkbox | ||
493 | inputName="enableContactForm" formControlName="enabled" | ||
494 | i18n-labelText labelText="Enable contact form" | ||
495 | ></my-peertube-checkbox> | ||
496 | </div> | ||
408 | 497 | ||
409 | <div class="form-group" formGroupName="contactForm"> | 498 | </div> |
410 | <my-peertube-checkbox | ||
411 | inputName="enableContactForm" formControlName="enabled" | ||
412 | i18n-labelText labelText="Enable contact form" | ||
413 | ></my-peertube-checkbox> | ||
414 | </div> | 499 | </div> |
415 | 500 | ||
416 | </ng-template> | 501 | </ng-template> |
417 | </ngb-tab> | 502 | </ng-container> |
418 | |||
419 | <ngb-tab i18n-title title="Services"> | ||
420 | <ng-template ngbTabContent> | ||
421 | <div i18n class="inner-form-title">Twitter</div> | ||
422 | 503 | ||
423 | <ng-container formGroupName="services"> | 504 | <ng-container ngbNavItem="services"> |
424 | <ng-container formGroupName="twitter"> | 505 | <a ngbNavLink i18n>Services</a> |
425 | 506 | ||
426 | <div class="form-group"> | 507 | <ng-template ngbNavContent> |
427 | <label i18n for="signupLimit">Your Twitter username</label> | ||
428 | 508 | ||
429 | <my-help> | 509 | <div class="form-row mt-5"> <!-- twitter grid --> |
430 | <ng-template ptTemplate="customHtml"> | 510 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
431 | <ng-container i18n>Indicates the Twitter account for the website or platform on which the content was published.</ng-container> | 511 | <div i18n class="inner-form-title">TWITTER</div> |
432 | </ng-template> | 512 | <div i18n class="inner-form-description"> |
433 | </my-help> | 513 | Optional. If any, provide the Twitter account representing your instance to improve link previews. |
434 | |||
435 | <input | ||
436 | type="text" id="servicesTwitterUsername" | ||
437 | formControlName="username" [ngClass]="{ 'input-error': formErrors['services.twitter.username'] }" | ||
438 | > | ||
439 | <div *ngIf="formErrors.services.twitter.username" class="form-error">{{ formErrors.services.twitter.username }}</div> | ||
440 | </div> | 514 | </div> |
515 | </div> | ||
441 | 516 | ||
442 | <div class="form-group"> | 517 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
443 | <my-peertube-checkbox inputName="servicesTwitterWhitelisted" formControlName="whitelisted"> | ||
444 | <ng-template ptTemplate="label"> | ||
445 | <ng-container i18n>Instance whitelisted by Twitter</ng-container> | ||
446 | </ng-template> | ||
447 | |||
448 | <ng-template ptTemplate="help"> | ||
449 | <ng-container i18n> | ||
450 | If your instance is whitelisted by Twitter, a video player will be embedded in the Twitter feed on PeerTube video share.<br /> | ||
451 | If the instance is not whitelisted, we use an image link card that will redirect on your PeerTube instance.<br /><br /> | ||
452 | Check this checkbox, save the configuration and test with a video URL of your instance (https://example.com/videos/watch/blabla) on | ||
453 | <a target='_blank' rel='noopener noreferrer' href='https://cards-dev.twitter.com/validator'>https://cards-dev.twitter.com/validator</a> | ||
454 | to see if you instance is whitelisted. | ||
455 | </ng-container> | ||
456 | </ng-template> | ||
457 | </my-peertube-checkbox> | ||
458 | </div> | ||
459 | 518 | ||
460 | </ng-container> | 519 | <ng-container formGroupName="services"> |
461 | </ng-container> | 520 | <ng-container formGroupName="twitter"> |
462 | 521 | ||
463 | </ng-template> | 522 | <div class="form-group"> |
464 | </ngb-tab> | 523 | <label i18n for="signupLimit">Your Twitter username</label> |
465 | 524 | ||
466 | <ngb-tab i18n-title title="Advanced configuration"> | 525 | <input |
467 | <ng-template ngbTabContent> | 526 | type="text" id="servicesTwitterUsername" class="form-control" |
527 | formControlName="username" [ngClass]="{ 'input-error': formErrors['services.twitter.username'] }" | ||
528 | > | ||
529 | <div *ngIf="formErrors.services.twitter.username" class="form-error">{{ formErrors.services.twitter.username }}</div> | ||
530 | </div> | ||
468 | 531 | ||
469 | <div i18n class="inner-form-title">Transcoding</div> | 532 | <div class="form-group"> |
533 | <my-peertube-checkbox inputName="servicesTwitterWhitelisted" formControlName="whitelisted"> | ||
534 | <ng-template ptTemplate="label"> | ||
535 | <ng-container i18n>Instance whitelisted by Twitter</ng-container> | ||
536 | </ng-template> | ||
537 | |||
538 | <ng-template ptTemplate="help"> | ||
539 | <ng-container i18n> | ||
540 | If your instance is whitelisted by Twitter, a video player will be embedded in the Twitter feed on PeerTube video share.<br /> | ||
541 | If the instance is not whitelisted, we use an image link card that will redirect on your PeerTube instance.<br /><br /> | ||
542 | Check this checkbox, save the configuration and test with a video URL of your instance (https://example.com/videos/watch/blabla) on | ||
543 | <a target='_blank' rel='noopener noreferrer' href='https://cards-dev.twitter.com/validator'>https://cards-dev.twitter.com/validator</a> | ||
544 | to see if you instance is whitelisted. | ||
545 | </ng-container> | ||
546 | </ng-template> | ||
547 | </my-peertube-checkbox> | ||
548 | </div> | ||
470 | 549 | ||
471 | <ng-container formGroupName="transcoding"> | 550 | </ng-container> |
472 | <div class="form-group"> | 551 | </ng-container> |
473 | <my-peertube-checkbox inputName="transcodingEnabled" formControlName="enabled"> | ||
474 | <ng-template ptTemplate="label"> | ||
475 | <ng-container i18n>Transcoding enabled</ng-container> | ||
476 | </ng-template> | ||
477 | 552 | ||
478 | <ng-template ptTemplate="help"> | ||
479 | <ng-container i18n>If you disable transcoding, many videos from your users will not work!</ng-container> | ||
480 | </ng-template> | ||
481 | </my-peertube-checkbox> | ||
482 | </div> | 553 | </div> |
554 | </div> | ||
555 | </ng-template> | ||
556 | </ng-container> | ||
483 | 557 | ||
484 | <ng-container *ngIf="isTranscodingEnabled()"> | 558 | <ng-container ngbNavItem="advanced-configuration"> |
559 | <a ngbNavLink i18n>Advanced configuration</a> | ||
485 | 560 | ||
486 | <div class="form-group"> | 561 | <ng-template ngbNavContent> |
487 | <my-peertube-checkbox | ||
488 | inputName="transcodingAllowAdditionalExtensions" formControlName="allowAdditionalExtensions" | ||
489 | i18n-labelText labelText="Allow additional extensions" | ||
490 | > | ||
491 | <ng-template ptTemplate="help"> | ||
492 | <ng-container i18n>Allow your users to upload .mkv, .mov, .avi and .flv videos</ng-container> | ||
493 | </ng-template> | ||
494 | </my-peertube-checkbox> | ||
495 | </div> | ||
496 | 562 | ||
497 | <div class="form-group"> | 563 | <div class="form-row mt-5"> <!-- transcoding grid --> |
498 | <my-peertube-checkbox | 564 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
499 | inputName="transcodingAllowAudioFiles" formControlName="allowAudioFiles" | 565 | <div i18n class="inner-form-title">TRANSCODING</div> |
500 | i18n-labelText labelText="Allow audio files upload" | 566 | <div i18n class="inner-form-description"> |
501 | > | 567 | Process uploaded videos so that they are in a streamable form that any device can play. Though costly in |
502 | <ng-template ptTemplate="help"> | 568 | resources, this is a critical part of PeerTube, so tread carefully. |
503 | <ng-container i18n>Allow your users to upload audio files that will be merged with the preview file on upload</ng-container> | ||
504 | </ng-template> | ||
505 | </my-peertube-checkbox> | ||
506 | </div> | 569 | </div> |
570 | </div> | ||
507 | 571 | ||
508 | <ng-container formGroupName="webtorrent"> | 572 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
509 | <div class="form-group" > | ||
510 | <my-peertube-checkbox | ||
511 | inputName="transcodingWebTorrentEnabled" formControlName="enabled" | ||
512 | i18n-labelText labelText="WebTorrent support enabled" | ||
513 | > | ||
514 | <ng-template ptTemplate="help"> | ||
515 | <ng-container i18n> | ||
516 | <strong>Experimental, we suggest you to not disable webtorrent support for now</strong> | ||
517 | |||
518 | <p>If you also enabled HLS support, it will multiply videos storage by 2</p> | ||
519 | 573 | ||
520 | <br /> | 574 | <ng-container formGroupName="transcoding"> |
521 | 575 | ||
522 | <strong>If disabled, breaks federation with PeerTube instances < 2.1</strong> | 576 | <div class="form-group"> |
523 | </ng-container> | 577 | <my-peertube-checkbox inputName="transcodingEnabled" formControlName="enabled"> |
578 | <ng-template ptTemplate="label"> | ||
579 | <ng-container i18n>Transcoding enabled</ng-container> | ||
524 | </ng-template> | 580 | </ng-template> |
525 | </my-peertube-checkbox> | ||
526 | </div> | ||
527 | </ng-container> | ||
528 | 581 | ||
529 | <ng-container formGroupName="hls"> | ||
530 | <div class="form-group" > | ||
531 | <my-peertube-checkbox | ||
532 | inputName="transcodingHlsEnabled" formControlName="enabled" | ||
533 | i18n-labelText labelText="HLS support enabled" | ||
534 | > | ||
535 | <ng-template ptTemplate="help"> | 582 | <ng-template ptTemplate="help"> |
536 | <ng-container i18n> | 583 | <ng-container i18n>If you disable transcoding, many videos from your users will not work!</ng-container> |
537 | <strong>Requires ffmpeg >= 4.1</strong> | 584 | </ng-template> |
538 | 585 | ||
539 | <p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with the current default player:</p> | 586 | <ng-container ngProjectAs="extra"> |
540 | <ul> | ||
541 | <li>Resolution change is smoother</li> | ||
542 | <li>Faster playback in particular with long videos</li> | ||
543 | <li>More stable playback (less bugs/infinite loading)</li> | ||
544 | </ul> | ||
545 | 587 | ||
546 | <p>If you also enabled WebTorrent support, it will multiply videos storage by 2</p> | 588 | <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }"> |
589 | <my-peertube-checkbox | ||
590 | inputName="transcodingAllowAdditionalExtensions" formControlName="allowAdditionalExtensions" | ||
591 | i18n-labelText labelText="Allow additional extensions" | ||
592 | > | ||
593 | <ng-container ngProjectAs="description"> | ||
594 | <span i18n>Allows users to upload .mkv, .mov, .avi and .flv videos.</span> | ||
595 | </ng-container> | ||
596 | </my-peertube-checkbox> | ||
597 | </div> | ||
598 | |||
599 | <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }"> | ||
600 | <my-peertube-checkbox | ||
601 | inputName="transcodingAllowAudioFiles" formControlName="allowAudioFiles" | ||
602 | i18n-labelText labelText="Allow audio files upload" | ||
603 | > | ||
604 | <ng-container ngProjectAs="description"> | ||
605 | <span i18n>Allows users to upload audio files that will be merged with the preview file on upload.</span> | ||
606 | </ng-container> | ||
607 | </my-peertube-checkbox> | ||
608 | </div> | ||
609 | |||
610 | <ng-container formGroupName="webtorrent"> | ||
611 | <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }"> | ||
612 | <my-peertube-checkbox | ||
613 | inputName="transcodingWebTorrentEnabled" formControlName="enabled" | ||
614 | i18n-labelText labelText="WebTorrent support enabled" | ||
615 | > | ||
616 | <ng-template ptTemplate="help"> | ||
617 | <ng-container i18n> | ||
618 | <strong>Experimental, we suggest you to not disable webtorrent support for now</strong> | ||
619 | |||
620 | <p>If you also enabled HLS support, it will multiply videos storage by 2</p> | ||
621 | |||
622 | <br /> | ||
623 | |||
624 | <strong>If disabled, breaks federation with PeerTube instances < 2.1</strong> | ||
625 | </ng-container> | ||
626 | </ng-template> | ||
627 | </my-peertube-checkbox> | ||
628 | </div> | ||
547 | </ng-container> | 629 | </ng-container> |
548 | </ng-template> | 630 | |
631 | <ng-container formGroupName="hls"> | ||
632 | <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }"> | ||
633 | <my-peertube-checkbox | ||
634 | inputName="transcodingHlsEnabled" formControlName="enabled" | ||
635 | i18n-labelText labelText="HLS support enabled" | ||
636 | > | ||
637 | <ng-template ptTemplate="help"> | ||
638 | <ng-container i18n> | ||
639 | <strong>Requires ffmpeg >= 4.1</strong> | ||
640 | |||
641 | <p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with the current default player:</p> | ||
642 | <ul> | ||
643 | <li>Resolution change is smoother</li> | ||
644 | <li>Faster playback in particular with long videos</li> | ||
645 | <li>More stable playback (less bugs/infinite loading)</li> | ||
646 | </ul> | ||
647 | |||
648 | <p>If you also enabled WebTorrent support, it will multiply videos storage by 2</p> | ||
649 | </ng-container> | ||
650 | </ng-template> | ||
651 | </my-peertube-checkbox> | ||
652 | </div> | ||
653 | </ng-container> | ||
654 | |||
655 | </ng-container> | ||
549 | </my-peertube-checkbox> | 656 | </my-peertube-checkbox> |
550 | </div> | 657 | </div> |
551 | </ng-container> | ||
552 | 658 | ||
553 | <div class="form-group"> | 659 | <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }"> |
554 | <label i18n for="transcodingThreads">Transcoding threads</label> | 660 | <label i18n for="transcodingThreads">Transcoding threads</label> |
555 | <div class="peertube-select-container"> | 661 | <div class="peertube-select-container"> |
556 | <select id="transcodingThreads" formControlName="threads"> | 662 | <select id="transcodingThreads" formControlName="threads" class="form-control"> |
557 | <option *ngFor="let transcodingThreadOption of transcodingThreadOptions" [value]="transcodingThreadOption.value"> | 663 | <option *ngFor="let transcodingThreadOption of transcodingThreadOptions" [value]="transcodingThreadOption.value"> |
558 | {{ transcodingThreadOption.label }} | 664 | {{ transcodingThreadOption.label }} |
559 | </option> | 665 | </option> |
560 | </select> | 666 | </select> |
667 | </div> | ||
668 | <div *ngIf="formErrors.transcoding.threads" class="form-error">{{ formErrors.transcoding.threads }}</div> | ||
561 | </div> | 669 | </div> |
562 | <div *ngIf="formErrors.transcoding.threads" class="form-error">{{ formErrors.transcoding.threads }}</div> | ||
563 | </div> | ||
564 | 670 | ||
565 | <ng-container formGroupName="resolutions"> | 671 | <div class="form-group" [ngClass]="{ 'disabled-checkbox-extra': !isTranscodingEnabled() }"> |
566 | <div class="form-group" *ngFor="let resolution of resolutions"> | ||
567 | <my-peertube-checkbox | ||
568 | [inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id" | ||
569 | i18n-labelText labelText="Resolution {{resolution.label}} enabled" | ||
570 | > | ||
571 | <ng-template *ngIf="resolution.description" ptTemplate="help"> | ||
572 | <div [innerHTML]="resolution.description"></div> | ||
573 | </ng-template> | ||
574 | </my-peertube-checkbox> | ||
575 | </div> | ||
576 | </ng-container> | ||
577 | 672 | ||
578 | </ng-container> | 673 | <label i18n for="transcodingThreads">Resolutions to generate</label> |
579 | </ng-container> | ||
580 | 674 | ||
581 | <div class="inner-form-title"> | 675 | <div class="ml-2 mt-2 d-flex flex-column"> |
582 | <ng-container i18n>Cache</ng-container> | 676 | <ng-container formGroupName="resolutions"> |
677 | <div class="form-group" *ngFor="let resolution of resolutions"> | ||
678 | <my-peertube-checkbox | ||
679 | [inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id" | ||
680 | labelText="{{resolution.label}}" | ||
681 | > | ||
682 | <ng-template *ngIf="resolution.description" ptTemplate="help"> | ||
683 | <div [innerHTML]="resolution.description"></div> | ||
684 | </ng-template> | ||
685 | </my-peertube-checkbox> | ||
686 | </div> | ||
687 | </ng-container> | ||
688 | </div> | ||
583 | 689 | ||
584 | <my-help> | 690 | </div> |
585 | <ng-template ptTemplate="customHtml"> | 691 | |
586 | <ng-container i18n>Some files are not federated (previews, captions). We fetch them directly from the origin instance and cache them.</ng-container> | 692 | </ng-container> |
587 | </ng-template> | ||
588 | </my-help> | ||
589 | </div> | ||
590 | 693 | ||
591 | <ng-container formGroupName="cache"> | ||
592 | <div class="form-group" formGroupName="previews"> | ||
593 | <label i18n for="cachePreviewsSize">Previews cache size</label> | ||
594 | <input | ||
595 | type="text" id="cachePreviewsSize" | ||
596 | formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.previews.size'] }" | ||
597 | > | ||
598 | <div *ngIf="formErrors.cache.previews.size" class="form-error">{{ formErrors.cache.previews.size }}</div> | ||
599 | </div> | 694 | </div> |
695 | </div> | ||
600 | 696 | ||
601 | <div class="form-group" formGroupName="captions"> | 697 | <div class="form-row mt-4"> <!-- cache grid --> |
602 | <label i18n for="cacheCaptionsSize">Video captions cache size</label> | 698 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
603 | <input | 699 | <div i18n class="inner-form-title">CACHE</div> |
604 | type="text" id="cacheCaptionsSize" | 700 | <div i18n class="inner-form-description"> |
605 | formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.captions.size'] }" | 701 | Some files are not federated, and fetched when necessary. Define their caching policies. |
606 | > | 702 | </div> |
607 | <div *ngIf="formErrors.cache.captions.size" class="form-error">{{ formErrors.cache.captions.size }}</div> | ||
608 | </div> | 703 | </div> |
609 | </ng-container> | ||
610 | 704 | ||
611 | <div i18n class="inner-form-title">Customizations</div> | 705 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
612 | 706 | ||
613 | <ng-container formGroupName="instance"> | 707 | <ng-container formGroupName="cache"> |
614 | <ng-container formGroupName="customizations"> | 708 | <div class="form-group" formGroupName="previews"> |
615 | <div class="form-group"> | 709 | <label i18n for="cachePreviewsSize">Number of previews to keep in cache</label> |
616 | <label i18n for="customizationJavascript">JavaScript</label> | 710 | <input |
617 | <my-help> | 711 | type="number" min="0" id="cachePreviewsSize" class="form-control" |
618 | <ng-template ptTemplate="customHtml"> | 712 | formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.previews.size'] }" |
619 | <ng-container i18n> | 713 | > |
620 | Write JavaScript code directly.<br />Example: <pre>console.log('my instance is amazing');</pre> | 714 | <div *ngIf="formErrors.cache.previews.size" class="form-error">{{ formErrors.cache.previews.size }}</div> |
621 | </ng-container> | 715 | </div> |
622 | </ng-template> | ||
623 | </my-help> | ||
624 | 716 | ||
625 | <textarea | 717 | <div class="form-group" formGroupName="captions"> |
626 | id="customizationJavascript" formControlName="javascript" | 718 | <label i18n for="cacheCaptionsSize">Number of video captions to keep in cache</label> |
627 | [ngClass]="{ 'input-error': formErrors['instance.customizations.javascript'] }" | 719 | <input |
628 | ></textarea> | 720 | type="number" min="0" id="cacheCaptionsSize" class="form-control" |
721 | formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.captions.size'] }" | ||
722 | > | ||
723 | <div *ngIf="formErrors.cache.captions.size" class="form-error">{{ formErrors.cache.captions.size }}</div> | ||
724 | </div> | ||
725 | </ng-container> | ||
629 | 726 | ||
630 | <div *ngIf="formErrors.instance.customizations.javascript" class="form-error">{{ formErrors.instance.customizations.javascript }}</div> | 727 | </div> |
631 | </div> | 728 | </div> |
632 | 729 | ||
633 | <div class="form-group"> | 730 | <div class="form-row mt-4"> <!-- cache grid --> |
634 | <label for="customizationCSS">CSS</label> | 731 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
635 | 732 | <div class="anchor" id="customizations"></div> <!-- customizations anchor --> | |
636 | <my-help> | 733 | <div i18n class="inner-form-title">CUSTOMIZATIONS</div> |
637 | <ng-template ptTemplate="customHtml"> | 734 | <div i18n class="inner-form-description"> |
638 | <ng-container i18n> | 735 | Slight modifications to your PeerTube instance for when creating a plugin or theme is overkill. |
639 | Write CSS code directly. Example:<br /><br /> | ||
640 | <pre> | ||
641 | #custom-css {{ '{' }} | ||
642 | color: red; | ||
643 | {{ '}' }} | ||
644 | </pre> | ||
645 | |||
646 | Prepend with <em>#custom-css</em> to override styles. Example:<br /><br /> | ||
647 | <pre> | ||
648 | #custom-css .logged-in-email {{ '{' }} | ||
649 | color: red; | ||
650 | {{ '}' }} | ||
651 | </pre> | ||
652 | </ng-container> | ||
653 | </ng-template> | ||
654 | </my-help> | ||
655 | |||
656 | <textarea | ||
657 | id="customizationCSS" formControlName="css" | ||
658 | [ngClass]="{ 'input-error': formErrors['instance.customizations.css'] }" | ||
659 | ></textarea> | ||
660 | <div *ngIf="formErrors.instance.customizations.css" class="form-error">{{ formErrors.instance.customizations.css }}</div> | ||
661 | </div> | 736 | </div> |
662 | </ng-container> | 737 | </div> |
663 | </ng-container> | ||
664 | 738 | ||
665 | </ng-template> | 739 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
666 | </ngb-tab> | 740 | |
667 | </ngb-tabset> | 741 | <ng-container formGroupName="instance"> |
742 | <ng-container formGroupName="customizations"> | ||
743 | <div class="form-group"> | ||
744 | <label i18n for="customizationJavascript">JavaScript</label> | ||
745 | <my-help> | ||
746 | <ng-template ptTemplate="customHtml"> | ||
747 | <ng-container i18n> | ||
748 | Write JavaScript code directly.<br />Example: <pre>console.log('my instance is amazing');</pre> | ||
749 | </ng-container> | ||
750 | </ng-template> | ||
751 | </my-help> | ||
752 | |||
753 | <textarea | ||
754 | id="customizationJavascript" formControlName="javascript" class="form-control" | ||
755 | [ngClass]="{ 'input-error': formErrors['instance.customizations.javascript'] }" | ||
756 | ></textarea> | ||
757 | |||
758 | <div *ngIf="formErrors.instance.customizations.javascript" class="form-error">{{ formErrors.instance.customizations.javascript }}</div> | ||
759 | </div> | ||
668 | 760 | ||
669 | <input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid"> | 761 | <div class="form-group"> |
670 | <span class="form-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span> | 762 | <label for="customizationCSS">CSS</label> |
763 | |||
764 | <my-help> | ||
765 | <ng-template ptTemplate="customHtml"> | ||
766 | <ng-container i18n> | ||
767 | Write CSS code directly. Example:<br /><br /> | ||
768 | <pre> | ||
769 | #custom-css {{ '{' }} | ||
770 | color: red; | ||
771 | {{ '}' }} | ||
772 | </pre> | ||
773 | Prepend with <em>#custom-css</em> to override styles. Example:<br /><br /> | ||
774 | <pre> | ||
775 | #custom-css .logged-in-email {{ '{' }} | ||
776 | color: red; | ||
777 | {{ '}' }} | ||
778 | </pre> | ||
779 | </ng-container> | ||
780 | </ng-template> | ||
781 | </my-help> | ||
782 | |||
783 | <textarea | ||
784 | id="customizationCSS" formControlName="css" class="form-control" | ||
785 | [ngClass]="{ 'input-error': formErrors['instance.customizations.css'] }" | ||
786 | ></textarea> | ||
787 | <div *ngIf="formErrors.instance.customizations.css" class="form-error">{{ formErrors.instance.customizations.css }}</div> | ||
788 | </div> | ||
789 | </ng-container> | ||
790 | </ng-container> | ||
791 | |||
792 | </div> | ||
793 | </div> | ||
794 | |||
795 | </ng-template> | ||
796 | </ng-container> | ||
797 | </div> | ||
798 | |||
799 | <div [ngbNavOutlet]="nav"></div> | ||
800 | |||
801 | <div class="form-row mt-4"> <!-- submit placement block --> | ||
802 | <div class="col-md-7 col-xl-5"></div> | ||
803 | <div class="col-md-5 col-xl-5"> | ||
804 | <input (click)="formValidated()" type="submit" i18n-value value="Update configuration" [disabled]="!form.valid"> | ||
805 | <span class="form-error" i18n *ngIf="!form.valid">It seems like the configuration is invalid. Please search for potential errors in the different tabs.</span> | ||
806 | </div> | ||
807 | </div> | ||
671 | </form> | 808 | </form> |
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss index 60d608028..9ee960ad6 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss | |||
@@ -1,12 +1,24 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .form-group { | 4 | $form-base-input-width: 340px; |
5 | margin-bottom: 25px; | 5 | |
6 | label { | ||
7 | font-weight: $font-regular; | ||
8 | font-size: 100%; | ||
9 | } | ||
10 | |||
11 | form { | ||
12 | padding-bottom: 1.5rem; | ||
6 | } | 13 | } |
7 | 14 | ||
8 | input[type=text] { | 15 | input[type=text] { |
9 | @include peertube-input-text(340px); | 16 | @include peertube-input-text($form-base-input-width); |
17 | display: block; | ||
18 | } | ||
19 | |||
20 | input[type=number] { | ||
21 | @include peertube-input-text(315px); | ||
10 | display: block; | 22 | display: block; |
11 | } | 23 | } |
12 | 24 | ||
@@ -15,14 +27,15 @@ input[type=checkbox] { | |||
15 | } | 27 | } |
16 | 28 | ||
17 | .peertube-select-container { | 29 | .peertube-select-container { |
18 | @include peertube-select-container(340px); | 30 | @include peertube-select-container($form-base-input-width); |
19 | } | 31 | } |
20 | 32 | ||
21 | input[type=submit] { | 33 | input[type=submit] { |
22 | @include peertube-button; | 34 | @include peertube-button; |
23 | @include orange-button; | 35 | @include orange-button; |
24 | 36 | ||
25 | margin-top: 20px; | 37 | display: flex; |
38 | margin-left: auto; | ||
26 | 39 | ||
27 | & + .form-error { | 40 | & + .form-error { |
28 | display: inline; | 41 | display: inline; |
@@ -31,17 +44,13 @@ input[type=submit] { | |||
31 | } | 44 | } |
32 | 45 | ||
33 | .inner-form-title { | 46 | .inner-form-title { |
34 | text-transform: uppercase; | 47 | @include settings-big-title; |
35 | color: var(--mainColor); | ||
36 | font-weight: $font-bold; | ||
37 | font-size: 13px; | ||
38 | margin-top: 30px; | ||
39 | margin-bottom: 10px; | ||
40 | } | 48 | } |
41 | 49 | ||
42 | textarea { | 50 | textarea { |
43 | @include peertube-textarea(500px, 150px); | 51 | @include peertube-textarea(500px, 150px); |
44 | 52 | ||
53 | max-width: 100%; | ||
45 | display: block; | 54 | display: block; |
46 | 55 | ||
47 | &.small { | 56 | &.small { |
@@ -58,3 +67,13 @@ textarea { | |||
58 | opacity: .5; | 67 | opacity: .5; |
59 | pointer-events: none; | 68 | pointer-events: none; |
60 | } | 69 | } |
70 | |||
71 | .form-group-right { | ||
72 | padding-top: 2px; | ||
73 | } | ||
74 | |||
75 | ngb-tabset:not(.previews) ::ng-deep { | ||
76 | .nav-link { | ||
77 | font-size: 105%; | ||
78 | } | ||
79 | } \ No newline at end of file | ||
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 c88e81c01..cea314cea 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { AfterViewChecked, Component, OnInit, ViewChild } from '@angular/core' |
2 | import { ConfigService } from '@app/+admin/config/shared/config.service' | 2 | import { ConfigService } from '@app/+admin/config/shared/config.service' |
3 | import { ServerService } from '@app/core/server/server.service' | 3 | import { ServerService } from '@app/core/server/server.service' |
4 | import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared' | 4 | import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared' |
@@ -9,13 +9,19 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val | |||
9 | import { SelectItem } from 'primeng/api' | 9 | import { SelectItem } from 'primeng/api' |
10 | import { forkJoin } from 'rxjs' | 10 | import { forkJoin } from 'rxjs' |
11 | import { ServerConfig } from '@shared/models' | 11 | import { ServerConfig } from '@shared/models' |
12 | import { ViewportScroller } from '@angular/common' | ||
13 | import { NgbNav } from '@ng-bootstrap/ng-bootstrap' | ||
12 | 14 | ||
13 | @Component({ | 15 | @Component({ |
14 | selector: 'my-edit-custom-config', | 16 | selector: 'my-edit-custom-config', |
15 | templateUrl: './edit-custom-config.component.html', | 17 | templateUrl: './edit-custom-config.component.html', |
16 | styleUrls: [ './edit-custom-config.component.scss' ] | 18 | styleUrls: [ './edit-custom-config.component.scss' ] |
17 | }) | 19 | }) |
18 | export class EditCustomConfigComponent extends FormReactive implements OnInit { | 20 | export class EditCustomConfigComponent extends FormReactive implements OnInit, AfterViewChecked { |
21 | // FIXME: use built-in router | ||
22 | @ViewChild('nav') nav: NgbNav | ||
23 | |||
24 | initDone = false | ||
19 | customConfig: CustomConfig | 25 | customConfig: CustomConfig |
20 | 26 | ||
21 | resolutions: { id: string, label: string, description?: string }[] = [] | 27 | resolutions: { id: string, label: string, description?: string }[] = [] |
@@ -27,6 +33,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
27 | private serverConfig: ServerConfig | 33 | private serverConfig: ServerConfig |
28 | 34 | ||
29 | constructor ( | 35 | constructor ( |
36 | private viewportScroller: ViewportScroller, | ||
30 | protected formValidatorService: FormValidatorService, | 37 | protected formValidatorService: FormValidatorService, |
31 | private customConfigValidatorsService: CustomConfigValidatorsService, | 38 | private customConfigValidatorsService: CustomConfigValidatorsService, |
32 | private userValidatorsService: UserValidatorsService, | 39 | private userValidatorsService: UserValidatorsService, |
@@ -226,6 +233,13 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
226 | this.checkTranscodingFields() | 233 | this.checkTranscodingFields() |
227 | } | 234 | } |
228 | 235 | ||
236 | ngAfterViewChecked () { | ||
237 | if (!this.initDone) { | ||
238 | this.initDone = true | ||
239 | this.gotoAnchor() | ||
240 | } | ||
241 | } | ||
242 | |||
229 | isTranscodingEnabled () { | 243 | isTranscodingEnabled () { |
230 | return this.form.value['transcoding']['enabled'] === true | 244 | return this.form.value['transcoding']['enabled'] === true |
231 | } | 245 | } |
@@ -272,6 +286,18 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
272 | return this.i18n('No category') | 286 | return this.i18n('No category') |
273 | } | 287 | } |
274 | 288 | ||
289 | gotoAnchor () { | ||
290 | const hashToNav = { | ||
291 | 'customizations': 'advanced-configuration' | ||
292 | } | ||
293 | const hash = window.location.hash.replace('#', '') | ||
294 | |||
295 | if (hash && Object.keys(hashToNav).includes(hash)) { | ||
296 | this.nav.select(hashToNav[hash]) | ||
297 | setTimeout(() => this.viewportScroller.scrollToAnchor(hash), 100) | ||
298 | } | ||
299 | } | ||
300 | |||
275 | private updateForm () { | 301 | private updateForm () { |
276 | this.form.patchValue(this.customConfig) | 302 | this.form.patchValue(this.customConfig) |
277 | } | 303 | } |
diff --git a/client/src/app/+admin/config/shared/batch-domains-modal.component.html b/client/src/app/+admin/config/shared/batch-domains-modal.component.html new file mode 100644 index 000000000..1b85c8f48 --- /dev/null +++ b/client/src/app/+admin/config/shared/batch-domains-modal.component.html | |||
@@ -0,0 +1,43 @@ | |||
1 | <ng-template #modal> | ||
2 | <div class="modal-header"> | ||
3 | <h4 i18n class="modal-title">{{ action }}</h4> | ||
4 | |||
5 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> | ||
6 | </div> | ||
7 | |||
8 | <div class="modal-body"> | ||
9 | <form novalidate [formGroup]="form" (ngSubmit)="submit()"> | ||
10 | <div class="form-group"> | ||
11 | <label i18n for="hosts">1 host (without "http://") per line</label> | ||
12 | |||
13 | <textarea | ||
14 | [placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts" | ||
15 | class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus | ||
16 | ></textarea> | ||
17 | |||
18 | <div *ngIf="formErrors.domains" class="form-error"> | ||
19 | {{ formErrors.domains }} | ||
20 | |||
21 | <div *ngIf="form.controls['domains'].errors.validDomains"> | ||
22 | {{ form.controls['domains'].errors.validDomains.value }} | ||
23 | </div> | ||
24 | </div> | ||
25 | </div> | ||
26 | |||
27 | <ng-content select="warning"></ng-content> | ||
28 | |||
29 | <div class="form-group inputs"> | ||
30 | <input | ||
31 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | ||
32 | (click)="hide()" (key.enter)="hide()" | ||
33 | > | ||
34 | |||
35 | <input | ||
36 | type="submit" [value]="action" class="action-button-submit" | ||
37 | [disabled]="!form.valid" | ||
38 | > | ||
39 | </div> | ||
40 | </form> | ||
41 | </div> | ||
42 | |||
43 | </ng-template> | ||
diff --git a/client/src/app/+admin/config/shared/batch-domains-modal.component.scss b/client/src/app/+admin/config/shared/batch-domains-modal.component.scss new file mode 100644 index 000000000..9621a566f --- /dev/null +++ b/client/src/app/+admin/config/shared/batch-domains-modal.component.scss | |||
@@ -0,0 +1,3 @@ | |||
1 | textarea { | ||
2 | height: 200px; | ||
3 | } | ||
diff --git a/client/src/app/+admin/config/shared/batch-domains-modal.component.ts b/client/src/app/+admin/config/shared/batch-domains-modal.component.ts new file mode 100644 index 000000000..620f2726b --- /dev/null +++ b/client/src/app/+admin/config/shared/batch-domains-modal.component.ts | |||
@@ -0,0 +1,54 @@ | |||
1 | import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core' | ||
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
4 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | ||
5 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | ||
6 | import { FormReactive } from '@app/shared/forms' | ||
7 | import { BatchDomainsValidatorsService } from './batch-domains-validators.service' | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-batch-domains-modal', | ||
11 | templateUrl: './batch-domains-modal.component.html', | ||
12 | styleUrls: [ './batch-domains-modal.component.scss' ] | ||
13 | }) | ||
14 | export class BatchDomainsModalComponent extends FormReactive implements OnInit { | ||
15 | @ViewChild('modal', { static: true }) modal: NgbModal | ||
16 | @Input() placeholder = 'example.com' | ||
17 | @Input() action: string | ||
18 | @Output() domains = new EventEmitter<string[]>() | ||
19 | |||
20 | private openedModal: NgbModalRef | ||
21 | |||
22 | constructor ( | ||
23 | protected formValidatorService: FormValidatorService, | ||
24 | private modalService: NgbModal, | ||
25 | private batchDomainsValidatorsService: BatchDomainsValidatorsService, | ||
26 | private i18n: I18n | ||
27 | ) { | ||
28 | super() | ||
29 | } | ||
30 | |||
31 | ngOnInit () { | ||
32 | if (!this.action) this.action = this.i18n('Process domains') | ||
33 | |||
34 | this.buildForm({ | ||
35 | domains: this.batchDomainsValidatorsService.DOMAINS | ||
36 | }) | ||
37 | } | ||
38 | |||
39 | openModal () { | ||
40 | this.openedModal = this.modalService.open(this.modal, { centered: true }) | ||
41 | } | ||
42 | |||
43 | hide () { | ||
44 | this.openedModal.close() | ||
45 | } | ||
46 | |||
47 | submit () { | ||
48 | this.domains.emit( | ||
49 | this.batchDomainsValidatorsService.getNotEmptyHosts(this.form.controls['domains'].value) | ||
50 | ) | ||
51 | this.form.reset() | ||
52 | this.hide() | ||
53 | } | ||
54 | } | ||
diff --git a/client/src/app/+admin/config/shared/batch-domains-validators.service.ts b/client/src/app/+admin/config/shared/batch-domains-validators.service.ts new file mode 100644 index 000000000..46fa6514d --- /dev/null +++ b/client/src/app/+admin/config/shared/batch-domains-validators.service.ts | |||
@@ -0,0 +1,68 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators, ValidatorFn } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator, validateHost } from '@app/shared/forms/form-validators' | ||
5 | |||
6 | @Injectable() | ||
7 | export class BatchDomainsValidatorsService { | ||
8 | readonly DOMAINS: BuildFormValidator | ||
9 | |||
10 | constructor (private i18n: I18n) { | ||
11 | this.DOMAINS = { | ||
12 | VALIDATORS: [ Validators.required, this.validDomains, this.isHostsUnique ], | ||
13 | MESSAGES: { | ||
14 | 'required': this.i18n('Domain is required.'), | ||
15 | 'validDomains': this.i18n('Domains entered are invalid.'), | ||
16 | 'uniqueDomains': this.i18n('Domains entered contain duplicates.') | ||
17 | } | ||
18 | } | ||
19 | } | ||
20 | |||
21 | getNotEmptyHosts (hosts: string) { | ||
22 | return hosts | ||
23 | .split('\n') | ||
24 | .filter((host: string) => host && host.length !== 0) // Eject empty hosts | ||
25 | } | ||
26 | |||
27 | private validDomains: ValidatorFn = (control) => { | ||
28 | if (!control.value) return null | ||
29 | |||
30 | const newHostsErrors = [] | ||
31 | const hosts = this.getNotEmptyHosts(control.value) | ||
32 | |||
33 | for (const host of hosts) { | ||
34 | if (validateHost(host) === false) { | ||
35 | newHostsErrors.push(this.i18n('{{host}} is not valid', { host })) | ||
36 | } | ||
37 | } | ||
38 | |||
39 | /* Is not valid. */ | ||
40 | if (newHostsErrors.length !== 0) { | ||
41 | return { | ||
42 | 'validDomains': { | ||
43 | reason: 'invalid', | ||
44 | value: newHostsErrors.join('. ') + '.' | ||
45 | } | ||
46 | } | ||
47 | } | ||
48 | |||
49 | /* Is valid. */ | ||
50 | return null | ||
51 | } | ||
52 | |||
53 | private isHostsUnique: ValidatorFn = (control) => { | ||
54 | if (!control.value) return null | ||
55 | |||
56 | const hosts = this.getNotEmptyHosts(control.value) | ||
57 | |||
58 | if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) { | ||
59 | return null | ||
60 | } else { | ||
61 | return { | ||
62 | 'uniqueDomains': { | ||
63 | reason: 'invalid' | ||
64 | } | ||
65 | } | ||
66 | } | ||
67 | } | ||
68 | } | ||
diff --git a/client/src/app/+admin/config/shared/config.service.ts b/client/src/app/+admin/config/shared/config.service.ts index 28a3d67d6..874b8094d 100644 --- a/client/src/app/+admin/config/shared/config.service.ts +++ b/client/src/app/+admin/config/shared/config.service.ts | |||
@@ -10,8 +10,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
10 | export class ConfigService { | 10 | export class ConfigService { |
11 | private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config' | 11 | private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config' |
12 | 12 | ||
13 | videoQuotaOptions: { value: number, label: string }[] = [] | 13 | videoQuotaOptions: { value: number, label: string, disabled?: boolean }[] = [] |
14 | videoQuotaDailyOptions: { value: number, label: string }[] = [] | 14 | videoQuotaDailyOptions: { value: number, label: string, disabled?: boolean }[] = [] |
15 | 15 | ||
16 | constructor ( | 16 | constructor ( |
17 | private authHttp: HttpClient, | 17 | private authHttp: HttpClient, |
@@ -19,8 +19,10 @@ export class ConfigService { | |||
19 | private i18n: I18n | 19 | private i18n: I18n |
20 | ) { | 20 | ) { |
21 | this.videoQuotaOptions = [ | 21 | this.videoQuotaOptions = [ |
22 | { value: undefined, label: 'Default quota', disabled: true }, | ||
22 | { value: -1, label: this.i18n('Unlimited') }, | 23 | { value: -1, label: this.i18n('Unlimited') }, |
23 | { value: 0, label: '0' }, | 24 | { value: undefined, label: '─────', disabled: true }, |
25 | { value: 0, label: this.i18n('None - no upload possible') }, | ||
24 | { value: 100 * 1024 * 1024, label: this.i18n('100MB') }, | 26 | { value: 100 * 1024 * 1024, label: this.i18n('100MB') }, |
25 | { value: 500 * 1024 * 1024, label: this.i18n('500MB') }, | 27 | { value: 500 * 1024 * 1024, label: this.i18n('500MB') }, |
26 | { value: 1024 * 1024 * 1024, label: this.i18n('1GB') }, | 28 | { value: 1024 * 1024 * 1024, label: this.i18n('1GB') }, |
@@ -30,8 +32,10 @@ export class ConfigService { | |||
30 | ] | 32 | ] |
31 | 33 | ||
32 | this.videoQuotaDailyOptions = [ | 34 | this.videoQuotaDailyOptions = [ |
35 | { value: undefined, label: 'Default daily upload limit', disabled: true }, | ||
33 | { value: -1, label: this.i18n('Unlimited') }, | 36 | { value: -1, label: this.i18n('Unlimited') }, |
34 | { value: 0, label: '0' }, | 37 | { value: undefined, label: '─────', disabled: true }, |
38 | { value: 0, label: this.i18n('None - no upload possible') }, | ||
35 | { value: 10 * 1024 * 1024, label: this.i18n('10MB') }, | 39 | { value: 10 * 1024 * 1024, label: this.i18n('10MB') }, |
36 | { value: 50 * 1024 * 1024, label: this.i18n('50MB') }, | 40 | { value: 50 * 1024 * 1024, label: this.i18n('50MB') }, |
37 | { value: 100 * 1024 * 1024, label: this.i18n('100MB') }, | 41 | { value: 100 * 1024 * 1024, label: this.i18n('100MB') }, |
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.html b/client/src/app/+admin/follows/followers-list/followers-list.component.html index 6b5f3b450..93378a533 100644 --- a/client/src/app/+admin/follows/followers-list/followers-list.component.html +++ b/client/src/app/+admin/follows/followers-list/followers-list.component.html | |||
@@ -1,35 +1,46 @@ | |||
1 | <p-table | 1 | <p-table |
2 | [value]="followers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 2 | [value]="followers" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" |
3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" | 3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)" |
4 | [showCurrentPageReport]="true" i18n-currentPageReportTemplate | ||
5 | currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers" | ||
4 | > | 6 | > |
5 | <ng-template pTemplate="caption"> | 7 | <ng-template pTemplate="caption"> |
6 | <div class="caption"> | 8 | <div class="caption"> |
7 | <input | 9 | <div class="ml-auto has-feedback has-clear"> |
8 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | 10 | <input |
9 | (keyup)="onSearch($event.target.value)" | 11 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." |
10 | > | 12 | (keyup)="onSearch($event)" |
13 | > | ||
14 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a> | ||
15 | <span class="sr-only" i18n>Clear filters</span> | ||
16 | </div> | ||
11 | </div> | 17 | </div> |
12 | </ng-template> | 18 | </ng-template> |
13 | 19 | ||
14 | <ng-template pTemplate="header"> | 20 | <ng-template pTemplate="header"> |
15 | <tr> | 21 | <tr> |
16 | <th i18n>Follower handle</th> | 22 | <th i18n>Follower handle</th> |
17 | <th i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> | 23 | <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> |
18 | <th i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th> | 24 | <th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th> |
19 | <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> | 25 | <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> |
20 | <th></th> | 26 | <th style="width: 100px;"></th> |
21 | </tr> | 27 | </tr> |
22 | </ng-template> | 28 | </ng-template> |
23 | 29 | ||
24 | <ng-template pTemplate="body" let-follow> | 30 | <ng-template pTemplate="body" let-follow> |
25 | <tr> | 31 | <tr> |
26 | <td><a [href]="follow.follower.url" target="_blank" rel="noopener noreferrer">{{ follow.follower.name + '@' + follow.follower.host }}</a></td> | 32 | <td> |
33 | <a [href]="follow.follower.url" i18n-title title="Open actor page in a new tab" target="_blank" rel="noopener noreferrer"> | ||
34 | {{ follow.follower.name + '@' + follow.follower.host }} | ||
35 | <span class="glyphicon glyphicon-new-window"></span> | ||
36 | </a> | ||
37 | </td> | ||
27 | 38 | ||
28 | <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td> | 39 | <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td> |
29 | <td *ngIf="follow.state === 'pending'" i18n>Pending</td> | 40 | <td *ngIf="follow.state === 'pending'" i18n>Pending</td> |
30 | 41 | ||
31 | <td>{{ follow.score }}</td> | 42 | <td>{{ follow.score }}</td> |
32 | <td>{{ follow.createdAt }}</td> | 43 | <td>{{ follow.createdAt | date: 'short' }}</td> |
33 | 44 | ||
34 | <td class="action-cell"> | 45 | <td class="action-cell"> |
35 | <ng-container *ngIf="follow.state === 'pending'"> | 46 | <ng-container *ngIf="follow.state === 'pending'"> |
@@ -41,4 +52,15 @@ | |||
41 | </td> | 52 | </td> |
42 | </tr> | 53 | </tr> |
43 | </ng-template> | 54 | </ng-template> |
55 | |||
56 | <ng-template pTemplate="emptymessage"> | ||
57 | <tr> | ||
58 | <td colspan="6"> | ||
59 | <div class="empty-table-message"> | ||
60 | <ng-container *ngIf="search" i18n>No follower found matching current filters.</ng-container> | ||
61 | <ng-container *ngIf="!search" i18n>Your instance doesn't have any follower.</ng-container> | ||
62 | </div> | ||
63 | </td> | ||
64 | </tr> | ||
65 | </ng-template> | ||
44 | </p-table> | 66 | </p-table> |
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.scss b/client/src/app/+admin/follows/followers-list/followers-list.component.scss index 964b3f99b..14189ff11 100644 --- a/client/src/app/+admin/follows/followers-list/followers-list.component.scss +++ b/client/src/app/+admin/follows/followers-list/followers-list.component.scss | |||
@@ -9,6 +9,20 @@ | |||
9 | } | 9 | } |
10 | } | 10 | } |
11 | 11 | ||
12 | a { | ||
13 | @include disable-default-a-behaviour; | ||
14 | display: inline-block; | ||
15 | |||
16 | &, &:hover { | ||
17 | color: var(--mainForegroundColor); | ||
18 | } | ||
19 | |||
20 | span { | ||
21 | font-size: 80%; | ||
22 | color: var(--inputPlaceholderColor); | ||
23 | } | ||
24 | } | ||
25 | |||
12 | .action-cell { | 26 | .action-cell { |
13 | my-button:first-child { | 27 | my-button:first-child { |
14 | margin-right: 10px; | 28 | margin-right: 10px; |
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 707daef84..17352a601 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 | |||
@@ -9,13 +9,12 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
9 | @Component({ | 9 | @Component({ |
10 | selector: 'my-followers-list', | 10 | selector: 'my-followers-list', |
11 | templateUrl: './followers-list.component.html', | 11 | templateUrl: './followers-list.component.html', |
12 | styleUrls: [ './followers-list.component.scss' ] | 12 | styleUrls: [ '../follows.component.scss', './followers-list.component.scss' ] |
13 | }) | 13 | }) |
14 | export class FollowersListComponent extends RestTable implements OnInit { | 14 | export class FollowersListComponent extends RestTable implements OnInit { |
15 | followers: ActorFollow[] = [] | 15 | followers: ActorFollow[] = [] |
16 | totalRecords = 0 | 16 | totalRecords = 0 |
17 | rowsPerPage = 10 | 17 | sort: SortMeta = { field: 'createdAt', order: -1 } |
18 | sort: SortMeta = { field: 'createdAt', order: 1 } | ||
19 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 18 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
20 | 19 | ||
21 | constructor ( | 20 | constructor ( |
@@ -31,6 +30,10 @@ export class FollowersListComponent extends RestTable implements OnInit { | |||
31 | this.initialize() | 30 | this.initialize() |
32 | } | 31 | } |
33 | 32 | ||
33 | getIdentifier () { | ||
34 | return 'FollowersListComponent' | ||
35 | } | ||
36 | |||
34 | acceptFollower (follow: ActorFollow) { | 37 | acceptFollower (follow: ActorFollow) { |
35 | follow.state = 'accepted' | 38 | follow.state = 'accepted' |
36 | 39 | ||
diff --git a/client/src/app/+admin/follows/following-add/following-add.component.html b/client/src/app/+admin/follows/following-add/following-add.component.html deleted file mode 100644 index e08decb3f..000000000 --- a/client/src/app/+admin/follows/following-add/following-add.component.html +++ /dev/null | |||
@@ -1,22 +0,0 @@ | |||
1 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | ||
2 | |||
3 | <form (ngSubmit)="addFollowing()"> | ||
4 | <div class="form-group"> | ||
5 | <label i18n for="hosts">1 host (without "http://") per line</label> | ||
6 | |||
7 | <textarea | ||
8 | type="text" class="form-control" placeholder="example.com" id="hosts" name="hosts" | ||
9 | [(ngModel)]="hostsString" (ngModelChange)="onHostsChanged()" [ngClass]="{ 'input-error': hostsError }" | ||
10 | ></textarea> | ||
11 | |||
12 | <div *ngIf="hostsError" class="form-error"> | ||
13 | {{ hostsError }} | ||
14 | </div> | ||
15 | </div> | ||
16 | |||
17 | <div i18n *ngIf="httpEnabled() === false" class="alert alert-warning"> | ||
18 | It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers. | ||
19 | </div> | ||
20 | |||
21 | <input type="submit" i18n-value value="Add following" [disabled]="hostsError || !hostsString" class="btn btn-secondary"> | ||
22 | </form> | ||
diff --git a/client/src/app/+admin/follows/following-add/following-add.component.scss b/client/src/app/+admin/follows/following-add/following-add.component.scss deleted file mode 100644 index 1baddc95f..000000000 --- a/client/src/app/+admin/follows/following-add/following-add.component.scss +++ /dev/null | |||
@@ -1,18 +0,0 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | textarea { | ||
5 | height: 250px; | ||
6 | } | ||
7 | |||
8 | .form-control { | ||
9 | &, &:focus { | ||
10 | background-color: var(--inputColor); | ||
11 | color: var(--mainForegroundColor); | ||
12 | } | ||
13 | } | ||
14 | |||
15 | input[type=submit] { | ||
16 | @include peertube-button; | ||
17 | @include orange-button; | ||
18 | } | ||
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 deleted file mode 100644 index 308bbb0c5..000000000 --- a/client/src/app/+admin/follows/following-add/following-add.component.ts +++ /dev/null | |||
@@ -1,85 +0,0 @@ | |||
1 | import { Component } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { Notifier } from '@app/core' | ||
4 | import { ConfirmService } from '../../../core' | ||
5 | import { validateHost } from '../../../shared' | ||
6 | import { FollowService } from '@app/shared/instance/follow.service' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-following-add', | ||
11 | templateUrl: './following-add.component.html', | ||
12 | styleUrls: [ './following-add.component.scss' ] | ||
13 | }) | ||
14 | export class FollowingAddComponent { | ||
15 | hostsString = '' | ||
16 | hostsError: string = null | ||
17 | error: string = null | ||
18 | |||
19 | constructor ( | ||
20 | private router: Router, | ||
21 | private notifier: Notifier, | ||
22 | private confirmService: ConfirmService, | ||
23 | private followService: FollowService, | ||
24 | private i18n: I18n | ||
25 | ) {} | ||
26 | |||
27 | httpEnabled () { | ||
28 | return window.location.protocol === 'https:' | ||
29 | } | ||
30 | |||
31 | onHostsChanged () { | ||
32 | this.hostsError = null | ||
33 | |||
34 | const newHostsErrors = [] | ||
35 | const hosts = this.getNotEmptyHosts() | ||
36 | |||
37 | for (const host of hosts) { | ||
38 | if (validateHost(host) === false) { | ||
39 | newHostsErrors.push(this.i18n('{{host}} is not valid', { host })) | ||
40 | } | ||
41 | } | ||
42 | |||
43 | if (newHostsErrors.length !== 0) { | ||
44 | this.hostsError = newHostsErrors.join('. ') | ||
45 | } | ||
46 | } | ||
47 | |||
48 | async addFollowing () { | ||
49 | this.error = '' | ||
50 | |||
51 | const hosts = this.getNotEmptyHosts() | ||
52 | if (hosts.length === 0) { | ||
53 | this.error = this.i18n('You need to specify hosts to follow.') | ||
54 | } | ||
55 | |||
56 | if (!this.isHostsUnique(hosts)) { | ||
57 | this.error = this.i18n('Hosts need to be unique.') | ||
58 | return | ||
59 | } | ||
60 | |||
61 | const confirmMessage = this.i18n('If you confirm, you will send a follow request to:<br /> - ') + hosts.join('<br /> - ') | ||
62 | const res = await this.confirmService.confirm(confirmMessage, this.i18n('Follow new server(s)')) | ||
63 | if (res === false) return | ||
64 | |||
65 | this.followService.follow(hosts).subscribe( | ||
66 | () => { | ||
67 | this.notifier.success(this.i18n('Follow request(s) sent!')) | ||
68 | |||
69 | setTimeout(() => this.router.navigate([ '/admin/follows/following-list' ]), 500) | ||
70 | }, | ||
71 | |||
72 | err => this.notifier.error(err.message) | ||
73 | ) | ||
74 | } | ||
75 | |||
76 | private isHostsUnique (hosts: string[]) { | ||
77 | return hosts.every(host => hosts.indexOf(host) === hosts.lastIndexOf(host)) | ||
78 | } | ||
79 | |||
80 | private getNotEmptyHosts () { | ||
81 | return this.hostsString | ||
82 | .split('\n') | ||
83 | .filter(host => host && host.length !== 0) // Eject empty hosts | ||
84 | } | ||
85 | } | ||
diff --git a/client/src/app/+admin/follows/following-add/index.ts b/client/src/app/+admin/follows/following-add/index.ts deleted file mode 100644 index 1b1897ffa..000000000 --- a/client/src/app/+admin/follows/following-add/index.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | export * from './following-add.component' | ||
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html index 5a252eda9..059c07295 100644 --- a/client/src/app/+admin/follows/following-list/following-list.component.html +++ b/client/src/app/+admin/follows/following-list/following-list.component.html | |||
@@ -1,36 +1,49 @@ | |||
1 | <p-table | 1 | <p-table |
2 | [value]="following" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 2 | [value]="following" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" |
3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" | 3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)" |
4 | [showCurrentPageReport]="true" i18n-currentPageReportTemplate | ||
5 | currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts" | ||
4 | > | 6 | > |
5 | <ng-template pTemplate="caption"> | 7 | <ng-template pTemplate="caption"> |
6 | <div class="caption"> | 8 | <div class="caption"> |
7 | <div> | 9 | <div class="ml-auto has-feedback has-clear"> |
8 | <input | 10 | <input |
9 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | 11 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." |
10 | (keyup)="onSearch($event.target.value)" | 12 | (keyup)="onSearch($event)" |
11 | > | 13 | > |
14 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a> | ||
15 | <span class="sr-only" i18n>Clear filters</span> | ||
12 | </div> | 16 | </div> |
17 | <a class="ml-2 follow-button" (click)="addDomainsToFollow()" (key.enter)="addDomainsToFollow()"> | ||
18 | <my-global-icon iconName="add"></my-global-icon> | ||
19 | <ng-container i18n>Follow domain</ng-container> | ||
20 | </a> | ||
13 | </div> | 21 | </div> |
14 | </ng-template> | 22 | </ng-template> |
15 | 23 | ||
16 | <ng-template pTemplate="header"> | 24 | <ng-template pTemplate="header"> |
17 | <tr> | 25 | <tr> |
18 | <th i18n>Host</th> | 26 | <th i18n>Host</th> |
19 | <th i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> | 27 | <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> |
20 | <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> | 28 | <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> |
21 | <th i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th> | 29 | <th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th> |
22 | <th></th> | 30 | <th style="width: 100px;"></th> |
23 | </tr> | 31 | </tr> |
24 | </ng-template> | 32 | </ng-template> |
25 | 33 | ||
26 | <ng-template pTemplate="body" let-follow> | 34 | <ng-template pTemplate="body" let-follow> |
27 | <tr> | 35 | <tr> |
28 | <td>{{ follow.following.host }}</td> | 36 | <td> |
37 | <a [href]="'https://' + follow.following.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer"> | ||
38 | {{ follow.following.host }} | ||
39 | <span class="glyphicon glyphicon-new-window"></span> | ||
40 | </a> | ||
41 | </td> | ||
29 | 42 | ||
30 | <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td> | 43 | <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td> |
31 | <td *ngIf="follow.state === 'pending'" i18n>Pending</td> | 44 | <td *ngIf="follow.state === 'pending'" i18n>Pending</td> |
32 | 45 | ||
33 | <td>{{ follow.createdAt }}</td> | 46 | <td>{{ follow.createdAt | date: 'short' }}</td> |
34 | <td> | 47 | <td> |
35 | <my-redundancy-checkbox | 48 | <my-redundancy-checkbox |
36 | [host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed" | 49 | [host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed" |
@@ -41,4 +54,23 @@ | |||
41 | </td> | 54 | </td> |
42 | </tr> | 55 | </tr> |
43 | </ng-template> | 56 | </ng-template> |
57 | |||
58 | <ng-template pTemplate="emptymessage"> | ||
59 | <tr> | ||
60 | <td colspan="6"> | ||
61 | <div class="empty-table-message"> | ||
62 | <ng-container *ngIf="search" i18n>No host found matching current filters.</ng-container> | ||
63 | <ng-container *ngIf="!search" i18n>Your instance is not following anyone.</ng-container> | ||
64 | </div> | ||
65 | </td> | ||
66 | </tr> | ||
67 | </ng-template> | ||
44 | </p-table> | 68 | </p-table> |
69 | |||
70 | <my-batch-domains-modal #batchDomainsModal i18n-action action="Follow domains" (domains)="addFollowing($event)"> | ||
71 | <ng-container ngProjectAs="warning"> | ||
72 | <div i18n *ngIf="httpEnabled() === false" class="alert alert-warning"> | ||
73 | It seems that you are not on a HTTPS server. Your webserver needs to have TLS activated in order to follow servers. | ||
74 | </div> | ||
75 | </ng-container> | ||
76 | </my-batch-domains-modal> | ||
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.scss b/client/src/app/+admin/follows/following-list/following-list.component.scss index a6f0656b8..563f8d2bc 100644 --- a/client/src/app/+admin/follows/following-list/following-list.component.scss +++ b/client/src/app/+admin/follows/following-list/following-list.component.scss | |||
@@ -1,10 +1,28 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | a { | ||
5 | @include disable-default-a-behaviour; | ||
6 | display: inline-block; | ||
7 | |||
8 | &, &:hover { | ||
9 | color: var(--mainForegroundColor); | ||
10 | } | ||
11 | |||
12 | span { | ||
13 | font-size: 80%; | ||
14 | color: var(--inputPlaceholderColor); | ||
15 | } | ||
16 | } | ||
17 | |||
4 | .caption { | 18 | .caption { |
5 | justify-content: flex-end; | 19 | justify-content: flex-end; |
6 | 20 | ||
7 | input { | 21 | input { |
8 | @include peertube-input-text(250px); | 22 | @include peertube-input-text(250px); |
9 | } | 23 | } |
10 | } \ No newline at end of file | 24 | } |
25 | |||
26 | .follow-button { | ||
27 | @include create-button; | ||
28 | } | ||
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 3d78c254f..6ddbf02d6 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 | |||
@@ -1,4 +1,4 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit, ViewChild } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { SortMeta } from 'primeng/api' | 3 | import { SortMeta } from 'primeng/api' |
4 | import { ActorFollow } from '../../../../../../shared/models/actors/follow.model' | 4 | import { ActorFollow } from '../../../../../../shared/models/actors/follow.model' |
@@ -6,17 +6,19 @@ import { ConfirmService } from '../../../core/confirm/confirm.service' | |||
6 | import { RestPagination, RestTable } from '../../../shared' | 6 | import { RestPagination, RestTable } from '../../../shared' |
7 | import { FollowService } from '@app/shared/instance/follow.service' | 7 | import { FollowService } from '@app/shared/instance/follow.service' |
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | 8 | import { I18n } from '@ngx-translate/i18n-polyfill' |
9 | import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component' | ||
9 | 10 | ||
10 | @Component({ | 11 | @Component({ |
11 | selector: 'my-followers-list', | 12 | selector: 'my-followers-list', |
12 | templateUrl: './following-list.component.html', | 13 | templateUrl: './following-list.component.html', |
13 | styleUrls: [ './following-list.component.scss' ] | 14 | styleUrls: [ '../follows.component.scss', './following-list.component.scss' ] |
14 | }) | 15 | }) |
15 | export class FollowingListComponent extends RestTable implements OnInit { | 16 | export class FollowingListComponent extends RestTable implements OnInit { |
17 | @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent | ||
18 | |||
16 | following: ActorFollow[] = [] | 19 | following: ActorFollow[] = [] |
17 | totalRecords = 0 | 20 | totalRecords = 0 |
18 | rowsPerPage = 10 | 21 | sort: SortMeta = { field: 'createdAt', order: -1 } |
19 | sort: SortMeta = { field: 'createdAt', order: 1 } | ||
20 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 22 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
21 | 23 | ||
22 | constructor ( | 24 | constructor ( |
@@ -32,6 +34,29 @@ export class FollowingListComponent extends RestTable implements OnInit { | |||
32 | this.initialize() | 34 | this.initialize() |
33 | } | 35 | } |
34 | 36 | ||
37 | getIdentifier () { | ||
38 | return 'FollowingListComponent' | ||
39 | } | ||
40 | |||
41 | addDomainsToFollow () { | ||
42 | this.batchDomainsModal.openModal() | ||
43 | } | ||
44 | |||
45 | httpEnabled () { | ||
46 | return window.location.protocol === 'https:' | ||
47 | } | ||
48 | |||
49 | async addFollowing (hosts: string[]) { | ||
50 | this.followService.follow(hosts).subscribe( | ||
51 | () => { | ||
52 | this.notifier.success(this.i18n('Follow request(s) sent!')) | ||
53 | this.loadData() | ||
54 | }, | ||
55 | |||
56 | err => this.notifier.error(err.message) | ||
57 | ) | ||
58 | } | ||
59 | |||
35 | async removeFollowing (follow: ActorFollow) { | 60 | async removeFollowing (follow: ActorFollow) { |
36 | const res = await this.confirmService.confirm( | 61 | const res = await this.confirmService.confirm( |
37 | this.i18n('Do you really want to unfollow {{host}}?', { host: follow.following.host }), | 62 | this.i18n('Do you really want to unfollow {{host}}?', { host: follow.following.host }), |
diff --git a/client/src/app/+admin/follows/follows.component.html b/client/src/app/+admin/follows/follows.component.html index 21d477132..7b5bcc2db 100644 --- a/client/src/app/+admin/follows/follows.component.html +++ b/client/src/app/+admin/follows/follows.component.html | |||
@@ -1,13 +1,13 @@ | |||
1 | <div class="admin-sub-header"> | 1 | <div class="admin-sub-header"> |
2 | <div i18n class="form-sub-title">Manage follows</div> | 2 | <div i18n class="form-sub-title">Follows & redundancies</div> |
3 | 3 | ||
4 | <div class="admin-sub-nav"> | 4 | <div class="admin-sub-nav"> |
5 | <a i18n routerLink="following-list" routerLinkActive="active">Following</a> | 5 | <a i18n routerLink="following-list" routerLinkActive="active">Following</a> |
6 | 6 | ||
7 | <a i18n routerLink="following-add" routerLinkActive="active">Follow</a> | ||
8 | |||
9 | <a i18n routerLink="followers-list" routerLinkActive="active">Followers</a> | 7 | <a i18n routerLink="followers-list" routerLinkActive="active">Followers</a> |
8 | |||
9 | <a i18n routerLink="video-redundancies-list" routerLinkActive="active">Video redundancies</a> | ||
10 | </div> | 10 | </div> |
11 | </div> | 11 | </div> |
12 | 12 | ||
13 | <router-outlet></router-outlet> \ No newline at end of file | 13 | <router-outlet></router-outlet> |
diff --git a/client/src/app/+admin/follows/follows.component.scss b/client/src/app/+admin/follows/follows.component.scss index 766d7853b..32394f698 100644 --- a/client/src/app/+admin/follows/follows.component.scss +++ b/client/src/app/+admin/follows/follows.component.scss | |||
@@ -1,4 +1,10 @@ | |||
1 | @import "mixins"; | ||
2 | |||
1 | .form-sub-title { | 3 | .form-sub-title { |
2 | flex-grow: 0; | 4 | flex-grow: 0; |
3 | margin-right: 30px; | 5 | margin-right: 30px; |
4 | } | 6 | } |
7 | |||
8 | .empty-table-message { | ||
9 | @include empty-state; | ||
10 | } | ||
diff --git a/client/src/app/+admin/follows/follows.routes.ts b/client/src/app/+admin/follows/follows.routes.ts index e84c79e82..8270ae444 100644 --- a/client/src/app/+admin/follows/follows.routes.ts +++ b/client/src/app/+admin/follows/follows.routes.ts | |||
@@ -2,10 +2,10 @@ import { Routes } from '@angular/router' | |||
2 | 2 | ||
3 | import { UserRightGuard } from '../../core' | 3 | import { UserRightGuard } from '../../core' |
4 | import { FollowsComponent } from './follows.component' | 4 | import { FollowsComponent } from './follows.component' |
5 | import { FollowingAddComponent } from './following-add' | ||
6 | import { FollowersListComponent } from './followers-list' | 5 | import { FollowersListComponent } from './followers-list' |
7 | import { UserRight } from '../../../../../shared' | 6 | import { UserRight } from '../../../../../shared' |
8 | import { FollowingListComponent } from './following-list/following-list.component' | 7 | import { FollowingListComponent } from './following-list/following-list.component' |
8 | import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list' | ||
9 | 9 | ||
10 | export const FollowsRoutes: Routes = [ | 10 | export const FollowsRoutes: Routes = [ |
11 | { | 11 | { |
@@ -41,12 +41,11 @@ export const FollowsRoutes: Routes = [ | |||
41 | }, | 41 | }, |
42 | { | 42 | { |
43 | path: 'following-add', | 43 | path: 'following-add', |
44 | component: FollowingAddComponent, | 44 | redirectTo: 'following-list' |
45 | data: { | 45 | }, |
46 | meta: { | 46 | { |
47 | title: 'Add follow' | 47 | path: 'video-redundancies-list', |
48 | } | 48 | component: VideoRedundanciesListComponent |
49 | } | ||
50 | } | 49 | } |
51 | ] | 50 | ] |
52 | } | 51 | } |
diff --git a/client/src/app/+admin/follows/index.ts b/client/src/app/+admin/follows/index.ts index e94f33710..285955468 100644 --- a/client/src/app/+admin/follows/index.ts +++ b/client/src/app/+admin/follows/index.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | export * from './following-add' | ||
2 | export * from './followers-list' | 1 | export * from './followers-list' |
3 | export * from './following-list' | 2 | export * from './following-list' |
3 | export * from './video-redundancies-list' | ||
4 | export * from './follows.component' | 4 | export * from './follows.component' |
5 | export * from './follows.routes' | 5 | export * from './follows.routes' |
diff --git a/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts b/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts index fa1da26bf..9d7883d97 100644 --- a/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts +++ b/client/src/app/+admin/follows/shared/redundancy-checkbox.component.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { Component, Input } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | 3 | import { I18n } from '@ngx-translate/i18n-polyfill' |
4 | import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' | 4 | import { RedundancyService } from '@app/shared/video/redundancy.service' |
5 | 5 | ||
6 | @Component({ | 6 | @Component({ |
7 | selector: 'my-redundancy-checkbox', | 7 | selector: 'my-redundancy-checkbox', |
diff --git a/client/src/app/+admin/follows/shared/redundancy.service.ts b/client/src/app/+admin/follows/shared/redundancy.service.ts deleted file mode 100644 index 87ae01c04..000000000 --- a/client/src/app/+admin/follows/shared/redundancy.service.ts +++ /dev/null | |||
@@ -1,28 +0,0 @@ | |||
1 | import { catchError, map } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { RestExtractor } from '@app/shared' | ||
5 | import { environment } from '../../../../environments/environment' | ||
6 | |||
7 | @Injectable() | ||
8 | export class RedundancyService { | ||
9 | static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/server/redundancy' | ||
10 | |||
11 | constructor ( | ||
12 | private authHttp: HttpClient, | ||
13 | private restExtractor: RestExtractor | ||
14 | ) { } | ||
15 | |||
16 | updateRedundancy (host: string, redundancyAllowed: boolean) { | ||
17 | const url = RedundancyService.BASE_USER_SUBSCRIPTIONS_URL + '/' + host | ||
18 | |||
19 | const body = { redundancyAllowed } | ||
20 | |||
21 | return this.authHttp.put(url, body) | ||
22 | .pipe( | ||
23 | map(this.restExtractor.extractDataBool), | ||
24 | catchError(err => this.restExtractor.handleError(err)) | ||
25 | ) | ||
26 | } | ||
27 | |||
28 | } | ||
diff --git a/client/src/app/+admin/follows/video-redundancies-list/index.ts b/client/src/app/+admin/follows/video-redundancies-list/index.ts new file mode 100644 index 000000000..6a7c7f483 --- /dev/null +++ b/client/src/app/+admin/follows/video-redundancies-list/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './video-redundancies-list.component' | |||
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html new file mode 100644 index 000000000..28d57f83c --- /dev/null +++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html | |||
@@ -0,0 +1,100 @@ | |||
1 | <div class="admin-sub-header"> | ||
2 | <div class="select-filter-block"> | ||
3 | <label for="displayType" i18n>Display</label> | ||
4 | |||
5 | <div class="peertube-select-container"> | ||
6 | <select id="displayType" name="displayType" [(ngModel)]="displayType" (ngModelChange)="onDisplayTypeChanged()" class="form-control"> | ||
7 | <option value="my-videos" i18n>My videos duplicated by remote instances</option> | ||
8 | <option value="remote-videos" i18n>Remote videos duplicated by my instance</option> | ||
9 | </select> | ||
10 | </div> | ||
11 | </div> | ||
12 | </div> | ||
13 | |||
14 | <p-table | ||
15 | [value]="videoRedundancies" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" | ||
16 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" | ||
17 | (onPage)="onPage($event)" [expandedRowKeys]="expandedRows" | ||
18 | > | ||
19 | <ng-template pTemplate="header"> | ||
20 | <tr> | ||
21 | <th style="width: 40px;"></th> | ||
22 | <th style="width: 160px;" i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th> | ||
23 | <th i18n pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th > | ||
24 | <th style="width: 100px;" i18n *ngIf="isDisplayingRemoteVideos()">Total size</th> | ||
25 | <th style="width: 80px;"></th> | ||
26 | </tr> | ||
27 | </ng-template> | ||
28 | |||
29 | <ng-template pTemplate="body" let-expanded="expanded" let-redundancy> | ||
30 | <tr> | ||
31 | |||
32 | <td> | ||
33 | <span class="expander" i18n-ngbTooltip ngbTooltip="List redundancies" [pRowToggler]="redundancy"> | ||
34 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> | ||
35 | </span> | ||
36 | </td> | ||
37 | |||
38 | <td *ngIf="isDisplayingRemoteVideos()">{{ getRedundancyStrategy(redundancy) }}</td> | ||
39 | |||
40 | <td> | ||
41 | <a [href]="redundancy.url" i18n-title title="Open video in a new tab" target="_blank" rel="noopener noreferrer"> | ||
42 | {{ redundancy.name }} | ||
43 | <span class="glyphicon glyphicon-new-window"></span> | ||
44 | </a> | ||
45 | </td> | ||
46 | |||
47 | <td *ngIf="isDisplayingRemoteVideos()">{{ getTotalSize(redundancy) | bytes: 1 }}</td> | ||
48 | |||
49 | <td class="action-cell"> | ||
50 | <my-delete-button (click)="removeRedundancy(redundancy)"></my-delete-button> | ||
51 | </td> | ||
52 | </tr> | ||
53 | </ng-template> | ||
54 | |||
55 | <ng-template pTemplate="rowexpansion" let-redundancy> | ||
56 | <tr *ngIf="redundancy.redundancies.files.length !== 0"> | ||
57 | <td class="expand-cell" [attr.colspan]="getColspan()"> | ||
58 | <div *ngFor="let file of redundancy.redundancies.files" class="expansion-block"> | ||
59 | <my-video-redundancy-information [redundancyElement]="file"></my-video-redundancy-information> | ||
60 | </div> | ||
61 | </td> | ||
62 | </tr> | ||
63 | |||
64 | <tr *ngIf="redundancy.redundancies.streamingPlaylists.length !== 0"> | ||
65 | <td class="expand-cell" [attr.colspan]="getColspan()"> | ||
66 | <div *ngFor="let playlist of redundancy.redundancies.streamingPlaylists"> | ||
67 | <my-video-redundancy-information [redundancyElement]="playlist"></my-video-redundancy-information> | ||
68 | </div> | ||
69 | </td> | ||
70 | </tr> | ||
71 | </ng-template> | ||
72 | |||
73 | <ng-template pTemplate="emptymessage"> | ||
74 | <tr> | ||
75 | <td colspan="6"> | ||
76 | <div class="empty-table-message"> | ||
77 | <ng-container *ngIf="isDisplayingRemoteVideos()" i18n>Your instance doesn't mirror any video.</ng-container> | ||
78 | <ng-container *ngIf="!isDisplayingRemoteVideos()" i18n>Your instance has no mirrored videos.</ng-container> | ||
79 | </div> | ||
80 | </td> | ||
81 | </tr> | ||
82 | </ng-template> | ||
83 | </p-table> | ||
84 | |||
85 | |||
86 | <div class="redundancies-charts" *ngIf="isDisplayingRemoteVideos()"> | ||
87 | <div class="form-sub-title" i18n>Enabled strategies stats</div> | ||
88 | |||
89 | <div class="chart-blocks"> | ||
90 | |||
91 | <div *ngIf="noRedundancies" i18n class="no-results"> | ||
92 | No redundancy strategy is enabled on your instance. | ||
93 | </div> | ||
94 | |||
95 | <div class="chart-block" *ngFor="let r of redundanciesGraphsData"> | ||
96 | <p-chart type="pie" [data]="r.graphData" [options]="r.options" width="300px" height="300px"></p-chart> | ||
97 | </div> | ||
98 | |||
99 | </div> | ||
100 | </div> | ||
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss new file mode 100644 index 000000000..dc43e4007 --- /dev/null +++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.scss | |||
@@ -0,0 +1,51 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | a { | ||
5 | @include disable-default-a-behaviour; | ||
6 | display: inline-block; | ||
7 | |||
8 | &, &:hover { | ||
9 | color: var(--mainForegroundColor); | ||
10 | } | ||
11 | |||
12 | span { | ||
13 | font-size: 80%; | ||
14 | color: var(--inputPlaceholderColor); | ||
15 | } | ||
16 | } | ||
17 | |||
18 | .expansion-block { | ||
19 | margin-bottom: 20px; | ||
20 | } | ||
21 | |||
22 | .admin-sub-header { | ||
23 | justify-content: flex-end; | ||
24 | |||
25 | .select-filter-block { | ||
26 | &:not(:last-child) { | ||
27 | margin-right: 10px; | ||
28 | } | ||
29 | |||
30 | label { | ||
31 | margin-bottom: 2px; | ||
32 | } | ||
33 | |||
34 | .peertube-select-container { | ||
35 | @include peertube-select-container(auto); | ||
36 | } | ||
37 | } | ||
38 | } | ||
39 | |||
40 | .redundancies-charts { | ||
41 | margin-top: 50px; | ||
42 | |||
43 | .chart-blocks { | ||
44 | display: flex; | ||
45 | justify-content: center; | ||
46 | |||
47 | .chart-block { | ||
48 | margin: 0 20px; | ||
49 | } | ||
50 | } | ||
51 | } | ||
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts new file mode 100644 index 000000000..267a1f58e --- /dev/null +++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts | |||
@@ -0,0 +1,187 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { Notifier, ServerService } from '@app/core' | ||
3 | import { SortMeta } from 'primeng/api' | ||
4 | import { ConfirmService } from '../../../core/confirm/confirm.service' | ||
5 | import { RestPagination, RestTable } from '../../../shared' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models' | ||
8 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' | ||
9 | import { VideosRedundancyStats } from '@shared/models/server' | ||
10 | import { BytesPipe } from 'ngx-pipes' | ||
11 | import { RedundancyService } from '@app/shared/video/redundancy.service' | ||
12 | |||
13 | @Component({ | ||
14 | selector: 'my-video-redundancies-list', | ||
15 | templateUrl: './video-redundancies-list.component.html', | ||
16 | styleUrls: [ '../follows.component.scss', './video-redundancies-list.component.scss' ] | ||
17 | }) | ||
18 | export class VideoRedundanciesListComponent extends RestTable implements OnInit { | ||
19 | private static LOCAL_STORAGE_DISPLAY_TYPE = 'video-redundancies-list-display-type' | ||
20 | |||
21 | videoRedundancies: VideoRedundancy[] = [] | ||
22 | totalRecords = 0 | ||
23 | |||
24 | sort: SortMeta = { field: 'name', order: 1 } | ||
25 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | ||
26 | displayType: VideoRedundanciesTarget = 'my-videos' | ||
27 | |||
28 | redundanciesGraphsData: { stats: VideosRedundancyStats, graphData: object, options: object }[] = [] | ||
29 | |||
30 | noRedundancies = false | ||
31 | |||
32 | private bytesPipe: BytesPipe | ||
33 | |||
34 | constructor ( | ||
35 | private notifier: Notifier, | ||
36 | private confirmService: ConfirmService, | ||
37 | private redundancyService: RedundancyService, | ||
38 | private serverService: ServerService, | ||
39 | private i18n: I18n | ||
40 | ) { | ||
41 | super() | ||
42 | |||
43 | this.bytesPipe = new BytesPipe() | ||
44 | } | ||
45 | |||
46 | getIdentifier () { | ||
47 | return 'VideoRedundanciesListComponent' | ||
48 | } | ||
49 | |||
50 | ngOnInit () { | ||
51 | this.loadSelectLocalStorage() | ||
52 | |||
53 | this.initialize() | ||
54 | |||
55 | this.serverService.getServerStats() | ||
56 | .subscribe(res => { | ||
57 | const redundancies = res.videosRedundancy | ||
58 | |||
59 | if (redundancies.length === 0) this.noRedundancies = true | ||
60 | |||
61 | for (const r of redundancies) { | ||
62 | this.buildPieData(r) | ||
63 | } | ||
64 | }) | ||
65 | } | ||
66 | |||
67 | getColspan () { | ||
68 | if (this.isDisplayingRemoteVideos()) return 5 | ||
69 | |||
70 | return 4 | ||
71 | } | ||
72 | |||
73 | isDisplayingRemoteVideos () { | ||
74 | return this.displayType === 'remote-videos' | ||
75 | } | ||
76 | |||
77 | getTotalSize (redundancy: VideoRedundancy) { | ||
78 | return redundancy.redundancies.files.reduce((a, b) => a + b.size, 0) + | ||
79 | redundancy.redundancies.streamingPlaylists.reduce((a, b) => a + b.size, 0) | ||
80 | } | ||
81 | |||
82 | onDisplayTypeChanged () { | ||
83 | this.pagination.start = 0 | ||
84 | this.saveSelectLocalStorage() | ||
85 | |||
86 | this.loadData() | ||
87 | } | ||
88 | |||
89 | getRedundancyStrategy (redundancy: VideoRedundancy) { | ||
90 | if (redundancy.redundancies.files.length !== 0) return redundancy.redundancies.files[0].strategy | ||
91 | if (redundancy.redundancies.streamingPlaylists.length !== 0) return redundancy.redundancies.streamingPlaylists[0].strategy | ||
92 | |||
93 | return '' | ||
94 | } | ||
95 | |||
96 | buildPieData (stats: VideosRedundancyStats) { | ||
97 | const totalSize = stats.totalSize | ||
98 | ? stats.totalSize - stats.totalUsed | ||
99 | : stats.totalUsed | ||
100 | |||
101 | if (totalSize === 0) return | ||
102 | |||
103 | this.redundanciesGraphsData.push({ | ||
104 | stats, | ||
105 | graphData: { | ||
106 | labels: [ this.i18n('Used'), this.i18n('Available') ], | ||
107 | datasets: [ | ||
108 | { | ||
109 | data: [ stats.totalUsed, totalSize ], | ||
110 | backgroundColor: [ | ||
111 | '#FF6384', | ||
112 | '#36A2EB' | ||
113 | ], | ||
114 | hoverBackgroundColor: [ | ||
115 | '#FF6384', | ||
116 | '#36A2EB' | ||
117 | ] | ||
118 | } | ||
119 | ] | ||
120 | }, | ||
121 | options: { | ||
122 | title: { | ||
123 | display: true, | ||
124 | text: stats.strategy | ||
125 | }, | ||
126 | |||
127 | tooltips: { | ||
128 | callbacks: { | ||
129 | label: (tooltipItem: any, data: any) => { | ||
130 | const dataset = data.datasets[tooltipItem.datasetIndex] | ||
131 | let label = data.labels[tooltipItem.index] | ||
132 | if (label) label += ': ' | ||
133 | else label = '' | ||
134 | |||
135 | label += this.bytesPipe.transform(dataset.data[tooltipItem.index], 1) | ||
136 | return label | ||
137 | } | ||
138 | } | ||
139 | } | ||
140 | } | ||
141 | }) | ||
142 | } | ||
143 | |||
144 | async removeRedundancy (redundancy: VideoRedundancy) { | ||
145 | const message = this.i18n('Do you really want to remove this video redundancy?') | ||
146 | const res = await this.confirmService.confirm(message, this.i18n('Remove redundancy')) | ||
147 | if (res === false) return | ||
148 | |||
149 | this.redundancyService.removeVideoRedundancies(redundancy) | ||
150 | .subscribe( | ||
151 | () => { | ||
152 | this.notifier.success(this.i18n('Video redundancies removed!')) | ||
153 | this.loadData() | ||
154 | }, | ||
155 | |||
156 | err => this.notifier.error(err.message) | ||
157 | ) | ||
158 | |||
159 | } | ||
160 | |||
161 | protected loadData () { | ||
162 | const options = { | ||
163 | pagination: this.pagination, | ||
164 | sort: this.sort, | ||
165 | target: this.displayType | ||
166 | } | ||
167 | |||
168 | this.redundancyService.listVideoRedundancies(options) | ||
169 | .subscribe( | ||
170 | resultList => { | ||
171 | this.videoRedundancies = resultList.data | ||
172 | this.totalRecords = resultList.total | ||
173 | }, | ||
174 | |||
175 | err => this.notifier.error(err.message) | ||
176 | ) | ||
177 | } | ||
178 | |||
179 | private loadSelectLocalStorage () { | ||
180 | const displayType = peertubeLocalStorage.getItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE) | ||
181 | if (displayType) this.displayType = displayType as VideoRedundanciesTarget | ||
182 | } | ||
183 | |||
184 | private saveSelectLocalStorage () { | ||
185 | peertubeLocalStorage.setItem(VideoRedundanciesListComponent.LOCAL_STORAGE_DISPLAY_TYPE, this.displayType) | ||
186 | } | ||
187 | } | ||
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html new file mode 100644 index 000000000..9de6e4661 --- /dev/null +++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.html | |||
@@ -0,0 +1,19 @@ | |||
1 | <div> | ||
2 | <span class="label">Url</span> | ||
3 | <a target="_blank" rel="noopener noreferrer" [href]="redundancyElement.fileUrl">{{ redundancyElement.fileUrl }}</a> | ||
4 | </div> | ||
5 | |||
6 | <div> | ||
7 | <span class="label">Created on</span> | ||
8 | <span>{{ redundancyElement.createdAt | date: 'medium' }}</span> | ||
9 | </div> | ||
10 | |||
11 | <div> | ||
12 | <span class="label">Expires on</span> | ||
13 | <span>{{ redundancyElement.expiresOn | date: 'medium' }}</span> | ||
14 | </div> | ||
15 | |||
16 | <div> | ||
17 | <span class="label">Size</span> | ||
18 | <span>{{ redundancyElement.size | bytes: 1 }}</span> | ||
19 | </div> | ||
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss new file mode 100644 index 000000000..6b09fbb01 --- /dev/null +++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.scss | |||
@@ -0,0 +1,8 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .label { | ||
5 | display: inline-block; | ||
6 | min-width: 100px; | ||
7 | font-weight: $font-semibold; | ||
8 | } | ||
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts new file mode 100644 index 000000000..6f3090c08 --- /dev/null +++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancy-information.component.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { FileRedundancyInformation, StreamingPlaylistRedundancyInformation } from '@shared/models' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-video-redundancy-information', | ||
6 | templateUrl: './video-redundancy-information.component.html', | ||
7 | styleUrls: [ './video-redundancy-information.component.scss' ] | ||
8 | }) | ||
9 | export class VideoRedundancyInformationComponent { | ||
10 | @Input() redundancyElement: FileRedundancyInformation | StreamingPlaylistRedundancyInformation | ||
11 | } | ||
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html index 7797bc56e..a4ab2a58c 100644 --- a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html +++ b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html | |||
@@ -1,22 +1,64 @@ | |||
1 | <p-table | 1 | <p-table |
2 | [value]="blockedAccounts" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 2 | [value]="blockedAccounts" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" |
3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" | 3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)" |
4 | [showCurrentPageReport]="true" i18n-currentPageReportTemplate | ||
5 | currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted accounts" | ||
4 | > | 6 | > |
7 | <ng-template pTemplate="caption"> | ||
8 | <div class="caption"> | ||
9 | <div class="ml-auto has-feedback has-clear"> | ||
10 | <input | ||
11 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | ||
12 | (keyup)="onSearch($event)" | ||
13 | > | ||
14 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a> | ||
15 | <span class="sr-only" i18n>Clear filters</span> | ||
16 | </div> | ||
17 | </div> | ||
18 | </ng-template> | ||
5 | 19 | ||
6 | <ng-template pTemplate="header"> | 20 | <ng-template pTemplate="header"> |
7 | <tr> | 21 | <tr> |
8 | <th i18n>Account</th> | 22 | <th style="width: 100%;" i18n>Account</th> |
9 | <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> | 23 | <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> |
24 | <th style="width: 100px;"></th> <!-- column for action buttons --> | ||
10 | </tr> | 25 | </tr> |
11 | </ng-template> | 26 | </ng-template> |
12 | 27 | ||
13 | <ng-template pTemplate="body" let-accountBlock> | 28 | <ng-template pTemplate="body" let-accountBlock> |
14 | <tr> | 29 | <tr> |
15 | <td>{{ accountBlock.blockedAccount.nameWithHost }}</td> | 30 | <td> |
16 | <td>{{ accountBlock.createdAt }}</td> | 31 | <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> |
32 | <div class="chip two-lines"> | ||
33 | <img | ||
34 | class="avatar" | ||
35 | [src]="accountBlock.blockedAccount.avatar?.path" | ||
36 | (error)="switchToDefaultAvatar($event)" | ||
37 | alt="Avatar" | ||
38 | > | ||
39 | <div> | ||
40 | {{ accountBlock.blockedAccount.displayName }} | ||
41 | <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span> | ||
42 | </div> | ||
43 | </div> | ||
44 | </a> | ||
45 | </td> | ||
46 | |||
47 | <td>{{ accountBlock.createdAt | date: 'short' }}</td> | ||
17 | <td class="action-cell"> | 48 | <td class="action-cell"> |
18 | <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button> | 49 | <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button> |
19 | </td> | 50 | </td> |
20 | </tr> | 51 | </tr> |
21 | </ng-template> | 52 | </ng-template> |
53 | |||
54 | <ng-template pTemplate="emptymessage"> | ||
55 | <tr> | ||
56 | <td colspan="6"> | ||
57 | <div class="empty-table-message"> | ||
58 | <ng-container *ngIf="search" i18n>No account found matching current filters.</ng-container> | ||
59 | <ng-container *ngIf="!search" i18n>No account found.</ng-container> | ||
60 | </div> | ||
61 | </td> | ||
62 | </tr> | ||
63 | </ng-template> | ||
22 | </p-table> | 64 | </p-table> |
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts index 032bf745a..73a9ae75d 100644 --- a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts +++ b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts | |||
@@ -2,18 +2,18 @@ import { Component, OnInit } from '@angular/core' | |||
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | 3 | import { I18n } from '@ngx-translate/i18n-polyfill' |
4 | import { RestPagination, RestTable } from '@app/shared' | 4 | import { RestPagination, RestTable } from '@app/shared' |
5 | import { SortMeta } from 'primeng/components/common/sortmeta' | 5 | import { SortMeta } from 'primeng/api' |
6 | import { AccountBlock, BlocklistService } from '@app/shared/blocklist' | 6 | import { AccountBlock, BlocklistService } from '@app/shared/blocklist' |
7 | import { Actor } from '@app/shared/actor/actor.model' | ||
7 | 8 | ||
8 | @Component({ | 9 | @Component({ |
9 | selector: 'my-instance-account-blocklist', | 10 | selector: 'my-instance-account-blocklist', |
10 | styleUrls: [ './instance-account-blocklist.component.scss' ], | 11 | styleUrls: [ '../moderation.component.scss', './instance-account-blocklist.component.scss' ], |
11 | templateUrl: './instance-account-blocklist.component.html' | 12 | templateUrl: './instance-account-blocklist.component.html' |
12 | }) | 13 | }) |
13 | export class InstanceAccountBlocklistComponent extends RestTable implements OnInit { | 14 | export class InstanceAccountBlocklistComponent extends RestTable implements OnInit { |
14 | blockedAccounts: AccountBlock[] = [] | 15 | blockedAccounts: AccountBlock[] = [] |
15 | totalRecords = 0 | 16 | totalRecords = 0 |
16 | rowsPerPage = 10 | ||
17 | sort: SortMeta = { field: 'createdAt', order: -1 } | 17 | sort: SortMeta = { field: 'createdAt', order: -1 } |
18 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 18 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
19 | 19 | ||
@@ -29,6 +29,14 @@ export class InstanceAccountBlocklistComponent extends RestTable implements OnIn | |||
29 | this.initialize() | 29 | this.initialize() |
30 | } | 30 | } |
31 | 31 | ||
32 | getIdentifier () { | ||
33 | return 'InstanceAccountBlocklistComponent' | ||
34 | } | ||
35 | |||
36 | switchToDefaultAvatar ($event: Event) { | ||
37 | ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() | ||
38 | } | ||
39 | |||
32 | unblockAccount (accountBlock: AccountBlock) { | 40 | unblockAccount (accountBlock: AccountBlock) { |
33 | const blockedAccount = accountBlock.blockedAccount | 41 | const blockedAccount = accountBlock.blockedAccount |
34 | 42 | ||
@@ -45,7 +53,11 @@ export class InstanceAccountBlocklistComponent extends RestTable implements OnIn | |||
45 | } | 53 | } |
46 | 54 | ||
47 | protected loadData () { | 55 | protected loadData () { |
48 | return this.blocklistService.getInstanceAccountBlocklist(this.pagination, this.sort) | 56 | return this.blocklistService.getInstanceAccountBlocklist({ |
57 | pagination: this.pagination, | ||
58 | sort: this.sort, | ||
59 | search: this.search | ||
60 | }) | ||
49 | .subscribe( | 61 | .subscribe( |
50 | resultList => { | 62 | resultList => { |
51 | this.blockedAccounts = resultList.data | 63 | this.blockedAccounts = resultList.data |
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html index f634ba834..dab068dd6 100644 --- a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html +++ b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html | |||
@@ -1,23 +1,59 @@ | |||
1 | <p-table | 1 | <p-table |
2 | [value]="blockedServers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 2 | [value]="blockedServers" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" |
3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" | 3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)" |
4 | [showCurrentPageReport]="true" i18n-currentPageReportTemplate | ||
5 | currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted instances" | ||
4 | > | 6 | > |
7 | <ng-template pTemplate="caption"> | ||
8 | <div class="caption"> | ||
9 | <div class="ml-auto has-feedback has-clear"> | ||
10 | <input | ||
11 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | ||
12 | (keyup)="onSearch($event)" | ||
13 | > | ||
14 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a> | ||
15 | <span class="sr-only" i18n>Clear filters</span> | ||
16 | </div> | ||
17 | <a class="ml-2 block-button" (click)="addServersToBlock()" (key.enter)="addServersToBlock()"> | ||
18 | <my-global-icon iconName="add"></my-global-icon> | ||
19 | <ng-container i18n>Mute domain</ng-container> | ||
20 | </a> | ||
21 | </div> | ||
22 | </ng-template> | ||
5 | 23 | ||
6 | <ng-template pTemplate="header"> | 24 | <ng-template pTemplate="header"> |
7 | <tr> | 25 | <tr> |
8 | <th i18n>Instance</th> | 26 | <th style="width: 100%;" i18n>Instance</th> |
9 | <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> | 27 | <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> |
10 | <th></th> | 28 | <th style="width: 100px;"></th> <!-- column for action buttons --> |
11 | </tr> | 29 | </tr> |
12 | </ng-template> | 30 | </ng-template> |
13 | 31 | ||
14 | <ng-template pTemplate="body" let-serverBlock> | 32 | <ng-template pTemplate="body" let-serverBlock> |
15 | <tr> | 33 | <tr> |
16 | <td>{{ serverBlock.blockedServer.host }}</td> | 34 | <td> |
17 | <td>{{ serverBlock.createdAt }}</td> | 35 | <a [href]="'https://' + serverBlock.blockedServer.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer"> |
36 | {{ serverBlock.blockedServer.host }} | ||
37 | <span class="glyphicon glyphicon-new-window"></span> | ||
38 | </a> | ||
39 | </td> | ||
40 | <td>{{ serverBlock.createdAt | date: 'short' }}</td> | ||
18 | <td class="action-cell"> | 41 | <td class="action-cell"> |
19 | <button class="unblock-button" (click)="unblockServer(serverBlock)" i18n>Unmute</button> | 42 | <button class="unblock-button" (click)="unblockServer(serverBlock)" i18n>Unmute</button> |
20 | </td> | 43 | </td> |
21 | </tr> | 44 | </tr> |
22 | </ng-template> | 45 | </ng-template> |
46 | |||
47 | <ng-template pTemplate="emptymessage"> | ||
48 | <tr> | ||
49 | <td colspan="6"> | ||
50 | <div class="empty-table-message"> | ||
51 | <ng-container *ngIf="search" i18n>No server found matching current filters.</ng-container> | ||
52 | <ng-container *ngIf="!search" i18n>No server found.</ng-container> | ||
53 | </div> | ||
54 | </td> | ||
55 | </tr> | ||
56 | </ng-template> | ||
23 | </p-table> | 57 | </p-table> |
58 | |||
59 | <my-batch-domains-modal #batchDomainsModal i18n-action action="Mute domains" (domains)="onDomainsToBlock($event)"></my-batch-domains-modal> | ||
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.scss b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.scss index 6028b75ea..c6c71587f 100644 --- a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.scss +++ b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.scss | |||
@@ -1,7 +1,25 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | a { | ||
5 | @include disable-default-a-behaviour; | ||
6 | display: inline-block; | ||
7 | |||
8 | &, &:hover { | ||
9 | color: var(--mainForegroundColor); | ||
10 | } | ||
11 | |||
12 | span { | ||
13 | font-size: 80%; | ||
14 | color: var(--inputPlaceholderColor); | ||
15 | } | ||
16 | } | ||
17 | |||
4 | .unblock-button { | 18 | .unblock-button { |
5 | @include peertube-button; | 19 | @include peertube-button; |
6 | @include grey-button; | 20 | @include grey-button; |
7 | } \ No newline at end of file | 21 | } |
22 | |||
23 | .block-button { | ||
24 | @include create-button; | ||
25 | } | ||
diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts index db3dfcd1c..559c9c0b0 100644 --- a/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts +++ b/client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.ts | |||
@@ -1,20 +1,22 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit, ViewChild } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | 3 | import { I18n } from '@ngx-translate/i18n-polyfill' |
4 | import { RestPagination, RestTable } from '@app/shared' | 4 | import { RestPagination, RestTable } from '@app/shared' |
5 | import { SortMeta } from 'primeng/components/common/sortmeta' | 5 | import { SortMeta } from 'primeng/api' |
6 | import { BlocklistService } from '@app/shared/blocklist' | 6 | import { BlocklistService } from '@app/shared/blocklist' |
7 | import { ServerBlock } from '../../../../../../shared' | 7 | import { ServerBlock } from '../../../../../../shared' |
8 | import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component' | ||
8 | 9 | ||
9 | @Component({ | 10 | @Component({ |
10 | selector: 'my-instance-server-blocklist', | 11 | selector: 'my-instance-server-blocklist', |
11 | styleUrls: [ './instance-server-blocklist.component.scss' ], | 12 | styleUrls: [ '../moderation.component.scss', './instance-server-blocklist.component.scss' ], |
12 | templateUrl: './instance-server-blocklist.component.html' | 13 | templateUrl: './instance-server-blocklist.component.html' |
13 | }) | 14 | }) |
14 | export class InstanceServerBlocklistComponent extends RestTable implements OnInit { | 15 | export class InstanceServerBlocklistComponent extends RestTable implements OnInit { |
16 | @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent | ||
17 | |||
15 | blockedServers: ServerBlock[] = [] | 18 | blockedServers: ServerBlock[] = [] |
16 | totalRecords = 0 | 19 | totalRecords = 0 |
17 | rowsPerPage = 10 | ||
18 | sort: SortMeta = { field: 'createdAt', order: -1 } | 20 | sort: SortMeta = { field: 'createdAt', order: -1 } |
19 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 21 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
20 | 22 | ||
@@ -30,6 +32,10 @@ export class InstanceServerBlocklistComponent extends RestTable implements OnIni | |||
30 | this.initialize() | 32 | this.initialize() |
31 | } | 33 | } |
32 | 34 | ||
35 | getIdentifier () { | ||
36 | return 'InstanceServerBlocklistComponent' | ||
37 | } | ||
38 | |||
33 | unblockServer (serverBlock: ServerBlock) { | 39 | unblockServer (serverBlock: ServerBlock) { |
34 | const host = serverBlock.blockedServer.host | 40 | const host = serverBlock.blockedServer.host |
35 | 41 | ||
@@ -43,8 +49,29 @@ export class InstanceServerBlocklistComponent extends RestTable implements OnIni | |||
43 | ) | 49 | ) |
44 | } | 50 | } |
45 | 51 | ||
52 | addServersToBlock () { | ||
53 | this.batchDomainsModal.openModal() | ||
54 | } | ||
55 | |||
56 | onDomainsToBlock (domains: string[]) { | ||
57 | domains.forEach(domain => { | ||
58 | this.blocklistService.blockServerByInstance(domain) | ||
59 | .subscribe( | ||
60 | () => { | ||
61 | this.notifier.success(this.i18n('Instance {{domain}} muted by your instance.', { domain })) | ||
62 | |||
63 | this.loadData() | ||
64 | } | ||
65 | ) | ||
66 | }) | ||
67 | } | ||
68 | |||
46 | protected loadData () { | 69 | protected loadData () { |
47 | return this.blocklistService.getInstanceServerBlocklist(this.pagination, this.sort) | 70 | return this.blocklistService.getInstanceServerBlocklist({ |
71 | pagination: this.pagination, | ||
72 | sort: this.sort, | ||
73 | search: this.search | ||
74 | }) | ||
48 | .subscribe( | 75 | .subscribe( |
49 | resultList => { | 76 | resultList => { |
50 | this.blockedServers = resultList.data | 77 | this.blockedServers = resultList.data |
diff --git a/client/src/app/+admin/moderation/moderation.component.scss b/client/src/app/+admin/moderation/moderation.component.scss index 13b019c5b..26c2a30d4 100644 --- a/client/src/app/+admin/moderation/moderation.component.scss +++ b/client/src/app/+admin/moderation/moderation.component.scss | |||
@@ -1,25 +1,151 @@ | |||
1 | @import 'variables'; | 1 | @import 'variables'; |
2 | @import 'mixins'; | 2 | @import 'mixins'; |
3 | @import 'miniature'; | ||
3 | 4 | ||
4 | .form-sub-title { | 5 | .form-sub-title { |
5 | flex-grow: 0; | 6 | flex-grow: 0; |
6 | margin-right: 30px; | 7 | margin-right: 30px; |
7 | } | 8 | } |
8 | 9 | ||
9 | .moderation-expanded-label { | 10 | .caption { |
10 | font-weight: $font-semibold; | 11 | justify-content: flex-end; |
11 | min-width: 200px; | 12 | |
12 | display: inline-block; | 13 | input { |
13 | vertical-align: top; | 14 | @include peertube-input-text(250px); |
15 | flex-grow: 1; | ||
16 | } | ||
14 | } | 17 | } |
15 | 18 | ||
16 | .moderation-expanded-text { | 19 | .empty-table-message { |
17 | display: inline-block; | 20 | @include empty-state; |
18 | } | 21 | } |
19 | 22 | ||
20 | .moderation-expanded { | 23 | .moderation-expanded { |
21 | word-wrap: break-word; | 24 | font-size: 90%; |
22 | overflow: visible !important; | 25 | |
23 | text-overflow: unset !important; | 26 | .moderation-expanded-label { |
24 | white-space: unset !important; | 27 | font-weight: $font-semibold; |
28 | display: inline-block; | ||
29 | vertical-align: top; | ||
30 | text-align: right; | ||
31 | } | ||
32 | |||
33 | .moderation-expanded-text { | ||
34 | display: inline-flex; | ||
35 | word-wrap: break-word; | ||
36 | |||
37 | ::ng-deep p:last-child { | ||
38 | margin-bottom: 0px !important; | ||
39 | } | ||
40 | } | ||
41 | } | ||
42 | |||
43 | .video-table-states { | ||
44 | & > :not(:first-child) { | ||
45 | margin-left: .4rem; | ||
46 | } | ||
47 | } | ||
48 | |||
49 | .screenratio { | ||
50 | position: relative; | ||
51 | width: 100%; | ||
52 | height: 0; | ||
53 | padding-bottom: 56%; | ||
54 | |||
55 | div { | ||
56 | @include miniature-thumbnail; | ||
57 | position: absolute; | ||
58 | height: 100%; | ||
59 | width: 100%; | ||
60 | display: inline-flex; | ||
61 | justify-content: center; | ||
62 | align-items: center; | ||
63 | color: var(--inputPlaceholderColor); | ||
64 | } | ||
65 | |||
66 | ::ng-deep iframe { | ||
67 | position: absolute; | ||
68 | width: 100% !important; | ||
69 | height: 100% !important; | ||
70 | left: 0; | ||
71 | top: 0; | ||
72 | } | ||
73 | } | ||
74 | |||
75 | .chip { | ||
76 | @include chip; | ||
77 | } | ||
78 | |||
79 | my-action-dropdown.show { | ||
80 | ::ng-deep .dropdown-root { | ||
81 | display: block !important; | ||
82 | } | ||
83 | } | ||
84 | |||
85 | |||
86 | .video-table-video-link { | ||
87 | @include disable-outline; | ||
88 | position: relative; | ||
89 | top: 3px; | ||
90 | } | ||
91 | |||
92 | .video-table-video { | ||
93 | display: inline-flex; | ||
94 | |||
95 | .video-table-video-image { | ||
96 | @include miniature-thumbnail; | ||
97 | |||
98 | $image-height: 45px; | ||
99 | |||
100 | height: $image-height; | ||
101 | width: #{(16/9) * $image-height}; | ||
102 | margin-right: 0.5rem; | ||
103 | border-radius: 2px; | ||
104 | border: none; | ||
105 | background: transparent; | ||
106 | display: inline-flex; | ||
107 | justify-content: center; | ||
108 | align-items: center; | ||
109 | position: relative; | ||
110 | |||
111 | img { | ||
112 | height: 100%; | ||
113 | width: 100%; | ||
114 | border-radius: 2px; | ||
115 | } | ||
116 | |||
117 | span { | ||
118 | color: var(--inputPlaceholderColor); | ||
119 | } | ||
120 | |||
121 | .video-table-video-image-label { | ||
122 | @include static-thumbnail-overlay; | ||
123 | position: absolute; | ||
124 | border-radius: 3px; | ||
125 | font-size: 10px; | ||
126 | padding: 0 3px; | ||
127 | line-height: 1.3; | ||
128 | bottom: 2px; | ||
129 | right: 2px; | ||
130 | } | ||
131 | } | ||
132 | |||
133 | .video-table-video-text { | ||
134 | display: inline-flex; | ||
135 | flex-direction: column; | ||
136 | justify-content: center; | ||
137 | font-size: 90%; | ||
138 | color: var(--mainForegroundColor); | ||
139 | line-height: 1rem; | ||
140 | |||
141 | div .glyphicon { | ||
142 | font-size: 80%; | ||
143 | color: gray; | ||
144 | margin-left: 0.1rem; | ||
145 | } | ||
146 | |||
147 | div + div { | ||
148 | font-size: 80%; | ||
149 | } | ||
150 | } | ||
25 | } | 151 | } |
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html index 303a788d2..8082e93f4 100644 --- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html +++ b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.html | |||
@@ -8,7 +8,9 @@ | |||
8 | <div class="modal-body"> | 8 | <div class="modal-body"> |
9 | <form novalidate [formGroup]="form" (ngSubmit)="banUser()"> | 9 | <form novalidate [formGroup]="form" (ngSubmit)="banUser()"> |
10 | <div class="form-group"> | 10 | <div class="form-group"> |
11 | <textarea formControlName="moderationComment" [ngClass]="{ 'input-error': formErrors['moderationComment'] }"> | 11 | <textarea |
12 | formControlName="moderationComment" ngbAutofocus i18-placeholder placeholder="Comment this report…" | ||
13 | [ngClass]="{ 'input-error': formErrors['moderationComment'] }" class="form-control"> | ||
12 | </textarea> | 14 | </textarea> |
13 | <div *ngIf="formErrors.moderationComment" class="form-error"> | 15 | <div *ngIf="formErrors.moderationComment" class="form-error"> |
14 | {{ formErrors.moderationComment }} | 16 | {{ formErrors.moderationComment }} |
@@ -20,7 +22,10 @@ | |||
20 | </div> | 22 | </div> |
21 | 23 | ||
22 | <div class="form-group inputs"> | 24 | <div class="form-group inputs"> |
23 | <span i18n class="action-button action-button-cancel" (click)="hide()">Cancel</span> | 25 | <input |
26 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | ||
27 | (click)="hide()" (key.enter)="hide()" | ||
28 | > | ||
24 | 29 | ||
25 | <input | 30 | <input |
26 | type="submit" i18n-value value="Update this comment" class="action-button-submit" | 31 | type="submit" i18n-value value="Update this comment" class="action-button-submit" |
diff --git a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts index f8a5ef8cb..a0471f2b0 100644 --- a/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts +++ b/client/src/app/+admin/moderation/video-abuse-list/moderation-comment-modal.component.ts | |||
@@ -32,13 +32,13 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI | |||
32 | 32 | ||
33 | ngOnInit () { | 33 | ngOnInit () { |
34 | this.buildForm({ | 34 | this.buildForm({ |
35 | moderationComment: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON | 35 | moderationComment: this.videoAbuseValidatorsService.VIDEO_ABUSE_MODERATION_COMMENT |
36 | }) | 36 | }) |
37 | } | 37 | } |
38 | 38 | ||
39 | openModal (abuseToComment: VideoAbuse) { | 39 | openModal (abuseToComment: VideoAbuse) { |
40 | this.abuseToComment = abuseToComment | 40 | this.abuseToComment = abuseToComment |
41 | this.openedModal = this.modalService.open(this.modal) | 41 | this.openedModal = this.modalService.open(this.modal, { centered: true }) |
42 | 42 | ||
43 | this.form.patchValue({ | 43 | this.form.patchValue({ |
44 | moderationComment: this.abuseToComment.moderationComment | 44 | moderationComment: this.abuseToComment.moderationComment |
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html new file mode 100644 index 000000000..2abcc0669 --- /dev/null +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html | |||
@@ -0,0 +1,77 @@ | |||
1 | <div class="d-flex moderation-expanded"> | ||
2 | <!-- report left part (report details) --> | ||
3 | <div class="col-8"> | ||
4 | |||
5 | <!-- report metadata --> | ||
6 | <div class="d-flex"> | ||
7 | <span class="col-3 moderation-expanded-label" i18n>Reporter</span> | ||
8 | <span class="col-9 moderation-expanded-text"> | ||
9 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:"' + videoAbuse.reporterAccount.displayName + '"' }" class="chip"> | ||
10 | <img | ||
11 | class="avatar" | ||
12 | [src]="videoAbuse.reporterAccount.avatar?.path" | ||
13 | (error)="switchToDefaultAvatar($event)" | ||
14 | alt="Avatar" | ||
15 | > | ||
16 | <div> | ||
17 | <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span> | ||
18 | </div> | ||
19 | </a> | ||
20 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:"' + videoAbuse.reporterAccount.displayName + '"' }" class="ml-auto text-muted video-details-links" i18n> | ||
21 | {videoAbuse.countReportsForReporter, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span> | ||
22 | </a> | ||
23 | </span> | ||
24 | </div> | ||
25 | |||
26 | <div class="d-flex"> | ||
27 | <span class="col-3 moderation-expanded-label" i18n>Reportee</span> | ||
28 | <span class="col-9 moderation-expanded-text"> | ||
29 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:"' +videoAbuse.video.channel.ownerAccount.displayName + '"' }" class="chip"> | ||
30 | <img | ||
31 | class="avatar" | ||
32 | [src]="videoAbuse.video.channel.ownerAccount?.avatar?.path" | ||
33 | (error)="switchToDefaultAvatar($event)" | ||
34 | alt="Avatar" | ||
35 | > | ||
36 | <div> | ||
37 | <span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? videoAbuse.video.channel.ownerAccount.nameWithHost : '' }}</span> | ||
38 | </div> | ||
39 | </a> | ||
40 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:"' +videoAbuse.video.channel.ownerAccount.displayName + '"' }" class="ml-auto text-muted video-details-links" i18n> | ||
41 | {videoAbuse.countReportsForReportee, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span> | ||
42 | </a> | ||
43 | </span> | ||
44 | </div> | ||
45 | |||
46 | <div class="d-flex" *ngIf="videoAbuse.updatedAt"> | ||
47 | <span class="col-3 moderation-expanded-label" i18n>Updated</span> | ||
48 | <time class="col-9 moderation-expanded-text video-details-date-updated">{{ videoAbuse.updatedAt | date: 'medium' }}</time> | ||
49 | </div> | ||
50 | |||
51 | <!-- report text --> | ||
52 | <div class="mt-3 d-flex"> | ||
53 | <span class="col-3 moderation-expanded-label"> | ||
54 | <ng-container i18n>Report</ng-container> | ||
55 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': '#' + videoAbuse.id }" class="ml-1 text-muted">#{{ videoAbuse.id }}</a> | ||
56 | </span> | ||
57 | <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span> | ||
58 | </div> | ||
59 | |||
60 | <div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment"> | ||
61 | <span class="col-3 moderation-expanded-label" i18n>Note</span> | ||
62 | <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span> | ||
63 | </div> | ||
64 | |||
65 | </div> | ||
66 | |||
67 | <!-- report right part (video details) --> | ||
68 | <div class="col-4"> | ||
69 | <div class="screenratio"> | ||
70 | <div *ngIf="videoAbuse.video.deleted || videoAbuse.video.blacklisted"> | ||
71 | <span i18n *ngIf="videoAbuse.video.deleted">The video was deleted</span> | ||
72 | <span i18n *ngIf="!videoAbuse.video.deleted">The video was blacklisted</span> | ||
73 | </div> | ||
74 | <div *ngIf="!videoAbuse.video.deleted && !videoAbuse.video.blacklisted" [innerHTML]="videoAbuse.embedHtml"></div> | ||
75 | </div> | ||
76 | </div> | ||
77 | </div> \ No newline at end of file | ||
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts new file mode 100644 index 000000000..d9cb19845 --- /dev/null +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts | |||
@@ -0,0 +1,17 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { Account } from '@app/shared/account/account.model' | ||
3 | import { Actor } from '@app/shared/actor/actor.model' | ||
4 | import { ProcessedVideoAbuse } from './video-abuse-list.component' | ||
5 | |||
6 | @Component({ | ||
7 | selector: 'my-video-abuse-details', | ||
8 | templateUrl: './video-abuse-details.component.html', | ||
9 | styleUrls: [ '../moderation.component.scss' ] | ||
10 | }) | ||
11 | export class VideoAbuseDetailsComponent { | ||
12 | @Input() videoAbuse: ProcessedVideoAbuse | ||
13 | |||
14 | switchToDefaultAvatar ($event: Event) { | ||
15 | ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() | ||
16 | } | ||
17 | } | ||
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html index 30eb2dbde..1c9530152 100644 --- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html | |||
@@ -1,13 +1,45 @@ | |||
1 | <p-table | 1 | <p-table |
2 | [value]="videoAbuses" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 2 | [value]="videoAbuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" |
3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" | 3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true" |
4 | [showCurrentPageReport]="true" i18n-currentPageReportTemplate | ||
5 | currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports" | ||
6 | (onPage)="onPage($event)" [expandedRowKeys]="expandedRows" | ||
4 | > | 7 | > |
8 | <ng-template pTemplate="caption"> | ||
9 | <div class="caption"> | ||
10 | <div class="ml-auto"> | ||
11 | <div class="input-group has-feedback has-clear"> | ||
12 | <div class="input-group-prepend c-hand" ngbDropdown placement="bottom-left auto" container="body"> | ||
13 | <div class="input-group-text" ngbDropdownToggle> | ||
14 | <span class="caret" aria-haspopup="menu" role="button"></span> | ||
15 | </div> | ||
16 | |||
17 | <div role="menu" ngbDropdownMenu> | ||
18 | <h6 class="dropdown-header" i18n>Advanced report filters</h6> | ||
19 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:pending' }" class="dropdown-item" i18n>Unsolved reports</a> | ||
20 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:accepted' }" class="dropdown-item" i18n>Accepted reports</a> | ||
21 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'state:rejected' }" class="dropdown-item" i18n>Refused reports</a> | ||
22 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:blacklisted' }" class="dropdown-item" i18n>Reports with blacklisted videos</a> | ||
23 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a> | ||
24 | </div> | ||
25 | </div> | ||
26 | <input | ||
27 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | ||
28 | (keyup)="onAbuseSearch($event)" | ||
29 | > | ||
30 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetTableFilter()"></a> | ||
31 | <span class="sr-only" i18n>Clear filters</span> | ||
32 | </div> | ||
33 | </div> | ||
34 | </div> | ||
35 | </ng-template> | ||
36 | |||
5 | <ng-template pTemplate="header"> | 37 | <ng-template pTemplate="header"> |
6 | <tr> | 38 | <tr> <!-- header --> |
7 | <th style="width: 40px"></th> | 39 | <th style="width: 40px;"></th> |
8 | <th i18n>Reporter</th> | 40 | <th style="width: 20%;" pResizableColumn i18n>Reporter</th> |
9 | <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> | ||
10 | <th i18n>Video</th> | 41 | <th i18n>Video</th> |
42 | <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> | ||
11 | <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th> | 43 | <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th> |
12 | <th style="width: 120px;"></th> | 44 | <th style="width: 120px;"></th> |
13 | </tr> | 45 | </tr> |
@@ -15,51 +47,103 @@ | |||
15 | 47 | ||
16 | <ng-template pTemplate="body" let-expanded="expanded" let-videoAbuse> | 48 | <ng-template pTemplate="body" let-expanded="expanded" let-videoAbuse> |
17 | <tr> | 49 | <tr> |
18 | <td class="expand-cell"> | 50 | <td class="c-hand" [pRowToggler]="videoAbuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body"> |
19 | <span class="expander" [pRowToggler]="videoAbuse"> | 51 | <span class="expander"> |
20 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> | 52 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> |
21 | </span> | 53 | </span> |
22 | </td> | 54 | </td> |
23 | 55 | ||
24 | <td> | 56 | <td> |
25 | <a [href]="videoAbuse.reporterAccount.url" i18n-title title="Go to the account" target="_blank" rel="noopener noreferrer"> | 57 | <a [href]="videoAbuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> |
26 | {{ createByString(videoAbuse.reporterAccount) }} | 58 | <div class="chip two-lines"> |
59 | <img | ||
60 | class="avatar" | ||
61 | [src]="videoAbuse.reporterAccount.avatar?.path" | ||
62 | (error)="switchToDefaultAvatar($event)" | ||
63 | alt="Avatar" | ||
64 | > | ||
65 | <div> | ||
66 | {{ videoAbuse.reporterAccount.displayName }} | ||
67 | <span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span> | ||
68 | </div> | ||
69 | </div> | ||
27 | </a> | 70 | </a> |
28 | </td> | 71 | </td> |
29 | 72 | ||
30 | <td>{{ videoAbuse.createdAt }}</td> | 73 | <td *ngIf="!videoAbuse.video.deleted"> |
31 | 74 | <a [href]="getVideoUrl(videoAbuse)" class="video-table-video-link" i18n-title title="Open video in a new tab" target="_blank" rel="noopener noreferrer"> | |
32 | <td> | 75 | <div class="video-table-video"> |
33 | <a [href]="getVideoUrl(videoAbuse)" i18n-title title="Go to the video" target="_blank" rel="noopener noreferrer"> | 76 | <div class="video-table-video-image"> |
34 | {{ videoAbuse.video.name }} | 77 | <img [src]="videoAbuse.video.thumbnailPath"> |
78 | <span | ||
79 | class="video-table-video-image-label" *ngIf="videoAbuse.count > 1" | ||
80 | i18n-title title="This video has been reported multiple times." | ||
81 | > | ||
82 | {{ videoAbuse.nth }}/{{ videoAbuse.count }} | ||
83 | </span> | ||
84 | </div> | ||
85 | <div class="video-table-video-text"> | ||
86 | <div> | ||
87 | {{ videoAbuse.video.name }} | ||
88 | <span *ngIf="!videoAbuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span> | ||
89 | <span *ngIf="videoAbuse.video.blacklisted" i18n-title title="Video was blacklisted" class="glyphicon glyphicon-ban-circle"></span> | ||
90 | </div> | ||
91 | <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div> | ||
92 | </div> | ||
93 | </div> | ||
35 | </a> | 94 | </a> |
36 | </td> | 95 | </td> |
37 | 96 | ||
38 | <td> | 97 | <td *ngIf="videoAbuse.video.deleted" class="c-hand" [pRowToggler]="videoAbuse"> |
98 | <div class="video-table-video" i18n-title title="Video was deleted"> | ||
99 | <div class="video-table-video-image"> | ||
100 | <span i18n>Deleted</span> | ||
101 | </div> | ||
102 | <div class="video-table-video-text"> | ||
103 | <div> | ||
104 | {{ videoAbuse.video.name }} | ||
105 | <span class="glyphicon glyphicon-trash"></span> | ||
106 | </div> | ||
107 | <div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div> | ||
108 | </div> | ||
109 | </div> | ||
110 | </td> | ||
111 | |||
112 | <td class="c-hand" [pRowToggler]="videoAbuse">{{ videoAbuse.createdAt | date: 'short' }}</td> | ||
113 | |||
114 | <td class="c-hand video-abuse-states" [pRowToggler]="videoAbuse"> | ||
39 | <span *ngIf="isVideoAbuseAccepted(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-ok"></span> | 115 | <span *ngIf="isVideoAbuseAccepted(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-ok"></span> |
40 | <span *ngIf="isVideoAbuseRejected(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-remove"></span> | 116 | <span *ngIf="isVideoAbuseRejected(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-remove"></span> |
117 | <span *ngIf="videoAbuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="videoAbuse.moderationComment" class="glyphicon glyphicon-comment"></span> | ||
41 | </td> | 118 | </td> |
42 | 119 | ||
43 | <td class="action-cell"> | 120 | <td class="action-cell"> |
44 | <my-action-dropdown placement="bottom-right" i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse"></my-action-dropdown> | 121 | <my-action-dropdown |
122 | [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body" | ||
123 | i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse" | ||
124 | ></my-action-dropdown> | ||
45 | </td> | 125 | </td> |
46 | </tr> | 126 | </tr> |
47 | </ng-template> | 127 | </ng-template> |
48 | 128 | ||
49 | <ng-template pTemplate="rowexpansion" let-videoAbuse> | 129 | <ng-template pTemplate="rowexpansion" let-videoAbuse> |
50 | <tr> | 130 | <tr> |
51 | <td class="moderation-expanded" colspan="6"> | 131 | <td class="expand-cell" colspan="6"> |
52 | <div> | 132 | <my-video-abuse-details [videoAbuse]="videoAbuse"></my-video-abuse-details> |
53 | <span i18n class="moderation-expanded-label">Reason:</span> | ||
54 | <span class="moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span> | ||
55 | </div> | ||
56 | <div *ngIf="videoAbuse.moderationComment"> | ||
57 | <span i18n class="moderation-expanded-label">Moderation comment:</span> | ||
58 | <span class="moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span> | ||
59 | </div> | ||
60 | </td> | 133 | </td> |
61 | </tr> | 134 | </tr> |
62 | </ng-template> | 135 | </ng-template> |
136 | |||
137 | <ng-template pTemplate="emptymessage"> | ||
138 | <tr> | ||
139 | <td colspan="6"> | ||
140 | <div class="empty-table-message"> | ||
141 | <ng-container *ngIf="search" i18n>No video abuses found matching current filters.</ng-container> | ||
142 | <ng-container *ngIf="!search" i18n>No video abuses found.</ng-container> | ||
143 | </div> | ||
144 | </td> | ||
145 | </tr> | ||
146 | </ng-template> | ||
63 | </p-table> | 147 | </p-table> |
64 | 148 | ||
65 | <my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal> | 149 | <my-moderation-comment-modal #moderationCommentModal (commentUpdated)="onModerationCommentUpdated()"></my-moderation-comment-modal> |
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss new file mode 100644 index 000000000..8eee15b64 --- /dev/null +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.scss | |||
@@ -0,0 +1,23 @@ | |||
1 | @import 'mixins'; | ||
2 | @import 'miniature'; | ||
3 | |||
4 | .video-details-date-updated { | ||
5 | font-size: 90%; | ||
6 | margin-top: .1rem; | ||
7 | } | ||
8 | |||
9 | .video-details-links { | ||
10 | @include disable-default-a-behaviour; | ||
11 | } | ||
12 | |||
13 | .video-abuse-states .glyphicon-comment { | ||
14 | margin-left: 0.5rem; | ||
15 | } | ||
16 | |||
17 | .input-group { | ||
18 | @include peertube-input-group(300px); | ||
19 | |||
20 | .dropdown-toggle::after { | ||
21 | margin-left: 0; | ||
22 | } | ||
23 | } | ||
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts index 778f18d3d..39f619cc3 100644 --- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts | |||
@@ -1,65 +1,222 @@ | |||
1 | import { Component, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core' |
2 | import { Account } from '../../../shared/account/account.model' | 2 | import { Account } from '@app/shared/account/account.model' |
3 | import { Notifier } from '@app/core' | 3 | import { Notifier } from '@app/core' |
4 | import { SortMeta } from 'primeng/components/common/sortmeta' | 4 | import { SortMeta } from 'primeng/api' |
5 | import { VideoAbuse, VideoAbuseState } from '../../../../../../shared' | 5 | import { VideoAbuse, VideoAbuseState } from '../../../../../../shared' |
6 | import { RestPagination, RestTable, VideoAbuseService } from '../../../shared' | 6 | import { RestPagination, RestTable, VideoAbuseService, VideoBlacklistService } from '../../../shared' |
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | 7 | import { I18n } from '@ngx-translate/i18n-polyfill' |
8 | import { DropdownAction } from '../../../shared/buttons/action-dropdown.component' | 8 | import { DropdownAction } from '../../../shared/buttons/action-dropdown.component' |
9 | import { ConfirmService } from '../../../core/index' | 9 | import { ConfirmService } from '../../../core/index' |
10 | import { ModerationCommentModalComponent } from './moderation-comment-modal.component' | 10 | import { ModerationCommentModalComponent } from './moderation-comment-modal.component' |
11 | import { Video } from '../../../shared/video/video.model' | 11 | import { Video } from '../../../shared/video/video.model' |
12 | import { MarkdownService } from '@app/shared/renderer' | 12 | import { MarkdownService } from '@app/shared/renderer' |
13 | import { Actor } from '@app/shared/actor/actor.model' | ||
14 | import { buildVideoLink, buildVideoEmbed } from 'src/assets/player/utils' | ||
15 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' | ||
16 | import { DomSanitizer } from '@angular/platform-browser' | ||
17 | import { BlocklistService } from '@app/shared/blocklist' | ||
18 | import { VideoService } from '@app/shared/video/video.service' | ||
19 | import { ActivatedRoute, Params, Router } from '@angular/router' | ||
20 | import { filter } from 'rxjs/operators' | ||
21 | |||
22 | export type ProcessedVideoAbuse = VideoAbuse & { | ||
23 | moderationCommentHtml?: string, | ||
24 | reasonHtml?: string | ||
25 | embedHtml?: string | ||
26 | updatedAt?: Date | ||
27 | // override bare server-side definitions with rich client-side definitions | ||
28 | reporterAccount: Account | ||
29 | video: VideoAbuse['video'] & { | ||
30 | channel: VideoAbuse['video']['channel'] & { | ||
31 | ownerAccount: Account | ||
32 | } | ||
33 | } | ||
34 | } | ||
13 | 35 | ||
14 | @Component({ | 36 | @Component({ |
15 | selector: 'my-video-abuse-list', | 37 | selector: 'my-video-abuse-list', |
16 | templateUrl: './video-abuse-list.component.html', | 38 | templateUrl: './video-abuse-list.component.html', |
17 | styleUrls: [ '../moderation.component.scss'] | 39 | styleUrls: [ '../moderation.component.scss', './video-abuse-list.component.scss' ] |
18 | }) | 40 | }) |
19 | export class VideoAbuseListComponent extends RestTable implements OnInit { | 41 | export class VideoAbuseListComponent extends RestTable implements OnInit, AfterViewInit { |
20 | @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent | 42 | @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent |
21 | 43 | ||
22 | videoAbuses: (VideoAbuse & { moderationCommentHtml?: string, reasonHtml?: string })[] = [] | 44 | videoAbuses: ProcessedVideoAbuse[] = [] |
23 | totalRecords = 0 | 45 | totalRecords = 0 |
24 | rowsPerPage = 10 | ||
25 | sort: SortMeta = { field: 'createdAt', order: 1 } | 46 | sort: SortMeta = { field: 'createdAt', order: 1 } |
26 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 47 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
27 | 48 | ||
28 | videoAbuseActions: DropdownAction<VideoAbuse>[] = [] | 49 | videoAbuseActions: DropdownAction<VideoAbuse>[][] = [] |
29 | 50 | ||
30 | constructor ( | 51 | constructor ( |
31 | private notifier: Notifier, | 52 | private notifier: Notifier, |
32 | private videoAbuseService: VideoAbuseService, | 53 | private videoAbuseService: VideoAbuseService, |
54 | private blocklistService: BlocklistService, | ||
55 | private videoService: VideoService, | ||
56 | private videoBlacklistService: VideoBlacklistService, | ||
33 | private confirmService: ConfirmService, | 57 | private confirmService: ConfirmService, |
34 | private i18n: I18n, | 58 | private i18n: I18n, |
35 | private markdownRenderer: MarkdownService | 59 | private markdownRenderer: MarkdownService, |
60 | private sanitizer: DomSanitizer, | ||
61 | private route: ActivatedRoute, | ||
62 | private router: Router | ||
36 | ) { | 63 | ) { |
37 | super() | 64 | super() |
38 | 65 | ||
39 | this.videoAbuseActions = [ | 66 | this.videoAbuseActions = [ |
40 | { | 67 | [ |
41 | label: this.i18n('Delete this report'), | 68 | { |
42 | handler: videoAbuse => this.removeVideoAbuse(videoAbuse) | 69 | label: this.i18n('Internal actions'), |
43 | }, | 70 | isHeader: true |
44 | { | 71 | }, |
45 | label: this.i18n('Update moderation comment'), | 72 | { |
46 | handler: videoAbuse => this.openModerationCommentModal(videoAbuse) | 73 | label: this.i18n('Delete report'), |
47 | }, | 74 | handler: videoAbuse => this.removeVideoAbuse(videoAbuse) |
48 | { | 75 | }, |
49 | label: this.i18n('Mark as accepted'), | 76 | { |
50 | handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED), | 77 | label: this.i18n('Add note'), |
51 | isDisplayed: videoAbuse => !this.isVideoAbuseAccepted(videoAbuse) | 78 | handler: videoAbuse => this.openModerationCommentModal(videoAbuse), |
52 | }, | 79 | isDisplayed: videoAbuse => !videoAbuse.moderationComment |
53 | { | 80 | }, |
54 | label: this.i18n('Mark as rejected'), | 81 | { |
55 | handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.REJECTED), | 82 | label: this.i18n('Update note'), |
56 | isDisplayed: videoAbuse => !this.isVideoAbuseRejected(videoAbuse) | 83 | handler: videoAbuse => this.openModerationCommentModal(videoAbuse), |
57 | } | 84 | isDisplayed: videoAbuse => !!videoAbuse.moderationComment |
85 | }, | ||
86 | { | ||
87 | label: this.i18n('Mark as accepted'), | ||
88 | handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED), | ||
89 | isDisplayed: videoAbuse => !this.isVideoAbuseAccepted(videoAbuse) | ||
90 | }, | ||
91 | { | ||
92 | label: this.i18n('Mark as rejected'), | ||
93 | handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.REJECTED), | ||
94 | isDisplayed: videoAbuse => !this.isVideoAbuseRejected(videoAbuse) | ||
95 | } | ||
96 | ], | ||
97 | [ | ||
98 | { | ||
99 | label: this.i18n('Actions for the video'), | ||
100 | isHeader: true, | ||
101 | isDisplayed: videoAbuse => !videoAbuse.video.deleted | ||
102 | }, | ||
103 | { | ||
104 | label: this.i18n('Blacklist video'), | ||
105 | isDisplayed: videoAbuse => !videoAbuse.video.deleted && !videoAbuse.video.blacklisted, | ||
106 | handler: videoAbuse => { | ||
107 | this.videoBlacklistService.blacklistVideo(videoAbuse.video.id, undefined, true) | ||
108 | .subscribe( | ||
109 | () => { | ||
110 | this.notifier.success(this.i18n('Video blacklisted.')) | ||
111 | |||
112 | this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED) | ||
113 | }, | ||
114 | |||
115 | err => this.notifier.error(err.message) | ||
116 | ) | ||
117 | } | ||
118 | }, | ||
119 | { | ||
120 | label: this.i18n('Unblacklist video'), | ||
121 | isDisplayed: videoAbuse => !videoAbuse.video.deleted && videoAbuse.video.blacklisted, | ||
122 | handler: videoAbuse => { | ||
123 | this.videoBlacklistService.removeVideoFromBlacklist(videoAbuse.video.id) | ||
124 | .subscribe( | ||
125 | () => { | ||
126 | this.notifier.success(this.i18n('Video unblacklisted.')) | ||
127 | |||
128 | this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED) | ||
129 | }, | ||
130 | |||
131 | err => this.notifier.error(err.message) | ||
132 | ) | ||
133 | } | ||
134 | }, | ||
135 | { | ||
136 | label: this.i18n('Delete video'), | ||
137 | isDisplayed: videoAbuse => !videoAbuse.video.deleted, | ||
138 | handler: async videoAbuse => { | ||
139 | const res = await this.confirmService.confirm( | ||
140 | this.i18n('Do you really want to delete this video?'), | ||
141 | this.i18n('Delete') | ||
142 | ) | ||
143 | if (res === false) return | ||
144 | |||
145 | this.videoService.removeVideo(videoAbuse.video.id) | ||
146 | .subscribe( | ||
147 | () => { | ||
148 | this.notifier.success(this.i18n('Video deleted.')) | ||
149 | |||
150 | this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED) | ||
151 | }, | ||
152 | |||
153 | err => this.notifier.error(err.message) | ||
154 | ) | ||
155 | } | ||
156 | } | ||
157 | ], | ||
158 | [ | ||
159 | { | ||
160 | label: this.i18n('Actions for the reporter'), | ||
161 | isHeader: true | ||
162 | }, | ||
163 | { | ||
164 | label: this.i18n('Mute reporter'), | ||
165 | handler: async videoAbuse => { | ||
166 | const account = videoAbuse.reporterAccount as Account | ||
167 | |||
168 | this.blocklistService.blockAccountByInstance(account) | ||
169 | .subscribe( | ||
170 | () => { | ||
171 | this.notifier.success( | ||
172 | this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost }) | ||
173 | ) | ||
174 | |||
175 | account.mutedByInstance = true | ||
176 | }, | ||
177 | |||
178 | err => this.notifier.error(err.message) | ||
179 | ) | ||
180 | } | ||
181 | }, | ||
182 | { | ||
183 | label: this.i18n('Mute server'), | ||
184 | isDisplayed: videoAbuse => !videoAbuse.reporterAccount.userId, | ||
185 | handler: async videoAbuse => { | ||
186 | this.blocklistService.blockServerByInstance(videoAbuse.reporterAccount.host) | ||
187 | .subscribe( | ||
188 | () => { | ||
189 | this.notifier.success( | ||
190 | this.i18n('Server {{host}} muted by the instance.', { host: videoAbuse.reporterAccount.host }) | ||
191 | ) | ||
192 | }, | ||
193 | |||
194 | err => this.notifier.error(err.message) | ||
195 | ) | ||
196 | } | ||
197 | } | ||
198 | ] | ||
58 | ] | 199 | ] |
59 | } | 200 | } |
60 | 201 | ||
61 | ngOnInit () { | 202 | ngOnInit () { |
62 | this.initialize() | 203 | this.initialize() |
204 | |||
205 | this.route.queryParams | ||
206 | .pipe(filter(params => params.search !== undefined && params.search !== null)) | ||
207 | .subscribe(params => { | ||
208 | this.search = params.search | ||
209 | this.setTableFilter(params.search) | ||
210 | this.loadData() | ||
211 | }) | ||
212 | } | ||
213 | |||
214 | ngAfterViewInit () { | ||
215 | if (this.search) this.setTableFilter(this.search) | ||
216 | } | ||
217 | |||
218 | getIdentifier () { | ||
219 | return 'VideoAbuseListComponent' | ||
63 | } | 220 | } |
64 | 221 | ||
65 | openModerationCommentModal (videoAbuse: VideoAbuse) { | 222 | openModerationCommentModal (videoAbuse: VideoAbuse) { |
@@ -70,10 +227,25 @@ export class VideoAbuseListComponent extends RestTable implements OnInit { | |||
70 | this.loadData() | 227 | this.loadData() |
71 | } | 228 | } |
72 | 229 | ||
73 | createByString (account: Account) { | 230 | /* Table filter functions */ |
74 | return Account.CREATE_BY_STRING(account.name, account.host) | 231 | onAbuseSearch (event: Event) { |
232 | this.onSearch(event) | ||
233 | this.setQueryParams((event.target as HTMLInputElement).value) | ||
234 | } | ||
235 | |||
236 | setQueryParams (search: string) { | ||
237 | const queryParams: Params = {} | ||
238 | if (search) Object.assign(queryParams, { search }) | ||
239 | this.router.navigate([ '/admin/moderation/video-abuses/list' ], { queryParams }) | ||
75 | } | 240 | } |
76 | 241 | ||
242 | resetTableFilter () { | ||
243 | this.setTableFilter('') | ||
244 | this.setQueryParams('') | ||
245 | this.resetSearch() | ||
246 | } | ||
247 | /* END Table filter functions */ | ||
248 | |||
77 | isVideoAbuseAccepted (videoAbuse: VideoAbuse) { | 249 | isVideoAbuseAccepted (videoAbuse: VideoAbuse) { |
78 | return videoAbuse.state.id === VideoAbuseState.ACCEPTED | 250 | return videoAbuse.state.id === VideoAbuseState.ACCEPTED |
79 | } | 251 | } |
@@ -86,6 +258,19 @@ export class VideoAbuseListComponent extends RestTable implements OnInit { | |||
86 | return Video.buildClientUrl(videoAbuse.video.uuid) | 258 | return Video.buildClientUrl(videoAbuse.video.uuid) |
87 | } | 259 | } |
88 | 260 | ||
261 | getVideoEmbed (videoAbuse: VideoAbuse) { | ||
262 | const absoluteAPIUrl = getAbsoluteAPIUrl() | ||
263 | const embedUrl = buildVideoLink({ | ||
264 | baseUrl: absoluteAPIUrl + '/videos/embed/' + videoAbuse.video.uuid, | ||
265 | warningTitle: false | ||
266 | }) | ||
267 | return buildVideoEmbed(embedUrl) | ||
268 | } | ||
269 | |||
270 | switchToDefaultAvatar ($event: Event) { | ||
271 | ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() | ||
272 | } | ||
273 | |||
89 | async removeVideoAbuse (videoAbuse: VideoAbuse) { | 274 | async removeVideoAbuse (videoAbuse: VideoAbuse) { |
90 | const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete')) | 275 | const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete')) |
91 | if (res === false) return | 276 | if (res === false) return |
@@ -111,24 +296,34 @@ export class VideoAbuseListComponent extends RestTable implements OnInit { | |||
111 | } | 296 | } |
112 | 297 | ||
113 | protected loadData () { | 298 | protected loadData () { |
114 | return this.videoAbuseService.getVideoAbuses(this.pagination, this.sort) | 299 | return this.videoAbuseService.getVideoAbuses({ |
115 | .subscribe( | 300 | pagination: this.pagination, |
116 | async resultList => { | 301 | sort: this.sort, |
117 | this.totalRecords = resultList.total | 302 | search: this.search |
303 | }).subscribe( | ||
304 | async resultList => { | ||
305 | this.totalRecords = resultList.total | ||
306 | const videoAbuses = [] | ||
307 | |||
308 | for (const abuse of resultList.data) { | ||
309 | Object.assign(abuse, { | ||
310 | reasonHtml: await this.toHtml(abuse.reason), | ||
311 | moderationCommentHtml: await this.toHtml(abuse.moderationComment), | ||
312 | embedHtml: this.sanitizer.bypassSecurityTrustHtml(this.getVideoEmbed(abuse)), | ||
313 | reporterAccount: new Account(abuse.reporterAccount) | ||
314 | }) | ||
118 | 315 | ||
119 | this.videoAbuses = resultList.data | 316 | if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount) |
317 | if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt | ||
120 | 318 | ||
121 | for (const abuse of this.videoAbuses) { | 319 | videoAbuses.push(abuse as ProcessedVideoAbuse) |
122 | Object.assign(abuse, { | 320 | } |
123 | reasonHtml: await this.toHtml(abuse.reason), | ||
124 | moderationCommentHtml: await this.toHtml(abuse.moderationComment) | ||
125 | }) | ||
126 | } | ||
127 | 321 | ||
128 | }, | 322 | this.videoAbuses = videoAbuses |
323 | }, | ||
129 | 324 | ||
130 | err => this.notifier.error(err.message) | 325 | err => this.notifier.error(err.message) |
131 | ) | 326 | ) |
132 | } | 327 | } |
133 | 328 | ||
134 | private toHtml (text: string) { | 329 | private toHtml (text: string) { |
diff --git a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html index a0b89acc6..c4c4e765a 100644 --- a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html +++ b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.html | |||
@@ -1,47 +1,98 @@ | |||
1 | <p-table | 1 | <p-table |
2 | [value]="blacklist" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 2 | [value]="blacklist" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" |
3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" | 3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" |
4 | [showCurrentPageReport]="true" i18n-currentPageReportTemplate | ||
5 | currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} blacklisted videos" | ||
6 | (onPage)="onPage($event)" [expandedRowKeys]="expandedRows" | ||
4 | > | 7 | > |
8 | <ng-template pTemplate="caption"> | ||
9 | <div class="caption"> | ||
10 | <div class="ml-auto has-feedback has-clear"> | ||
11 | <input | ||
12 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | ||
13 | (keyup)="onSearch($event)" | ||
14 | > | ||
15 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a> | ||
16 | <span class="sr-only" i18n>Clear filters</span> | ||
17 | </div> | ||
18 | </div> | ||
19 | </ng-template> | ||
20 | |||
5 | <ng-template pTemplate="header"> | 21 | <ng-template pTemplate="header"> |
6 | <tr> | 22 | <tr> |
7 | <th style="width: 40px"></th> | 23 | <th style="width: 40px"></th> |
8 | <th i18n pSortableColumn="name">Video name <p-sortIcon field="name"></p-sortIcon></th> | 24 | <th i18n pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th> |
9 | <th i18n>Sensitive</th> | 25 | <th style="width: 100px;" i18n>Sensitive</th> |
10 | <th i18n>Unfederated</th> | 26 | <th style="width: 120px;" i18n>Unfederated</th> |
11 | <th i18n pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th> | 27 | <th style="width: 150px;" i18n pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th> |
12 | <th style="width: 120px;"></th> | 28 | <th style="width: 120px;"></th> |
13 | </tr> | 29 | </tr> |
14 | </ng-template> | 30 | </ng-template> |
15 | 31 | ||
16 | <ng-template pTemplate="body" let-videoBlacklist let-expanded="expanded"> | 32 | <ng-template pTemplate="body" let-videoBlacklist let-expanded="expanded"> |
17 | <tr> | 33 | <tr> |
18 | <td class="expand-cell"> | 34 | <td *ngIf="!videoBlacklist.reason"></td> |
19 | <span *ngIf="videoBlacklist.reason" class="expander" [pRowToggler]="videoBlacklist"> | 35 | <td *ngIf="videoBlacklist.reason" class="expand-cell c-hand" [pRowToggler]="videoBlacklist" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body"> |
36 | <span class="expander"> | ||
20 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> | 37 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> |
21 | </span> | 38 | </span> |
22 | </td> | 39 | </td> |
23 | 40 | ||
24 | <td> | 41 | <td> |
25 | <a [href]="getVideoUrl(videoBlacklist)" i18n-title title="Go to the video" target="_blank" rel="noopener noreferrer"> | 42 | <a [href]="getVideoUrl(videoBlacklist)" class="video-table-video-link" i18n-title title="Open video in a new tab" target="_blank" rel="noopener noreferrer"> |
26 | {{ videoBlacklist.video.name }} | 43 | <div class="video-table-video"> |
44 | <div class="video-table-video-image"> | ||
45 | <img [src]="videoBlacklist.video.thumbnailPath"> | ||
46 | </div> | ||
47 | <div class="video-table-video-text"> | ||
48 | <div> | ||
49 | {{ videoBlacklist.video.name }} | ||
50 | <span i18n-title title="Video was blacklisted" class="glyphicon glyphicon-ban-circle"></span> | ||
51 | </div> | ||
52 | <div class="text-muted">by {{ videoBlacklist.video.channel?.displayName }} on {{ videoBlacklist.video.channel?.host }} </div> | ||
53 | </div> | ||
54 | </div> | ||
27 | </a> | 55 | </a> |
28 | </td> | 56 | </td> |
29 | 57 | ||
30 | <td>{{ booleanToText(videoBlacklist.video.nsfw) }}</td> | 58 | <ng-container *ngIf="videoBlacklist.reason"> |
31 | <td>{{ booleanToText(videoBlacklist.unfederated) }}</td> | 59 | <td class="c-hand" [pRowToggler]="videoBlacklist">{{ booleanToText(videoBlacklist.video.nsfw) }}</td> |
32 | <td>{{ videoBlacklist.createdAt }}</td> | 60 | <td class="c-hand" [pRowToggler]="videoBlacklist">{{ booleanToText(videoBlacklist.unfederated) }}</td> |
61 | <td class="c-hand" [pRowToggler]="videoBlacklist">{{ videoBlacklist.createdAt | date: 'short' }}</td> | ||
62 | </ng-container> | ||
63 | <ng-container *ngIf="!videoBlacklist.reason"> | ||
64 | <td>{{ booleanToText(videoBlacklist.video.nsfw) }}</td> | ||
65 | <td>{{ booleanToText(videoBlacklist.unfederated) }}</td> | ||
66 | <td>{{ videoBlacklist.createdAt | date: 'short' }}</td> | ||
67 | </ng-container> | ||
33 | 68 | ||
34 | <td class="action-cell"> | 69 | <td class="action-cell"> |
35 | <my-action-dropdown i18n-label placement="bottom-right" label="Actions" [actions]="videoBlacklistActions" [entry]="videoBlacklist"></my-action-dropdown> | 70 | <my-action-dropdown |
71 | [ngClass]="{ 'show': expanded }" placement="bottom-right" container="body" | ||
72 | i18n-label label="Actions" [actions]="videoBlacklistActions" [entry]="videoBlacklist" | ||
73 | ></my-action-dropdown> | ||
36 | </td> | 74 | </td> |
37 | </tr> | 75 | </tr> |
38 | </ng-template> | 76 | </ng-template> |
39 | 77 | ||
40 | <ng-template pTemplate="rowexpansion" let-videoBlacklist> | 78 | <ng-template pTemplate="rowexpansion" let-videoBlacklist> |
41 | <tr> | 79 | <tr> |
42 | <td class="moderation-expanded" colspan="6"> | 80 | <td class="expand-cell" colspan="6"> |
43 | <span i18n class="moderation-expanded-label">Blacklist reason:</span> | 81 | <div class="d-flex moderation-expanded"> |
44 | <span class="moderation-expanded-text" [innerHTML]="videoBlacklist.reasonHtml"></span> | 82 | <span class="col-2 moderation-expanded-label" i18n>Blacklist reason:</span> |
83 | <span class="col-9 moderation-expanded-text" [innerHTML]="videoBlacklist.reasonHtml"></span> | ||
84 | </div> | ||
85 | </td> | ||
86 | </tr> | ||
87 | </ng-template> | ||
88 | |||
89 | <ng-template pTemplate="emptymessage"> | ||
90 | <tr> | ||
91 | <td colspan="6"> | ||
92 | <div class="empty-table-message"> | ||
93 | <ng-container *ngIf="search" i18n>No blacklisted video found matching current filters.</ng-container> | ||
94 | <ng-container *ngIf="!search" i18n>No blacklisted video found.</ng-container> | ||
95 | </div> | ||
45 | </td> | 96 | </td> |
46 | </tr> | 97 | </tr> |
47 | </ng-template> | 98 | </ng-template> |
diff --git a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts index 5876f658b..63ecdeb9f 100644 --- a/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts +++ b/client/src/app/+admin/moderation/video-blacklist-list/video-blacklist-list.component.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
2 | import { SortMeta } from 'primeng/components/common/sortmeta' | 2 | import { SortMeta } from 'primeng/api' |
3 | import { Notifier, ServerService } from '@app/core' | 3 | import { Notifier, ServerService } from '@app/core' |
4 | import { ConfirmService } from '../../../core' | 4 | import { ConfirmService } from '../../../core' |
5 | import { RestPagination, RestTable, VideoBlacklistService } from '../../../shared' | 5 | import { RestPagination, RestTable, VideoBlacklistService } from '../../../shared' |
@@ -17,8 +17,7 @@ import { MarkdownService } from '@app/shared/renderer' | |||
17 | export class VideoBlacklistListComponent extends RestTable implements OnInit { | 17 | export class VideoBlacklistListComponent extends RestTable implements OnInit { |
18 | blacklist: (VideoBlacklist & { reasonHtml?: string })[] = [] | 18 | blacklist: (VideoBlacklist & { reasonHtml?: string })[] = [] |
19 | totalRecords = 0 | 19 | totalRecords = 0 |
20 | rowsPerPage = 10 | 20 | sort: SortMeta = { field: 'createdAt', order: -1 } |
21 | sort: SortMeta = { field: 'createdAt', order: 1 } | ||
22 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 21 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
23 | listBlacklistTypeFilter: VideoBlacklistType = undefined | 22 | listBlacklistTypeFilter: VideoBlacklistType = undefined |
24 | 23 | ||
@@ -38,7 +37,7 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit { | |||
38 | ngOnInit () { | 37 | ngOnInit () { |
39 | this.serverService.getConfig() | 38 | this.serverService.getConfig() |
40 | .subscribe(config => { | 39 | .subscribe(config => { |
41 | // don't filter if auto-blacklist not enabled as this will be only list | 40 | // don't filter if auto-blacklist is not enabled as this will be the only list |
42 | if (config.autoBlacklist.videos.ofUsers.enabled) { | 41 | if (config.autoBlacklist.videos.ofUsers.enabled) { |
43 | this.listBlacklistTypeFilter = VideoBlacklistType.MANUAL | 42 | this.listBlacklistTypeFilter = VideoBlacklistType.MANUAL |
44 | } | 43 | } |
@@ -54,6 +53,10 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit { | |||
54 | ] | 53 | ] |
55 | } | 54 | } |
56 | 55 | ||
56 | getIdentifier () { | ||
57 | return 'VideoBlacklistListComponent' | ||
58 | } | ||
59 | |||
57 | getVideoUrl (videoBlacklist: VideoBlacklist) { | 60 | getVideoUrl (videoBlacklist: VideoBlacklist) { |
58 | return Video.buildClientUrl(videoBlacklist.video.uuid) | 61 | return Video.buildClientUrl(videoBlacklist.video.uuid) |
59 | } | 62 | } |
@@ -87,7 +90,12 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit { | |||
87 | } | 90 | } |
88 | 91 | ||
89 | protected loadData () { | 92 | protected loadData () { |
90 | this.videoBlacklistService.listBlacklist(this.pagination, this.sort, this.listBlacklistTypeFilter) | 93 | this.videoBlacklistService.listBlacklist({ |
94 | pagination: this.pagination, | ||
95 | sort: this.sort, | ||
96 | search: this.search, | ||
97 | type: this.listBlacklistTypeFilter | ||
98 | }) | ||
91 | .subscribe( | 99 | .subscribe( |
92 | async resultList => { | 100 | async resultList => { |
93 | this.totalRecords = resultList.total | 101 | this.totalRecords = resultList.total |
diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html index 4526aaf66..a2d0fde08 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html | |||
@@ -10,22 +10,19 @@ | |||
10 | <div class="card plugin" *ngFor="let plugin of plugins"> | 10 | <div class="card plugin" *ngFor="let plugin of plugins"> |
11 | <div class="card-body"> | 11 | <div class="card-body"> |
12 | <div class="first-row"> | 12 | <div class="first-row"> |
13 | <a class="plugin-name" [routerLink]="getShowRouterLink(plugin)" title="Show plugin settings">{{ plugin.name }}</a> | 13 | <span class="plugin-name">{{ plugin.name }}</span> |
14 | 14 | ||
15 | <span class="plugin-version">{{ plugin.version }}</span> | 15 | <span class="plugin-version">{{ plugin.version }}</span> |
16 | </div> | ||
17 | 16 | ||
18 | <div class="second-row"> | 17 | <a class="plugin-icon" target="_blank" rel="noopener noreferrer" [href]="plugin.homepage" i18n-title title="Go to the plugin homepage"> |
19 | <div class="description">{{ plugin.description }}</div> | 18 | <my-global-icon iconName="home"></my-global-icon> |
19 | </a> | ||
20 | 20 | ||
21 | <div class="buttons"> | 21 | <a class="plugin-icon" target="_blank" rel="noopener noreferrer" [href]="'https://www.npmjs.com/package/peertube-plugin-' + plugin.name" i18n-title title="Go to the plugin homepage"> |
22 | <a class="action-button action-button-edit grey-button" target="_blank" rel="noopener noreferrer" | 22 | <my-global-icon iconName="npm"></my-global-icon> |
23 | [href]="plugin.homepage" i18n-title title="Go to the plugin homepage" | 23 | </a> |
24 | > | ||
25 | <my-global-icon iconName="go"></my-global-icon> | ||
26 | <span i18n class="button-label">Homepage</span> | ||
27 | </a> | ||
28 | 24 | ||
25 | <div class="buttons"> | ||
29 | <my-edit-button *ngIf="pluginType !== PluginType.THEME" [routerLink]="getShowRouterLink(plugin)" label="Settings" i18n-label></my-edit-button> | 26 | <my-edit-button *ngIf="pluginType !== PluginType.THEME" [routerLink]="getShowRouterLink(plugin)" label="Settings" i18n-label></my-edit-button> |
30 | 27 | ||
31 | <my-button class="update-button" *ngIf="isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)" | 28 | <my-button class="update-button" *ngIf="isUpdateAvailable(plugin)" (click)="update(plugin)" [loading]="isUpdating(plugin)" |
@@ -35,6 +32,10 @@ | |||
35 | <my-delete-button (click)="uninstall(plugin)" label="Uninstall" i18n-label></my-delete-button> | 32 | <my-delete-button (click)="uninstall(plugin)" label="Uninstall" i18n-label></my-delete-button> |
36 | </div> | 33 | </div> |
37 | </div> | 34 | </div> |
35 | |||
36 | <div class="second-row"> | ||
37 | <div class="description">{{ plugin.description }}</div> | ||
38 | </div> | ||
38 | </div> | 39 | </div> |
39 | </div> | 40 | </div> |
40 | </div> | 41 | </div> |
diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts index f18c2e6ca..a8973f2b2 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.ts | |||
@@ -89,10 +89,10 @@ export class PluginListInstalledComponent implements OnInit { | |||
89 | 89 | ||
90 | getNoResultMessage () { | 90 | getNoResultMessage () { |
91 | if (this.pluginType === PluginType.PLUGIN) { | 91 | if (this.pluginType === PluginType.PLUGIN) { |
92 | return this.i18n('You don\'t have plugins installed yet.') | 92 | return this.i18n("You don't have plugins installed yet.") |
93 | } | 93 | } |
94 | 94 | ||
95 | return this.i18n('You don\'t have themes installed yet.') | 95 | return this.i18n("You don't have themes installed yet.") |
96 | } | 96 | } |
97 | 97 | ||
98 | isUpdateAvailable (plugin: PeerTubePlugin) { | 98 | isUpdateAvailable (plugin: PeerTubePlugin) { |
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html index 6ec6301b1..9f942c4b3 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html | |||
@@ -3,7 +3,7 @@ | |||
3 | </div> | 3 | </div> |
4 | 4 | ||
5 | <div class="search-bar"> | 5 | <div class="search-bar"> |
6 | <input type="text" (input)="onSearchChange($event.target.value)" i18n-placeholder placeholder="Search..."/> | 6 | <input type="text" (input)="onSearchChange($event)" i18n-placeholder placeholder="Search..."/> |
7 | </div> | 7 | </div> |
8 | 8 | ||
9 | <div class="alert alert-info" i18n *ngIf="pluginInstalled"> | 9 | <div class="alert alert-info" i18n *ngIf="pluginInstalled"> |
@@ -37,25 +37,26 @@ | |||
37 | 37 | ||
38 | <span class="plugin-version">{{ plugin.latestVersion }}</span> | 38 | <span class="plugin-version">{{ plugin.latestVersion }}</span> |
39 | 39 | ||
40 | <span *ngIf="plugin.installed" class="badge badge-success">Installed</span> | 40 | <a class="plugin-icon" target="_blank" rel="noopener noreferrer" [href]="plugin.homepage" i18n-title title="Go to the plugin homepage"> |
41 | </div> | 41 | <my-global-icon iconName="home"></my-global-icon> |
42 | </a> | ||
42 | 43 | ||
43 | <div class="second-row"> | 44 | <a class="plugin-icon" target="_blank" rel="noopener noreferrer" [href]="'https://www.npmjs.com/package/peertube-plugin-' + plugin.name" i18n-title title="Go to the plugin npm package"> |
44 | <div class="description">{{ plugin.description }}</div> | 45 | <my-global-icon iconName="npm"></my-global-icon> |
46 | </a> | ||
45 | 47 | ||
46 | <div class="buttons"> | 48 | <span *ngIf="plugin.installed" class="badge badge-success">Installed</span> |
47 | <a class="action-button action-button-edit grey-button" target="_blank" rel="noopener noreferrer" | ||
48 | [href]="plugin.homepage" i18n-title title="Go to the plugin homepage" | ||
49 | > | ||
50 | <my-global-icon iconName="go"></my-global-icon> | ||
51 | <span i18n class="button-label">Homepage</span> | ||
52 | </a> | ||
53 | 49 | ||
50 | <div class="buttons"> | ||
54 | <my-button class="update-button" *ngIf="plugin.installed === false" (click)="install(plugin)" [loading]="isInstalling(plugin)" | 51 | <my-button class="update-button" *ngIf="plugin.installed === false" (click)="install(plugin)" [loading]="isInstalling(plugin)" |
55 | label="Install" icon="cloud-download" [attr.disabled]="isInstalling(plugin)" | 52 | label="Install" icon="cloud-download" [attr.disabled]="isInstalling(plugin)" |
56 | ></my-button> | 53 | ></my-button> |
57 | </div> | 54 | </div> |
58 | </div> | 55 | </div> |
56 | |||
57 | <div class="second-row"> | ||
58 | <div class="description">{{ plugin.description }}</div> | ||
59 | </div> | ||
59 | </div> | 60 | </div> |
60 | </div> | 61 | </div> |
61 | </div> | 62 | </div> |
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss index ed06825c8..20f169e13 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.scss | |||
@@ -25,5 +25,5 @@ | |||
25 | .badge { | 25 | .badge { |
26 | font-size: 13px; | 26 | font-size: 13px; |
27 | font-weight: $font-semibold; | 27 | font-weight: $font-semibold; |
28 | margin-left: 5px; | 28 | margin-left: 15px; |
29 | } | 29 | } |
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts index e08ded3f1..dc59e759b 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.ts | |||
@@ -70,8 +70,10 @@ export class PluginSearchComponent implements OnInit { | |||
70 | this.reloadPlugins() | 70 | this.reloadPlugins() |
71 | } | 71 | } |
72 | 72 | ||
73 | onSearchChange (search: string) { | 73 | onSearchChange (event: Event) { |
74 | this.searchSubject.next(search) | 74 | const target = event.target as HTMLInputElement |
75 | |||
76 | this.searchSubject.next(target.value) | ||
75 | } | 77 | } |
76 | 78 | ||
77 | reloadPlugins () { | 79 | reloadPlugins () { |
diff --git a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.html b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.html index 95dd74d31..f3fc429ff 100644 --- a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.html +++ b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.html | |||
@@ -8,9 +8,27 @@ | |||
8 | <form *ngIf="hasRegisteredSettings()" role="form" (ngSubmit)="formValidated()" [formGroup]="form"> | 8 | <form *ngIf="hasRegisteredSettings()" role="form" (ngSubmit)="formValidated()" [formGroup]="form"> |
9 | <div class="form-group" *ngFor="let setting of registeredSettings"> | 9 | <div class="form-group" *ngFor="let setting of registeredSettings"> |
10 | <label *ngIf="setting.type !== 'input-checkbox'" [attr.for]="setting.name" [innerHTML]="setting.label"></label> | 10 | <label *ngIf="setting.type !== 'input-checkbox'" [attr.for]="setting.name" [innerHTML]="setting.label"></label> |
11 | |||
11 | <input *ngIf="setting.type === 'input'" type="text" [id]="setting.name" [formControlName]="setting.name" /> | 12 | <input *ngIf="setting.type === 'input'" type="text" [id]="setting.name" [formControlName]="setting.name" /> |
13 | |||
12 | <textarea *ngIf="setting.type === 'input-textarea'" type="text" [id]="setting.name" [formControlName]="setting.name"></textarea> | 14 | <textarea *ngIf="setting.type === 'input-textarea'" type="text" [id]="setting.name" [formControlName]="setting.name"></textarea> |
13 | 15 | ||
16 | <my-help *ngIf="setting.type === 'markdown-text'" helpType="markdownText"></my-help> | ||
17 | |||
18 | <my-help *ngIf="setting.type === 'markdown-enhanced'" helpType="markdownEnhanced"></my-help> | ||
19 | |||
20 | <my-markdown-textarea | ||
21 | *ngIf="setting.type === 'markdown-text'" | ||
22 | markdownType="text" [id]="setting.name" [formControlName]="setting.name" textareaWidth="500px" | ||
23 | [classes]="{ 'input-error': formErrors['settings.name'] }" | ||
24 | ></my-markdown-textarea> | ||
25 | |||
26 | <my-markdown-textarea | ||
27 | *ngIf="setting.type === 'markdown-enhanced'" | ||
28 | markdownType="enhanced" [id]="setting.name" [formControlName]="setting.name" textareaWidth="500px" | ||
29 | [classes]="{ 'input-error': formErrors['settings.name'] }" | ||
30 | ></my-markdown-textarea> | ||
31 | |||
14 | <my-peertube-checkbox | 32 | <my-peertube-checkbox |
15 | *ngIf="setting.type === 'input-checkbox'" | 33 | *ngIf="setting.type === 'input-checkbox'" |
16 | [id]="setting.name" | 34 | [id]="setting.name" |
diff --git a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.scss b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.scss index 21c180a70..cc35aec57 100644 --- a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.scss +++ b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.scss | |||
@@ -5,9 +5,15 @@ h2 { | |||
5 | margin-bottom: 20px; | 5 | margin-bottom: 20px; |
6 | } | 6 | } |
7 | 7 | ||
8 | textarea, | ||
9 | input:not([type=submit]) { | 8 | input:not([type=submit]) { |
10 | @include peertube-input-text(340px); | 9 | @include peertube-input-text(340px); |
10 | |||
11 | display: block; | ||
12 | } | ||
13 | |||
14 | textarea { | ||
15 | @include peertube-textarea(340px, 200px); | ||
16 | |||
11 | display: block; | 17 | display: block; |
12 | } | 18 | } |
13 | 19 | ||
diff --git a/client/src/app/+admin/plugins/plugins.component.scss b/client/src/app/+admin/plugins/plugins.component.scss index 9f61bcf7a..04ca8126a 100644 --- a/client/src/app/+admin/plugins/plugins.component.scss +++ b/client/src/app/+admin/plugins/plugins.component.scss | |||
@@ -5,3 +5,29 @@ | |||
5 | flex-grow: 0; | 5 | flex-grow: 0; |
6 | margin-right: 30px; | 6 | margin-right: 30px; |
7 | } | 7 | } |
8 | |||
9 | @media screen and (max-width: $small-view) { | ||
10 | ::ng-deep .plugins .plugin .first-row { | ||
11 | flex-wrap: wrap; | ||
12 | |||
13 | .plugin-name, | ||
14 | .plugin-version, | ||
15 | .plugin-icon { | ||
16 | margin-bottom: 10px; | ||
17 | } | ||
18 | |||
19 | .buttons { | ||
20 | my-edit-button, | ||
21 | my-delete-button, | ||
22 | my-button { | ||
23 | .action-button { | ||
24 | padding: 0 13px; | ||
25 | } | ||
26 | |||
27 | .button-label { | ||
28 | display: none; | ||
29 | } | ||
30 | } | ||
31 | } | ||
32 | } | ||
33 | } | ||
diff --git a/client/src/app/+admin/plugins/shared/plugin-list.component.scss b/client/src/app/+admin/plugins/shared/plugin-list.component.scss index 87a709b00..3f4fad7b9 100644 --- a/client/src/app/+admin/plugins/shared/plugin-list.component.scss +++ b/client/src/app/+admin/plugins/shared/plugin-list.component.scss | |||
@@ -7,6 +7,8 @@ | |||
7 | } | 7 | } |
8 | 8 | ||
9 | .first-row { | 9 | .first-row { |
10 | display: flex; | ||
11 | align-items: center; | ||
10 | margin-bottom: 10px; | 12 | margin-bottom: 10px; |
11 | 13 | ||
12 | .plugin-name { | 14 | .plugin-name { |
@@ -18,6 +20,26 @@ | |||
18 | .plugin-version { | 20 | .plugin-version { |
19 | opacity: 0.6; | 21 | opacity: 0.6; |
20 | } | 22 | } |
23 | |||
24 | .plugin-icon { | ||
25 | margin-left: 10px; | ||
26 | |||
27 | my-global-icon { | ||
28 | @include apply-svg-color($grey-foreground-color); | ||
29 | |||
30 | &[iconName="npm"] { | ||
31 | @include fill-svg-color($grey-foreground-color); | ||
32 | } | ||
33 | } | ||
34 | } | ||
35 | |||
36 | .buttons { | ||
37 | margin-left: auto; | ||
38 | width: max-content; | ||
39 | > *:not(:last-child) { | ||
40 | margin-right: 10px; | ||
41 | } | ||
42 | } | ||
21 | } | 43 | } |
22 | 44 | ||
23 | .second-row { | 45 | .second-row { |
@@ -29,13 +51,6 @@ | |||
29 | .description { | 51 | .description { |
30 | opacity: 0.8 | 52 | opacity: 0.8 |
31 | } | 53 | } |
32 | |||
33 | .buttons { | ||
34 | margin-left: 10px; | ||
35 | > *:not(:last-child) { | ||
36 | margin-right: 10px; | ||
37 | } | ||
38 | } | ||
39 | } | 54 | } |
40 | 55 | ||
41 | .action-button { | 56 | .action-button { |
diff --git a/client/src/app/+admin/plugins/shared/toggle-plugin-type.scss b/client/src/app/+admin/plugins/shared/toggle-plugin-type.scss index 7e2c40aae..56ea91d0b 100644 --- a/client/src/app/+admin/plugins/shared/toggle-plugin-type.scss +++ b/client/src/app/+admin/plugins/shared/toggle-plugin-type.scss | |||
@@ -10,11 +10,26 @@ | |||
10 | ::ng-deep { | 10 | ::ng-deep { |
11 | .ui-button-text { | 11 | .ui-button-text { |
12 | font-size: 15px; | 12 | font-size: 15px; |
13 | font-weight: 600; | ||
14 | } | ||
15 | |||
16 | .ui-button.ui-state-default { | ||
17 | background-color: #f0f0f0; | ||
18 | border: 1px solid #f0f0f0; | ||
13 | } | 19 | } |
14 | 20 | ||
15 | .ui-button.ui-state-active { | 21 | .ui-button.ui-state-active { |
16 | background-color: var(--mainColor); | 22 | background-color: var(--mainColor); |
17 | border-color: var(--mainColor); | 23 | border-color: var(--mainColor); |
24 | |||
25 | &:hover { | ||
26 | background-color: var(--mainHoverColor); | ||
27 | border-color: var(--mainHoverColor); | ||
28 | } | ||
29 | } | ||
30 | |||
31 | .ui-button:not(.ui-state-active).ui-state-focus { | ||
32 | box-shadow: 0 0 0 .1rem rgba(87, 85, 217, .2); | ||
18 | } | 33 | } |
19 | } | 34 | } |
20 | } | 35 | } |
diff --git a/client/src/app/+admin/system/debug/debug.component.scss b/client/src/app/+admin/system/debug/debug.component.scss index 90addd284..7bc8fa946 100644 --- a/client/src/app/+admin/system/debug/debug.component.scss +++ b/client/src/app/+admin/system/debug/debug.component.scss | |||
@@ -2,5 +2,10 @@ | |||
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .root { | 4 | .root { |
5 | font-size: 14px; | 5 | font-size: 15px; |
6 | |||
7 | code { | ||
8 | font-size: 14px; | ||
9 | font-weight: $font-semibold; | ||
10 | } | ||
6 | } | 11 | } |
diff --git a/client/src/app/+admin/system/jobs/jobs.component.html b/client/src/app/+admin/system/jobs/jobs.component.html index b0f68eadd..038dfa522 100644 --- a/client/src/app/+admin/system/jobs/jobs.component.html +++ b/client/src/app/+admin/system/jobs/jobs.component.html | |||
@@ -1,10 +1,8 @@ | |||
1 | <div class="admin-sub-header"> | 1 | <div class="admin-sub-header"> |
2 | <div i18n class="form-sub-title">Jobs list</div> | ||
3 | |||
4 | <div class="select-filter-block"> | 2 | <div class="select-filter-block"> |
5 | <label for="jobType" i18n>Job type</label> | 3 | <label for="jobType" i18n>Job type</label> |
6 | <div class="peertube-select-container"> | 4 | <div class="peertube-select-container"> |
7 | <select id="jobType" name="jobType" [(ngModel)]="jobType" (ngModelChange)="onJobStateOrTypeChanged()"> | 5 | <select id="jobType" name="jobType" [(ngModel)]="jobType" (ngModelChange)="onJobStateOrTypeChanged()" class="form-control"> |
8 | <option *ngFor="let jobType of jobTypes" [value]="jobType">{{ jobType }}</option> | 6 | <option *ngFor="let jobType of jobTypes" [value]="jobType">{{ jobType }}</option> |
9 | </select> | 7 | </select> |
10 | </div> | 8 | </div> |
@@ -13,7 +11,7 @@ | |||
13 | <div class="select-filter-block"> | 11 | <div class="select-filter-block"> |
14 | <label for="jobState" i18n>Job state</label> | 12 | <label for="jobState" i18n>Job state</label> |
15 | <div class="peertube-select-container"> | 13 | <div class="peertube-select-container"> |
16 | <select id="jobState" name="jobState" [(ngModel)]="jobState" (ngModelChange)="onJobStateOrTypeChanged()"> | 14 | <select id="jobState" name="jobState" [(ngModel)]="jobState" (ngModelChange)="onJobStateOrTypeChanged()" class="form-control"> |
17 | <option *ngFor="let state of jobStates" [value]="state">{{ state }}</option> | 15 | <option *ngFor="let state of jobStates" [value]="state">{{ state }}</option> |
18 | </select> | 16 | </select> |
19 | </div> | 17 | </div> |
@@ -21,12 +19,13 @@ | |||
21 | </div> | 19 | </div> |
22 | 20 | ||
23 | <p-table | 21 | <p-table |
24 | [value]="jobs" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" dataKey="uniqId" | 22 | [value]="jobs" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" dataKey="uniqId" |
25 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" [first]="pagination.start" | 23 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" [first]="pagination.start" |
26 | [tableStyle]="{'table-layout':'auto'}" | 24 | [tableStyle]="{'table-layout':'auto'}" (onPage)="onPage($event)" [expandedRowKeys]="expandedRows" |
27 | > | 25 | > |
28 | <ng-template pTemplate="header"> | 26 | <ng-template pTemplate="header"> |
29 | <tr> | 27 | <tr> |
28 | <th style="width: 40px"></th> | ||
30 | <th class="job-id" i18n>ID</th> | 29 | <th class="job-id" i18n>ID</th> |
31 | <th class="job-type" i18n>Type</th> | 30 | <th class="job-type" i18n>Type</th> |
32 | <th class="job-date" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> | 31 | <th class="job-date" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> |
@@ -35,7 +34,13 @@ | |||
35 | </ng-template> | 34 | </ng-template> |
36 | 35 | ||
37 | <ng-template pTemplate="body" let-expanded="expanded" let-job> | 36 | <ng-template pTemplate="body" let-expanded="expanded" let-job> |
38 | <tr class="expander" [pRowToggler]="job"> | 37 | <tr> |
38 | <td class="expand-cell"> | ||
39 | <span class="expander" [pRowToggler]="job" i18n-ngbTooltip ngbTooltip="More information"> | ||
40 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> | ||
41 | </span> | ||
42 | </td> | ||
43 | |||
39 | <td class="job-id" [title]="job.id">{{ job.id }}</td> | 44 | <td class="job-id" [title]="job.id">{{ job.id }}</td> |
40 | <td class="job-type">{{ job.type }}</td> | 45 | <td class="job-type">{{ job.type }}</td> |
41 | <td class="job-date">{{ job.createdAt }}</td> | 46 | <td class="job-date">{{ job.createdAt }}</td> |
diff --git a/client/src/app/+admin/system/jobs/jobs.component.scss b/client/src/app/+admin/system/jobs/jobs.component.scss index 4cb706d2d..c33e14292 100644 --- a/client/src/app/+admin/system/jobs/jobs.component.scss +++ b/client/src/app/+admin/system/jobs/jobs.component.scss | |||
@@ -18,7 +18,8 @@ | |||
18 | } | 18 | } |
19 | 19 | ||
20 | .admin-sub-header { | 20 | .admin-sub-header { |
21 | align-items: flex-end; | 21 | flex-direction: row !important; |
22 | justify-content: flex-end; | ||
22 | 23 | ||
23 | .select-filter-block { | 24 | .select-filter-block { |
24 | &:not(:last-child) { | 25 | &:not(:last-child) { |
diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts index 20c8ea71a..4f7f7c368 100644 --- a/client/src/app/+admin/system/jobs/jobs.component.ts +++ b/client/src/app/+admin/system/jobs/jobs.component.ts | |||
@@ -16,8 +16,8 @@ import { JobTypeClient } from '../../../../types/job-type-client.type' | |||
16 | styleUrls: [ './jobs.component.scss' ] | 16 | styleUrls: [ './jobs.component.scss' ] |
17 | }) | 17 | }) |
18 | export class JobsComponent extends RestTable implements OnInit { | 18 | export class JobsComponent extends RestTable implements OnInit { |
19 | private static JOB_STATE_LOCAL_STORAGE_STATE = 'jobs-list-state' | 19 | private static LOCAL_STORAGE_STATE = 'jobs-list-state' |
20 | private static JOB_STATE_LOCAL_STORAGE_TYPE = 'jobs-list-type' | 20 | private static LOCAL_STORAGE_TYPE = 'jobs-list-type' |
21 | 21 | ||
22 | jobState: JobStateClient = 'waiting' | 22 | jobState: JobStateClient = 'waiting' |
23 | jobStates: JobStateClient[] = [ 'active', 'completed', 'failed', 'waiting', 'delayed' ] | 23 | jobStates: JobStateClient[] = [ 'active', 'completed', 'failed', 'waiting', 'delayed' ] |
@@ -34,12 +34,12 @@ export class JobsComponent extends RestTable implements OnInit { | |||
34 | 'video-file-import', | 34 | 'video-file-import', |
35 | 'video-import', | 35 | 'video-import', |
36 | 'videos-views', | 36 | 'videos-views', |
37 | 'activitypub-refresher' | 37 | 'activitypub-refresher', |
38 | 'video-redundancy' | ||
38 | ] | 39 | ] |
39 | 40 | ||
40 | jobs: Job[] = [] | 41 | jobs: Job[] = [] |
41 | totalRecords: number | 42 | totalRecords: number |
42 | rowsPerPage = 10 | ||
43 | sort: SortMeta = { field: 'createdAt', order: -1 } | 43 | sort: SortMeta = { field: 'createdAt', order: -1 } |
44 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 44 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
45 | 45 | ||
@@ -56,6 +56,10 @@ export class JobsComponent extends RestTable implements OnInit { | |||
56 | this.initialize() | 56 | this.initialize() |
57 | } | 57 | } |
58 | 58 | ||
59 | getIdentifier () { | ||
60 | return 'JobsComponent' | ||
61 | } | ||
62 | |||
59 | onJobStateOrTypeChanged () { | 63 | onJobStateOrTypeChanged () { |
60 | this.pagination.start = 0 | 64 | this.pagination.start = 0 |
61 | 65 | ||
@@ -77,15 +81,15 @@ export class JobsComponent extends RestTable implements OnInit { | |||
77 | } | 81 | } |
78 | 82 | ||
79 | private loadJobStateAndType () { | 83 | private loadJobStateAndType () { |
80 | const state = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE) | 84 | const state = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_STATE) |
81 | if (state) this.jobState = state as JobState | 85 | if (state) this.jobState = state as JobState |
82 | 86 | ||
83 | const type = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE) | 87 | const type = peertubeLocalStorage.getItem(JobsComponent.LOCAL_STORAGE_TYPE) |
84 | if (type) this.jobType = type as JobType | 88 | if (type) this.jobType = type as JobType |
85 | } | 89 | } |
86 | 90 | ||
87 | private saveJobStateAndType () { | 91 | private saveJobStateAndType () { |
88 | peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE, this.jobState) | 92 | peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_STATE, this.jobState) |
89 | peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_TYPE, this.jobType) | 93 | peertubeLocalStorage.setItem(JobsComponent.LOCAL_STORAGE_TYPE, this.jobType) |
90 | } | 94 | } |
91 | } | 95 | } |
diff --git a/client/src/app/+admin/system/logs/logs.component.html b/client/src/app/+admin/system/logs/logs.component.html index ddad1314f..ae1b0c601 100644 --- a/client/src/app/+admin/system/logs/logs.component.html +++ b/client/src/app/+admin/system/logs/logs.component.html | |||
@@ -1,18 +1,18 @@ | |||
1 | <div class="header"> | 1 | <div class="header"> |
2 | <div class="peertube-select-container"> | 2 | <div class="peertube-select-container"> |
3 | <select [(ngModel)]="logType" (ngModelChange)="refresh()"> | 3 | <select [(ngModel)]="logType" (ngModelChange)="refresh()" class="form-control"> |
4 | <option *ngFor="let logTypeChoice of logTypeChoices" [value]="logTypeChoice.id">{{ logTypeChoice.label }}</option> | 4 | <option *ngFor="let logTypeChoice of logTypeChoices" [value]="logTypeChoice.id">{{ logTypeChoice.label }}</option> |
5 | </select> | 5 | </select> |
6 | </div> | 6 | </div> |
7 | 7 | ||
8 | <div class="peertube-select-container"> | 8 | <div class="peertube-select-container"> |
9 | <select [(ngModel)]="startDate" (ngModelChange)="refresh()"> | 9 | <select [(ngModel)]="startDate" (ngModelChange)="refresh()" class="form-control"> |
10 | <option *ngFor="let timeChoice of timeChoices" [value]="timeChoice.id">{{ timeChoice.label }}</option> | 10 | <option *ngFor="let timeChoice of timeChoices" [value]="timeChoice.id">{{ timeChoice.label }}</option> |
11 | </select> | 11 | </select> |
12 | </div> | 12 | </div> |
13 | 13 | ||
14 | <div class="peertube-select-container" *ngIf="!isAuditLog()"> | 14 | <div class="peertube-select-container" *ngIf="!isAuditLog()"> |
15 | <select [(ngModel)]="level" (ngModelChange)="refresh()"> | 15 | <select [(ngModel)]="level" (ngModelChange)="refresh()" class="form-control"> |
16 | <option *ngFor="let levelChoice of levelChoices" [value]="levelChoice.id">{{ levelChoice.label }}</option> | 16 | <option *ngFor="let levelChoice of levelChoices" [value]="levelChoice.id">{{ levelChoice.label }}</option> |
17 | </select> | 17 | </select> |
18 | </div> | 18 | </div> |
@@ -21,7 +21,7 @@ | |||
21 | </div> | 21 | </div> |
22 | 22 | ||
23 | <div class="logs"> | 23 | <div class="logs"> |
24 | <div *ngIf="loading">Loading...</div> | 24 | <div *ngIf="loading" i18n>Loading...</div> |
25 | 25 | ||
26 | <div #logsElement> | 26 | <div #logsElement> |
27 | <div *ngFor="let log of logs" class="log-row" [ngClass]="{ error: log.level === 'error', warn: log.level === 'warn' }"> | 27 | <div *ngFor="let log of logs" class="log-row" [ngClass]="{ error: log.level === 'error', warn: log.level === 'warn' }"> |
diff --git a/client/src/app/+admin/system/logs/logs.component.scss b/client/src/app/+admin/system/logs/logs.component.scss index dae8b21c7..087155254 100644 --- a/client/src/app/+admin/system/logs/logs.component.scss +++ b/client/src/app/+admin/system/logs/logs.component.scss | |||
@@ -28,7 +28,7 @@ | |||
28 | } | 28 | } |
29 | 29 | ||
30 | .warn { | 30 | .warn { |
31 | color: $orange-color; | 31 | color: var(--mainColor); |
32 | } | 32 | } |
33 | 33 | ||
34 | .error { | 34 | .error { |
@@ -57,3 +57,38 @@ | |||
57 | } | 57 | } |
58 | } | 58 | } |
59 | 59 | ||
60 | @media screen and (max-width: $small-view) { | ||
61 | .header { | ||
62 | flex-direction: column; | ||
63 | |||
64 | .peertube-select-container, | ||
65 | my-button { | ||
66 | width: 100% !important; | ||
67 | margin-left: 0px !important; | ||
68 | margin-bottom: 10px !important; | ||
69 | } | ||
70 | |||
71 | my-button { | ||
72 | text-align: center; | ||
73 | } | ||
74 | } | ||
75 | } | ||
76 | |||
77 | @media screen and (max-width: #{$small-view + $menu-width}) { | ||
78 | :host-context(.main-col:not(.expanded)) { | ||
79 | .header { | ||
80 | flex-direction: column; | ||
81 | |||
82 | .peertube-select-container, | ||
83 | my-button { | ||
84 | width: 100% !important; | ||
85 | margin-left: 0px !important; | ||
86 | margin-bottom: 10px !important; | ||
87 | } | ||
88 | |||
89 | my-button { | ||
90 | text-align: center; | ||
91 | } | ||
92 | } | ||
93 | } | ||
94 | } | ||
diff --git a/client/src/app/+admin/users/user-edit/user-create.component.ts b/client/src/app/+admin/users/user-edit/user-create.component.ts index e726ec4d7..a394418cb 100644 --- a/client/src/app/+admin/users/user-edit/user-create.component.ts +++ b/client/src/app/+admin/users/user-edit/user-create.component.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
2 | import { Router } from '@angular/router' | 2 | import { Router, ActivatedRoute } from '@angular/router' |
3 | import { AuthService, Notifier, ServerService } from '@app/core' | 3 | import { AuthService, Notifier, ServerService } from '@app/core' |
4 | import { UserCreate, UserRole } from '../../../../../../shared' | 4 | import { UserCreate, UserRole } from '../../../../../../shared' |
5 | import { UserEdit } from './user-edit' | 5 | import { UserEdit } from './user-edit' |
@@ -8,6 +8,7 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val | |||
8 | import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' | 8 | import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' |
9 | import { ConfigService } from '@app/+admin/config/shared/config.service' | 9 | import { ConfigService } from '@app/+admin/config/shared/config.service' |
10 | import { UserService } from '@app/shared' | 10 | import { UserService } from '@app/shared' |
11 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
11 | 12 | ||
12 | @Component({ | 13 | @Component({ |
13 | selector: 'my-user-create', | 14 | selector: 'my-user-create', |
@@ -21,8 +22,10 @@ export class UserCreateComponent extends UserEdit implements OnInit { | |||
21 | protected serverService: ServerService, | 22 | protected serverService: ServerService, |
22 | protected formValidatorService: FormValidatorService, | 23 | protected formValidatorService: FormValidatorService, |
23 | protected configService: ConfigService, | 24 | protected configService: ConfigService, |
25 | protected screenService: ScreenService, | ||
24 | protected auth: AuthService, | 26 | protected auth: AuthService, |
25 | private userValidatorsService: UserValidatorsService, | 27 | private userValidatorsService: UserValidatorsService, |
28 | private route: ActivatedRoute, | ||
26 | private router: Router, | 29 | private router: Router, |
27 | private notifier: Notifier, | 30 | private notifier: Notifier, |
28 | private userService: UserService, | 31 | private userService: UserService, |
@@ -45,7 +48,7 @@ export class UserCreateComponent extends UserEdit implements OnInit { | |||
45 | this.buildForm({ | 48 | this.buildForm({ |
46 | username: this.userValidatorsService.USER_USERNAME, | 49 | username: this.userValidatorsService.USER_USERNAME, |
47 | email: this.userValidatorsService.USER_EMAIL, | 50 | email: this.userValidatorsService.USER_EMAIL, |
48 | password: this.userValidatorsService.USER_PASSWORD, | 51 | password: this.isPasswordOptional() ? this.userValidatorsService.USER_PASSWORD_OPTIONAL : this.userValidatorsService.USER_PASSWORD, |
49 | role: this.userValidatorsService.USER_ROLE, | 52 | role: this.userValidatorsService.USER_ROLE, |
50 | videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, | 53 | videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, |
51 | videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY, | 54 | videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY, |
@@ -78,6 +81,11 @@ export class UserCreateComponent extends UserEdit implements OnInit { | |||
78 | return true | 81 | return true |
79 | } | 82 | } |
80 | 83 | ||
84 | isPasswordOptional () { | ||
85 | const serverConfig = this.route.snapshot.data.serverConfig | ||
86 | return serverConfig.email.enabled | ||
87 | } | ||
88 | |||
81 | getFormButtonTitle () { | 89 | getFormButtonTitle () { |
82 | return this.i18n('Create user') | 90 | return this.i18n('Create user') |
83 | } | 91 | } |
diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.html b/client/src/app/+admin/users/user-edit/user-edit.component.html index 4ff4d0d12..d30a606d6 100644 --- a/client/src/app/+admin/users/user-edit/user-edit.component.html +++ b/client/src/app/+admin/users/user-edit/user-edit.component.html | |||
@@ -1,105 +1,204 @@ | |||
1 | <div i18n class="form-sub-title" *ngIf="isCreation() === true">Create user</div> | 1 | <nav aria-label="breadcrumb"> |
2 | <div i18n class="form-sub-title" *ngIf="isCreation() === false">Edit user {{ username }}</div> | 2 | <ol class="breadcrumb"> |
3 | <li class="breadcrumb-item"> | ||
4 | <a routerLink="/admin/users" i18n>Users</a> | ||
5 | </li> | ||
3 | 6 | ||
4 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | 7 | <ng-container *ngIf="isCreation()"> |
8 | <li class="breadcrumb-item active" i18n>Create</li> | ||
9 | </ng-container> | ||
10 | <ng-container *ngIf="!isCreation()"> | ||
11 | <li class="breadcrumb-item active" i18n>Edit</li> | ||
12 | <li class="breadcrumb-item active" aria-current="page"> | ||
13 | <a *ngIf="user" [routerLink]="[ '/accounts', user?.username ]">{{ user?.username }}</a> | ||
14 | </li> | ||
15 | </ng-container> | ||
16 | </ol> | ||
17 | </nav> | ||
5 | 18 | ||
6 | <form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> | 19 | <ng-template #dashboard> |
7 | <div class="form-group" *ngIf="isCreation()"> | 20 | <div *ngIf="!isCreation() && user" class="dashboard"> |
8 | <label i18n for="username">Username</label> | 21 | <div> |
9 | <input | 22 | <a> |
10 | type="text" id="username" i18n-placeholder placeholder="john" | 23 | <div class="dashboard-num">{{ user.videosCount }} ({{ user.videoQuotaUsed | bytes: 0 }})</div> |
11 | formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }" | 24 | <div class="dashboard-label" i18n>{user.videosCount, plural, =1 {Video} other {Videos}}</div> |
12 | > | 25 | </a> |
13 | <div *ngIf="formErrors.username" class="form-error"> | ||
14 | {{ formErrors.username }} | ||
15 | </div> | 26 | </div> |
16 | </div> | 27 | <div> |
17 | 28 | <a> | |
18 | <div class="form-group"> | 29 | <div class="dashboard-num">{{ user.videoChannels.length || 0 }}</div> |
19 | <label i18n for="email">Email</label> | 30 | <div class="dashboard-label" i18n>{user.videoChannels.length, plural, =1 {Channel} other {Channels}}</div> |
20 | <input | 31 | </a> |
21 | type="text" id="email" i18n-placeholder placeholder="mail@example.com" | ||
22 | formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }" | ||
23 | autocomplete="off" | ||
24 | > | ||
25 | <div *ngIf="formErrors.email" class="form-error"> | ||
26 | {{ formErrors.email }} | ||
27 | </div> | 32 | </div> |
28 | </div> | 33 | <div> |
29 | 34 | <a> | |
30 | <div class="form-group" *ngIf="isCreation()"> | 35 | <div class="dashboard-num">{{ subscribersCount }}</div> |
31 | <label i18n for="password">Password</label> | 36 | <div class="dashboard-label" i18n>{subscribersCount, plural, =1 {Subscriber} other {Subscribers}}</div> |
32 | <input | 37 | </a> |
33 | type="password" id="password" autocomplete="new-password" | 38 | </div> |
34 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | 39 | <div> |
35 | > | 40 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:' + user?.account.displayName }"> |
36 | <div *ngIf="formErrors.password" class="form-error"> | 41 | <div class="dashboard-num">{{ user.videoAbusesCount }}</div> |
37 | {{ formErrors.password }} | 42 | <div class="dashboard-label" i18n>Incriminated in reports</div> |
43 | </a> | ||
44 | </div> | ||
45 | <div> | ||
46 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:' + user?.account.displayName + ' state:accepted' }"> | ||
47 | <div class="dashboard-num">{{ user.videoAbusesAcceptedCount }} / {{ user.videoAbusesCreatedCount }}</div> | ||
48 | <div class="dashboard-label" i18n>Authored reports accepted</div> | ||
49 | </a> | ||
50 | </div> | ||
51 | <div> | ||
52 | <a> | ||
53 | <div class="dashboard-num">{{ user.videoCommentsCount }}</div> | ||
54 | <div class="dashboard-label" i18n>{user.videoCommentsCount, plural, =1 {Comment} other {Comments}}</div> | ||
55 | </a> | ||
38 | </div> | 56 | </div> |
39 | </div> | 57 | </div> |
58 | </ng-template> | ||
40 | 59 | ||
41 | <div class="form-group"> | 60 | <div class="form-row" *ngIf="!isInBigView()"> <!-- hidden on large screens, as it is then displayed on the right side of the form --> |
42 | <label i18n for="role">Role</label> | 61 | <div class="col-12 col-xl-3"></div> |
43 | <div class="peertube-select-container"> | ||
44 | <select id="role" formControlName="role"> | ||
45 | <option *ngFor="let role of getRoles()" [value]="role.value"> | ||
46 | {{ role.label }} | ||
47 | </option> | ||
48 | </select> | ||
49 | </div> | ||
50 | 62 | ||
51 | <div *ngIf="formErrors.role" class="form-error"> | 63 | <div class="form-group-right col-12 col-xl-9"> |
52 | {{ formErrors.role }} | 64 | <ng-template *ngTemplateOutlet="dashboard"></ng-template> |
53 | </div> | ||
54 | </div> | 65 | </div> |
66 | </div> | ||
55 | 67 | ||
56 | <div class="form-group"> | 68 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> |
57 | <label i18n for="videoQuota">Video quota</label> | ||
58 | <div class="peertube-select-container"> | ||
59 | <select id="videoQuota" formControlName="videoQuota"> | ||
60 | <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value"> | ||
61 | {{ videoQuotaOption.label }} | ||
62 | </option> | ||
63 | </select> | ||
64 | </div> | ||
65 | 69 | ||
66 | <div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()"> | 70 | <div class="form-row mt-4"> <!-- user grid --> |
67 | Transcoding is enabled on server. The video quota only take in account <strong>original</strong> video. <br /> | 71 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
68 | At most, this user could use ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}. | 72 | <div class="anchor" id="user"></div> <!-- user anchor --> |
73 | <div *ngIf="isCreation()" class="account-title" i18n>NEW USER</div> | ||
74 | <div *ngIf="!isCreation() && user" class="account-title"> | ||
75 | <my-actor-avatar-info [actor]="user.account"></my-actor-avatar-info> | ||
69 | </div> | 76 | </div> |
70 | </div> | 77 | </div> |
71 | 78 | ||
72 | <div class="form-group"> | 79 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }"> |
73 | <label i18n for="videoQuotaDaily">Daily video quota</label> | 80 | |
74 | <div class="peertube-select-container"> | 81 | <form role="form" (ngSubmit)="formValidated()" [formGroup]="form" [ngClass]="{ 'col-5': isInBigView() }"> |
75 | <select id="videoQuotaDaily" formControlName="videoQuotaDaily"> | 82 | <div class="form-group" *ngIf="isCreation()"> |
76 | <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value"> | 83 | <label i18n for="username">Username</label> |
77 | {{ videoQuotaDailyOption.label }} | 84 | <input |
78 | </option> | 85 | type="text" id="username" i18n-placeholder placeholder="john" class="form-control" |
79 | </select> | 86 | formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }" |
87 | > | ||
88 | <div *ngIf="formErrors.username" class="form-error"> | ||
89 | {{ formErrors.username }} | ||
90 | </div> | ||
91 | </div> | ||
92 | |||
93 | <div class="form-group"> | ||
94 | <label i18n for="email">Email</label> | ||
95 | <input | ||
96 | type="text" id="email" i18n-placeholder placeholder="mail@example.com" class="form-control" | ||
97 | formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }" | ||
98 | autocomplete="off" | ||
99 | > | ||
100 | <div *ngIf="formErrors.email" class="form-error"> | ||
101 | {{ formErrors.email }} | ||
102 | </div> | ||
103 | </div> | ||
104 | |||
105 | <div class="form-group" *ngIf="isCreation()"> | ||
106 | <label i18n for="password">Password</label> | ||
107 | <my-help *ngIf="isPasswordOptional()"> | ||
108 | <ng-template ptTemplate="customHtml"> | ||
109 | <ng-container i18n> | ||
110 | If you leave the password empty, an email will be sent to the user. | ||
111 | </ng-container> | ||
112 | </ng-template> | ||
113 | </my-help> | ||
114 | <input | ||
115 | type="password" id="password" autocomplete="new-password" class="form-control" | ||
116 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | ||
117 | > | ||
118 | <div *ngIf="formErrors.password" class="form-error"> | ||
119 | {{ formErrors.password }} | ||
120 | </div> | ||
121 | </div> | ||
122 | |||
123 | <div class="form-group"> | ||
124 | <label i18n for="role">Role</label> | ||
125 | <div class="peertube-select-container"> | ||
126 | <select id="role" formControlName="role" class="form-control"> | ||
127 | <option *ngFor="let role of roles" [value]="role.value"> | ||
128 | {{ role.label }} | ||
129 | </option> | ||
130 | </select> | ||
131 | </div> | ||
132 | |||
133 | <div *ngIf="formErrors.role" class="form-error"> | ||
134 | {{ formErrors.role }} | ||
135 | </div> | ||
136 | </div> | ||
137 | |||
138 | <div class="form-group"> | ||
139 | <label i18n for="videoQuota">Video quota</label> | ||
140 | <div class="peertube-select-container"> | ||
141 | <select id="videoQuota" formControlName="videoQuota" class="form-control"> | ||
142 | <option *ngFor="let videoQuotaOption of videoQuotaOptions" [value]="videoQuotaOption.value" [disabled]="videoQuotaOption.disabled"> | ||
143 | {{ videoQuotaOption.label }} | ||
144 | </option> | ||
145 | </select> | ||
146 | </div> | ||
147 | |||
148 | <div i18n class="transcoding-information" *ngIf="isTranscodingInformationDisplayed()"> | ||
149 | Transcoding is enabled. The video quota only takes into account <strong>original</strong> video size. <br /> | ||
150 | At most, this user could upload ~ {{ computeQuotaWithTranscoding() | bytes: 0 }}. | ||
151 | </div> | ||
152 | </div> | ||
153 | |||
154 | <div class="form-group"> | ||
155 | <label i18n for="videoQuotaDaily">Daily video quota</label> | ||
156 | <div class="peertube-select-container"> | ||
157 | <select id="videoQuotaDaily" formControlName="videoQuotaDaily" class="form-control"> | ||
158 | <option *ngFor="let videoQuotaDailyOption of videoQuotaDailyOptions" [value]="videoQuotaDailyOption.value" [disabled]="videoQuotaDailyOption.disabled"> | ||
159 | {{ videoQuotaDailyOption.label }} | ||
160 | </option> | ||
161 | </select> | ||
162 | </div> | ||
163 | </div> | ||
164 | |||
165 | <div class="form-group"> | ||
166 | <my-peertube-checkbox | ||
167 | inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist" | ||
168 | i18n-labelText labelText="Doesn't need review before a video goes public" | ||
169 | ></my-peertube-checkbox> | ||
170 | </div> | ||
171 | |||
172 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> | ||
173 | </form> | ||
174 | |||
175 | <div *ngIf="isInBigView()" class="col-7"> | ||
176 | <ng-template *ngTemplateOutlet="dashboard"></ng-template> | ||
80 | </div> | 177 | </div> |
178 | |||
81 | </div> | 179 | </div> |
180 | </div> | ||
181 | |||
82 | 182 | ||
83 | <div class="form-group"> | 183 | <div *ngIf="!isCreation() && user" class="form-row mt-4"> <!-- danger zone grid --> |
84 | <my-peertube-checkbox | 184 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
85 | inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist" | 185 | <div class="anchor" id="danger"></div> <!-- danger zone anchor --> |
86 | i18n-labelText labelText="Bypass video auto blacklist" | 186 | <div i18n class="account-title">DANGER ZONE</div> |
87 | ></my-peertube-checkbox> | ||
88 | </div> | 187 | </div> |
89 | 188 | ||
90 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> | 189 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9" [ngClass]="{ 'form-row': isInBigView() }"> |
91 | </form> | ||
92 | 190 | ||
93 | <div *ngIf="!isCreation()" class="danger-zone"> | 191 | <div class="danger-zone"> |
94 | <div class="account-title" i18n>Danger Zone</div> | 192 | <div class="form-group reset-password-email"> |
193 | <label i18n>Send a link to reset the password by email to the user</label> | ||
194 | <button (click)="resetPassword()" i18n>Ask for new password</button> | ||
195 | </div> | ||
95 | 196 | ||
96 | <div class="form-group reset-password-email"> | 197 | <div class="form-group"> |
97 | <label i18n>Send a link to reset the password by email to the user</label> | 198 | <label i18n>Manually set the user password</label> |
98 | <button (click)="resetPassword()" i18n>Ask for new password</button> | 199 | <my-user-password [userId]="user.id"></my-user-password> |
99 | </div> | 200 | </div> |
201 | </div> | ||
100 | 202 | ||
101 | <div class="form-group"> | ||
102 | <label i18n>Manually set the user password</label> | ||
103 | <my-user-password [userId]="userId"></my-user-password> | ||
104 | </div> | 203 | </div> |
105 | </div> | 204 | </div> |
diff --git a/client/src/app/+admin/users/user-edit/user-edit.component.scss b/client/src/app/+admin/users/user-edit/user-edit.component.scss index c1cc4ca45..d4c1b600e 100644 --- a/client/src/app/+admin/users/user-edit/user-edit.component.scss +++ b/client/src/app/+admin/users/user-edit/user-edit.component.scss | |||
@@ -1,8 +1,13 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .form-sub-title { | 4 | label { |
5 | margin-bottom: 30px; | 5 | font-weight: $font-regular; |
6 | font-size: 100%; | ||
7 | } | ||
8 | |||
9 | .account-title { | ||
10 | @include settings-big-title; | ||
6 | } | 11 | } |
7 | 12 | ||
8 | input:not([type=submit]) { | 13 | input:not([type=submit]) { |
@@ -26,18 +31,9 @@ input[type=submit], button { | |||
26 | font-size: 11px; | 31 | font-size: 11px; |
27 | } | 32 | } |
28 | 33 | ||
29 | .account-title { | ||
30 | @include in-content-small-title; | ||
31 | |||
32 | margin-top: 55px; | ||
33 | margin-bottom: 30px; | ||
34 | } | ||
35 | |||
36 | .danger-zone { | 34 | .danger-zone { |
37 | .reset-password-email { | 35 | .reset-password-email { |
38 | margin-bottom: 30px; | 36 | margin-bottom: 30px; |
39 | padding-bottom: 30px; | ||
40 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); | ||
41 | 37 | ||
42 | button { | 38 | button { |
43 | display: block; | 39 | display: block; |
@@ -45,3 +41,20 @@ input[type=submit], button { | |||
45 | } | 41 | } |
46 | } | 42 | } |
47 | } | 43 | } |
44 | |||
45 | .breadcrumb { | ||
46 | @include breadcrumb; | ||
47 | } | ||
48 | |||
49 | .dashboard { | ||
50 | @include dashboard; | ||
51 | max-width: 900px; | ||
52 | } | ||
53 | |||
54 | my-actor-avatar-info ::ng-deep { | ||
55 | .actor-img-edit-container, | ||
56 | .actor-info-followers, | ||
57 | .actor-info-username { | ||
58 | display: none; | ||
59 | } | ||
60 | } | ||
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 02f1dcd42..6e2952c44 100644 --- a/client/src/app/+admin/users/user-edit/user-edit.ts +++ b/client/src/app/+admin/users/user-edit/user-edit.ts | |||
@@ -4,17 +4,22 @@ import { ServerConfig, USER_ROLE_LABELS, UserRole, VideoResolution } from '../.. | |||
4 | import { ConfigService } from '@app/+admin/config/shared/config.service' | 4 | import { ConfigService } from '@app/+admin/config/shared/config.service' |
5 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' | 5 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' |
6 | import { OnInit } from '@angular/core' | 6 | import { OnInit } from '@angular/core' |
7 | import { User } from '@app/shared/users/user.model' | ||
8 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
7 | 9 | ||
8 | export abstract class UserEdit extends FormReactive implements OnInit { | 10 | export abstract class UserEdit extends FormReactive implements OnInit { |
9 | videoQuotaOptions: { value: string, label: string }[] = [] | 11 | videoQuotaOptions: { value: string, label: string, disabled?: boolean }[] = [] |
10 | videoQuotaDailyOptions: { value: string, label: string }[] = [] | 12 | videoQuotaDailyOptions: { value: string, label: string, disabled?: boolean }[] = [] |
11 | username: string | 13 | username: string |
12 | userId: number | 14 | user: User |
15 | |||
16 | roles: { value: string, label: string }[] = [] | ||
13 | 17 | ||
14 | protected serverConfig: ServerConfig | 18 | protected serverConfig: ServerConfig |
15 | 19 | ||
16 | protected abstract serverService: ServerService | 20 | protected abstract serverService: ServerService |
17 | protected abstract configService: ConfigService | 21 | protected abstract configService: ConfigService |
22 | protected abstract screenService: ScreenService | ||
18 | protected abstract auth: AuthService | 23 | protected abstract auth: AuthService |
19 | abstract isCreation (): boolean | 24 | abstract isCreation (): boolean |
20 | abstract getFormButtonTitle (): string | 25 | abstract getFormButtonTitle (): string |
@@ -23,17 +28,34 @@ export abstract class UserEdit extends FormReactive implements OnInit { | |||
23 | this.serverConfig = this.serverService.getTmpConfig() | 28 | this.serverConfig = this.serverService.getTmpConfig() |
24 | this.serverService.getConfig() | 29 | this.serverService.getConfig() |
25 | .subscribe(config => this.serverConfig = config) | 30 | .subscribe(config => this.serverConfig = config) |
31 | |||
32 | this.buildRoles() | ||
33 | } | ||
34 | |||
35 | get subscribersCount () { | ||
36 | const forAccount = this.user | ||
37 | ? this.user.account.followersCount | ||
38 | : 0 | ||
39 | const forChannels = this.user | ||
40 | ? this.user.videoChannels.map(c => c.followersCount).reduce((a, b) => a + b, 0) | ||
41 | : 0 | ||
42 | return forAccount + forChannels | ||
43 | } | ||
44 | |||
45 | isInBigView () { | ||
46 | return this.screenService.getWindowInnerWidth() > 1600 | ||
26 | } | 47 | } |
27 | 48 | ||
28 | getRoles () { | 49 | buildRoles () { |
29 | const authUser = this.auth.getUser() | 50 | const authUser = this.auth.getUser() |
30 | 51 | ||
31 | if (authUser.role === UserRole.ADMINISTRATOR) { | 52 | if (authUser.role === UserRole.ADMINISTRATOR) { |
32 | return Object.keys(USER_ROLE_LABELS) | 53 | this.roles = Object.keys(USER_ROLE_LABELS) |
33 | .map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) | 54 | .map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) |
55 | return | ||
34 | } | 56 | } |
35 | 57 | ||
36 | return [ | 58 | this.roles = [ |
37 | { value: UserRole.USER.toString(), label: USER_ROLE_LABELS[UserRole.USER] } | 59 | { value: UserRole.USER.toString(), label: USER_ROLE_LABELS[UserRole.USER] } |
38 | ] | 60 | ] |
39 | } | 61 | } |
@@ -72,9 +94,22 @@ export abstract class UserEdit extends FormReactive implements OnInit { | |||
72 | protected buildQuotaOptions () { | 94 | protected buildQuotaOptions () { |
73 | // These are used by a HTML select, so convert key into strings | 95 | // These are used by a HTML select, so convert key into strings |
74 | this.videoQuotaOptions = this.configService | 96 | this.videoQuotaOptions = this.configService |
75 | .videoQuotaOptions.map(q => ({ value: q.value.toString(), label: q.label })) | 97 | .videoQuotaOptions.map(q => ({ |
98 | value: q.value?.toString(), | ||
99 | label: q.label, | ||
100 | disabled: q.disabled | ||
101 | })) | ||
76 | 102 | ||
77 | this.videoQuotaDailyOptions = this.configService | 103 | this.videoQuotaDailyOptions = this.configService |
78 | .videoQuotaDailyOptions.map(q => ({ value: q.value.toString(), label: q.label })) | 104 | .videoQuotaDailyOptions.map(q => ({ |
105 | value: q.value?.toString(), | ||
106 | label: q.label, | ||
107 | disabled: q.disabled | ||
108 | })) | ||
109 | |||
110 | console.log( | ||
111 | this.videoQuotaOptions, | ||
112 | this.videoQuotaDailyOptions | ||
113 | ) | ||
79 | } | 114 | } |
80 | } | 115 | } |
diff --git a/client/src/app/+admin/users/user-edit/user-password.component.html b/client/src/app/+admin/users/user-edit/user-password.component.html index a1e1f6216..1238d1839 100644 --- a/client/src/app/+admin/users/user-edit/user-password.component.html +++ b/client/src/app/+admin/users/user-edit/user-password.component.html | |||
@@ -2,7 +2,7 @@ | |||
2 | <div class="form-group"> | 2 | <div class="form-group"> |
3 | 3 | ||
4 | <div class="input-group"> | 4 | <div class="input-group"> |
5 | <input id="password" [attr.type]="showPassword ? 'text' : 'password'" | 5 | <input id="password" [attr.type]="showPassword ? 'text' : 'password'" class="form-control" |
6 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | 6 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" |
7 | > | 7 | > |
8 | <div class="input-group-append"> | 8 | <div class="input-group-append"> |
diff --git a/client/src/app/+admin/users/user-edit/user-password.component.ts b/client/src/app/+admin/users/user-edit/user-password.component.ts index 5b3040440..ecad000f7 100644 --- a/client/src/app/+admin/users/user-edit/user-password.component.ts +++ b/client/src/app/+admin/users/user-edit/user-password.component.ts | |||
@@ -23,8 +23,6 @@ export class UserPasswordComponent extends FormReactive implements OnInit { | |||
23 | constructor ( | 23 | constructor ( |
24 | protected formValidatorService: FormValidatorService, | 24 | protected formValidatorService: FormValidatorService, |
25 | private userValidatorsService: UserValidatorsService, | 25 | private userValidatorsService: UserValidatorsService, |
26 | private route: ActivatedRoute, | ||
27 | private router: Router, | ||
28 | private notifier: Notifier, | 26 | private notifier: Notifier, |
29 | private userService: UserService, | 27 | private userService: UserService, |
30 | private i18n: I18n | 28 | private i18n: I18n |
diff --git a/client/src/app/+admin/users/user-edit/user-update.component.ts b/client/src/app/+admin/users/user-edit/user-update.component.ts index d1682a99d..e0e1fbddf 100644 --- a/client/src/app/+admin/users/user-edit/user-update.component.ts +++ b/client/src/app/+admin/users/user-edit/user-update.component.ts | |||
@@ -4,13 +4,15 @@ import { Subscription } from 'rxjs' | |||
4 | import { AuthService, Notifier } from '@app/core' | 4 | import { AuthService, Notifier } from '@app/core' |
5 | import { ServerService } from '../../../core' | 5 | import { ServerService } from '../../../core' |
6 | import { UserEdit } from './user-edit' | 6 | import { UserEdit } from './user-edit' |
7 | import { User, UserUpdate } from '../../../../../../shared' | 7 | import { User as UserType, UserUpdate, UserRole } from '../../../../../../shared' |
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | 8 | import { I18n } from '@ngx-translate/i18n-polyfill' |
9 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 9 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
10 | import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' | 10 | import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' |
11 | import { ConfigService } from '@app/+admin/config/shared/config.service' | 11 | import { ConfigService } from '@app/+admin/config/shared/config.service' |
12 | import { UserService } from '@app/shared' | 12 | import { UserService } from '@app/shared' |
13 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' | 13 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' |
14 | import { User } from '@app/shared/users/user.model' | ||
15 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
14 | 16 | ||
15 | @Component({ | 17 | @Component({ |
16 | selector: 'my-user-update', | 18 | selector: 'my-user-update', |
@@ -19,9 +21,6 @@ import { UserAdminFlag } from '@shared/models/users/user-flag.model' | |||
19 | }) | 21 | }) |
20 | export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | 22 | export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { |
21 | error: string | 23 | error: string |
22 | userId: number | ||
23 | userEmail: string | ||
24 | username: string | ||
25 | 24 | ||
26 | private paramsSub: Subscription | 25 | private paramsSub: Subscription |
27 | 26 | ||
@@ -29,6 +28,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | |||
29 | protected formValidatorService: FormValidatorService, | 28 | protected formValidatorService: FormValidatorService, |
30 | protected serverService: ServerService, | 29 | protected serverService: ServerService, |
31 | protected configService: ConfigService, | 30 | protected configService: ConfigService, |
31 | protected screenService: ScreenService, | ||
32 | protected auth: AuthService, | 32 | protected auth: AuthService, |
33 | private userValidatorsService: UserValidatorsService, | 33 | private userValidatorsService: UserValidatorsService, |
34 | private route: ActivatedRoute, | 34 | private route: ActivatedRoute, |
@@ -45,7 +45,12 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | |||
45 | ngOnInit () { | 45 | ngOnInit () { |
46 | super.ngOnInit() | 46 | super.ngOnInit() |
47 | 47 | ||
48 | const defaultValues = { videoQuota: '-1', videoQuotaDaily: '-1' } | 48 | const defaultValues = { |
49 | role: UserRole.USER.toString(), | ||
50 | videoQuota: '-1', | ||
51 | videoQuotaDaily: '-1' | ||
52 | } | ||
53 | |||
49 | this.buildForm({ | 54 | this.buildForm({ |
50 | email: this.userValidatorsService.USER_EMAIL, | 55 | email: this.userValidatorsService.USER_EMAIL, |
51 | role: this.userValidatorsService.USER_ROLE, | 56 | role: this.userValidatorsService.USER_ROLE, |
@@ -56,7 +61,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | |||
56 | 61 | ||
57 | this.paramsSub = this.route.params.subscribe(routeParams => { | 62 | this.paramsSub = this.route.params.subscribe(routeParams => { |
58 | const userId = routeParams['id'] | 63 | const userId = routeParams['id'] |
59 | this.userService.getUser(userId).subscribe( | 64 | this.userService.getUser(userId, true).subscribe( |
60 | user => this.onUserFetched(user), | 65 | user => this.onUserFetched(user), |
61 | 66 | ||
62 | err => this.error = err.message | 67 | err => this.error = err.message |
@@ -78,9 +83,9 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | |||
78 | userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10) | 83 | userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10) |
79 | userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10) | 84 | userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10) |
80 | 85 | ||
81 | this.userService.updateUser(this.userId, userUpdate).subscribe( | 86 | this.userService.updateUser(this.user.id, userUpdate).subscribe( |
82 | () => { | 87 | () => { |
83 | this.notifier.success(this.i18n('User {{username}} updated.', { username: this.username })) | 88 | this.notifier.success(this.i18n('User {{username}} updated.', { username: this.user.username })) |
84 | this.router.navigate([ '/admin/users/list' ]) | 89 | this.router.navigate([ '/admin/users/list' ]) |
85 | }, | 90 | }, |
86 | 91 | ||
@@ -92,15 +97,19 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | |||
92 | return false | 97 | return false |
93 | } | 98 | } |
94 | 99 | ||
100 | isPasswordOptional () { | ||
101 | return false | ||
102 | } | ||
103 | |||
95 | getFormButtonTitle () { | 104 | getFormButtonTitle () { |
96 | return this.i18n('Update user') | 105 | return this.i18n('Update user') |
97 | } | 106 | } |
98 | 107 | ||
99 | resetPassword () { | 108 | resetPassword () { |
100 | this.userService.askResetPassword(this.userEmail).subscribe( | 109 | this.userService.askResetPassword(this.user.email).subscribe( |
101 | () => { | 110 | () => { |
102 | this.notifier.success( | 111 | this.notifier.success( |
103 | this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.username }) | 112 | this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.user.username }) |
104 | ) | 113 | ) |
105 | }, | 114 | }, |
106 | 115 | ||
@@ -108,14 +117,12 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | |||
108 | ) | 117 | ) |
109 | } | 118 | } |
110 | 119 | ||
111 | private onUserFetched (userJson: User) { | 120 | private onUserFetched (userJson: UserType) { |
112 | this.userId = userJson.id | 121 | this.user = new User(userJson) |
113 | this.username = userJson.username | ||
114 | this.userEmail = userJson.email | ||
115 | 122 | ||
116 | this.form.patchValue({ | 123 | this.form.patchValue({ |
117 | email: userJson.email, | 124 | email: userJson.email, |
118 | role: userJson.role, | 125 | role: userJson.role.toString(), |
119 | videoQuota: userJson.videoQuota, | 126 | videoQuota: userJson.videoQuota, |
120 | videoQuotaDaily: userJson.videoQuotaDaily, | 127 | videoQuotaDaily: userJson.videoQuotaDaily, |
121 | byPassAutoBlacklist: userJson.adminFlags & UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST | 128 | byPassAutoBlacklist: userJson.adminFlags & UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST |
diff --git a/client/src/app/+admin/users/user-list/user-list.component.html b/client/src/app/+admin/users/user-list/user-list.component.html index ca05bac19..768a3034d 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.html +++ b/client/src/app/+admin/users/user-list/user-list.component.html | |||
@@ -8,9 +8,12 @@ | |||
8 | </div> | 8 | </div> |
9 | 9 | ||
10 | <p-table | 10 | <p-table |
11 | [value]="users" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 11 | [value]="users" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" |
12 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" | 12 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true" |
13 | [(selection)]="selectedUsers" | 13 | [(selection)]="selectedUsers" |
14 | [showCurrentPageReport]="true" i18n-currentPageReportTemplate | ||
15 | currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users" | ||
16 | (onPage)="onPage($event)" [expandedRowKeys]="expandedRows" | ||
14 | > | 17 | > |
15 | <ng-template pTemplate="caption"> | 18 | <ng-template pTemplate="caption"> |
16 | <div class="caption"> | 19 | <div class="caption"> |
@@ -22,11 +25,13 @@ | |||
22 | </my-action-dropdown> | 25 | </my-action-dropdown> |
23 | </div> | 26 | </div> |
24 | 27 | ||
25 | <div> | 28 | <div class="has-feedback has-clear"> |
26 | <input | 29 | <input |
27 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." | 30 | type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..." |
28 | (keyup)="onSearch($event.target.value)" | 31 | (keyup)="onSearch($event)" |
29 | > | 32 | > |
33 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a> | ||
34 | <span class="sr-only" i18n>Clear filters</span> | ||
30 | </div> | 35 | </div> |
31 | </div> | 36 | </div> |
32 | </ng-template> | 37 | </ng-template> |
@@ -37,11 +42,12 @@ | |||
37 | <p-tableHeaderCheckbox></p-tableHeaderCheckbox> | 42 | <p-tableHeaderCheckbox></p-tableHeaderCheckbox> |
38 | </th> | 43 | </th> |
39 | <th style="width: 40px"></th> | 44 | <th style="width: 40px"></th> |
40 | <th i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th> | 45 | <th pResizableColumn i18n pSortableColumn="username">Username <p-sortIcon field="username"></p-sortIcon></th> |
41 | <th i18n>Email</th> | 46 | <th i18n>Email</th> |
42 | <th i18n pSortableColumn="videoQuotaUsed">Video quota <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th> | 47 | <th style="width: 140px;" i18n pSortableColumn="videoQuotaUsed">Video quota <p-sortIcon field="videoQuotaUsed"></p-sortIcon></th> |
43 | <th i18n>Role</th> | 48 | <th style="width: 120px;" i18n>Role</th> |
44 | <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> | 49 | <th style="width: 140px;" pResizableColumn i18n>Auth plugin</th> |
50 | <th style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> | ||
45 | <th style="width: 50px;"></th> | 51 | <th style="width: 50px;"></th> |
46 | </tr> | 52 | </tr> |
47 | </ng-template> | 53 | </ng-template> |
@@ -49,19 +55,30 @@ | |||
49 | <ng-template pTemplate="body" let-expanded="expanded" let-user> | 55 | <ng-template pTemplate="body" let-expanded="expanded" let-user> |
50 | 56 | ||
51 | <tr [pSelectableRow]="user" [ngClass]="{ banned: user.blocked }"> | 57 | <tr [pSelectableRow]="user" [ngClass]="{ banned: user.blocked }"> |
52 | <td class="expand-cell"> | 58 | <td> |
53 | <p-tableCheckbox [value]="user"></p-tableCheckbox> | 59 | <p-tableCheckbox [value]="user"></p-tableCheckbox> |
54 | </td> | 60 | </td> |
55 | 61 | ||
56 | <td> | 62 | <td class="expand-cell"> |
57 | <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user"> | 63 | <span *ngIf="user.blockedReason" class="expander" [pRowToggler]="user"> |
58 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> | 64 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> |
59 | </span> | 65 | </span> |
60 | </td> | 66 | </td> |
61 | 67 | ||
62 | <td> | 68 | <td> |
63 | <a i18n-title title="Go to the account page" target="_blank" rel="noopener noreferrer" [routerLink]="[ '/accounts/' + user.username ]"> | 69 | <a i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer" [routerLink]="[ '/accounts/' + user.username ]"> |
64 | {{ user.username }} | 70 | <div class="chip two-lines"> |
71 | <img | ||
72 | class="avatar" | ||
73 | [src]="user?.account?.avatar?.path" | ||
74 | (error)="switchToDefaultAvatar($event)" | ||
75 | alt="Avatar" | ||
76 | > | ||
77 | <div> | ||
78 | {{ user.account.displayName }} | ||
79 | <span class="text-muted">{{ user.username }}</span> | ||
80 | </div> | ||
81 | </div> | ||
65 | <span i18n *ngIf="user.blocked" class="banned-info">(banned)</span> | 82 | <span i18n *ngIf="user.blocked" class="banned-info">(banned)</span> |
66 | </a> | 83 | </a> |
67 | </td> | 84 | </td> |
@@ -81,7 +98,13 @@ | |||
81 | 98 | ||
82 | <td>{{ user.videoQuotaUsed }} / {{ user.videoQuota }}</td> | 99 | <td>{{ user.videoQuotaUsed }} / {{ user.videoQuota }}</td> |
83 | <td>{{ user.roleLabel }}</td> | 100 | <td>{{ user.roleLabel }}</td> |
84 | <td [title]="user.createdAt">{{ user.createdAt }}</td> | 101 | |
102 | <td> | ||
103 | <ng-container *ngIf="user.pluginAuth">{{ user.pluginAuth }}</ng-container> | ||
104 | </td> | ||
105 | |||
106 | <td [title]="user.createdAt">{{ user.createdAt | date: 'short' }}</td> | ||
107 | |||
85 | <td class="action-cell"> | 108 | <td class="action-cell"> |
86 | <my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()"> | 109 | <my-user-moderation-dropdown *ngIf="!isInSelectionMode()" [user]="user" (userChanged)="onUserChanged()" (userDeleted)="onUserChanged()"> |
87 | </my-user-moderation-dropdown> | 110 | </my-user-moderation-dropdown> |
diff --git a/client/src/app/+admin/users/user-list/user-list.component.scss b/client/src/app/+admin/users/user-list/user-list.component.scss index 5274be01c..99b22aaea 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.scss +++ b/client/src/app/+admin/users/user-list/user-list.component.scss | |||
@@ -24,3 +24,12 @@ tr.banned { | |||
24 | @include peertube-input-text(250px); | 24 | @include peertube-input-text(250px); |
25 | } | 25 | } |
26 | } | 26 | } |
27 | |||
28 | p-tableCheckbox { | ||
29 | position: relative; | ||
30 | top: -2.5px; | ||
31 | } | ||
32 | |||
33 | .chip { | ||
34 | @include chip; | ||
35 | } | ||
diff --git a/client/src/app/+admin/users/user-list/user-list.component.ts b/client/src/app/+admin/users/user-list/user-list.component.ts index 7c5e8eaa4..da50b7ed0 100644 --- a/client/src/app/+admin/users/user-list/user-list.component.ts +++ b/client/src/app/+admin/users/user-list/user-list.component.ts | |||
@@ -1,12 +1,13 @@ | |||
1 | import { Component, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, OnInit, ViewChild } from '@angular/core' |
2 | import { AuthService, Notifier } from '@app/core' | 2 | import { AuthService, Notifier } from '@app/core' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | 3 | import { SortMeta } from 'primeng/api' |
4 | import { ConfirmService, ServerService } from '../../../core' | 4 | import { ConfirmService, ServerService } from '../../../core' |
5 | import { RestPagination, RestTable, UserService } from '../../../shared' | 5 | import { RestPagination, RestTable, UserService } from '../../../shared' |
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | 6 | import { I18n } from '@ngx-translate/i18n-polyfill' |
7 | import { ServerConfig, User } from '../../../../../../shared' | 7 | import { ServerConfig, User } from '../../../../../../shared' |
8 | import { UserBanModalComponent } from '@app/shared/moderation' | 8 | import { UserBanModalComponent } from '@app/shared/moderation' |
9 | import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' | 9 | import { DropdownAction } from '@app/shared/buttons/action-dropdown.component' |
10 | import { Actor } from '@app/shared/actor/actor.model' | ||
10 | 11 | ||
11 | @Component({ | 12 | @Component({ |
12 | selector: 'my-user-list', | 13 | selector: 'my-user-list', |
@@ -18,7 +19,6 @@ export class UserListComponent extends RestTable implements OnInit { | |||
18 | 19 | ||
19 | users: User[] = [] | 20 | users: User[] = [] |
20 | totalRecords = 0 | 21 | totalRecords = 0 |
21 | rowsPerPage = 10 | ||
22 | sort: SortMeta = { field: 'createdAt', order: 1 } | 22 | sort: SortMeta = { field: 'createdAt', order: 1 } |
23 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 23 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
24 | 24 | ||
@@ -86,6 +86,10 @@ export class UserListComponent extends RestTable implements OnInit { | |||
86 | ] | 86 | ] |
87 | } | 87 | } |
88 | 88 | ||
89 | getIdentifier () { | ||
90 | return 'UserListComponent' | ||
91 | } | ||
92 | |||
89 | openBanUserModal (users: User[]) { | 93 | openBanUserModal (users: User[]) { |
90 | for (const user of users) { | 94 | for (const user of users) { |
91 | if (user.username === 'root') { | 95 | if (user.username === 'root') { |
@@ -101,6 +105,10 @@ export class UserListComponent extends RestTable implements OnInit { | |||
101 | this.loadData() | 105 | this.loadData() |
102 | } | 106 | } |
103 | 107 | ||
108 | switchToDefaultAvatar ($event: Event) { | ||
109 | ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() | ||
110 | } | ||
111 | |||
104 | async unbanUsers (users: User[]) { | 112 | async unbanUsers (users: User[]) { |
105 | const message = this.i18n('Do you really want to unban {{num}} users?', { num: users.length }) | 113 | const message = this.i18n('Do you really want to unban {{num}} users?', { num: users.length }) |
106 | 114 | ||
diff --git a/client/src/app/+admin/users/users.routes.ts b/client/src/app/+admin/users/users.routes.ts index 8b3791bd3..2d4f9305e 100644 --- a/client/src/app/+admin/users/users.routes.ts +++ b/client/src/app/+admin/users/users.routes.ts | |||
@@ -5,6 +5,7 @@ import { UserRight } from '../../../../../shared' | |||
5 | import { UsersComponent } from './users.component' | 5 | import { UsersComponent } from './users.component' |
6 | import { UserCreateComponent, UserUpdateComponent } from './user-edit' | 6 | import { UserCreateComponent, UserUpdateComponent } from './user-edit' |
7 | import { UserListComponent } from './user-list' | 7 | import { UserListComponent } from './user-list' |
8 | import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service' | ||
8 | 9 | ||
9 | export const UsersRoutes: Routes = [ | 10 | export const UsersRoutes: Routes = [ |
10 | { | 11 | { |
@@ -36,6 +37,9 @@ export const UsersRoutes: Routes = [ | |||
36 | meta: { | 37 | meta: { |
37 | title: 'Create a user' | 38 | title: 'Create a user' |
38 | } | 39 | } |
40 | }, | ||
41 | resolve: { | ||
42 | serverConfig: ServerConfigResolver | ||
39 | } | 43 | } |
40 | }, | 44 | }, |
41 | { | 45 | { |
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.html b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.html index a96a11f5e..fb9e6546e 100644 --- a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.html +++ b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.html | |||
@@ -3,7 +3,7 @@ | |||
3 | </div> | 3 | </div> |
4 | 4 | ||
5 | <p-table | 5 | <p-table |
6 | [value]="blockedAccounts" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 6 | [value]="blockedAccounts" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" |
7 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" | 7 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" |
8 | > | 8 | > |
9 | 9 | ||
@@ -11,6 +11,7 @@ | |||
11 | <tr> | 11 | <tr> |
12 | <th i18n>Account</th> | 12 | <th i18n>Account</th> |
13 | <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> | 13 | <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> |
14 | <th></th> <!-- column for action buttons --> | ||
14 | </tr> | 15 | </tr> |
15 | </ng-template> | 16 | </ng-template> |
16 | 17 | ||
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts index e3025dec4..fd1fabcdb 100644 --- a/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts +++ b/client/src/app/+my-account/my-account-blocklist/my-account-blocklist.component.ts | |||
@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core' | |||
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | 3 | import { I18n } from '@ngx-translate/i18n-polyfill' |
4 | import { RestPagination, RestTable } from '@app/shared' | 4 | import { RestPagination, RestTable } from '@app/shared' |
5 | import { SortMeta } from 'primeng/components/common/sortmeta' | 5 | import { SortMeta } from 'primeng/api' |
6 | import { AccountBlock, BlocklistService } from '@app/shared/blocklist' | 6 | import { AccountBlock, BlocklistService } from '@app/shared/blocklist' |
7 | 7 | ||
8 | @Component({ | 8 | @Component({ |
@@ -13,7 +13,6 @@ import { AccountBlock, BlocklistService } from '@app/shared/blocklist' | |||
13 | export class MyAccountBlocklistComponent extends RestTable implements OnInit { | 13 | export class MyAccountBlocklistComponent extends RestTable implements OnInit { |
14 | blockedAccounts: AccountBlock[] = [] | 14 | blockedAccounts: AccountBlock[] = [] |
15 | totalRecords = 0 | 15 | totalRecords = 0 |
16 | rowsPerPage = 10 | ||
17 | sort: SortMeta = { field: 'createdAt', order: -1 } | 16 | sort: SortMeta = { field: 'createdAt', order: -1 } |
18 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 17 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
19 | 18 | ||
@@ -29,6 +28,10 @@ export class MyAccountBlocklistComponent extends RestTable implements OnInit { | |||
29 | this.initialize() | 28 | this.initialize() |
30 | } | 29 | } |
31 | 30 | ||
31 | getIdentifier () { | ||
32 | return 'MyAccountBlocklistComponent' | ||
33 | } | ||
34 | |||
32 | unblockAccount (accountBlock: AccountBlock) { | 35 | unblockAccount (accountBlock: AccountBlock) { |
33 | const blockedAccount = accountBlock.blockedAccount | 36 | const blockedAccount = accountBlock.blockedAccount |
34 | 37 | ||
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.html b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.html index 329cfb08f..6359b4461 100644 --- a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.html +++ b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.html | |||
@@ -3,7 +3,7 @@ | |||
3 | </div> | 3 | </div> |
4 | 4 | ||
5 | <p-table | 5 | <p-table |
6 | [value]="blockedServers" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 6 | [value]="blockedServers" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" |
7 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" | 7 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" |
8 | > | 8 | > |
9 | 9 | ||
@@ -11,7 +11,7 @@ | |||
11 | <tr> | 11 | <tr> |
12 | <th i18n>Instance</th> | 12 | <th i18n>Instance</th> |
13 | <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> | 13 | <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th> |
14 | <th></th> | 14 | <th></th> <!-- column for action buttons --> |
15 | </tr> | 15 | </tr> |
16 | </ng-template> | 16 | </ng-template> |
17 | 17 | ||
diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts index 4c5cc28b8..483c11804 100644 --- a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts +++ b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts | |||
@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core' | |||
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | 3 | import { I18n } from '@ngx-translate/i18n-polyfill' |
4 | import { RestPagination, RestTable } from '@app/shared' | 4 | import { RestPagination, RestTable } from '@app/shared' |
5 | import { SortMeta } from 'primeng/components/common/sortmeta' | 5 | import { SortMeta } from 'primeng/api' |
6 | import { ServerBlock } from '../../../../../shared' | 6 | import { ServerBlock } from '../../../../../shared' |
7 | import { BlocklistService } from '@app/shared/blocklist' | 7 | import { BlocklistService } from '@app/shared/blocklist' |
8 | 8 | ||
@@ -14,7 +14,6 @@ import { BlocklistService } from '@app/shared/blocklist' | |||
14 | export class MyAccountServerBlocklistComponent extends RestTable implements OnInit { | 14 | export class MyAccountServerBlocklistComponent extends RestTable implements OnInit { |
15 | blockedServers: ServerBlock[] = [] | 15 | blockedServers: ServerBlock[] = [] |
16 | totalRecords = 0 | 16 | totalRecords = 0 |
17 | rowsPerPage = 10 | ||
18 | sort: SortMeta = { field: 'createdAt', order: -1 } | 17 | sort: SortMeta = { field: 'createdAt', order: -1 } |
19 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 18 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
20 | 19 | ||
@@ -30,6 +29,10 @@ export class MyAccountServerBlocklistComponent extends RestTable implements OnIn | |||
30 | this.initialize() | 29 | this.initialize() |
31 | } | 30 | } |
32 | 31 | ||
32 | getIdentifier () { | ||
33 | return 'MyAccountServerBlocklistComponent' | ||
34 | } | ||
35 | |||
33 | unblockServer (serverBlock: ServerBlock) { | 36 | unblockServer (serverBlock: ServerBlock) { |
34 | const host = serverBlock.blockedServer.host | 37 | const host = serverBlock.blockedServer.host |
35 | 38 | ||
diff --git a/client/src/app/+my-account/my-account-history/my-account-history.component.html b/client/src/app/+my-account/my-account-history/my-account-history.component.html index 4c361cec3..56d63f299 100644 --- a/client/src/app/+my-account/my-account-history/my-account-history.component.html +++ b/client/src/app/+my-account/my-account-history/my-account-history.component.html | |||
@@ -11,7 +11,7 @@ | |||
11 | </div> | 11 | </div> |
12 | 12 | ||
13 | 13 | ||
14 | <div class="no-history" i18n *ngIf="pagination.totalItems === 0">You don't have videos history yet.</div> | 14 | <div class="no-history" i18n *ngIf="pagination.totalItems === 0">You don't have any video history yet.</div> |
15 | 15 | ||
16 | <div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" class="videos"> | 16 | <div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" class="videos"> |
17 | <div class="video" *ngFor="let video of videos"> | 17 | <div class="video" *ngFor="let video of videos"> |
diff --git a/client/src/app/+my-account/my-account-history/my-account-history.component.scss b/client/src/app/+my-account/my-account-history/my-account-history.component.scss index af6395fb1..9eeeaf310 100644 --- a/client/src/app/+my-account/my-account-history/my-account-history.component.scss +++ b/client/src/app/+my-account/my-account-history/my-account-history.component.scss | |||
@@ -12,6 +12,8 @@ | |||
12 | .top-buttons { | 12 | .top-buttons { |
13 | margin-bottom: 20px; | 13 | margin-bottom: 20px; |
14 | display: flex; | 14 | display: flex; |
15 | align-items: center; | ||
16 | flex-wrap: wrap; | ||
15 | 17 | ||
16 | .history-switch { | 18 | .history-switch { |
17 | display: flex; | 19 | display: flex; |
@@ -38,3 +40,20 @@ | |||
38 | flex-grow: 1; | 40 | flex-grow: 1; |
39 | } | 41 | } |
40 | } | 42 | } |
43 | |||
44 | @media screen and (max-width: $mobile-view) { | ||
45 | .top-buttons { | ||
46 | .history-switch label, .delete-history { | ||
47 | @include ellipsis; | ||
48 | } | ||
49 | |||
50 | .history-switch label { | ||
51 | width: 60%; | ||
52 | } | ||
53 | |||
54 | .delete-history { | ||
55 | margin-left: auto; | ||
56 | max-width: 32%; | ||
57 | } | ||
58 | } | ||
59 | } | ||
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 13607119e..5f0ccee50 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 | |||
@@ -11,6 +11,7 @@ import { ScreenService } from '@app/shared/misc/screen.service' | |||
11 | import { UserHistoryService } from '@app/shared/users/user-history.service' | 11 | import { UserHistoryService } from '@app/shared/users/user-history.service' |
12 | import { UserService } from '@app/shared' | 12 | import { UserService } from '@app/shared' |
13 | import { Notifier, ServerService } from '@app/core' | 13 | import { Notifier, ServerService } from '@app/core' |
14 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
14 | 15 | ||
15 | @Component({ | 16 | @Component({ |
16 | selector: 'my-account-history', | 17 | selector: 'my-account-history', |
@@ -35,6 +36,7 @@ export class MyAccountHistoryComponent extends AbstractVideoList implements OnIn | |||
35 | protected userService: UserService, | 36 | protected userService: UserService, |
36 | protected notifier: Notifier, | 37 | protected notifier: Notifier, |
37 | protected screenService: ScreenService, | 38 | protected screenService: ScreenService, |
39 | protected storageService: LocalStorageService, | ||
38 | private confirmService: ConfirmService, | 40 | private confirmService: ConfirmService, |
39 | private videoService: VideoService, | 41 | private videoService: VideoService, |
40 | private userHistoryService: UserHistoryService | 42 | private userHistoryService: UserHistoryService |
diff --git a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss index 43d1f82ab..73f7c7b24 100644 --- a/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss +++ b/client/src/app/+my-account/my-account-notifications/my-account-notifications.component.scss | |||
@@ -23,3 +23,13 @@ | |||
23 | my-user-notifications { | 23 | my-user-notifications { |
24 | font-size: 15px; | 24 | font-size: 15px; |
25 | } | 25 | } |
26 | |||
27 | @media screen and (max-width: $mobile-view) { | ||
28 | .header { | ||
29 | flex-direction: column; | ||
30 | |||
31 | & >:first-child { | ||
32 | margin-bottom: 15px; | ||
33 | } | ||
34 | } | ||
35 | } | ||
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html index 674a4e8a2..a155d90e0 100644 --- a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html +++ b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.html | |||
@@ -21,9 +21,10 @@ | |||
21 | 21 | ||
22 | <div class="modal-footer inputs"> | 22 | <div class="modal-footer inputs"> |
23 | <div class="form-group inputs"> | 23 | <div class="form-group inputs"> |
24 | <span i18n class="action-button action-button-cancel" (click)="dismiss()"> | 24 | <input |
25 | Cancel | 25 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" |
26 | </span> | 26 | (click)="dismiss()" (key.enter)="dismiss()" |
27 | > | ||
27 | 28 | ||
28 | <input | 29 | <input |
29 | type="submit" i18n-value value="Submit" class="action-button-submit" | 30 | type="submit" i18n-value value="Submit" class="action-button-submit" |
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts index 6df929ec9..d5682914e 100644 --- a/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts +++ b/client/src/app/+my-account/my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component.ts | |||
@@ -53,7 +53,7 @@ export class MyAccountAcceptOwnershipComponent extends FormReactive implements O | |||
53 | show (videoChangeOwnership: VideoChangeOwnership) { | 53 | show (videoChangeOwnership: VideoChangeOwnership) { |
54 | this.videoChangeOwnership = videoChangeOwnership | 54 | this.videoChangeOwnership = videoChangeOwnership |
55 | this.modalService | 55 | this.modalService |
56 | .open(this.modal) | 56 | .open(this.modal, { centered: true }) |
57 | .result | 57 | .result |
58 | .then(() => this.acceptOwnership()) | 58 | .then(() => this.acceptOwnership()) |
59 | .catch(() => this.videoChangeOwnership = undefined) | 59 | .catch(() => this.videoChangeOwnership = undefined) |
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html index c5fd3ccb9..354176a11 100644 --- a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html +++ b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.html | |||
@@ -1,7 +1,7 @@ | |||
1 | <p-table | 1 | <p-table |
2 | [value]="videoChangeOwnerships" | 2 | [value]="videoChangeOwnerships" |
3 | [lazy]="true" | 3 | [lazy]="true" |
4 | [paginator]="true" | 4 | [paginator]="totalRecords > 0" |
5 | [totalRecords]="totalRecords" | 5 | [totalRecords]="totalRecords" |
6 | [rows]="rowsPerPage" | 6 | [rows]="rowsPerPage" |
7 | [sortField]="sort.field" | 7 | [sortField]="sort.field" |
diff --git a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts index aeeb0e5a7..f0a6303d1 100644 --- a/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts +++ b/client/src/app/+my-account/my-account-ownership/my-account-ownership.component.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { Component, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, OnInit, ViewChild } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { RestPagination, RestTable } from '@app/shared' | 3 | import { RestPagination, RestTable } from '@app/shared' |
4 | import { SortMeta } from 'primeng/components/common/sortmeta' | 4 | import { SortMeta } from 'primeng/api' |
5 | import { VideoChangeOwnership } from '../../../../../shared' | 5 | import { VideoChangeOwnership } from '../../../../../shared' |
6 | import { VideoOwnershipService } from '@app/shared/video-ownership' | 6 | import { VideoOwnershipService } from '@app/shared/video-ownership' |
7 | import { Account } from '@app/shared/account/account.model' | 7 | import { Account } from '@app/shared/account/account.model' |
@@ -14,7 +14,6 @@ import { MyAccountAcceptOwnershipComponent } from './my-account-accept-ownership | |||
14 | export class MyAccountOwnershipComponent extends RestTable implements OnInit { | 14 | export class MyAccountOwnershipComponent extends RestTable implements OnInit { |
15 | videoChangeOwnerships: VideoChangeOwnership[] = [] | 15 | videoChangeOwnerships: VideoChangeOwnership[] = [] |
16 | totalRecords = 0 | 16 | totalRecords = 0 |
17 | rowsPerPage = 10 | ||
18 | sort: SortMeta = { field: 'createdAt', order: -1 } | 17 | sort: SortMeta = { field: 'createdAt', order: -1 } |
19 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 18 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
20 | 19 | ||
@@ -31,6 +30,10 @@ export class MyAccountOwnershipComponent extends RestTable implements OnInit { | |||
31 | this.initialize() | 30 | this.initialize() |
32 | } | 31 | } |
33 | 32 | ||
33 | getIdentifier () { | ||
34 | return 'MyAccountOwnershipComponent' | ||
35 | } | ||
36 | |||
34 | createByString (account: Account) { | 37 | createByString (account: Account) { |
35 | return Account.CREATE_BY_STRING(account.name, account.host) | 38 | return Account.CREATE_BY_STRING(account.name, account.host) |
36 | } | 39 | } |
diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts index 018d6f996..f44b60ec9 100644 --- a/client/src/app/+my-account/my-account-routing.module.ts +++ b/client/src/app/+my-account/my-account-routing.module.ts | |||
@@ -5,9 +5,6 @@ import { LoginGuard } from '../core' | |||
5 | import { MyAccountComponent } from './my-account.component' | 5 | import { MyAccountComponent } from './my-account.component' |
6 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' | 6 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' |
7 | import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component' | 7 | import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component' |
8 | import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component' | ||
9 | import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component' | ||
10 | import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component' | ||
11 | import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component' | 8 | import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component' |
12 | import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component' | 9 | import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component' |
13 | import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownership/my-account-ownership.component' | 10 | import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownership/my-account-ownership.component' |
@@ -49,30 +46,7 @@ const myAccountRoutes: Routes = [ | |||
49 | 46 | ||
50 | { | 47 | { |
51 | path: 'video-channels', | 48 | path: 'video-channels', |
52 | component: MyAccountVideoChannelsComponent, | 49 | loadChildren: () => import('./my-account-video-channels/my-account-video-channels.module').then(m => m.MyAccountVideoChannelsModule) |
53 | data: { | ||
54 | meta: { | ||
55 | title: 'Account video channels' | ||
56 | } | ||
57 | } | ||
58 | }, | ||
59 | { | ||
60 | path: 'video-channels/create', | ||
61 | component: MyAccountVideoChannelCreateComponent, | ||
62 | data: { | ||
63 | meta: { | ||
64 | title: 'Create new video channel' | ||
65 | } | ||
66 | } | ||
67 | }, | ||
68 | { | ||
69 | path: 'video-channels/update/:videoChannelId', | ||
70 | component: MyAccountVideoChannelUpdateComponent, | ||
71 | data: { | ||
72 | meta: { | ||
73 | title: 'Update video channel' | ||
74 | } | ||
75 | } | ||
76 | }, | 50 | }, |
77 | 51 | ||
78 | { | 52 | { |
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 index 76886c73e..f39f66696 100644 --- 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 | |||
@@ -14,7 +14,7 @@ | |||
14 | <div class="form-group"> | 14 | <div class="form-group"> |
15 | <label i18n for="new-email">New email</label> | 15 | <label i18n for="new-email">New email</label> |
16 | <input | 16 | <input |
17 | type="email" id="new-email" i18n-placeholder placeholder="Your new email" | 17 | type="email" id="new-email" i18n-placeholder placeholder="Your new email" class="form-control" |
18 | formControlName="new-email" [ngClass]="{ 'input-error': formErrors['new-email'] }" | 18 | formControlName="new-email" [ngClass]="{ 'input-error': formErrors['new-email'] }" |
19 | > | 19 | > |
20 | <div *ngIf="formErrors['new-email']" class="form-error"> | 20 | <div *ngIf="formErrors['new-email']" class="form-error"> |
@@ -25,7 +25,7 @@ | |||
25 | <div class="form-group"> | 25 | <div class="form-group"> |
26 | <input | 26 | <input |
27 | type="password" id="password" i18n-placeholder placeholder="Your password" autocomplete="off" | 27 | type="password" id="password" i18n-placeholder placeholder="Your password" autocomplete="off" |
28 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | 28 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" class="form-control" |
29 | > | 29 | > |
30 | <div *ngIf="formErrors['password']" class="form-error"> | 30 | <div *ngIf="formErrors['password']" class="form-error"> |
31 | {{ formErrors['password'] }} | 31 | {{ formErrors['password'] }} |
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 index 81eba3ec9..aec709ea0 100644 --- 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 | |||
@@ -1,6 +1,11 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | label { | ||
5 | font-weight: $font-regular; | ||
6 | font-size: 100%; | ||
7 | } | ||
8 | |||
4 | input[type=password], | 9 | input[type=password], |
5 | input[type=email] { | 10 | input[type=email] { |
6 | @include peertube-input-text(340px); | 11 | @include peertube-input-text(340px); |
@@ -16,7 +21,7 @@ input[type=submit] { | |||
16 | .current-email, | 21 | .current-email, |
17 | .pending-email { | 22 | .pending-email { |
18 | font-size: 16px; | 23 | font-size: 16px; |
19 | margin: 15px 0; | 24 | margin-bottom: 15px; |
20 | 25 | ||
21 | .email { | 26 | .email { |
22 | font-weight: $font-semibold; | 27 | font-weight: $font-semibold; |
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 cec70c6b5..4756cfecd 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 | |||
@@ -5,7 +5,7 @@ | |||
5 | <label i18n for="current-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" autocomplete="current-password" | 7 | type="password" id="current-password" i18n-placeholder placeholder="Current password" autocomplete="current-password" |
8 | formControlName="current-password" [ngClass]="{ 'input-error': formErrors['current-password'] }" | 8 | formControlName="current-password" [ngClass]="{ 'input-error': formErrors['current-password'] }" class="form-control" |
9 | > | 9 | > |
10 | <div *ngIf="formErrors['current-password']" class="form-error"> | 10 | <div *ngIf="formErrors['current-password']" class="form-error"> |
11 | {{ formErrors['current-password'] }} | 11 | {{ formErrors['current-password'] }} |
@@ -13,7 +13,7 @@ | |||
13 | 13 | ||
14 | <input | 14 | <input |
15 | type="password" id="new-password" i18n-placeholder placeholder="New password" autocomplete="new-password" | 15 | type="password" id="new-password" i18n-placeholder placeholder="New password" autocomplete="new-password" |
16 | formControlName="new-password" [ngClass]="{ 'input-error': formErrors['new-password'] }" | 16 | formControlName="new-password" [ngClass]="{ 'input-error': formErrors['new-password'] }" class="form-control" |
17 | > | 17 | > |
18 | <div *ngIf="formErrors['new-password']" class="form-error"> | 18 | <div *ngIf="formErrors['new-password']" class="form-error"> |
19 | {{ formErrors['new-password'] }} | 19 | {{ formErrors['new-password'] }} |
@@ -21,7 +21,7 @@ | |||
21 | 21 | ||
22 | <input | 22 | <input |
23 | type="password" id="new-confirmed-password" i18n-placeholder placeholder="Confirm new password" autocomplete="new-password" | 23 | type="password" id="new-confirmed-password" i18n-placeholder placeholder="Confirm new password" autocomplete="new-password" |
24 | formControlName="new-confirmed-password" | 24 | formControlName="new-confirmed-password" class="form-control" |
25 | > | 25 | > |
26 | <div *ngIf="formErrors['new-confirmed-password']" class="form-error"> | 26 | <div *ngIf="formErrors['new-confirmed-password']" class="form-error"> |
27 | {{ formErrors['new-confirmed-password'] }} | 27 | {{ formErrors['new-confirmed-password'] }} |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.scss b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.scss index e641482f0..381afae07 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.scss | |||
@@ -1,6 +1,11 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | label { | ||
5 | font-weight: $font-regular; | ||
6 | font-size: 100%; | ||
7 | } | ||
8 | |||
4 | input[type=password] { | 9 | input[type=password] { |
5 | @include peertube-input-text(340px); | 10 | @include peertube-input-text(340px); |
6 | display: block; | 11 | display: block; |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.html b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.html index c542cc675..6e22abeed 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.html | |||
@@ -1,5 +1,5 @@ | |||
1 | <div class="delete-me"> | 1 | <div class="delete-me"> |
2 | <p i18n>Once you delete your account, there is no going back. Please be certain.</p> | 2 | <p i18n>Once you delete your account, there is no going back. You will be asked to confirm this action.</p> |
3 | 3 | ||
4 | <button (click)="deleteMe()" i18n>Delete your account</button> | 4 | <button (click)="deleteMe()" i18n>Delete your account</button> |
5 | </div> \ No newline at end of file | 5 | </div> |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.scss b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.scss index 0ca310468..7f7806732 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.scss | |||
@@ -7,5 +7,6 @@ | |||
7 | button { | 7 | button { |
8 | @include peertube-button; | 8 | @include peertube-button; |
9 | @include grey-button; | 9 | @include grey-button; |
10 | @include disable-outline; | ||
10 | } | 11 | } |
11 | } \ No newline at end of file | 12 | } \ No newline at end of file |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts index 41021c592..25d862867 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts | |||
@@ -24,7 +24,7 @@ export class MyAccountDangerZoneComponent { | |||
24 | 24 | ||
25 | async deleteMe () { | 25 | async deleteMe () { |
26 | const res = await this.confirmService.confirmWithInput( | 26 | const res = await this.confirmService.confirmWithInput( |
27 | this.i18n('Are you sure you want to delete your account? This will delete all your data, including channels, videos etc.'), | 27 | this.i18n('Are you sure you want to delete your account? This will delete all your data, including channels, videos and comments. Content cached by other servers and other third-parties might make longer to be deleted.'), |
28 | this.i18n('Type your username to confirm'), | 28 | this.i18n('Type your username to confirm'), |
29 | this.user.username, | 29 | this.user.username, |
30 | this.i18n('Delete your account'), | 30 | this.i18n('Delete your account'), |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html index f034c6bb3..0d0ddc0f2 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.html | |||
@@ -1,9 +1,10 @@ | |||
1 | <form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form"> | 1 | <form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form"> |
2 | |||
2 | <div class="form-group"> | 3 | <div class="form-group"> |
3 | <label i18n for="theme">Theme</label> | 4 | <label i18n for="theme">Theme</label> |
4 | 5 | ||
5 | <div class="peertube-select-container"> | 6 | <div class="peertube-select-container"> |
6 | <select formControlName="theme" id="theme"> | 7 | <select formControlName="theme" id="theme" class="form-control"> |
7 | <option i18n value="instance-default">instance default</option> | 8 | <option i18n value="instance-default">instance default</option> |
8 | <option i18n value="default">peertube default</option> | 9 | <option i18n value="default">peertube default</option> |
9 | 10 | ||
@@ -12,5 +13,5 @@ | |||
12 | </div> | 13 | </div> |
13 | </div> | 14 | </div> |
14 | 15 | ||
15 | <input type="submit" i18n-value value="Save" [disabled]="!form.valid"> | 16 | <input *ngIf="!reactiveUpdate" type="submit" class="mt-0" i18n-value value="Save" [disabled]="!form.valid"> |
16 | </form> | 17 | </form> |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss index 629f01733..7818dfc02 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.scss | |||
@@ -1,6 +1,11 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | label { | ||
5 | font-weight: $font-regular; | ||
6 | font-size: 100%; | ||
7 | } | ||
8 | |||
4 | input[type=submit] { | 9 | input[type=submit] { |
5 | @include peertube-button; | 10 | @include peertube-button; |
6 | @include orange-button; | 11 | @include orange-button; |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts index 441f89f10..b6c17c0e3 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-interface/my-account-interface-settings.component.ts | |||
@@ -1,21 +1,26 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | 1 | import { Component, Input, OnInit, OnDestroy } from '@angular/core' |
2 | import { Notifier, ServerService } from '@app/core' | 2 | import { Notifier, ServerService } from '@app/core' |
3 | import { ServerConfig, UserUpdateMe } from '../../../../../../shared' | 3 | import { ServerConfig, UserUpdateMe } from '../../../../../../shared' |
4 | import { AuthService } from '../../../core' | 4 | import { AuthService } from '../../../core' |
5 | import { FormReactive, User, UserService } from '../../../shared' | 5 | import { FormReactive } from '../../../shared/forms/form-reactive' |
6 | import { User, UserService } from '../../../shared/users' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | 7 | import { I18n } from '@ngx-translate/i18n-polyfill' |
7 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 8 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
8 | import { Subject } from 'rxjs' | 9 | import { Subject, Subscription } from 'rxjs' |
9 | 10 | ||
10 | @Component({ | 11 | @Component({ |
11 | selector: 'my-account-interface-settings', | 12 | selector: 'my-account-interface-settings', |
12 | templateUrl: './my-account-interface-settings.component.html', | 13 | templateUrl: './my-account-interface-settings.component.html', |
13 | styleUrls: [ './my-account-interface-settings.component.scss' ] | 14 | styleUrls: [ './my-account-interface-settings.component.scss' ] |
14 | }) | 15 | }) |
15 | export class MyAccountInterfaceSettingsComponent extends FormReactive implements OnInit { | 16 | export class MyAccountInterfaceSettingsComponent extends FormReactive implements OnInit, OnDestroy { |
16 | @Input() user: User = null | 17 | @Input() user: User = null |
18 | @Input() reactiveUpdate = false | ||
19 | @Input() notifyOnUpdate = true | ||
17 | @Input() userInformationLoaded: Subject<any> | 20 | @Input() userInformationLoaded: Subject<any> |
18 | 21 | ||
22 | formValuesWatcher: Subscription | ||
23 | |||
19 | private serverConfig: ServerConfig | 24 | private serverConfig: ServerConfig |
20 | 25 | ||
21 | constructor ( | 26 | constructor ( |
@@ -48,9 +53,17 @@ export class MyAccountInterfaceSettingsComponent extends FormReactive implements | |||
48 | this.form.patchValue({ | 53 | this.form.patchValue({ |
49 | theme: this.user.theme | 54 | theme: this.user.theme |
50 | }) | 55 | }) |
56 | |||
57 | if (this.reactiveUpdate) { | ||
58 | this.formValuesWatcher = this.form.valueChanges.subscribe(val => this.updateInterfaceSettings()) | ||
59 | } | ||
51 | }) | 60 | }) |
52 | } | 61 | } |
53 | 62 | ||
63 | ngOnDestroy () { | ||
64 | this.formValuesWatcher?.unsubscribe() | ||
65 | } | ||
66 | |||
54 | updateInterfaceSettings () { | 67 | updateInterfaceSettings () { |
55 | const theme = this.form.value['theme'] | 68 | const theme = this.form.value['theme'] |
56 | 69 | ||
@@ -58,14 +71,19 @@ export class MyAccountInterfaceSettingsComponent extends FormReactive implements | |||
58 | theme | 71 | theme |
59 | } | 72 | } |
60 | 73 | ||
61 | this.userService.updateMyProfile(details).subscribe( | 74 | if (this.authService.isLoggedIn()) { |
62 | () => { | 75 | this.userService.updateMyProfile(details).subscribe( |
63 | this.authService.refreshUserInformation() | 76 | () => { |
77 | this.authService.refreshUserInformation() | ||
64 | 78 | ||
65 | this.notifier.success(this.i18n('Interface settings updated.')) | 79 | if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.')) |
66 | }, | 80 | }, |
67 | 81 | ||
68 | err => this.notifier.error(err.message) | 82 | err => this.notifier.error(err.message) |
69 | ) | 83 | ) |
84 | } else { | ||
85 | this.userService.updateMyAnonymousProfile(details) | ||
86 | if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.')) | ||
87 | } | ||
70 | } | 88 | } |
71 | } | 89 | } |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss index 7cd5c3b46..75e52fa1b 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.scss | |||
@@ -8,15 +8,16 @@ | |||
8 | 8 | ||
9 | &:first-child { | 9 | &:first-child { |
10 | font-size: 16px; | 10 | font-size: 16px; |
11 | |||
12 | & > div { | ||
13 | font-weight: $font-semibold; | ||
14 | } | ||
15 | } | 11 | } |
16 | 12 | ||
17 | & > div { | 13 | & > div { |
14 | padding: 10px; | ||
18 | width: 350px; | 15 | width: 350px; |
19 | 16 | ||
17 | &:nth-child(2) { | ||
18 | max-width: 60px !important; | ||
19 | } | ||
20 | |||
20 | @media screen and (max-width: $small-view) { | 21 | @media screen and (max-width: $small-view) { |
21 | width: auto; | 22 | width: auto; |
22 | 23 | ||
@@ -25,9 +26,4 @@ | |||
25 | } | 26 | } |
26 | } | 27 | } |
27 | } | 28 | } |
28 | |||
29 | & > div { | ||
30 | padding: 10px | ||
31 | } | ||
32 | } | 29 | } |
33 | |||
diff --git a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.html b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.html index 05c0b5ddc..818e34ee0 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.html | |||
@@ -5,7 +5,7 @@ | |||
5 | <div class="form-group"> | 5 | <div class="form-group"> |
6 | <label i18n for="display-name">Display name</label> | 6 | <label i18n for="display-name">Display name</label> |
7 | <input | 7 | <input |
8 | type="text" id="display-name" | 8 | type="text" id="display-name" class="form-control" |
9 | formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }" | 9 | formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }" |
10 | > | 10 | > |
11 | <div *ngIf="formErrors['display-name']" class="form-error"> | 11 | <div *ngIf="formErrors['display-name']" class="form-error"> |
@@ -16,7 +16,7 @@ | |||
16 | <div class="form-group"> | 16 | <div class="form-group"> |
17 | <label i18n for="description">Description</label> | 17 | <label i18n for="description">Description</label> |
18 | <textarea | 18 | <textarea |
19 | id="description" formControlName="description" | 19 | id="description" formControlName="description" class="form-control" |
20 | [ngClass]="{ 'input-error': formErrors['description'] }" | 20 | [ngClass]="{ 'input-error': formErrors['description'] }" |
21 | ></textarea> | 21 | ></textarea> |
22 | <div *ngIf="formErrors.description" class="form-error"> | 22 | <div *ngIf="formErrors.description" class="form-error"> |
@@ -24,5 +24,5 @@ | |||
24 | </div> | 24 | </div> |
25 | </div> | 25 | </div> |
26 | 26 | ||
27 | <input type="submit" i18n-value value="Update my profile" [disabled]="!form.valid"> | 27 | <input type="submit" i18n-value value="Save" [disabled]="!form.valid"> |
28 | </form> | 28 | </form> |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.scss b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.scss index 6aabb60f4..5995bae4a 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.scss | |||
@@ -1,6 +1,11 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | label { | ||
5 | font-weight: $font-regular; | ||
6 | font-size: 100%; | ||
7 | } | ||
8 | |||
4 | .form-group:first-child { | 9 | .form-group:first-child { |
5 | margin-bottom: 15px; | 10 | margin-bottom: 15px; |
6 | } | 11 | } |
@@ -11,12 +16,6 @@ input[type=text] { | |||
11 | display: block; | 16 | display: block; |
12 | } | 17 | } |
13 | 18 | ||
14 | textarea { | ||
15 | @include peertube-textarea(500px, 150px); | ||
16 | |||
17 | display: block; | ||
18 | } | ||
19 | |||
20 | input[type=submit] { | 19 | input[type=submit] { |
21 | @include peertube-button; | 20 | @include peertube-button; |
22 | @include orange-button; | 21 | @include orange-button; |
@@ -24,3 +23,9 @@ input[type=submit] { | |||
24 | margin-top: 15px; | 23 | margin-top: 15px; |
25 | } | 24 | } |
26 | 25 | ||
26 | textarea { | ||
27 | @include peertube-textarea(500px, 150px); | ||
28 | max-width: 100%; | ||
29 | |||
30 | display: block; | ||
31 | } | ||
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 9f187b574..f1c466545 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 | |||
@@ -1,34 +1,89 @@ | |||
1 | <my-actor-avatar-info [actor]="user.account" (avatarChange)="onAvatarChange($event)"></my-actor-avatar-info> | 1 | <div class="form-row"> <!-- profile grid --> |
2 | <div class="form-group col-12 col-lg-4 col-xl-3"> | ||
3 | <div i18n class="account-title">PROFILE</div> | ||
4 | </div> | ||
5 | |||
6 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> | ||
7 | <my-actor-avatar-info [actor]="user.account" (avatarChange)="onAvatarChange($event)"></my-actor-avatar-info> | ||
8 | |||
9 | <div class="user-quota mb-3"> | ||
10 | <div> | ||
11 | <div class="progress" i18n-title title="Total video quota"> | ||
12 | <div class="progress-bar" role="progressbar" [style]="{ width: userVideoQuotaPercentage + '%' }" [attr.aria-valuenow]="userVideoQuotaUsed" aria-valuemin="0" [attr.aria-valuemax]="userVideoQuota">{{ userVideoQuotaUsed | bytes: 0 }}</div> | ||
13 | <span class="ml-auto mr-2">{{ userVideoQuota }}</span> | ||
14 | </div> | ||
15 | </div> | ||
16 | |||
17 | <div *ngIf="hasDailyQuota()" class="mt-3"> | ||
18 | <div class="progress" i18n-title title="Daily video quota"> | ||
19 | <div class="progress-bar secondary" role="progressbar" [style]="{ width: userVideoQuotaDailyPercentage + '%' }" [attr.aria-valuenow]="userVideoQuotaUsedDaily" aria-valuemin="0" [attr.aria-valuemax]="userVideoQuotaDaily">{{ userVideoQuotaUsedDaily | bytes: 0 }}</div> | ||
20 | <span class="ml-auto mr-2">{{ userVideoQuotaDaily }}</span> | ||
21 | </div> | ||
22 | </div> | ||
23 | </div> | ||
24 | |||
25 | <my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile> | ||
26 | </div> | ||
27 | </div> | ||
2 | 28 | ||
3 | <div class="user-quota"> | 29 | <div class="form-row mt-5"> <!-- video settings grid --> |
4 | <div> | 30 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
5 | <span i18n class="user-quota-label">Total video quota:</span> | 31 | <div class="anchor" id="video-settings"></div> <!-- video settings anchor --> |
6 | <ng-container i18n>{{ userVideoQuotaUsed | bytes: 0 }} used</ng-container> / {{ userVideoQuota }} | 32 | <div i18n class="account-title">VIDEO SETTINGS</div> |
7 | </div> | 33 | </div> |
8 | 34 | ||
9 | <div *ngIf="hasDailyQuota()"> | 35 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
10 | <span i18n class="user-quota-label">Daily video quota:</span> | 36 | <my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings> |
11 | <ng-container>{{ userVideoQuotaUsedDaily | bytes: 0 }} used</ng-container> / {{ userVideoQuotaDaily }} | ||
12 | </div> | 37 | </div> |
13 | </div> | 38 | </div> |
14 | 39 | ||
15 | <div i18n class="account-title">Profile</div> | 40 | <div class="form-row mt-5"> <!-- notifications grid --> |
16 | <my-account-profile [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-profile> | 41 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
42 | <div class="anchor" id="notifications"></div> <!-- notifications anchor --> | ||
43 | <div i18n class="account-title">NOTIFICATIONS</div> | ||
44 | </div> | ||
17 | 45 | ||
18 | <div i18n class="account-title">Video settings</div> | 46 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
19 | <my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings> | 47 | <my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences> |
48 | </div> | ||
49 | </div> | ||
20 | 50 | ||
21 | <div i18n class="account-title">Notifications</div> | 51 | <div class="form-row mt-5"> <!-- interface grid --> |
22 | <my-account-notification-preferences [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-notification-preferences> | 52 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
53 | <div i18n class="account-title">INTERFACE</div> | ||
54 | </div> | ||
23 | 55 | ||
24 | <div i18n class="account-title">Interface</div> | 56 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
25 | <my-account-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-interface-settings> | 57 | <my-account-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-interface-settings> |
58 | </div> | ||
59 | </div> | ||
26 | 60 | ||
27 | <div i18n class="account-title">Password</div> | 61 | <div class="form-row mt-5"> <!-- password grid --> |
28 | <my-account-change-password></my-account-change-password> | 62 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
63 | <div i18n class="account-title">PASSWORD</div> | ||
64 | </div> | ||
65 | |||
66 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> | ||
67 | <my-account-change-password></my-account-change-password> | ||
68 | </div> | ||
69 | </div> | ||
70 | |||
71 | <div class="form-row mt-5"> <!-- email grid --> | ||
72 | <div class="form-group col-12 col-lg-4 col-xl-3"> | ||
73 | <div i18n class="account-title">EMAIL</div> | ||
74 | </div> | ||
29 | 75 | ||
30 | <div i18n class="account-title">Email</div> | 76 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
31 | <my-account-change-email></my-account-change-email> | 77 | <my-account-change-email></my-account-change-email> |
78 | </div> | ||
79 | </div> | ||
80 | |||
81 | <div class="form-row mt-5"> <!-- danger zone grid --> | ||
82 | <div class="form-group col-12 col-lg-4 col-xl-3"> | ||
83 | <div i18n class="account-title">DANGER ZONE</div> | ||
84 | </div> | ||
32 | 85 | ||
33 | <div i18n class="account-title">Danger zone</div> | 86 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
34 | <my-account-danger-zone [user]="user"></my-account-danger-zone> | 87 | <my-account-danger-zone [user]="user"></my-account-danger-zone> |
88 | </div> | ||
89 | </div> | ||
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss index d0395aca9..3e1792e3e 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss | |||
@@ -5,15 +5,23 @@ | |||
5 | font-size: 15px; | 5 | font-size: 15px; |
6 | margin-top: 20px; | 6 | margin-top: 20px; |
7 | 7 | ||
8 | .user-quota-label { | 8 | label { |
9 | margin-right: 5px; | 9 | margin-right: 5px; |
10 | font-weight: $font-semibold; | ||
11 | } | 10 | } |
12 | } | 11 | } |
13 | 12 | ||
14 | .account-title { | 13 | .account-title { |
15 | @include in-content-small-title; | 14 | @include settings-big-title; |
15 | } | ||
16 | |||
17 | .progress { | ||
18 | @include progressbar; | ||
19 | width: 500px; | ||
20 | max-width: 100%; | ||
21 | } | ||
16 | 22 | ||
17 | margin-top: 55px; | 23 | @media screen and (max-width: $small-view) { |
18 | margin-bottom: 30px; | 24 | .progress { |
25 | width: 100%; | ||
26 | } | ||
19 | } | 27 | } |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts index e314cdbea..5f2db9854 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts | |||
@@ -1,26 +1,30 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit, AfterViewChecked } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { BytesPipe } from 'ngx-pipes' | 3 | import { BytesPipe } from 'ngx-pipes' |
4 | import { AuthService } from '../../core' | 4 | import { AuthService } from '../../core' |
5 | import { User } from '../../shared' | 5 | import { User } from '../../shared' |
6 | import { UserService } from '../../shared/users' | 6 | import { UserService } from '../../shared/users' |
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | 7 | import { I18n } from '@ngx-translate/i18n-polyfill' |
8 | import { ViewportScroller } from '@angular/common' | ||
8 | 9 | ||
9 | @Component({ | 10 | @Component({ |
10 | selector: 'my-account-settings', | 11 | selector: 'my-account-settings', |
11 | templateUrl: './my-account-settings.component.html', | 12 | templateUrl: './my-account-settings.component.html', |
12 | styleUrls: [ './my-account-settings.component.scss' ] | 13 | styleUrls: [ './my-account-settings.component.scss' ] |
13 | }) | 14 | }) |
14 | export class MyAccountSettingsComponent implements OnInit { | 15 | export class MyAccountSettingsComponent implements OnInit, AfterViewChecked { |
15 | user: User = null | 16 | user: User = null |
16 | 17 | ||
17 | userVideoQuota = '0' | 18 | userVideoQuota = '0' |
18 | userVideoQuotaUsed = 0 | 19 | userVideoQuotaUsed = 0 |
20 | userVideoQuotaPercentage = 15 | ||
19 | 21 | ||
20 | userVideoQuotaDaily = '0' | 22 | userVideoQuotaDaily = '0' |
21 | userVideoQuotaUsedDaily = 0 | 23 | userVideoQuotaUsedDaily = 0 |
24 | userVideoQuotaDailyPercentage = 15 | ||
22 | 25 | ||
23 | constructor ( | 26 | constructor ( |
27 | private viewportScroller: ViewportScroller, | ||
24 | private userService: UserService, | 28 | private userService: UserService, |
25 | private authService: AuthService, | 29 | private authService: AuthService, |
26 | private notifier: Notifier, | 30 | private notifier: Notifier, |
@@ -38,12 +42,14 @@ export class MyAccountSettingsComponent implements OnInit { | |||
38 | () => { | 42 | () => { |
39 | if (this.user.videoQuota !== -1) { | 43 | if (this.user.videoQuota !== -1) { |
40 | this.userVideoQuota = new BytesPipe().transform(this.user.videoQuota, 0).toString() | 44 | this.userVideoQuota = new BytesPipe().transform(this.user.videoQuota, 0).toString() |
45 | this.userVideoQuotaPercentage = this.user.videoQuota * 100 / this.userVideoQuotaUsed | ||
41 | } else { | 46 | } else { |
42 | this.userVideoQuota = this.i18n('Unlimited') | 47 | this.userVideoQuota = this.i18n('Unlimited') |
43 | } | 48 | } |
44 | 49 | ||
45 | if (this.user.videoQuotaDaily !== -1) { | 50 | if (this.user.videoQuotaDaily !== -1) { |
46 | this.userVideoQuotaDaily = new BytesPipe().transform(this.user.videoQuotaDaily, 0).toString() | 51 | this.userVideoQuotaDaily = new BytesPipe().transform(this.user.videoQuotaDaily, 0).toString() |
52 | this.userVideoQuotaDailyPercentage = this.user.videoQuotaDaily * 100 / this.userVideoQuotaUsedDaily | ||
47 | } else { | 53 | } else { |
48 | this.userVideoQuotaDaily = this.i18n('Unlimited') | 54 | this.userVideoQuotaDaily = this.i18n('Unlimited') |
49 | } | 55 | } |
@@ -57,6 +63,10 @@ export class MyAccountSettingsComponent implements OnInit { | |||
57 | }) | 63 | }) |
58 | } | 64 | } |
59 | 65 | ||
66 | ngAfterViewChecked () { | ||
67 | if (window.location.hash) this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', '')) | ||
68 | } | ||
69 | |||
60 | onAvatarChange (formData: FormData) { | 70 | onAvatarChange (formData: FormData) { |
61 | this.userService.changeAvatar(formData) | 71 | this.userService.changeAvatar(formData) |
62 | .subscribe( | 72 | .subscribe( |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html index 17d8cde06..0dda33af2 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.html | |||
@@ -1,5 +1,5 @@ | |||
1 | <form role="form" (ngSubmit)="updateDetails()" [formGroup]="form"> | 1 | <form role="form" (ngSubmit)="updateDetails()" [formGroup]="form"> |
2 | <div class="form-group"> | 2 | <div class="form-group form-group-select"> |
3 | <label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label> | 3 | <label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label> |
4 | <my-help> | 4 | <my-help> |
5 | <ng-template ptTemplate="customHtml"> | 5 | <ng-template ptTemplate="customHtml"> |
@@ -10,7 +10,8 @@ | |||
10 | </my-help> | 10 | </my-help> |
11 | 11 | ||
12 | <div class="peertube-select-container"> | 12 | <div class="peertube-select-container"> |
13 | <select id="nsfwPolicy" formControlName="nsfwPolicy"> | 13 | <select id="nsfwPolicy" formControlName="nsfwPolicy" class="form-control"> |
14 | <option i18n value="undefined" disabled>Policy for sensitive videos</option> | ||
14 | <option i18n value="do_not_list">Do not list</option> | 15 | <option i18n value="do_not_list">Do not list</option> |
15 | <option i18n value="blur">Blur thumbnails</option> | 16 | <option i18n value="blur">Blur thumbnails</option> |
16 | <option i18n value="display">Display</option> | 17 | <option i18n value="display">Display</option> |
@@ -18,7 +19,7 @@ | |||
18 | </div> | 19 | </div> |
19 | </div> | 20 | </div> |
20 | 21 | ||
21 | <div class="form-group"> | 22 | <div class="form-group form-group-select"> |
22 | <label i18n for="videoLanguages">Only display videos in the following languages/subtitles</label> | 23 | <label i18n for="videoLanguages">Only display videos in the following languages/subtitles</label> |
23 | <my-help> | 24 | <my-help> |
24 | <ng-template ptTemplate="customHtml"> | 25 | <ng-template ptTemplate="customHtml"> |
@@ -28,33 +29,47 @@ | |||
28 | 29 | ||
29 | <div> | 30 | <div> |
30 | <p-multiSelect | 31 | <p-multiSelect |
31 | inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" showToggleAll="true" | 32 | inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" [showToggleAll]="true" |
32 | [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()" | 33 | [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()" |
33 | emptyFilterMessage="No results found" i18n-emptyFilterMessage | 34 | emptyFilterMessage="No results found" i18n-emptyFilterMessage |
34 | ></p-multiSelect> | 35 | ></p-multiSelect> |
35 | </div> | 36 | </div> |
36 | </div> | 37 | </div> |
37 | 38 | ||
39 | <ng-content select="inner-title"></ng-content> | ||
40 | |||
38 | <div class="form-group"> | 41 | <div class="form-group"> |
39 | <my-peertube-checkbox | 42 | <my-peertube-checkbox |
40 | inputName="webTorrentEnabled" formControlName="webTorrentEnabled" | 43 | inputName="webTorrentEnabled" formControlName="webTorrentEnabled" [recommended]="true" |
41 | i18n-labelText labelText="Use P2P to exchange parts of the video with others" | 44 | i18n-labelText labelText="Help share videos being played" |
42 | ></my-peertube-checkbox> | 45 | > |
46 | <ng-container ngProjectAs="description"> | ||
47 | <span i18n>The <a routerLink="/about/peertube" fragment="privacy">sharing system</a> implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load.</span> | ||
48 | </ng-container> | ||
49 | </my-peertube-checkbox> | ||
43 | </div> | 50 | </div> |
44 | 51 | ||
45 | <div class="form-group"> | 52 | <div class="form-group"> |
46 | <my-peertube-checkbox | 53 | <my-peertube-checkbox |
47 | inputName="autoPlayVideo" formControlName="autoPlayVideo" | 54 | inputName="autoPlayVideo" formControlName="autoPlayVideo" |
48 | i18n-labelText labelText="Automatically plays video" | 55 | i18n-labelText labelText="Automatically play videos" |
49 | ></my-peertube-checkbox> | 56 | > |
57 | <ng-container ngProjectAs="description"> | ||
58 | <span i18n>When on a video page, directly start playing the video.</span> | ||
59 | </ng-container> | ||
60 | </my-peertube-checkbox> | ||
50 | </div> | 61 | </div> |
51 | 62 | ||
52 | <div class="form-group"> | 63 | <div class="form-group"> |
53 | <my-peertube-checkbox | 64 | <my-peertube-checkbox |
54 | inputName="autoPlayNextVideo" formControlName="autoPlayNextVideo" | 65 | inputName="autoPlayNextVideo" formControlName="autoPlayNextVideo" |
55 | i18n-labelText labelText="Automatically starts playing next video" | 66 | i18n-labelText labelText="Automatically start playing the next video" |
56 | ></my-peertube-checkbox> | 67 | > |
68 | <ng-container ngProjectAs="description"> | ||
69 | <span i18n>When a video ends, follow up with the next suggested video.</span> | ||
70 | </ng-container> | ||
71 | </my-peertube-checkbox> | ||
57 | </div> | 72 | </div> |
58 | 73 | ||
59 | <input type="submit" i18n-value value="Save" [disabled]="!form.valid"> | 74 | <input *ngIf="!reactiveUpdate" type="submit" i18n-value value="Save" [disabled]="!form.valid"> |
60 | </form> | 75 | </form> |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.scss index 1881be762..430250b87 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.scss +++ b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.scss | |||
@@ -1,11 +1,15 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | label { | ||
5 | font-weight: $font-regular; | ||
6 | font-size: 100%; | ||
7 | } | ||
8 | |||
4 | input[type=submit] { | 9 | input[type=submit] { |
5 | @include peertube-button; | 10 | @include peertube-button; |
6 | @include orange-button; | 11 | @include orange-button; |
7 | 12 | ||
8 | display: block; | ||
9 | margin-top: 15px; | 13 | margin-top: 15px; |
10 | } | 14 | } |
11 | 15 | ||
@@ -13,4 +17,8 @@ input[type=submit] { | |||
13 | @include peertube-select-container(340px); | 17 | @include peertube-select-container(340px); |
14 | 18 | ||
15 | margin-bottom: 30px; | 19 | margin-bottom: 30px; |
16 | } \ No newline at end of file | 20 | } |
21 | |||
22 | .form-group-select { | ||
23 | margin-bottom: 30px; | ||
24 | } | ||
diff --git a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts index a66159b3f..0aaa54cd7 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-video-settings/my-account-video-settings.component.ts | |||
@@ -1,24 +1,31 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | 1 | import { Component, Input, OnInit, OnDestroy } from '@angular/core' |
2 | import { Notifier, ServerService } from '@app/core' | 2 | import { Notifier, ServerService } from '@app/core' |
3 | import { UserUpdateMe } from '../../../../../../shared' | 3 | import { UserUpdateMe } from '../../../../../../shared/models/users' |
4 | import { User, UserService } from '@app/shared/users' | ||
4 | import { AuthService } from '../../../core' | 5 | import { AuthService } from '../../../core' |
5 | import { FormReactive, User, UserService } from '../../../shared' | 6 | import { FormReactive } from '@app/shared/forms/form-reactive' |
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | 7 | import { I18n } from '@ngx-translate/i18n-polyfill' |
7 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 8 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
8 | import { forkJoin, Subject } from 'rxjs' | 9 | import { forkJoin, Subject, Subscription } from 'rxjs' |
9 | import { SelectItem } from 'primeng/api' | 10 | import { SelectItem } from 'primeng/api' |
10 | import { first } from 'rxjs/operators' | 11 | import { first } from 'rxjs/operators' |
12 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | ||
13 | import { pick } from 'lodash-es' | ||
11 | 14 | ||
12 | @Component({ | 15 | @Component({ |
13 | selector: 'my-account-video-settings', | 16 | selector: 'my-account-video-settings', |
14 | templateUrl: './my-account-video-settings.component.html', | 17 | templateUrl: './my-account-video-settings.component.html', |
15 | styleUrls: [ './my-account-video-settings.component.scss' ] | 18 | styleUrls: [ './my-account-video-settings.component.scss' ] |
16 | }) | 19 | }) |
17 | export class MyAccountVideoSettingsComponent extends FormReactive implements OnInit { | 20 | export class MyAccountVideoSettingsComponent extends FormReactive implements OnInit, OnDestroy { |
18 | @Input() user: User = null | 21 | @Input() user: User = null |
22 | @Input() reactiveUpdate = false | ||
23 | @Input() notifyOnUpdate = true | ||
19 | @Input() userInformationLoaded: Subject<any> | 24 | @Input() userInformationLoaded: Subject<any> |
20 | 25 | ||
21 | languageItems: SelectItem[] = [] | 26 | languageItems: SelectItem[] = [] |
27 | defaultNSFWPolicy: NSFWPolicyType | ||
28 | formValuesWatcher: Subscription | ||
22 | 29 | ||
23 | constructor ( | 30 | constructor ( |
24 | protected formValidatorService: FormValidatorService, | 31 | protected formValidatorService: FormValidatorService, |
@@ -32,6 +39,8 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI | |||
32 | } | 39 | } |
33 | 40 | ||
34 | ngOnInit () { | 41 | ngOnInit () { |
42 | let oldForm: any | ||
43 | |||
35 | this.buildForm({ | 44 | this.buildForm({ |
36 | nsfwPolicy: null, | 45 | nsfwPolicy: null, |
37 | webTorrentEnabled: null, | 46 | webTorrentEnabled: null, |
@@ -42,8 +51,9 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI | |||
42 | 51 | ||
43 | forkJoin([ | 52 | forkJoin([ |
44 | this.serverService.getVideoLanguages(), | 53 | this.serverService.getVideoLanguages(), |
54 | this.serverService.getConfig(), | ||
45 | this.userInformationLoaded.pipe(first()) | 55 | this.userInformationLoaded.pipe(first()) |
46 | ]).subscribe(([ languages ]) => { | 56 | ]).subscribe(([ languages, config ]) => { |
47 | this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ] | 57 | this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ] |
48 | this.languageItems = this.languageItems | 58 | this.languageItems = this.languageItems |
49 | .concat(languages.map(l => ({ label: l.label, value: l.id }))) | 59 | .concat(languages.map(l => ({ label: l.label, value: l.id }))) |
@@ -52,17 +62,32 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI | |||
52 | ? this.user.videoLanguages | 62 | ? this.user.videoLanguages |
53 | : this.languageItems.map(l => l.value) | 63 | : this.languageItems.map(l => l.value) |
54 | 64 | ||
65 | this.defaultNSFWPolicy = config.instance.defaultNSFWPolicy | ||
66 | |||
55 | this.form.patchValue({ | 67 | this.form.patchValue({ |
56 | nsfwPolicy: this.user.nsfwPolicy, | 68 | nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy, |
57 | webTorrentEnabled: this.user.webTorrentEnabled, | 69 | webTorrentEnabled: this.user.webTorrentEnabled, |
58 | autoPlayVideo: this.user.autoPlayVideo === true, | 70 | autoPlayVideo: this.user.autoPlayVideo === true, |
59 | autoPlayNextVideo: this.user.autoPlayNextVideo, | 71 | autoPlayNextVideo: this.user.autoPlayNextVideo, |
60 | videoLanguages | 72 | videoLanguages |
61 | }) | 73 | }) |
74 | |||
75 | if (this.reactiveUpdate) { | ||
76 | oldForm = { ...this.form.value } | ||
77 | this.formValuesWatcher = this.form.valueChanges.subscribe((formValue: any) => { | ||
78 | const updatedKey = Object.keys(formValue).find(k => formValue[k] !== oldForm[k]) | ||
79 | oldForm = { ...this.form.value } | ||
80 | this.updateDetails([updatedKey]) | ||
81 | }) | ||
82 | } | ||
62 | }) | 83 | }) |
63 | } | 84 | } |
64 | 85 | ||
65 | updateDetails () { | 86 | ngOnDestroy () { |
87 | this.formValuesWatcher?.unsubscribe() | ||
88 | } | ||
89 | |||
90 | updateDetails (onlyKeys?: string[]) { | ||
66 | const nsfwPolicy = this.form.value[ 'nsfwPolicy' ] | 91 | const nsfwPolicy = this.form.value[ 'nsfwPolicy' ] |
67 | const webTorrentEnabled = this.form.value['webTorrentEnabled'] | 92 | const webTorrentEnabled = this.form.value['webTorrentEnabled'] |
68 | const autoPlayVideo = this.form.value['autoPlayVideo'] | 93 | const autoPlayVideo = this.form.value['autoPlayVideo'] |
@@ -81,7 +106,7 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI | |||
81 | } | 106 | } |
82 | } | 107 | } |
83 | 108 | ||
84 | const details: UserUpdateMe = { | 109 | let details: UserUpdateMe = { |
85 | nsfwPolicy, | 110 | nsfwPolicy, |
86 | webTorrentEnabled, | 111 | webTorrentEnabled, |
87 | autoPlayVideo, | 112 | autoPlayVideo, |
@@ -89,15 +114,22 @@ export class MyAccountVideoSettingsComponent extends FormReactive implements OnI | |||
89 | videoLanguages | 114 | videoLanguages |
90 | } | 115 | } |
91 | 116 | ||
92 | this.userService.updateMyProfile(details).subscribe( | 117 | if (onlyKeys) details = pick(details, onlyKeys) |
93 | () => { | ||
94 | this.notifier.success(this.i18n('Video settings updated.')) | ||
95 | 118 | ||
96 | this.authService.refreshUserInformation() | 119 | if (this.authService.isLoggedIn()) { |
97 | }, | 120 | this.userService.updateMyProfile(details).subscribe( |
121 | () => { | ||
122 | this.authService.refreshUserInformation() | ||
98 | 123 | ||
99 | err => this.notifier.error(err.message) | 124 | if (this.notifyOnUpdate) this.notifier.success(this.i18n('Video settings updated.')) |
100 | ) | 125 | }, |
126 | |||
127 | err => this.notifier.error(err.message) | ||
128 | ) | ||
129 | } else { | ||
130 | this.userService.updateMyAnonymousProfile(details) | ||
131 | if (this.notifyOnUpdate) this.notifier.success(this.i18n('Display/Video settings updated.')) | ||
132 | } | ||
101 | } | 133 | } |
102 | 134 | ||
103 | getDefaultVideoLanguageLabel () { | 135 | getDefaultVideoLanguageLabel () { |
diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss index 7ac3c910f..ba8d56689 100644 --- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss +++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.scss | |||
@@ -41,4 +41,27 @@ | |||
41 | } | 41 | } |
42 | } | 42 | } |
43 | 43 | ||
44 | @media screen and (max-width: $small-view) { | ||
45 | .video-channels-header { | ||
46 | text-align: center; | ||
47 | } | ||
48 | |||
49 | .video-channel { | ||
50 | .video-channel-info { | ||
51 | padding-bottom: 10px; | ||
52 | text-align: center; | ||
53 | |||
54 | .video-channel-names { | ||
55 | flex-direction: column; | ||
56 | align-items: center !important; | ||
57 | margin: auto; | ||
58 | } | ||
59 | } | ||
60 | |||
61 | img { | ||
62 | margin-right: 0; | ||
63 | } | ||
64 | } | ||
65 | } | ||
66 | |||
44 | 67 | ||
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 f87df87df..048d143cd 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 | |||
@@ -1,72 +1,105 @@ | |||
1 | <my-actor-avatar-info | 1 | <nav aria-label="breadcrumb"> |
2 | *ngIf="isCreation() === false && videoChannelToUpdate" | 2 | <ol class="breadcrumb"> |
3 | [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)" | 3 | <li class="breadcrumb-item"> |
4 | ></my-actor-avatar-info> | 4 | <a routerLink="/my-account/video-channels" i18n>My Channels</a> |
5 | </li> | ||
5 | 6 | ||
6 | <div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a video channel</div> | 7 | <ng-container *ngIf="isCreation()"> |
8 | <li class="breadcrumb-item active" i18n>Create</li> | ||
9 | </ng-container> | ||
10 | <ng-container *ngIf="!isCreation()"> | ||
11 | <li class="breadcrumb-item active" i18n>Edit</li> | ||
12 | <li class="breadcrumb-item active" aria-current="page"> | ||
13 | <a *ngIf="videoChannelToUpdate" [routerLink]="[ '/my-account/video-channels/update', videoChannelToUpdate?.nameWithHost ]">{{ videoChannelToUpdate?.displayName }}</a> | ||
14 | </li> | ||
15 | </ng-container> | ||
16 | </ol> | ||
17 | </nav> | ||
7 | 18 | ||
8 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | 19 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> |
9 | 20 | ||
10 | <form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> | 21 | <form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> |
11 | <div class="form-group" *ngIf="isCreation() === true"> | ||
12 | <label i18n for="name">Name</label> | ||
13 | <div class="input-group"> | ||
14 | <input | ||
15 | type="text" id="name" i18n-placeholder placeholder="Example: my_channel" | ||
16 | formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" | ||
17 | > | ||
18 | <div class="input-group-append"> | ||
19 | <span class="input-group-text">@{{ instanceHost }}</span> | ||
20 | </div> | ||
21 | </div> | ||
22 | <div *ngIf="formErrors['name']" class="form-error"> | ||
23 | {{ formErrors['name'] }} | ||
24 | </div> | ||
25 | </div> | ||
26 | 22 | ||
27 | <div class="form-group"> | 23 | <div class="form-row"> <!-- channel grid --> |
28 | <label i18n for="display-name">Display name</label> | 24 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
29 | <input | 25 | <div *ngIf="isCreation()" class="video-channel-title" i18n>NEW CHANNEL</div> |
30 | type="text" id="display-name" | 26 | <div *ngIf="!isCreation() && videoChannelToUpdate" class="video-channel-title" i18n>CHANNEL</div> |
31 | formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }" | ||
32 | > | ||
33 | <div *ngIf="formErrors['display-name']" class="form-error"> | ||
34 | {{ formErrors['display-name'] }} | ||
35 | </div> | 27 | </div> |
36 | </div> | ||
37 | 28 | ||
38 | <div class="form-group"> | 29 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
39 | <label i18n for="description">Description</label> | 30 | |
40 | <textarea | 31 | <div class="form-group" *ngIf="isCreation()"> |
41 | id="description" formControlName="description" | 32 | <label i18n for="name">Name</label> |
42 | [ngClass]="{ 'input-error': formErrors['description'] }" | 33 | <div class="input-group"> |
43 | ></textarea> | 34 | <input |
44 | <div *ngIf="formErrors.description" class="form-error"> | 35 | type="text" id="name" i18n-placeholder placeholder="Example: my_channel" |
45 | {{ formErrors.description }} | 36 | formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" class="form-control" |
46 | </div> | 37 | > |
47 | </div> | 38 | <div class="input-group-append"> |
39 | <span class="input-group-text">@{{ instanceHost }}</span> | ||
40 | </div> | ||
41 | </div> | ||
42 | <div *ngIf="formErrors['name']" class="form-error"> | ||
43 | {{ formErrors['name'] }} | ||
44 | </div> | ||
45 | </div> | ||
46 | |||
47 | <my-actor-avatar-info | ||
48 | *ngIf="!isCreation() && videoChannelToUpdate" | ||
49 | [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)" | ||
50 | ></my-actor-avatar-info> | ||
51 | |||
52 | <div class="form-group"> | ||
53 | <label i18n for="display-name">Display name</label> | ||
54 | <input | ||
55 | type="text" id="display-name" class="form-control" | ||
56 | formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }" | ||
57 | > | ||
58 | <div *ngIf="formErrors['display-name']" class="form-error"> | ||
59 | {{ formErrors['display-name'] }} | ||
60 | </div> | ||
61 | </div> | ||
62 | |||
63 | <div class="form-group"> | ||
64 | <label i18n for="description">Description</label> | ||
65 | <textarea | ||
66 | id="description" formControlName="description" class="form-control" | ||
67 | [ngClass]="{ 'input-error': formErrors['description'] }" | ||
68 | ></textarea> | ||
69 | <div *ngIf="formErrors.description" class="form-error"> | ||
70 | {{ formErrors.description }} | ||
71 | </div> | ||
72 | </div> | ||
73 | |||
74 | <div class="form-group"> | ||
75 | <label for="support">Support</label> | ||
76 | <my-help | ||
77 | helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support your channel (membership platform...).<br /><br /> | ||
78 | When you will upload a video in this channel, the video support field will be automatically filled by this text." | ||
79 | ></my-help> | ||
80 | <my-markdown-textarea | ||
81 | id="support" formControlName="support" textareaMaxWidth="500px" markdownType="enhanced" | ||
82 | [classes]="{ 'input-error': formErrors['support'] }" | ||
83 | ></my-markdown-textarea> | ||
84 | <div *ngIf="formErrors.support" class="form-error"> | ||
85 | {{ formErrors.support }} | ||
86 | </div> | ||
87 | </div> | ||
88 | |||
89 | <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()"> | ||
90 | <my-peertube-checkbox | ||
91 | inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate" | ||
92 | i18n-labelText labelText="Overwrite support field of all videos of this channel" | ||
93 | ></my-peertube-checkbox> | ||
94 | </div> | ||
48 | 95 | ||
49 | <div class="form-group"> | ||
50 | <label for="support">Support</label> | ||
51 | <my-help | ||
52 | helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support your channel (membership platform...).<br /><br /> | ||
53 | When you will upload a video in this channel, the video support field will be automatically filled by this text." | ||
54 | ></my-help> | ||
55 | <my-markdown-textarea | ||
56 | id="support" formControlName="support" textareaWidth="500px" [previewColumn]="true" markdownType="enhanced" | ||
57 | [classes]="{ 'input-error': formErrors['support'] }" | ||
58 | ></my-markdown-textarea> | ||
59 | <div *ngIf="formErrors.support" class="form-error"> | ||
60 | {{ formErrors.support }} | ||
61 | </div> | 96 | </div> |
62 | </div> | 97 | </div> |
63 | 98 | ||
64 | <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()"> | 99 | <div class="form-row"> <!-- submit placement block --> |
65 | <my-peertube-checkbox | 100 | <div class="col-md-7 col-xl-5"></div> |
66 | inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate" | 101 | <div class="col-md-5 col-xl-5 d-inline-flex"> |
67 | i18n-labelText labelText="Overwrite support field of all videos of this channel" | 102 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> |
68 | ></my-peertube-checkbox> | 103 | </div> |
69 | </div> | 104 | </div> |
70 | |||
71 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> | ||
72 | </form> | 105 | </form> |
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.scss b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.scss index d35e0ed64..8f8af655c 100644 --- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.scss +++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.component.scss | |||
@@ -1,8 +1,13 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .form-sub-title { | 4 | label { |
5 | margin-bottom: 20px; | 5 | font-weight: $font-regular; |
6 | font-size: 100%; | ||
7 | } | ||
8 | |||
9 | .video-channel-title { | ||
10 | @include settings-big-title; | ||
6 | } | 11 | } |
7 | 12 | ||
8 | my-actor-avatar-info { | 13 | my-actor-avatar-info { |
@@ -18,14 +23,22 @@ my-actor-avatar-info { | |||
18 | height: 30px; | 23 | height: 30px; |
19 | } | 24 | } |
20 | 25 | ||
21 | input[type=text] { | 26 | input { |
22 | @include peertube-input-text(340px); | 27 | &[type=text] { |
28 | @include peertube-input-text(340px); | ||
23 | 29 | ||
24 | display: block; | 30 | display: block; |
31 | |||
32 | &#name { | ||
33 | width: auto; | ||
34 | flex-grow: 1; | ||
35 | } | ||
36 | } | ||
25 | 37 | ||
26 | &#name { | 38 | &[type=submit] { |
27 | width: auto; | 39 | @include peertube-button; |
28 | flex-grow: 1; | 40 | @include orange-button; |
41 | margin-left: auto; | ||
29 | } | 42 | } |
30 | } | 43 | } |
31 | 44 | ||
@@ -39,7 +52,16 @@ textarea { | |||
39 | @include peertube-select-container(340px); | 52 | @include peertube-select-container(340px); |
40 | } | 53 | } |
41 | 54 | ||
42 | input[type=submit] { | 55 | .breadcrumb { |
43 | @include peertube-button; | 56 | @include breadcrumb; |
44 | @include orange-button; | 57 | } |
58 | |||
59 | @media screen and (max-width: $small-view) { | ||
60 | input[type=text]#name { | ||
61 | width: auto !important; | ||
62 | } | ||
63 | |||
64 | label[for=name] + div, textarea { | ||
65 | width: 100%; | ||
66 | } | ||
45 | } | 67 | } |
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts index 7479442d1..355cb4f55 100644 --- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts +++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channel-edit.ts | |||
@@ -9,7 +9,7 @@ export abstract class MyAccountVideoChannelEdit extends FormReactive { | |||
9 | abstract isCreation (): boolean | 9 | abstract isCreation (): boolean |
10 | abstract getFormButtonTitle (): string | 10 | abstract getFormButtonTitle (): string |
11 | 11 | ||
12 | // FIXME: We need this method so angular does not complain in the child template | 12 | // We need this method so angular does not complain in child template that doesn't need this |
13 | onAvatarChange (formData: FormData) { /* empty */ } | 13 | onAvatarChange (formData: FormData) { /* empty */ } |
14 | 14 | ||
15 | // Should be implemented by the child | 15 | // Should be implemented by the child |
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels-routing.module.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels-routing.module.ts new file mode 100644 index 000000000..94037e18f --- /dev/null +++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels-routing.module.ts | |||
@@ -0,0 +1,41 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component' | ||
4 | import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component' | ||
5 | import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component' | ||
6 | |||
7 | const myAccountVideoChannelsRoutes: Routes = [ | ||
8 | { | ||
9 | path: '', | ||
10 | component: MyAccountVideoChannelsComponent, | ||
11 | data: { | ||
12 | meta: { | ||
13 | title: 'Account video channels' | ||
14 | } | ||
15 | } | ||
16 | }, | ||
17 | { | ||
18 | path: 'create', | ||
19 | component: MyAccountVideoChannelCreateComponent, | ||
20 | data: { | ||
21 | meta: { | ||
22 | title: 'Create new video channel' | ||
23 | } | ||
24 | } | ||
25 | }, | ||
26 | { | ||
27 | path: 'update/:videoChannelId', | ||
28 | component: MyAccountVideoChannelUpdateComponent, | ||
29 | data: { | ||
30 | meta: { | ||
31 | title: 'Update video channel' | ||
32 | } | ||
33 | } | ||
34 | } | ||
35 | ] | ||
36 | |||
37 | @NgModule({ | ||
38 | imports: [ RouterModule.forChild(myAccountVideoChannelsRoutes) ], | ||
39 | exports: [ RouterModule ] | ||
40 | }) | ||
41 | export class MyAccountVideoChannelsRoutingModule {} | ||
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html index 11e87ba79..03d45227e 100644 --- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html +++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.html | |||
@@ -1,12 +1,12 @@ | |||
1 | <div class="video-channels-header"> | 1 | <div class="video-channels-header"> |
2 | <a class="create-button" routerLink="create"> | 2 | <a class="create-button" routerLink="create"> |
3 | <my-global-icon iconName="add"></my-global-icon> | 3 | <my-global-icon iconName="add"></my-global-icon> |
4 | <ng-container i18n>Create a new video channel</ng-container> | 4 | <ng-container i18n>Create video channel</ng-container> |
5 | </a> | 5 | </a> |
6 | </div> | 6 | </div> |
7 | 7 | ||
8 | <div class="video-channels"> | 8 | <div class="video-channels"> |
9 | <div *ngFor="let videoChannel of videoChannels" class="video-channel"> | 9 | <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel"> |
10 | <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]"> | 10 | <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]"> |
11 | <img [src]="videoChannel.avatarUrl" alt="Avatar" /> | 11 | <img [src]="videoChannel.avatarUrl" alt="Avatar" /> |
12 | </a> | 12 | </a> |
@@ -17,13 +17,16 @@ | |||
17 | <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div> | 17 | <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div> |
18 | </a> | 18 | </a> |
19 | 19 | ||
20 | <div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div> | 20 | <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div> |
21 | |||
22 | <div *ngIf="!isInSmallView" class="w-100 d-flex justify-content-end"> | ||
23 | <p-chart *ngIf="videoChannelsChartData && videoChannelsChartData[i]" type="line" [data]="videoChannelsChartData[i]" [options]="chartOptions" width="40vw" height="100px"></p-chart> | ||
24 | </div> | ||
21 | </div> | 25 | </div> |
22 | 26 | ||
23 | <div class="video-channel-buttons"> | 27 | <div class="video-channel-buttons"> |
24 | <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button> | ||
25 | |||
26 | <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button> | 28 | <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button> |
29 | <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button> | ||
27 | </div> | 30 | </div> |
28 | </div> | 31 | </div> |
29 | </div> | 32 | </div> |
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss index 20582e478..e1acf6cd6 100644 --- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss +++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.scss | |||
@@ -6,13 +6,14 @@ | |||
6 | } | 6 | } |
7 | 7 | ||
8 | ::ng-deep .action-button { | 8 | ::ng-deep .action-button { |
9 | &.action-button-delete { | 9 | &.action-button-edit { |
10 | margin-right: 10px; | 10 | margin-right: 10px; |
11 | } | 11 | } |
12 | } | 12 | } |
13 | 13 | ||
14 | .video-channel { | 14 | .video-channel { |
15 | @include row-blocks; | 15 | @include row-blocks; |
16 | padding-bottom: 0; | ||
16 | 17 | ||
17 | img { | 18 | img { |
18 | @include avatar(80px); | 19 | @include avatar(80px); |
@@ -58,15 +59,28 @@ | |||
58 | margin: 20px 0 50px; | 59 | margin: 20px 0 50px; |
59 | } | 60 | } |
60 | 61 | ||
61 | @media screen and (max-width: 800px) { | 62 | ::ng-deep .chartjs-render-monitor { |
63 | position: relative; | ||
64 | top: 1px; | ||
65 | } | ||
66 | |||
67 | @media screen and (max-width: $small-view) { | ||
62 | .video-channels-header { | 68 | .video-channels-header { |
63 | text-align: center; | 69 | text-align: center; |
64 | } | 70 | } |
65 | 71 | ||
66 | .video-channel { | 72 | .video-channel { |
67 | .video-channel-names { | 73 | padding-bottom: 10px; |
68 | flex-direction: column; | 74 | |
69 | align-items: center !important; | 75 | .video-channel-info { |
76 | padding-bottom: 10px; | ||
77 | text-align: center; | ||
78 | |||
79 | .video-channel-names { | ||
80 | flex-direction: column; | ||
81 | align-items: center !important; | ||
82 | margin: auto; | ||
83 | } | ||
70 | } | 84 | } |
71 | 85 | ||
72 | img { | 86 | img { |
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts index 3b01b6c9f..75d6d8acd 100644 --- a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts +++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.component.ts | |||
@@ -4,9 +4,12 @@ import { AuthService } from '../../core/auth' | |||
4 | import { ConfirmService } from '../../core/confirm' | 4 | import { ConfirmService } from '../../core/confirm' |
5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
6 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 6 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
7 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
7 | import { User } from '@app/shared' | 8 | import { User } from '@app/shared' |
8 | import { flatMap } from 'rxjs/operators' | 9 | import { flatMap } from 'rxjs/operators' |
9 | import { I18n } from '@ngx-translate/i18n-polyfill' | 10 | import { I18n } from '@ngx-translate/i18n-polyfill' |
11 | import { min, minBy, max, maxBy } from 'lodash-es' | ||
12 | import { ChartData } from 'chart.js' | ||
10 | 13 | ||
11 | @Component({ | 14 | @Component({ |
12 | selector: 'my-account-video-channels', | 15 | selector: 'my-account-video-channels', |
@@ -15,6 +18,9 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
15 | }) | 18 | }) |
16 | export class MyAccountVideoChannelsComponent implements OnInit { | 19 | export class MyAccountVideoChannelsComponent implements OnInit { |
17 | videoChannels: VideoChannel[] = [] | 20 | videoChannels: VideoChannel[] = [] |
21 | videoChannelsChartData: ChartData[] | ||
22 | videoChannelsMinimumDailyViews = 0 | ||
23 | videoChannelsMaximumDailyViews: number | ||
18 | 24 | ||
19 | private user: User | 25 | private user: User |
20 | 26 | ||
@@ -23,6 +29,7 @@ export class MyAccountVideoChannelsComponent implements OnInit { | |||
23 | private notifier: Notifier, | 29 | private notifier: Notifier, |
24 | private confirmService: ConfirmService, | 30 | private confirmService: ConfirmService, |
25 | private videoChannelService: VideoChannelService, | 31 | private videoChannelService: VideoChannelService, |
32 | private screenService: ScreenService, | ||
26 | private i18n: I18n | 33 | private i18n: I18n |
27 | ) {} | 34 | ) {} |
28 | 35 | ||
@@ -32,6 +39,59 @@ export class MyAccountVideoChannelsComponent implements OnInit { | |||
32 | this.loadVideoChannels() | 39 | this.loadVideoChannels() |
33 | } | 40 | } |
34 | 41 | ||
42 | get isInSmallView () { | ||
43 | return this.screenService.isInSmallView() | ||
44 | } | ||
45 | |||
46 | get chartOptions () { | ||
47 | return { | ||
48 | legend: { | ||
49 | display: false | ||
50 | }, | ||
51 | scales: { | ||
52 | xAxes: [{ | ||
53 | display: false | ||
54 | }], | ||
55 | yAxes: [{ | ||
56 | display: false, | ||
57 | ticks: { | ||
58 | min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)), | ||
59 | max: Math.max(1, this.videoChannelsMaximumDailyViews) | ||
60 | } | ||
61 | }] | ||
62 | }, | ||
63 | layout: { | ||
64 | padding: { | ||
65 | left: 15, | ||
66 | right: 15, | ||
67 | top: 10, | ||
68 | bottom: 0 | ||
69 | } | ||
70 | }, | ||
71 | elements: { | ||
72 | point: { | ||
73 | radius: 0 | ||
74 | } | ||
75 | }, | ||
76 | tooltips: { | ||
77 | mode: 'index', | ||
78 | intersect: false, | ||
79 | custom: function (tooltip: any) { | ||
80 | if (!tooltip) return | ||
81 | // disable displaying the color box | ||
82 | tooltip.displayColors = false | ||
83 | }, | ||
84 | callbacks: { | ||
85 | label: (tooltip: any, data: any) => `${tooltip.value} views` | ||
86 | } | ||
87 | }, | ||
88 | hover: { | ||
89 | mode: 'index', | ||
90 | intersect: false | ||
91 | } | ||
92 | } | ||
93 | } | ||
94 | |||
35 | async deleteVideoChannel (videoChannel: VideoChannel) { | 95 | async deleteVideoChannel (videoChannel: VideoChannel) { |
36 | const res = await this.confirmService.confirmWithInput( | 96 | const res = await this.confirmService.confirmWithInput( |
37 | this.i18n( | 97 | this.i18n( |
@@ -63,7 +123,37 @@ export class MyAccountVideoChannelsComponent implements OnInit { | |||
63 | 123 | ||
64 | private loadVideoChannels () { | 124 | private loadVideoChannels () { |
65 | this.authService.userInformationLoaded | 125 | this.authService.userInformationLoaded |
66 | .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account))) | 126 | .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true))) |
67 | .subscribe(res => this.videoChannels = res.data) | 127 | .subscribe(res => { |
128 | this.videoChannels = res.data | ||
129 | |||
130 | // chart data | ||
131 | this.videoChannelsChartData = this.videoChannels.map(v => ({ | ||
132 | labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()), | ||
133 | datasets: [ | ||
134 | { | ||
135 | label: this.i18n('Views for the day'), | ||
136 | data: v.viewsPerDay.map(day => day.views), | ||
137 | fill: false, | ||
138 | borderColor: "#c6c6c6" | ||
139 | } | ||
140 | ] | ||
141 | } as ChartData)) | ||
142 | |||
143 | // chart options that depend on chart data: | ||
144 | // we don't want to skew values and have min at 0, so we define what the floor/ceiling is here | ||
145 | this.videoChannelsMinimumDailyViews = min( | ||
146 | this.videoChannels.map(v => minBy( // compute local minimum daily views for each channel, by their "views" attribute | ||
147 | v.viewsPerDay, | ||
148 | day => day.views | ||
149 | ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute | ||
150 | ) | ||
151 | this.videoChannelsMaximumDailyViews = max( | ||
152 | this.videoChannels.map(v => maxBy( // compute local maximum daily views for each channel, by their "views" attribute | ||
153 | v.viewsPerDay, | ||
154 | day => day.views | ||
155 | ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute | ||
156 | ) | ||
157 | }) | ||
68 | } | 158 | } |
69 | } | 159 | } |
diff --git a/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.module.ts b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.module.ts new file mode 100644 index 000000000..87d6b762f --- /dev/null +++ b/client/src/app/+my-account/my-account-video-channels/my-account-video-channels.module.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { ChartModule } from 'primeng/chart' | ||
3 | import { MyAccountVideoChannelsRoutingModule } from './my-account-video-channels-routing.module' | ||
4 | import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component' | ||
5 | import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component' | ||
6 | import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component' | ||
7 | import { SharedModule } from '@app/shared' | ||
8 | |||
9 | @NgModule({ | ||
10 | imports: [ | ||
11 | MyAccountVideoChannelsRoutingModule, | ||
12 | SharedModule, | ||
13 | ChartModule | ||
14 | ], | ||
15 | |||
16 | declarations: [ | ||
17 | MyAccountVideoChannelsComponent, | ||
18 | MyAccountVideoChannelCreateComponent, | ||
19 | MyAccountVideoChannelUpdateComponent | ||
20 | ], | ||
21 | |||
22 | exports: [], | ||
23 | providers: [] | ||
24 | }) | ||
25 | export class MyAccountVideoChannelsModule { } | ||
diff --git a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html index 329948cb5..37c6ad6b4 100644 --- a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html +++ b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.html | |||
@@ -1,6 +1,7 @@ | |||
1 | <p-table | 1 | <p-table |
2 | [value]="videoImports" [lazy]="true" [paginator]="true" [totalRecords]="totalRecords" [rows]="rowsPerPage" | 2 | [value]="videoImports" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" |
3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" | 3 | [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" |
4 | (onPage)="onPage($event)" [expandedRowKeys]="expandedRows" | ||
4 | > | 5 | > |
5 | <ng-template pTemplate="header"> | 6 | <ng-template pTemplate="header"> |
6 | <tr> | 7 | <tr> |
@@ -15,8 +16,8 @@ | |||
15 | 16 | ||
16 | <ng-template pTemplate="body" let-expanded="expanded" let-videoImport> | 17 | <ng-template pTemplate="body" let-expanded="expanded" let-videoImport> |
17 | <tr> | 18 | <tr> |
18 | <td> | 19 | <td class="expand-cell"> |
19 | <span *ngIf="videoImport.error" class="expander" [pRowToggler]="videoImport"> | 20 | <span *ngIf="videoImport.error" class="expander" [pRowToggler]="videoImport" i18n-ngbTooltip ngbTooltip="See the error"> |
20 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> | 21 | <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> |
21 | </span> | 22 | </span> |
22 | </td> | 23 | </td> |
@@ -28,13 +29,14 @@ | |||
28 | </ng-template> | 29 | </ng-template> |
29 | </td> | 30 | </td> |
30 | 31 | ||
31 | <td *ngIf="isVideoImportPending(videoImport)"> | 32 | <td> |
32 | {{ videoImport.video?.name }} | 33 | <ng-container *ngIf="isVideoImportPending(videoImport)">{{ videoImport.video?.name }}</ng-container> |
33 | </td> | 34 | <ng-container *ngIf="isVideoImportSuccess(videoImport) && videoImport.video"> |
34 | <td *ngIf="isVideoImportSuccess(videoImport) && videoImport.video"> | 35 | <a [href]="getVideoUrl(videoImport.video)" target="_blank" rel="noopener noreferrer">{{ videoImport.video?.name }}</a> |
35 | <a [href]="getVideoUrl(videoImport.video)" target="_blank" rel="noopener noreferrer">{{ videoImport.video?.name }}</a> | 36 | </ng-container> |
37 | <ng-container *ngIf="isVideoImportSuccess(videoImport) && !videoImport.video" i18n>This video was deleted</ng-container> | ||
38 | <ng-container *ngIf="isVideoImportFailed(videoImport)"></ng-container> | ||
36 | </td> | 39 | </td> |
37 | <td *ngIf="isVideoImportFailed(videoImport)"></td> | ||
38 | 40 | ||
39 | <td>{{ videoImport.state.label }}</td> | 41 | <td>{{ videoImport.state.label }}</td> |
40 | <td>{{ videoImport.createdAt }}</td> | 42 | <td>{{ videoImport.createdAt }}</td> |
diff --git a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts index 21a10c8ff..4452154eb 100644 --- a/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts +++ b/client/src/app/+my-account/my-account-video-imports/my-account-video-imports.component.ts | |||
@@ -1,8 +1,7 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
2 | import { RestPagination, RestTable } from '@app/shared' | 2 | import { RestPagination, RestTable } from '@app/shared' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | 3 | import { SortMeta } from 'primeng/api' |
4 | import { Notifier } from '@app/core' | 4 | import { Notifier } from '@app/core' |
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
6 | import { VideoImport, VideoImportState } from '../../../../../shared/models/videos' | 5 | import { VideoImport, VideoImportState } from '../../../../../shared/models/videos' |
7 | import { VideoImportService } from '@app/shared/video-import' | 6 | import { VideoImportService } from '@app/shared/video-import' |
8 | 7 | ||
@@ -14,14 +13,12 @@ import { VideoImportService } from '@app/shared/video-import' | |||
14 | export class MyAccountVideoImportsComponent extends RestTable implements OnInit { | 13 | export class MyAccountVideoImportsComponent extends RestTable implements OnInit { |
15 | videoImports: VideoImport[] = [] | 14 | videoImports: VideoImport[] = [] |
16 | totalRecords = 0 | 15 | totalRecords = 0 |
17 | rowsPerPage = 10 | ||
18 | sort: SortMeta = { field: 'createdAt', order: 1 } | 16 | sort: SortMeta = { field: 'createdAt', order: 1 } |
19 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 17 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
20 | 18 | ||
21 | constructor ( | 19 | constructor ( |
22 | private notifier: Notifier, | 20 | private notifier: Notifier, |
23 | private videoImportService: VideoImportService, | 21 | private videoImportService: VideoImportService |
24 | private i18n: I18n | ||
25 | ) { | 22 | ) { |
26 | super() | 23 | super() |
27 | } | 24 | } |
@@ -30,6 +27,10 @@ export class MyAccountVideoImportsComponent extends RestTable implements OnInit | |||
30 | this.initialize() | 27 | this.initialize() |
31 | } | 28 | } |
32 | 29 | ||
30 | getIdentifier () { | ||
31 | return 'MyAccountVideoImportsComponent' | ||
32 | } | ||
33 | |||
33 | isVideoImportSuccess (videoImport: VideoImport) { | 34 | isVideoImportSuccess (videoImport: VideoImport) { |
34 | return videoImport.state.id === VideoImportState.SUCCESS | 35 | return videoImport.state.id === VideoImportState.SUCCESS |
35 | } | 36 | } |
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 82321459f..05335dc1a 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 | |||
@@ -1,71 +1,102 @@ | |||
1 | <div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a new playlist</div> | 1 | <nav aria-label="breadcrumb"> |
2 | <ol class="breadcrumb"> | ||
3 | <li class="breadcrumb-item"> | ||
4 | <a routerLink="/my-account/video-playlists" i18n>My Playlists</a> | ||
5 | </li> | ||
6 | |||
7 | <ng-container *ngIf="isCreation()"> | ||
8 | <li class="breadcrumb-item active" i18n>Create</li> | ||
9 | </ng-container> | ||
10 | <ng-container *ngIf="!isCreation()"> | ||
11 | <li class="breadcrumb-item active" i18n>Edit</li> | ||
12 | <li class="breadcrumb-item active" aria-current="page"> | ||
13 | <a *ngIf="videoPlaylistToUpdate" [routerLink]="[ '/my-account/video-playlists/update', videoPlaylistToUpdate?.uuid ]">{{ videoPlaylistToUpdate?.displayName }}</a> | ||
14 | </li> | ||
15 | </ng-container> | ||
16 | </ol> | ||
17 | </nav> | ||
2 | 18 | ||
3 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | 19 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> |
4 | 20 | ||
5 | <form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> | 21 | <form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> |
6 | <div class="row"> | ||
7 | <div class="col-md-12 col-xl-6"> | ||
8 | <div class="form-group"> | ||
9 | <label i18n for="displayName">Display name</label> | ||
10 | <input | ||
11 | type="text" id="displayName" | ||
12 | formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }" | ||
13 | > | ||
14 | <div *ngIf="formErrors['displayName']" class="form-error"> | ||
15 | {{ formErrors['displayName'] }} | ||
16 | </div> | ||
17 | </div> | ||
18 | 22 | ||
19 | <div class="form-group"> | 23 | <div class="form-row"> <!-- playlist grid --> |
20 | <label i18n for="description">Description</label> | 24 | <div class="form-group col-12 col-lg-4 col-xl-3"> |
21 | <textarea | 25 | <div *ngIf="isCreation()" class="video-playlist-title" i18n>NEW PLAYLIST</div> |
22 | id="description" formControlName="description" | 26 | <div *ngIf="!isCreation() && videoPlaylistToUpdate" class="video-playlist-title" i18n>PLAYLIST</div> |
23 | [ngClass]="{ 'input-error': formErrors['description'] }" | ||
24 | ></textarea> | ||
25 | <div *ngIf="formErrors.description" class="form-error"> | ||
26 | {{ formErrors.description }} | ||
27 | </div> | ||
28 | </div> | ||
29 | </div> | 27 | </div> |
30 | 28 | ||
31 | <div class="col-md-12 col-xl-6"> | 29 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> |
32 | <div class="form-group"> | 30 | |
33 | <label i18n for="privacy">Privacy</label> | 31 | <div class="col-md-12 col-xl-6"> |
34 | <div class="peertube-select-container"> | 32 | <div class="form-group"> |
35 | <select id="privacy" formControlName="privacy"> | 33 | <label i18n for="displayName">Display name</label> |
36 | <option *ngFor="let privacy of videoPlaylistPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | 34 | <input |
37 | </select> | 35 | type="text" id="displayName" |
36 | formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }" | ||
37 | > | ||
38 | <div *ngIf="formErrors['displayName']" class="form-error"> | ||
39 | {{ formErrors['displayName'] }} | ||
40 | </div> | ||
38 | </div> | 41 | </div> |
39 | 42 | ||
40 | <div *ngIf="formErrors.privacy" class="form-error"> | 43 | <div class="form-group"> |
41 | {{ formErrors.privacy }} | 44 | <label i18n for="description">Description</label> |
45 | <textarea | ||
46 | id="description" formControlName="description" | ||
47 | [ngClass]="{ 'input-error': formErrors['description'] }" | ||
48 | ></textarea> | ||
49 | <div *ngIf="formErrors.description" class="form-error"> | ||
50 | {{ formErrors.description }} | ||
51 | </div> | ||
42 | </div> | 52 | </div> |
43 | </div> | 53 | </div> |
44 | 54 | ||
45 | <div class="form-group"> | 55 | <div class="col-md-12 col-xl-6"> |
46 | <label i18n>Channel</label> | 56 | <div class="form-group"> |
47 | <div class="peertube-select-container"> | 57 | <label i18n for="privacy">Privacy</label> |
48 | <select formControlName="videoChannelId"> | 58 | <div class="peertube-select-container"> |
49 | <option></option> | 59 | <select id="privacy" formControlName="privacy"> |
50 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | 60 | <option *ngFor="let privacy of videoPlaylistPrivacies" [value]="privacy.id">{{ privacy.label }}</option> |
51 | </select> | 61 | </select> |
62 | </div> | ||
63 | |||
64 | <div *ngIf="formErrors.privacy" class="form-error"> | ||
65 | {{ formErrors.privacy }} | ||
66 | </div> | ||
52 | </div> | 67 | </div> |
53 | 68 | ||
54 | <div *ngIf="formErrors['videoChannelId']" class="form-error"> | 69 | <div class="form-group"> |
55 | {{ formErrors['videoChannelId'] }} | 70 | <label i18n>Channel</label> |
71 | <div class="peertube-select-container"> | ||
72 | <select formControlName="videoChannelId"> | ||
73 | <option></option> | ||
74 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | ||
75 | </select> | ||
76 | </div> | ||
77 | |||
78 | <div *ngIf="formErrors['videoChannelId']" class="form-error"> | ||
79 | {{ formErrors['videoChannelId'] }} | ||
80 | </div> | ||
56 | </div> | 81 | </div> |
57 | </div> | ||
58 | 82 | ||
59 | <div class="form-group"> | 83 | <div class="form-group"> |
60 | <label i18n>Playlist thumbnail</label> | 84 | <label i18n>Playlist thumbnail</label> |
85 | |||
86 | <my-preview-upload | ||
87 | i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile" | ||
88 | previewWidth="223px" previewHeight="122px" | ||
89 | ></my-preview-upload> | ||
90 | </div> | ||
91 | </div> | ||
61 | 92 | ||
62 | <my-preview-upload | 93 | <div class="form-row"> <!-- submit placement block --> |
63 | i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile" | 94 | <div class="col-md-7 col-xl-5"></div> |
64 | previewWidth="223px" previewHeight="122px" | 95 | <div class="col-md-5 col-xl-5 d-inline-flex"> |
65 | ></my-preview-upload> | 96 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> |
97 | </div> | ||
66 | </div> | 98 | </div> |
67 | </div> | 99 | </div> |
68 | </div> | 100 | </div> |
69 | 101 | ||
70 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> | ||
71 | </form> | 102 | </form> |
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss index 5af846d8e..08fab1101 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss | |||
@@ -1,8 +1,13 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .form-sub-title { | 4 | label { |
5 | margin-bottom: 20px; | 5 | font-weight: $font-regular; |
6 | font-size: 100%; | ||
7 | } | ||
8 | |||
9 | .video-playlist-title { | ||
10 | @include settings-big-title; | ||
6 | } | 11 | } |
7 | 12 | ||
8 | input[type=text] { | 13 | input[type=text] { |
@@ -25,3 +30,7 @@ input[type=submit] { | |||
25 | @include peertube-button; | 30 | @include peertube-button; |
26 | @include orange-button; | 31 | @include orange-button; |
27 | } | 32 | } |
33 | |||
34 | .breadcrumb { | ||
35 | @include breadcrumb; | ||
36 | } | ||
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss index 9657ac11d..a4ca0f45d 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss | |||
@@ -11,6 +11,11 @@ | |||
11 | 11 | ||
12 | display: flex; | 12 | display: flex; |
13 | justify-content: center; | 13 | justify-content: center; |
14 | |||
15 | /* fix ellipsis dots background color */ | ||
16 | ::ng-deep .miniature-name::after { | ||
17 | background-color: var(--submenuColor) !important; | ||
18 | } | ||
14 | } | 19 | } |
15 | 20 | ||
16 | // Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples | 21 | // Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples |
@@ -37,3 +42,9 @@ | |||
37 | .videos.cdk-drop-list-dragging .video:not(.cdk-drag-placeholder) { | 42 | .videos.cdk-drop-list-dragging .video:not(.cdk-drag-placeholder) { |
38 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); | 43 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); |
39 | } | 44 | } |
45 | |||
46 | @media screen and (max-width: $small-view) { | ||
47 | .playlist-info { | ||
48 | margin-top: -$sub-menu-margin-bottom-small-view; | ||
49 | } | ||
50 | } | ||
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html index dd6a0e55b..86844ce01 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html | |||
@@ -5,7 +5,7 @@ | |||
5 | 5 | ||
6 | <a class="create-button" routerLink="create"> | 6 | <a class="create-button" routerLink="create"> |
7 | <my-global-icon iconName="add"></my-global-icon> | 7 | <my-global-icon iconName="add"></my-global-icon> |
8 | <ng-container i18n>Create a new playlist</ng-container> | 8 | <ng-container i18n>Create playlist</ng-container> |
9 | </a> | 9 | </a> |
10 | </div> | 10 | </div> |
11 | 11 | ||
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss index 4e4156b22..4381d74b0 100644 --- a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss | |||
@@ -43,7 +43,7 @@ | |||
43 | } | 43 | } |
44 | } | 44 | } |
45 | 45 | ||
46 | @media screen and (max-width: 800px) { | 46 | @media screen and (max-width: $small-view) { |
47 | .video-playlists-header { | 47 | .video-playlists-header { |
48 | text-align: center; | 48 | text-align: center; |
49 | } | 49 | } |
@@ -67,3 +67,22 @@ | |||
67 | } | 67 | } |
68 | } | 68 | } |
69 | } | 69 | } |
70 | |||
71 | @media only screen and (min-width: $mobile-view) and (max-width: $small-view) { | ||
72 | .video-playlists-header { | ||
73 | input[type=text] { | ||
74 | width: 42% !important; | ||
75 | } | ||
76 | } | ||
77 | } | ||
78 | |||
79 | @media screen and (max-width: $mobile-view) { | ||
80 | .video-playlists-header { | ||
81 | flex-direction: column; | ||
82 | |||
83 | input[type=text] { | ||
84 | width: 100% !important; | ||
85 | margin-bottom: 12px; | ||
86 | } | ||
87 | } | ||
88 | } | ||
diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss b/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss index 8248cc94f..40bae7668 100644 --- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss +++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.scss | |||
@@ -54,3 +54,47 @@ my-delete-button, | |||
54 | my-edit-button { | 54 | my-edit-button { |
55 | margin-right: 10px; | 55 | margin-right: 10px; |
56 | } | 56 | } |
57 | |||
58 | @media screen and (max-width: $small-view) { | ||
59 | .videos-header { | ||
60 | flex-direction: column; | ||
61 | } | ||
62 | |||
63 | ::ng-deep { | ||
64 | .video-miniature { | ||
65 | align-items: center; | ||
66 | |||
67 | .video-bottom, | ||
68 | .video-bottom .video-miniature-information { | ||
69 | /* same width than a.video-thumbnail */ | ||
70 | max-width: 223px !important; | ||
71 | } | ||
72 | } | ||
73 | } | ||
74 | |||
75 | my-delete-button, | ||
76 | my-edit-button { | ||
77 | margin-right: 0px; | ||
78 | |||
79 | ::ng-deep { | ||
80 | span, a { | ||
81 | margin-right: 0px; | ||
82 | } | ||
83 | } | ||
84 | } | ||
85 | |||
86 | my-delete-button, | ||
87 | my-edit-button, | ||
88 | my-button { | ||
89 | margin-top: 15px; | ||
90 | width: 100%; | ||
91 | text-align: center; | ||
92 | |||
93 | ::ng-deep { | ||
94 | .action-button { | ||
95 | /* same width than a.video-thumbnail */ | ||
96 | width: 223px; | ||
97 | } | ||
98 | } | ||
99 | } | ||
100 | } | ||
diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html index 22f127904..9d809d2bf 100644 --- a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html +++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.html | |||
@@ -18,9 +18,10 @@ | |||
18 | 18 | ||
19 | <div class="modal-footer inputs"> | 19 | <div class="modal-footer inputs"> |
20 | <div class="form-group inputs"> | 20 | <div class="form-group inputs"> |
21 | <span i18n class="action-button action-button-cancel" (click)="dismiss()"> | 21 | <input |
22 | Cancel | 22 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" |
23 | </span> | 23 | (click)="dismiss()" (key.enter)="dismiss()" |
24 | > | ||
24 | 25 | ||
25 | <input | 26 | <input |
26 | type="submit" i18n-value value="Submit" class="action-button-submit" | 27 | type="submit" i18n-value value="Submit" class="action-button-submit" |
diff --git a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts index 36d1ea091..f4e2b5955 100644 --- a/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts +++ b/client/src/app/+my-account/my-account-videos/video-change-ownership/video-change-ownership.component.ts | |||
@@ -43,7 +43,7 @@ export class VideoChangeOwnershipComponent extends FormReactive implements OnIni | |||
43 | show (video: Video) { | 43 | show (video: Video) { |
44 | this.video = video | 44 | this.video = video |
45 | this.modalService | 45 | this.modalService |
46 | .open(this.modal) | 46 | .open(this.modal, { centered: true }) |
47 | .result | 47 | .result |
48 | .then(() => this.changeOwnership()) | 48 | .then(() => this.changeOwnership()) |
49 | .catch((_) => _) // Called when closing (cancel) the modal without validating, do nothing | 49 | .catch((_) => _) // Called when closing (cancel) the modal without validating, do nothing |
diff --git a/client/src/app/+my-account/my-account.component.html b/client/src/app/+my-account/my-account.component.html index 3999252be..d885eb243 100644 --- a/client/src/app/+my-account/my-account.component.html +++ b/client/src/app/+my-account/my-account.component.html | |||
@@ -1,7 +1,7 @@ | |||
1 | <div class="row"> | 1 | <div class="row"> |
2 | <my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown> | 2 | <my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown> |
3 | 3 | ||
4 | <div class="margin-content"> | 4 | <div class="margin-content pb-5"> |
5 | <router-outlet></router-outlet> | 5 | <router-outlet></router-outlet> |
6 | </div> | 6 | </div> |
7 | </div> | 7 | </div> |
diff --git a/client/src/app/+my-account/my-account.component.scss b/client/src/app/+my-account/my-account.component.scss index 4f111efdf..fd47aec86 100644 --- a/client/src/app/+my-account/my-account.component.scss +++ b/client/src/app/+my-account/my-account.component.scss | |||
@@ -1,3 +1,8 @@ | |||
1 | .row { | 1 | .row { |
2 | flex-direction: column; | 2 | flex-direction: column; |
3 | width: 100%; | ||
4 | |||
5 | & > my-top-menu-dropdown:nth-child(1) { | ||
6 | flex-grow: 1; | ||
7 | } | ||
3 | } | 8 | } |
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index 6cf1499d3..72b9fd9f2 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts | |||
@@ -1,11 +1,10 @@ | |||
1 | import { TableModule } from 'primeng/table' | ||
2 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { TableModule } from 'primeng/table' | ||
3 | import { AutoCompleteModule } from 'primeng/autocomplete' | 3 | import { AutoCompleteModule } from 'primeng/autocomplete' |
4 | import { InputSwitchModule } from 'primeng/inputswitch' | 4 | import { InputSwitchModule } from 'primeng/inputswitch' |
5 | import { SharedModule } from '../shared' | 5 | import { SharedModule } from '../shared' |
6 | import { MyAccountRoutingModule } from './my-account-routing.module' | 6 | import { MyAccountRoutingModule } from './my-account-routing.module' |
7 | import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' | 7 | import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component' |
8 | import { MyAccountVideoSettingsComponent } from './my-account-settings/my-account-video-settings/my-account-video-settings.component' | ||
9 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' | 8 | import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' |
10 | import { MyAccountComponent } from './my-account.component' | 9 | import { MyAccountComponent } from './my-account.component' |
11 | import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component' | 10 | import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component' |
@@ -13,10 +12,6 @@ import { VideoChangeOwnershipComponent } from './my-account-videos/video-change- | |||
13 | import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component' | 12 | import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component' |
14 | import { MyAccountAcceptOwnershipComponent } from './my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component' | 13 | import { MyAccountAcceptOwnershipComponent } from './my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component' |
15 | import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/my-account-profile/my-account-profile.component' | 14 | import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/my-account-profile/my-account-profile.component' |
16 | import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component' | ||
17 | import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component' | ||
18 | import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component' | ||
19 | import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component' | ||
20 | import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component' | 15 | import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component' |
21 | import { MyAccountDangerZoneComponent } from '@app/+my-account/my-account-settings/my-account-danger-zone' | 16 | import { MyAccountDangerZoneComponent } from '@app/+my-account/my-account-settings/my-account-danger-zone' |
22 | import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component' | 17 | import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component' |
@@ -37,7 +32,6 @@ import { | |||
37 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' | 32 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' |
38 | import { DragDropModule } from '@angular/cdk/drag-drop' | 33 | import { DragDropModule } from '@angular/cdk/drag-drop' |
39 | import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email' | 34 | import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email' |
40 | import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface' | ||
41 | 35 | ||
42 | @NgModule({ | 36 | @NgModule({ |
43 | imports: [ | 37 | imports: [ |
@@ -54,20 +48,14 @@ import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account | |||
54 | MyAccountComponent, | 48 | MyAccountComponent, |
55 | MyAccountSettingsComponent, | 49 | MyAccountSettingsComponent, |
56 | MyAccountChangePasswordComponent, | 50 | MyAccountChangePasswordComponent, |
57 | MyAccountVideoSettingsComponent, | ||
58 | MyAccountProfileComponent, | 51 | MyAccountProfileComponent, |
59 | MyAccountChangeEmailComponent, | 52 | MyAccountChangeEmailComponent, |
60 | MyAccountInterfaceSettingsComponent, | ||
61 | 53 | ||
62 | MyAccountVideosComponent, | 54 | MyAccountVideosComponent, |
63 | 55 | ||
64 | VideoChangeOwnershipComponent, | 56 | VideoChangeOwnershipComponent, |
65 | MyAccountOwnershipComponent, | 57 | MyAccountOwnershipComponent, |
66 | MyAccountAcceptOwnershipComponent, | 58 | MyAccountAcceptOwnershipComponent, |
67 | MyAccountVideoChannelsComponent, | ||
68 | MyAccountVideoChannelCreateComponent, | ||
69 | MyAccountVideoChannelUpdateComponent, | ||
70 | ActorAvatarInfoComponent, | ||
71 | MyAccountVideoImportsComponent, | 59 | MyAccountVideoImportsComponent, |
72 | MyAccountDangerZoneComponent, | 60 | MyAccountDangerZoneComponent, |
73 | MyAccountSubscriptionsComponent, | 61 | MyAccountSubscriptionsComponent, |
diff --git a/client/src/app/+my-account/shared/actor-avatar-info.component.html b/client/src/app/+my-account/shared/actor-avatar-info.component.html index 8bdff2f5a..82f5123de 100644 --- a/client/src/app/+my-account/shared/actor-avatar-info.component.html +++ b/client/src/app/+my-account/shared/actor-avatar-info.component.html | |||
@@ -1,6 +1,16 @@ | |||
1 | <ng-container *ngIf="actor"> | 1 | <ng-container *ngIf="actor"> |
2 | <div class="actor"> | 2 | <div class="actor"> |
3 | <img [src]="actor.avatarUrl" alt="Avatar" /> | 3 | <div class="d-flex"> |
4 | <img [src]="actor.avatarUrl" alt="Avatar" /> | ||
5 | |||
6 | <div class="actor-img-edit-container"> | ||
7 | <div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body"> | ||
8 | <my-global-icon iconName="edit"></my-global-icon> | ||
9 | <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/> | ||
10 | </div> | ||
11 | </div> | ||
12 | </div> | ||
13 | |||
4 | 14 | ||
5 | <div class="actor-info"> | 15 | <div class="actor-info"> |
6 | <div class="actor-info-names"> | 16 | <div class="actor-info-names"> |
@@ -10,10 +20,4 @@ | |||
10 | <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div> | 20 | <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div> |
11 | </div> | 21 | </div> |
12 | </div> | 22 | </div> |
13 | |||
14 | <div class="button-file"> | ||
15 | <span i18n>Change the avatar</span> | ||
16 | <input #avatarfileInput type="file" name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()" /> | ||
17 | </div> | ||
18 | <div i18n class="file-max-size">(extensions: {{ avatarExtensions }}, max size: {{ maxAvatarSize | bytes }})</div> | ||
19 | </ng-container> \ No newline at end of file | 23 | </ng-container> \ No newline at end of file |
diff --git a/client/src/app/+my-account/shared/actor-avatar-info.component.scss b/client/src/app/+my-account/shared/actor-avatar-info.component.scss index 86f8108b9..5a66ecfd2 100644 --- a/client/src/app/+my-account/shared/actor-avatar-info.component.scss +++ b/client/src/app/+my-account/shared/actor-avatar-info.component.scss | |||
@@ -5,12 +5,42 @@ | |||
5 | display: flex; | 5 | display: flex; |
6 | 6 | ||
7 | img { | 7 | img { |
8 | @include avatar(50px); | 8 | @include avatar(100px); |
9 | 9 | ||
10 | margin-right: 15px; | 10 | margin-right: 15px; |
11 | } | 11 | } |
12 | 12 | ||
13 | .actor-img-edit-container { | ||
14 | position: relative; | ||
15 | width: 0; | ||
16 | |||
17 | .actor-img-edit-button { | ||
18 | @include peertube-button-file(21px); | ||
19 | @include button-with-icon(19px); | ||
20 | |||
21 | margin-top: 10px; | ||
22 | margin-bottom: 5px; | ||
23 | border-radius: 50%; | ||
24 | top: 55px; | ||
25 | right: 45px; | ||
26 | cursor: pointer; | ||
27 | |||
28 | input { | ||
29 | width: 30px; | ||
30 | height: 30px; | ||
31 | } | ||
32 | |||
33 | my-global-icon { | ||
34 | right: 7px; | ||
35 | } | ||
36 | } | ||
37 | } | ||
38 | |||
13 | .actor-info { | 39 | .actor-info { |
40 | justify-content: center; | ||
41 | display: inline-flex; | ||
42 | flex-direction: column; | ||
43 | |||
14 | .actor-info-names { | 44 | .actor-info-names { |
15 | display: flex; | 45 | display: flex; |
16 | align-items: center; | 46 | align-items: center; |
@@ -35,21 +65,7 @@ | |||
35 | 65 | ||
36 | .actor-info-followers { | 66 | .actor-info-followers { |
37 | font-size: 15px; | 67 | font-size: 15px; |
68 | padding-bottom: .5rem; | ||
38 | } | 69 | } |
39 | } | 70 | } |
40 | } | 71 | } |
41 | |||
42 | .button-file { | ||
43 | @include peertube-button-file(160px); | ||
44 | |||
45 | margin-top: 10px; | ||
46 | margin-bottom: 5px; | ||
47 | } | ||
48 | |||
49 | .file-max-size { | ||
50 | display: inline-block; | ||
51 | font-size: 13px; | ||
52 | |||
53 | position: relative; | ||
54 | top: -10px; | ||
55 | } | ||
diff --git a/client/src/app/+my-account/shared/actor-avatar-info.component.ts b/client/src/app/+my-account/shared/actor-avatar-info.component.ts index 101dfa556..8e4a7a602 100644 --- a/client/src/app/+my-account/shared/actor-avatar-info.component.ts +++ b/client/src/app/+my-account/shared/actor-avatar-info.component.ts | |||
@@ -4,6 +4,8 @@ import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | |||
4 | import { Account } from '@app/shared/account/account.model' | 4 | import { Account } from '@app/shared/account/account.model' |
5 | import { Notifier } from '@app/core' | 5 | import { Notifier } from '@app/core' |
6 | import { ServerConfig } from '@shared/models' | 6 | import { ServerConfig } from '@shared/models' |
7 | import { BytesPipe } from 'ngx-pipes' | ||
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | 9 | ||
8 | @Component({ | 10 | @Component({ |
9 | selector: 'my-actor-avatar-info', | 11 | selector: 'my-actor-avatar-info', |
@@ -11,18 +13,25 @@ import { ServerConfig } from '@shared/models' | |||
11 | styleUrls: [ './actor-avatar-info.component.scss' ] | 13 | styleUrls: [ './actor-avatar-info.component.scss' ] |
12 | }) | 14 | }) |
13 | export class ActorAvatarInfoComponent implements OnInit { | 15 | export class ActorAvatarInfoComponent implements OnInit { |
14 | @ViewChild('avatarfileInput', { static: false }) avatarfileInput: ElementRef<HTMLInputElement> | 16 | @ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement> |
15 | 17 | ||
16 | @Input() actor: VideoChannel | Account | 18 | @Input() actor: VideoChannel | Account |
17 | 19 | ||
18 | @Output() avatarChange = new EventEmitter<FormData>() | 20 | @Output() avatarChange = new EventEmitter<FormData>() |
19 | 21 | ||
22 | maxSizeText: string | ||
23 | |||
20 | private serverConfig: ServerConfig | 24 | private serverConfig: ServerConfig |
25 | private bytesPipe: BytesPipe | ||
21 | 26 | ||
22 | constructor ( | 27 | constructor ( |
23 | private serverService: ServerService, | 28 | private serverService: ServerService, |
24 | private notifier: Notifier | 29 | private notifier: Notifier, |
25 | ) {} | 30 | private i18n: I18n |
31 | ) { | ||
32 | this.bytesPipe = new BytesPipe() | ||
33 | this.maxSizeText = this.i18n('max size') | ||
34 | } | ||
26 | 35 | ||
27 | ngOnInit (): void { | 36 | ngOnInit (): void { |
28 | this.serverConfig = this.serverService.getTmpConfig() | 37 | this.serverConfig = this.serverService.getTmpConfig() |
@@ -47,7 +56,11 @@ export class ActorAvatarInfoComponent implements OnInit { | |||
47 | return this.serverConfig.avatar.file.size.max | 56 | return this.serverConfig.avatar.file.size.max |
48 | } | 57 | } |
49 | 58 | ||
59 | get maxAvatarSizeInBytes () { | ||
60 | return this.bytesPipe.transform(this.maxAvatarSize) | ||
61 | } | ||
62 | |||
50 | get avatarExtensions () { | 63 | get avatarExtensions () { |
51 | return this.serverConfig.avatar.file.extensions.join(',') | 64 | return this.serverConfig.avatar.file.extensions.join(', ') |
52 | } | 65 | } |
53 | } | 66 | } |
diff --git a/client/src/app/+signup/+register/register-step-channel.component.html b/client/src/app/+signup/+register/register-step-channel.component.html index 88ff6e3ff..170c2964e 100644 --- a/client/src/app/+signup/+register/register-step-channel.component.html +++ b/client/src/app/+signup/+register/register-step-channel.component.html | |||
@@ -40,7 +40,7 @@ | |||
40 | </div> | 40 | </div> |
41 | 41 | ||
42 | <div class="name-information" i18n> | 42 | <div class="name-information" i18n> |
43 | The channel name is a unique identifier of your channel on this instance. It's like an address mail, so other people can find your channel. | 43 | The channel name is a unique identifier of your channel on this and all the other instances. It's as unique as an email address, which makes it easy for other people to interact with it. |
44 | </div> | 44 | </div> |
45 | 45 | ||
46 | <div *ngIf="formErrors.name" class="form-error"> | 46 | <div *ngIf="formErrors.name" class="form-error"> |
diff --git a/client/src/app/+signup/+register/register-step-user.component.html b/client/src/app/+signup/+register/register-step-user.component.html index a2a657660..1bd378b13 100644 --- a/client/src/app/+signup/+register/register-step-user.component.html +++ b/client/src/app/+signup/+register/register-step-user.component.html | |||
@@ -21,7 +21,7 @@ | |||
21 | <div class="input-group"> | 21 | <div class="input-group"> |
22 | <input | 22 | <input |
23 | type="text" id="username" i18n-placeholder placeholder="Example: jane_doe" | 23 | type="text" id="username" i18n-placeholder placeholder="Example: jane_doe" |
24 | formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }" | 24 | formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" |
25 | > | 25 | > |
26 | <div class="input-group-append"> | 26 | <div class="input-group-append"> |
27 | <span class="input-group-text">@{{ instanceHost }}</span> | 27 | <span class="input-group-text">@{{ instanceHost }}</span> |
@@ -29,7 +29,7 @@ | |||
29 | </div> | 29 | </div> |
30 | 30 | ||
31 | <div class="name-information" i18n> | 31 | <div class="name-information" i18n> |
32 | The username is a unique identifier of your account on this instance. It's like an address mail, so other people can find you. | 32 | The username is a unique identifier of your account on this and all the other instances. It's as unique as an email address, which makes it easy for other people to interact with it. |
33 | </div> | 33 | </div> |
34 | 34 | ||
35 | <div *ngIf="formErrors.username" class="form-error"> | 35 | <div *ngIf="formErrors.username" class="form-error"> |
@@ -41,7 +41,7 @@ | |||
41 | <label for="email" i18n>Email</label> | 41 | <label for="email" i18n>Email</label> |
42 | <input | 42 | <input |
43 | type="text" id="email" i18n-placeholder placeholder="Email" | 43 | type="text" id="email" i18n-placeholder placeholder="Email" |
44 | formControlName="email" [ngClass]="{ 'input-error': formErrors['email'] }" | 44 | formControlName="email" class="form-control" [ngClass]="{ 'input-error': formErrors['email'] }" |
45 | > | 45 | > |
46 | <div *ngIf="formErrors.email" class="form-error"> | 46 | <div *ngIf="formErrors.email" class="form-error"> |
47 | {{ formErrors.email }} | 47 | {{ formErrors.email }} |
@@ -52,7 +52,7 @@ | |||
52 | <label for="password" i18n>Password</label> | 52 | <label for="password" i18n>Password</label> |
53 | <input | 53 | <input |
54 | type="password" id="password" i18n-placeholder placeholder="Password" autocomplete="new-password" | 54 | type="password" id="password" i18n-placeholder placeholder="Password" autocomplete="new-password" |
55 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | 55 | formControlName="password" class="form-control" [ngClass]="{ 'input-error': formErrors['password'] }" |
56 | > | 56 | > |
57 | <div *ngIf="formErrors.password" class="form-error"> | 57 | <div *ngIf="formErrors.password" class="form-error"> |
58 | {{ formErrors.password }} | 58 | {{ formErrors.password }} |
diff --git a/client/src/app/+signup/+register/register.component.scss b/client/src/app/+signup/+register/register.component.scss index 2f62dd59d..e135b5cb4 100644 --- a/client/src/app/+signup/+register/register.component.scss +++ b/client/src/app/+signup/+register/register.component.scss | |||
@@ -44,7 +44,7 @@ | |||
44 | } | 44 | } |
45 | } | 45 | } |
46 | 46 | ||
47 | @media screen and (max-width: 500px) { | 47 | @media screen and (max-width: $mobile-view) { |
48 | width: auto; | 48 | width: auto; |
49 | } | 49 | } |
50 | } | 50 | } |
diff --git a/client/src/app/+signup/+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..ece9d1022 100644 --- a/client/src/app/+signup/+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 | |||
@@ -8,7 +8,7 @@ | |||
8 | <label i18n for="verify-email-email">Email</label> | 8 | <label i18n for="verify-email-email">Email</label> |
9 | <input | 9 | <input |
10 | type="email" id="verify-email-email" i18n-placeholder placeholder="Email address" required | 10 | type="email" id="verify-email-email" i18n-placeholder placeholder="Email address" required |
11 | formControlName="verify-email-email" [ngClass]="{ 'input-error': formErrors['verify-email-email'] }" | 11 | formControlName="verify-email-email" class="form-control" [ngClass]="{ 'input-error': formErrors['verify-email-email'] }" |
12 | > | 12 | > |
13 | <div *ngIf="formErrors['verify-email-email']" class="form-error"> | 13 | <div *ngIf="formErrors['verify-email-email']" class="form-error"> |
14 | {{ formErrors['verify-email-email'] }} | 14 | {{ formErrors['verify-email-email'] }} |
diff --git a/client/src/app/+video-channels/video-channel-about/video-channel-about.component.html b/client/src/app/+video-channels/video-channel-about/video-channel-about.component.html index 9655668d7..c02213ebb 100644 --- a/client/src/app/+video-channels/video-channel-about/video-channel-about.component.html +++ b/client/src/app/+video-channels/video-channel-about/video-channel-about.component.html | |||
@@ -1,19 +1,19 @@ | |||
1 | <div *ngIf="videoChannel" class="row"> | 1 | <div *ngIf="videoChannel" class="row"> |
2 | <div class="description col-md-6 col-sm-12"> | 2 | <div class="description col-md-6 col-sm-12"> |
3 | <div class="block"> | 3 | <div class="block"> |
4 | <div i18n class="small-title">Description</div> | 4 | <div i18n class="small-title">DESCRIPTION</div> |
5 | <div class="content" [innerHtml]="getVideoChannelDescription()"></div> | 5 | <div class="content" [innerHtml]="getVideoChannelDescription()"></div> |
6 | </div> | 6 | </div> |
7 | 7 | ||
8 | <div class="block" *ngIf="supportHTML"> | 8 | <div class="block" *ngIf="supportHTML"> |
9 | <div i18n class="small-title">Support this channel</div> | 9 | <div i18n class="small-title">SUPPORT THIS CHANNEL</div> |
10 | <div class="content" [innerHtml]="supportHTML"></div> | 10 | <div class="content" [innerHtml]="supportHTML"></div> |
11 | </div> | 11 | </div> |
12 | </div> | 12 | </div> |
13 | 13 | ||
14 | <div class="stats col-md-6 col-sm-12"> | 14 | <div class="stats col-md-6 col-sm-12"> |
15 | <div class="block"> | 15 | <div class="block"> |
16 | <div i18n class="small-title">Stats</div> | 16 | <div i18n class="small-title">STATS</div> |
17 | <div i18n class="content">Created {{ videoChannel.createdAt | date }}</div> | 17 | <div i18n class="content">Created {{ videoChannel.createdAt | date }}</div> |
18 | </div> | 18 | </div> |
19 | </div> | 19 | </div> |
diff --git a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts index f4fe14662..9eaa3ba32 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 | |||
@@ -12,6 +12,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
12 | import { Subscription } from 'rxjs' | 12 | import { Subscription } from 'rxjs' |
13 | import { ScreenService } from '@app/shared/misc/screen.service' | 13 | import { ScreenService } from '@app/shared/misc/screen.service' |
14 | import { Notifier, ServerService } from '@app/core' | 14 | import { Notifier, ServerService } from '@app/core' |
15 | import { UserService } from '@app/shared' | ||
16 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
15 | 17 | ||
16 | @Component({ | 18 | @Component({ |
17 | selector: 'my-video-channel-videos', | 19 | selector: 'my-video-channel-videos', |
@@ -34,9 +36,11 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On | |||
34 | protected serverService: ServerService, | 36 | protected serverService: ServerService, |
35 | protected route: ActivatedRoute, | 37 | protected route: ActivatedRoute, |
36 | protected authService: AuthService, | 38 | protected authService: AuthService, |
39 | protected userService: UserService, | ||
37 | protected notifier: Notifier, | 40 | protected notifier: Notifier, |
38 | protected confirmService: ConfirmService, | 41 | protected confirmService: ConfirmService, |
39 | protected screenService: ScreenService, | 42 | protected screenService: ScreenService, |
43 | protected storageService: LocalStorageService, | ||
40 | private videoChannelService: VideoChannelService, | 44 | private videoChannelService: VideoChannelService, |
41 | private videoService: VideoService | 45 | private videoService: VideoService |
42 | ) { | 46 | ) { |
@@ -72,7 +76,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On | |||
72 | .getVideoChannelVideos(this.videoChannel, newPagination, this.sort) | 76 | .getVideoChannelVideos(this.videoChannel, newPagination, this.sort) |
73 | .pipe( | 77 | .pipe( |
74 | tap(({ total }) => { | 78 | tap(({ total }) => { |
75 | this.titlePage = this.i18n(`{total, plural, =1 {Published 1 video} other {Published ${total} videos}}`, { total }) | 79 | this.titlePage = this.i18n(`{total, plural, =1 {Published 1 video} other {Published {{total}} videos}}`, { total }) |
76 | }) | 80 | }) |
77 | ) | 81 | ) |
78 | } | 82 | } |
diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html index f0bb083ca..43b5cd92e 100644 --- a/client/src/app/+video-channels/video-channels.component.html +++ b/client/src/app/+video-channels/video-channels.component.html | |||
@@ -7,32 +7,40 @@ | |||
7 | <div class="actor-info"> | 7 | <div class="actor-info"> |
8 | <div class="actor-names"> | 8 | <div class="actor-names"> |
9 | <div class="actor-display-name">{{ videoChannel.displayName }}</div> | 9 | <div class="actor-display-name">{{ videoChannel.displayName }}</div> |
10 | <div class="actor-name">{{ videoChannel.nameWithHost }} | 10 | <div class="actor-name"> |
11 | <button ngxClipboard [cbContent]="videoChannel.nameWithHost" (click)="activateCopiedMessage()" | 11 | <span>{{ videoChannel.nameWithHost }}</span> |
12 | <button [cdkCopyToClipboard]="videoChannel.nameWithHostForced" (click)="activateCopiedMessage()" | ||
12 | class="btn btn-outline-secondary btn-sm copy-button" | 13 | class="btn btn-outline-secondary btn-sm copy-button" |
13 | > | 14 | > |
14 | <span class="glyphicon glyphicon-copy"></span> | 15 | <span class="glyphicon glyphicon-copy"></span> |
15 | </button> | 16 | </button> |
16 | </div> | 17 | </div> |
18 | </div> | ||
17 | 19 | ||
18 | <div class="right-buttons"> | 20 | <div class="right-buttons"> |
19 | <a *ngIf="isChannelManageable" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n>Manage</a> | 21 | <a *ngIf="isChannelManageable && !isInSmallView" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n> |
20 | <my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button> | 22 | Manage channel |
21 | </div> | 23 | </a> |
24 | <my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button> | ||
22 | </div> | 25 | </div> |
23 | <div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div> | ||
24 | 26 | ||
25 | <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner"> | 27 | <div class="actor-lower"> |
26 | <span i18n>Created by {{ videoChannel.ownerBy }}</span> | 28 | <div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div> |
27 | <img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" /> | 29 | |
28 | </a> | 30 | <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner"> |
31 | <span class="d-inline-flex"><span i18n class="d-none d-sm-block mr-1">Created by</span>{{ videoChannel.ownerBy }}</span> | ||
32 | <img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" /> | ||
33 | </a> | ||
34 | </div> | ||
29 | </div> | 35 | </div> |
30 | </div> | 36 | </div> |
31 | 37 | ||
32 | <div class="links"> | 38 | <div class="links w-100"> |
33 | <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a> | 39 | <ng-template #linkTemplate let-item="item"> |
34 | <a i18n routerLink="video-playlists" routerLinkActive="active" class="title-page">Video playlists</a> | 40 | <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a> |
35 | <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> | 41 | </ng-template> |
42 | |||
43 | <list-overflow [items]="links" [itemTemplate]="linkTemplate"></list-overflow> | ||
36 | </div> | 44 | </div> |
37 | </div> | 45 | </div> |
38 | 46 | ||
diff --git a/client/src/app/+video-channels/video-channels.component.scss b/client/src/app/+video-channels/video-channels.component.scss index 50b69e7ac..0a49f53cf 100644 --- a/client/src/app/+video-channels/video-channels.component.scss +++ b/client/src/app/+video-channels/video-channels.component.scss | |||
@@ -1,3 +1,9 @@ | |||
1 | // Bootstrap grid utilities require functions, variables and mixins | ||
2 | @import 'node_modules/bootstrap/scss/functions'; | ||
3 | @import 'node_modules/bootstrap/scss/variables'; | ||
4 | @import 'node_modules/bootstrap/scss/mixins'; | ||
5 | @import 'node_modules/bootstrap/scss/grid'; | ||
6 | |||
1 | @import '_variables'; | 7 | @import '_variables'; |
2 | @import '_mixins'; | 8 | @import '_mixins'; |
3 | 9 | ||
@@ -8,6 +14,21 @@ | |||
8 | width: 100%; | 14 | width: 100%; |
9 | } | 15 | } |
10 | 16 | ||
17 | .actor-info { | ||
18 | display: grid !important; | ||
19 | grid-template-columns: 1fr auto; | ||
20 | grid-template-rows: 1fr auto / 1fr auto; | ||
21 | grid-template-areas: "name buttons" "lower buttons"; | ||
22 | |||
23 | @include media-breakpoint-down(lg) { | ||
24 | grid-template-areas: "name name" "lower buttons"; | ||
25 | } | ||
26 | } | ||
27 | |||
28 | .actor-names { | ||
29 | grid-area: name; | ||
30 | } | ||
31 | |||
11 | .actor-name { | 32 | .actor-name { |
12 | flex-grow: 1; | 33 | flex-grow: 1; |
13 | 34 | ||
@@ -23,7 +44,19 @@ | |||
23 | display: flex; | 44 | display: flex; |
24 | height: max-content; | 45 | height: max-content; |
25 | margin-left: auto; | 46 | margin-left: auto; |
26 | margin-top: 20px; | 47 | margin-top: 10px; |
48 | |||
49 | grid-row: buttons-start / span buttons-end; | ||
50 | grid-column: buttons-start; | ||
51 | |||
52 | @include media-breakpoint-down(lg) { | ||
53 | flex-flow: column-reverse; | ||
54 | |||
55 | a { | ||
56 | margin-top: 0.25rem; | ||
57 | margin-right: 0 !important; | ||
58 | } | ||
59 | } | ||
27 | 60 | ||
28 | a { | 61 | a { |
29 | @include peertube-button-outline; | 62 | @include peertube-button-outline; |
@@ -33,4 +66,17 @@ | |||
33 | my-subscribe-button { | 66 | my-subscribe-button { |
34 | height: min-content; | 67 | height: min-content; |
35 | } | 68 | } |
36 | } \ No newline at end of file | 69 | } |
70 | |||
71 | @media screen and (max-width: $mobile-view) { | ||
72 | .sub-menu { | ||
73 | .actor { | ||
74 | flex-direction: column; | ||
75 | |||
76 | .actor-info .actor-names { | ||
77 | flex-direction: column; | ||
78 | align-items: normal; | ||
79 | } | ||
80 | } | ||
81 | } | ||
82 | } | ||
diff --git a/client/src/app/+video-channels/video-channels.component.ts b/client/src/app/+video-channels/video-channels.component.ts index 7b335b13f..a3563c747 100644 --- a/client/src/app/+video-channels/video-channels.component.ts +++ b/client/src/app/+video-channels/video-channels.component.ts | |||
@@ -9,16 +9,19 @@ import { AuthService, Notifier } from '@app/core' | |||
9 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | 9 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' |
10 | import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component' | 10 | import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component' |
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | 11 | import { I18n } from '@ngx-translate/i18n-polyfill' |
12 | import { ListOverflowItem } from '@app/shared/misc/list-overflow.component' | ||
13 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
12 | 14 | ||
13 | @Component({ | 15 | @Component({ |
14 | templateUrl: './video-channels.component.html', | 16 | templateUrl: './video-channels.component.html', |
15 | styleUrls: [ './video-channels.component.scss' ] | 17 | styleUrls: [ './video-channels.component.scss' ] |
16 | }) | 18 | }) |
17 | export class VideoChannelsComponent implements OnInit, OnDestroy { | 19 | export class VideoChannelsComponent implements OnInit, OnDestroy { |
18 | @ViewChild('subscribeButton', { static: false }) subscribeButton: SubscribeButtonComponent | 20 | @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent |
19 | 21 | ||
20 | videoChannel: VideoChannel | 22 | videoChannel: VideoChannel |
21 | hotkeys: Hotkey[] | 23 | hotkeys: Hotkey[] |
24 | links: ListOverflowItem[] = [] | ||
22 | isChannelManageable = false | 25 | isChannelManageable = false |
23 | 26 | ||
24 | private routeSub: Subscription | 27 | private routeSub: Subscription |
@@ -30,7 +33,8 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { | |||
30 | private authService: AuthService, | 33 | private authService: AuthService, |
31 | private videoChannelService: VideoChannelService, | 34 | private videoChannelService: VideoChannelService, |
32 | private restExtractor: RestExtractor, | 35 | private restExtractor: RestExtractor, |
33 | private hotkeysService: HotkeysService | 36 | private hotkeysService: HotkeysService, |
37 | private screenService: ScreenService | ||
34 | ) { } | 38 | ) { } |
35 | 39 | ||
36 | ngOnInit () { | 40 | ngOnInit () { |
@@ -62,6 +66,12 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { | |||
62 | }, undefined, this.i18n('Subscribe to the account')) | 66 | }, undefined, this.i18n('Subscribe to the account')) |
63 | ] | 67 | ] |
64 | if (this.isUserLoggedIn()) this.hotkeysService.add(this.hotkeys) | 68 | if (this.isUserLoggedIn()) this.hotkeysService.add(this.hotkeys) |
69 | |||
70 | this.links = [ | ||
71 | { label: this.i18n('VIDEOS'), routerLink: 'videos' }, | ||
72 | { label: this.i18n('VIDEO PLAYLISTS'), routerLink: 'video-playlists' }, | ||
73 | { label: this.i18n('ABOUT'), routerLink: 'about' } | ||
74 | ] | ||
65 | } | 75 | } |
66 | 76 | ||
67 | ngOnDestroy () { | 77 | ngOnDestroy () { |
@@ -71,6 +81,10 @@ export class VideoChannelsComponent implements OnInit, OnDestroy { | |||
71 | if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys) | 81 | if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys) |
72 | } | 82 | } |
73 | 83 | ||
84 | get isInSmallView () { | ||
85 | return this.screenService.isInSmallView() | ||
86 | } | ||
87 | |||
74 | isUserLoggedIn () { | 88 | isUserLoggedIn () { |
75 | return this.authService.isLoggedIn() | 89 | return this.authService.isLoggedIn() |
76 | } | 90 | } |
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts index b5a677d15..a87f4ce1b 100644 --- a/client/src/app/app-routing.module.ts +++ b/client/src/app/app-routing.module.ts | |||
@@ -4,10 +4,13 @@ import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router' | |||
4 | import { PreloadSelectedModulesList } from './core' | 4 | import { PreloadSelectedModulesList } from './core' |
5 | import { AppComponent } from '@app/app.component' | 5 | import { AppComponent } from '@app/app.component' |
6 | import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy' | 6 | import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy' |
7 | import { MenuGuards } from '@app/core/routing/menu-guard.service' | ||
7 | 8 | ||
8 | const routes: Routes = [ | 9 | const routes: Routes = [ |
9 | { | 10 | { |
10 | path: 'admin', | 11 | path: 'admin', |
12 | canActivate: [ MenuGuards.close() ], | ||
13 | canDeactivate: [ MenuGuards.open() ], | ||
11 | loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule) | 14 | loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule) |
12 | }, | 15 | }, |
13 | { | 16 | { |
@@ -54,6 +57,7 @@ const routes: Routes = [ | |||
54 | }) | 57 | }) |
55 | ], | 58 | ], |
56 | providers: [ | 59 | providers: [ |
60 | MenuGuards.guards, | ||
57 | PreloadSelectedModulesList, | 61 | PreloadSelectedModulesList, |
58 | { provide: RouteReuseStrategy, useClass: CustomReuseStrategy } | 62 | { provide: RouteReuseStrategy, useClass: CustomReuseStrategy } |
59 | ], | 63 | ], |
diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html index 2660c5377..b0d2e5050 100644 --- a/client/src/app/app.component.html +++ b/client/src/app/app.component.html | |||
@@ -2,36 +2,31 @@ | |||
2 | 2 | ||
3 | <my-hotkeys-cheatsheet></my-hotkeys-cheatsheet> | 3 | <my-hotkeys-cheatsheet></my-hotkeys-cheatsheet> |
4 | 4 | ||
5 | <div [ngClass]="{ 'user-logged-in': isUserLoggedIn(), 'user-not-logged-in': !isUserLoggedIn() }"> | 5 | <div class="peertube-container" [ngClass]="{ 'user-logged-in': isUserLoggedIn(), 'user-not-logged-in': !isUserLoggedIn() }"> |
6 | <div class="header"> | 6 | <div class="header"> |
7 | 7 | ||
8 | <div class="top-left-block" [ngClass]="{ 'border-bottom': isMenuDisplayed === false }"> | 8 | <div class="top-left-block"> |
9 | <span class="icon icon-menu" (click)="toggleMenu()"></span> | 9 | <span class="icon icon-menu" (click)="menu.toggleMenu()"></span> |
10 | 10 | ||
11 | <a class="peertube-title" [routerLink]="defaultRoute" title="Homepage"> | 11 | <a class="peertube-title" [routerLink]="defaultRoute" title="Homepage" i18n-title> |
12 | <span class="icon icon-logo"></span> | 12 | <span class="icon icon-logo"></span> |
13 | <span class="instance-name">{{ instanceName }}</span> | 13 | <span class="instance-name">{{ instanceName }}</span> |
14 | </a> | 14 | </a> |
15 | </div> | 15 | </div> |
16 | 16 | ||
17 | <div class="header-right" [ngClass]="{ 'border-bottom': isMenuDisplayed === false }"> | 17 | <div class="header-right"> |
18 | <my-header></my-header> | 18 | <my-header class="w-100 d-flex justify-content-end"></my-header> |
19 | </div> | 19 | </div> |
20 | </div> | 20 | </div> |
21 | 21 | ||
22 | <div class="sub-header-container"> | 22 | <div class="sub-header-container"> |
23 | <my-menu *ngIf="isMenuDisplayed"></my-menu> | 23 | <my-menu *ngIf="menu.isMenuDisplayed"></my-menu> |
24 | 24 | ||
25 | <div id="content" tabindex="-1" class="main-col container-fluid" [ngClass]="{ expanded: isMenuDisplayed === false }"> | 25 | <div id="content" tabindex="-1" class="main-col" [ngClass]="{ expanded: menu.isMenuDisplayed === false }"> |
26 | 26 | ||
27 | <div class="main-row"> | 27 | <div class="main-row"> |
28 | <router-outlet></router-outlet> | 28 | <router-outlet></router-outlet> |
29 | </div> | 29 | </div> |
30 | |||
31 | <footer class="row"> | ||
32 | <a href="https://joinpeertube.org" title="PeerTube website" target="_blank" rel="noopener noreferrer" i18n>Powered by PeerTube</a> - | ||
33 | <a href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE" title="PeerTube license" target="_blank" rel="noopener noreferrer">CopyLeft 2015-2020</a> | ||
34 | </footer> | ||
35 | </div> | 30 | </div> |
36 | </div> | 31 | </div> |
37 | </div> | 32 | </div> |
@@ -59,3 +54,5 @@ | |||
59 | <my-welcome-modal #welcomeModal></my-welcome-modal> | 54 | <my-welcome-modal #welcomeModal></my-welcome-modal> |
60 | <my-instance-config-warning-modal #instanceConfigWarningModal></my-instance-config-warning-modal> | 55 | <my-instance-config-warning-modal #instanceConfigWarningModal></my-instance-config-warning-modal> |
61 | </ng-template> | 56 | </ng-template> |
57 | |||
58 | <my-custom-modal #customModal></my-custom-modal> | ||
diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss index 51a7a3dd1..0c33dc4a1 100644 --- a/client/src/app/app.component.scss +++ b/client/src/app/app.component.scss | |||
@@ -1,6 +1,10 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .peertube-container { | ||
5 | padding-bottom: 20px; | ||
6 | } | ||
7 | |||
4 | .main-row { | 8 | .main-row { |
5 | min-height: calc(100vh - #{$header-height} - #{$footer-height} - #{$footer-margin}); | 9 | min-height: calc(100vh - #{$header-height} - #{$footer-height} - #{$footer-margin}); |
6 | } | 10 | } |
@@ -8,6 +12,7 @@ | |||
8 | .sub-header-container { | 12 | .sub-header-container { |
9 | margin-top: $header-height; | 13 | margin-top: $header-height; |
10 | background-color: var(--mainBackgroundColor); | 14 | background-color: var(--mainBackgroundColor); |
15 | width: 100%; | ||
11 | } | 16 | } |
12 | 17 | ||
13 | .header { | 18 | .header { |
@@ -16,12 +21,12 @@ | |||
16 | top: 0; | 21 | top: 0; |
17 | width: 100%; | 22 | width: 100%; |
18 | background-color: var(--mainBackgroundColor); | 23 | background-color: var(--mainBackgroundColor); |
19 | z-index: 1000; | 24 | z-index: z(header); |
20 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16); | 25 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16); |
21 | display: flex; | 26 | display: flex; |
22 | 27 | ||
23 | .top-left-block { | 28 | .top-left-block { |
24 | z-index: 1001; | 29 | z-index: 1; |
25 | height: $header-height; | 30 | height: $header-height; |
26 | display: flex; | 31 | display: flex; |
27 | align-items: center; | 32 | align-items: center; |
@@ -61,7 +66,7 @@ | |||
61 | } | 66 | } |
62 | } | 67 | } |
63 | 68 | ||
64 | @media screen and (max-width: 500px) { | 69 | @media screen and (max-width: $mobile-view) { |
65 | width: 70px; | 70 | width: 70px; |
66 | 71 | ||
67 | .peertube-title { | 72 | .peertube-title { |
@@ -83,11 +88,3 @@ | |||
83 | flex: 1; | 88 | flex: 1; |
84 | } | 89 | } |
85 | } | 90 | } |
86 | |||
87 | footer { | ||
88 | padding: 10px 0; | ||
89 | font-size: 11px; | ||
90 | margin-top: $footer-margin; | ||
91 | height: $footer-height; | ||
92 | justify-content: center; | ||
93 | } | ||
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 03eb83cb8..12c0efd8a 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts | |||
@@ -1,13 +1,12 @@ | |||
1 | import { Component, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core' |
2 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser' | 2 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser' |
3 | import { Event, GuardsCheckStart, NavigationEnd, Router, Scroll } from '@angular/router' | 3 | import { Event, GuardsCheckStart, NavigationEnd, Router, Scroll } from '@angular/router' |
4 | import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core' | 4 | import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core' |
5 | import { is18nPath } from '../../../shared/models/i18n' | 5 | import { is18nPath } from '../../../shared/models/i18n' |
6 | import { ScreenService } from '@app/shared/misc/screen.service' | 6 | import { ScreenService } from '@app/shared/misc/screen.service' |
7 | import { debounceTime, filter, map, pairwise } from 'rxjs/operators' | 7 | import { filter, map, pairwise } from 'rxjs/operators' |
8 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | 8 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' |
9 | import { I18n } from '@ngx-translate/i18n-polyfill' | 9 | import { I18n } from '@ngx-translate/i18n-polyfill' |
10 | import { fromEvent } from 'rxjs' | ||
11 | import { PlatformLocation, ViewportScroller } from '@angular/common' | 10 | import { PlatformLocation, ViewportScroller } from '@angular/common' |
12 | import { PluginService } from '@app/core/plugins/plugin.service' | 11 | import { PluginService } from '@app/core/plugins/plugin.service' |
13 | import { HooksService } from '@app/core/plugins/hooks.service' | 12 | import { HooksService } from '@app/core/plugins/hooks.service' |
@@ -15,21 +14,21 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | |||
15 | import { POP_STATE_MODAL_DISMISS } from '@app/shared/misc/constants' | 14 | import { POP_STATE_MODAL_DISMISS } from '@app/shared/misc/constants' |
16 | import { WelcomeModalComponent } from '@app/modal/welcome-modal.component' | 15 | import { WelcomeModalComponent } from '@app/modal/welcome-modal.component' |
17 | import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component' | 16 | import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component' |
17 | import { CustomModalComponent } from '@app/modal/custom-modal.component' | ||
18 | import { ServerConfig, UserRole } from '@shared/models' | 18 | import { ServerConfig, UserRole } from '@shared/models' |
19 | import { User } from '@app/shared' | 19 | import { User } from '@app/shared' |
20 | import { InstanceService } from '@app/shared/instance/instance.service' | 20 | import { InstanceService } from '@app/shared/instance/instance.service' |
21 | import { MenuService } from './core/menu/menu.service' | ||
21 | 22 | ||
22 | @Component({ | 23 | @Component({ |
23 | selector: 'my-app', | 24 | selector: 'my-app', |
24 | templateUrl: './app.component.html', | 25 | templateUrl: './app.component.html', |
25 | styleUrls: [ './app.component.scss' ] | 26 | styleUrls: [ './app.component.scss' ] |
26 | }) | 27 | }) |
27 | export class AppComponent implements OnInit { | 28 | export class AppComponent implements OnInit, AfterViewInit { |
28 | @ViewChild('welcomeModal', { static: false }) welcomeModal: WelcomeModalComponent | 29 | @ViewChild('welcomeModal') welcomeModal: WelcomeModalComponent |
29 | @ViewChild('instanceConfigWarningModal', { static: false }) instanceConfigWarningModal: InstanceConfigWarningModalComponent | 30 | @ViewChild('instanceConfigWarningModal') instanceConfigWarningModal: InstanceConfigWarningModalComponent |
30 | 31 | @ViewChild('customModal') customModal: CustomModalComponent | |
31 | isMenuDisplayed = true | ||
32 | isMenuChangedByUser = false | ||
33 | 32 | ||
34 | customCSS: SafeHtml | 33 | customCSS: SafeHtml |
35 | 34 | ||
@@ -50,7 +49,8 @@ export class AppComponent implements OnInit { | |||
50 | private themeService: ThemeService, | 49 | private themeService: ThemeService, |
51 | private hooks: HooksService, | 50 | private hooks: HooksService, |
52 | private location: PlatformLocation, | 51 | private location: PlatformLocation, |
53 | private modalService: NgbModal | 52 | private modalService: NgbModal, |
53 | public menu: MenuService | ||
54 | ) { } | 54 | ) { } |
55 | 55 | ||
56 | get instanceName () { | 56 | get instanceName () { |
@@ -78,37 +78,23 @@ export class AppComponent implements OnInit { | |||
78 | this.authService.refreshUserInformation() | 78 | this.authService.refreshUserInformation() |
79 | } | 79 | } |
80 | 80 | ||
81 | // Do not display menu on small screens | ||
82 | if (this.screenService.isInSmallView()) { | ||
83 | this.isMenuDisplayed = false | ||
84 | } | ||
85 | |||
86 | this.initRouteEvents() | 81 | this.initRouteEvents() |
87 | this.injectJS() | 82 | this.injectJS() |
88 | this.injectCSS() | 83 | this.injectCSS() |
89 | 84 | ||
90 | this.initHotkeys() | 85 | this.initHotkeys() |
91 | 86 | ||
92 | fromEvent(window, 'resize') | ||
93 | .pipe(debounceTime(200)) | ||
94 | .subscribe(() => this.onResize()) | ||
95 | |||
96 | this.location.onPopState(() => this.modalService.dismissAll(POP_STATE_MODAL_DISMISS)) | 87 | this.location.onPopState(() => this.modalService.dismissAll(POP_STATE_MODAL_DISMISS)) |
97 | 88 | ||
98 | this.openModalsIfNeeded() | 89 | this.openModalsIfNeeded() |
99 | } | 90 | } |
100 | 91 | ||
101 | isUserLoggedIn () { | 92 | ngAfterViewInit () { |
102 | return this.authService.isLoggedIn() | 93 | this.pluginService.initializeCustomModal(this.customModal) |
103 | } | ||
104 | |||
105 | toggleMenu () { | ||
106 | this.isMenuDisplayed = !this.isMenuDisplayed | ||
107 | this.isMenuChangedByUser = true | ||
108 | } | 94 | } |
109 | 95 | ||
110 | onResize () { | 96 | isUserLoggedIn () { |
111 | this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser | 97 | return this.authService.isLoggedIn() |
112 | } | 98 | } |
113 | 99 | ||
114 | private initRouteEvents () { | 100 | private initRouteEvents () { |
@@ -176,7 +162,7 @@ export class AppComponent implements OnInit { | |||
176 | eventsObs.pipe( | 162 | eventsObs.pipe( |
177 | filter((e: Event): e is GuardsCheckStart => e instanceof GuardsCheckStart), | 163 | filter((e: Event): e is GuardsCheckStart => e instanceof GuardsCheckStart), |
178 | filter(() => this.screenService.isInSmallView()) | 164 | filter(() => this.screenService.isInSmallView()) |
179 | ).subscribe(() => this.isMenuDisplayed = false) // User clicked on a link in the menu, change the page | 165 | ).subscribe(() => this.menu.isMenuDisplayed = false) // User clicked on a link in the menu, change the page |
180 | } | 166 | } |
181 | 167 | ||
182 | private injectJS () { | 168 | private injectJS () { |
@@ -249,7 +235,7 @@ export class AppComponent implements OnInit { | |||
249 | }, undefined, this.i18n('Focus the search bar')), | 235 | }, undefined, this.i18n('Focus the search bar')), |
250 | 236 | ||
251 | new Hotkey('b', (event: KeyboardEvent): boolean => { | 237 | new Hotkey('b', (event: KeyboardEvent): boolean => { |
252 | this.toggleMenu() | 238 | this.menu.toggleMenu() |
253 | return false | 239 | return false |
254 | }, undefined, this.i18n('Toggle the left menu')), | 240 | }, undefined, this.i18n('Toggle the left menu')), |
255 | 241 | ||
diff --git a/client/src/app/app.module.ts b/client/src/app/app.module.ts index dda705811..5a3b109da 100644 --- a/client/src/app/app.module.ts +++ b/client/src/app/app.module.ts | |||
@@ -4,22 +4,23 @@ import { ServerService } from '@app/core' | |||
4 | import { ResetPasswordModule } from '@app/reset-password' | 4 | import { ResetPasswordModule } from '@app/reset-password' |
5 | 5 | ||
6 | import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core' | 6 | import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core' |
7 | import { ClipboardModule } from 'ngx-clipboard' | ||
8 | import 'focus-visible' | 7 | import 'focus-visible' |
9 | 8 | ||
10 | import { AppRoutingModule } from './app-routing.module' | 9 | import { AppRoutingModule } from './app-routing.module' |
11 | import { AppComponent } from './app.component' | 10 | import { AppComponent } from './app.component' |
12 | import { CoreModule } from './core' | 11 | import { CoreModule } from './core' |
13 | import { HeaderComponent } from './header' | 12 | import { HeaderComponent, SearchTypeaheadComponent, SuggestionsComponent, SuggestionComponent } from './header' |
14 | import { LoginModule } from './login' | 13 | import { LoginModule } from './login' |
15 | import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' | 14 | import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu' |
16 | import { SharedModule } from './shared' | 15 | import { SharedModule } from './shared' |
17 | import { VideosModule } from './videos' | 16 | import { VideosModule } from './videos' |
18 | import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n' | ||
19 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' | ||
20 | import { SearchModule } from '@app/search' | 17 | import { SearchModule } from '@app/search' |
21 | import { WelcomeModalComponent } from '@app/modal/welcome-modal.component' | 18 | import { WelcomeModalComponent } from '@app/modal/welcome-modal.component' |
22 | import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component' | 19 | import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component' |
20 | import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '@shared/models' | ||
21 | import { APP_BASE_HREF } from '@angular/common' | ||
22 | import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component' | ||
23 | import { CustomModalComponent } from '@app/modal/custom-modal.component' | ||
23 | 24 | ||
24 | export function metaFactory (serverService: ServerService): MetaLoader { | 25 | export function metaFactory (serverService: ServerService): MetaLoader { |
25 | return new MetaStaticLoader({ | 26 | return new MetaStaticLoader({ |
@@ -40,16 +41,19 @@ export function metaFactory (serverService: ServerService): MetaLoader { | |||
40 | 41 | ||
41 | MenuComponent, | 42 | MenuComponent, |
42 | LanguageChooserComponent, | 43 | LanguageChooserComponent, |
44 | QuickSettingsModalComponent, | ||
43 | AvatarNotificationComponent, | 45 | AvatarNotificationComponent, |
44 | HeaderComponent, | 46 | HeaderComponent, |
47 | SearchTypeaheadComponent, | ||
48 | SuggestionsComponent, | ||
49 | SuggestionComponent, | ||
45 | 50 | ||
51 | CustomModalComponent, | ||
46 | WelcomeModalComponent, | 52 | WelcomeModalComponent, |
47 | InstanceConfigWarningModalComponent | 53 | InstanceConfigWarningModalComponent |
48 | ], | 54 | ], |
49 | imports: [ | 55 | imports: [ |
50 | BrowserModule, | 56 | BrowserModule, |
51 | // FIXME: https://github.com/maxisam/ngx-clipboard/issues/133 | ||
52 | ClipboardModule, | ||
53 | 57 | ||
54 | CoreModule, | 58 | CoreModule, |
55 | SharedModule, | 59 | SharedModule, |
@@ -69,22 +73,22 @@ export function metaFactory (serverService: ServerService): MetaLoader { | |||
69 | 73 | ||
70 | AppRoutingModule // Put it after all the module because it has the 404 route | 74 | AppRoutingModule // Put it after all the module because it has the 404 route |
71 | ], | 75 | ], |
76 | |||
72 | providers: [ | 77 | providers: [ |
73 | { | 78 | { |
79 | provide: APP_BASE_HREF, | ||
80 | useValue: '/' | ||
81 | }, | ||
82 | |||
83 | { | ||
74 | provide: TRANSLATIONS, | 84 | provide: TRANSLATIONS, |
75 | useFactory: (locale: string) => { | 85 | useFactory: (locale: string) => { |
76 | // On dev mode, test localization | ||
77 | if (isOnDevLocale()) { | ||
78 | locale = buildFileLocale(getDevLocale()) | ||
79 | return require(`raw-loader!../locale/angular.${locale}.xlf`) | ||
80 | } | ||
81 | |||
82 | // Default locale, nothing to translate | 86 | // Default locale, nothing to translate |
83 | const completeLocale = getCompleteLocale(locale) | 87 | const completeLocale = getCompleteLocale(locale) |
84 | if (isDefaultLocale(completeLocale)) return '' | 88 | if (isDefaultLocale(completeLocale)) return '' |
85 | 89 | ||
86 | const fileLocale = buildFileLocale(locale) | 90 | const fileLocale = buildFileLocale(locale) |
87 | return require(`raw-loader!../locale/angular.${fileLocale}.xlf`) | 91 | return require(`raw-loader!../locale/angular.${fileLocale}.xlf`).default |
88 | }, | 92 | }, |
89 | deps: [ LOCALE_ID ] | 93 | deps: [ LOCALE_ID ] |
90 | }, | 94 | }, |
diff --git a/client/src/app/core/auth/auth-user.model.ts b/client/src/app/core/auth/auth-user.model.ts index 1447daead..4ad904beb 100644 --- a/client/src/app/core/auth/auth-user.model.ts +++ b/client/src/app/core/auth/auth-user.model.ts | |||
@@ -67,17 +67,6 @@ class Tokens { | |||
67 | } | 67 | } |
68 | 68 | ||
69 | export class AuthUser extends User implements ServerMyUserModel { | 69 | export class AuthUser extends User implements ServerMyUserModel { |
70 | private static KEYS = { | ||
71 | ID: 'id', | ||
72 | ROLE: 'role', | ||
73 | EMAIL: 'email', | ||
74 | VIDEOS_HISTORY_ENABLED: 'videos-history-enabled', | ||
75 | USERNAME: 'username', | ||
76 | NSFW_POLICY: 'nsfw_policy', | ||
77 | WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled', | ||
78 | AUTO_PLAY_VIDEO: 'auto_play_video' | ||
79 | } | ||
80 | |||
81 | tokens: Tokens | 70 | tokens: Tokens |
82 | specialPlaylists: MyUserSpecialPlaylist[] | 71 | specialPlaylists: MyUserSpecialPlaylist[] |
83 | 72 | ||
@@ -106,10 +95,6 @@ export class AuthUser extends User implements ServerMyUserModel { | |||
106 | peertubeLocalStorage.removeItem(this.KEYS.USERNAME) | 95 | peertubeLocalStorage.removeItem(this.KEYS.USERNAME) |
107 | peertubeLocalStorage.removeItem(this.KEYS.ID) | 96 | peertubeLocalStorage.removeItem(this.KEYS.ID) |
108 | peertubeLocalStorage.removeItem(this.KEYS.ROLE) | 97 | peertubeLocalStorage.removeItem(this.KEYS.ROLE) |
109 | peertubeLocalStorage.removeItem(this.KEYS.NSFW_POLICY) | ||
110 | peertubeLocalStorage.removeItem(this.KEYS.WEBTORRENT_ENABLED) | ||
111 | peertubeLocalStorage.removeItem(this.KEYS.VIDEOS_HISTORY_ENABLED) | ||
112 | peertubeLocalStorage.removeItem(this.KEYS.AUTO_PLAY_VIDEO) | ||
113 | peertubeLocalStorage.removeItem(this.KEYS.EMAIL) | 98 | peertubeLocalStorage.removeItem(this.KEYS.EMAIL) |
114 | Tokens.flush() | 99 | Tokens.flush() |
115 | } | 100 | } |
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index 9ae008e39..de8c509d1 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts | |||
@@ -29,6 +29,7 @@ type UserLoginWithUserInformation = UserLoginWithUsername & User | |||
29 | export class AuthService { | 29 | export class AuthService { |
30 | private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local' | 30 | private static BASE_CLIENT_URL = environment.apiUrl + '/api/v1/oauth-clients/local' |
31 | private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token' | 31 | private static BASE_TOKEN_URL = environment.apiUrl + '/api/v1/users/token' |
32 | private static BASE_REVOKE_TOKEN_URL = environment.apiUrl + '/api/v1/users/revoke-token' | ||
32 | private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me' | 33 | private static BASE_USER_INFORMATION_URL = environment.apiUrl + '/api/v1/users/me' |
33 | private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = { | 34 | private static LOCAL_STORAGE_OAUTH_CLIENT_KEYS = { |
34 | CLIENT_ID: 'client_id', | 35 | CLIENT_ID: 'client_id', |
@@ -145,7 +146,7 @@ export class AuthService { | |||
145 | return !!this.getAccessToken() | 146 | return !!this.getAccessToken() |
146 | } | 147 | } |
147 | 148 | ||
148 | login (username: string, password: string) { | 149 | login (username: string, password: string, token?: string) { |
149 | // Form url encoded | 150 | // Form url encoded |
150 | const body = { | 151 | const body = { |
151 | client_id: this.clientId, | 152 | client_id: this.clientId, |
@@ -157,6 +158,8 @@ export class AuthService { | |||
157 | password | 158 | password |
158 | } | 159 | } |
159 | 160 | ||
161 | if (token) Object.assign(body, { externalAuthToken: token }) | ||
162 | |||
160 | const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') | 163 | const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') |
161 | return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers }) | 164 | return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers }) |
162 | .pipe( | 165 | .pipe( |
@@ -168,7 +171,16 @@ export class AuthService { | |||
168 | } | 171 | } |
169 | 172 | ||
170 | logout () { | 173 | logout () { |
171 | // TODO: make an HTTP request to revoke the tokens | 174 | const authHeaderValue = this.getRequestHeaderValue() |
175 | const headers = new HttpHeaders().set('Authorization', authHeaderValue) | ||
176 | |||
177 | this.http.post<void>(AuthService.BASE_REVOKE_TOKEN_URL, {}, { headers }) | ||
178 | .subscribe( | ||
179 | () => { /* nothing to do */ }, | ||
180 | |||
181 | err => console.error(err) | ||
182 | ) | ||
183 | |||
172 | this.user = null | 184 | this.user = null |
173 | 185 | ||
174 | AuthUser.flush() | 186 | AuthUser.flush() |
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts index 5943af4da..a1734ad80 100644 --- a/client/src/app/core/core.module.ts +++ b/client/src/app/core/core.module.ts | |||
@@ -13,6 +13,7 @@ import { throwIfAlreadyLoaded } from './module-import-guard' | |||
13 | import { LoginGuard, RedirectService, UserRightGuard } from './routing' | 13 | import { LoginGuard, RedirectService, UserRightGuard } from './routing' |
14 | import { ServerService } from './server' | 14 | import { ServerService } from './server' |
15 | import { ThemeService } from './theme' | 15 | import { ThemeService } from './theme' |
16 | import { MenuService } from './menu' | ||
16 | import { HotkeyModule } from 'angular2-hotkeys' | 17 | import { HotkeyModule } from 'angular2-hotkeys' |
17 | import { CheatSheetComponent } from './hotkeys' | 18 | import { CheatSheetComponent } from './hotkeys' |
18 | import { ToastModule } from 'primeng/toast' | 19 | import { ToastModule } from 'primeng/toast' |
@@ -59,6 +60,7 @@ import { HooksService } from '@app/core/plugins/hooks.service' | |||
59 | ConfirmService, | 60 | ConfirmService, |
60 | ServerService, | 61 | ServerService, |
61 | ThemeService, | 62 | ThemeService, |
63 | MenuService, | ||
62 | LoginGuard, | 64 | LoginGuard, |
63 | UserRightGuard, | 65 | UserRightGuard, |
64 | UnloggedGuard, | 66 | UnloggedGuard, |
diff --git a/client/src/app/core/hotkeys/hotkeys.component.scss b/client/src/app/core/hotkeys/hotkeys.component.scss index 3aa0b6252..a970260c9 100644 --- a/client/src/app/core/hotkeys/hotkeys.component.scss +++ b/client/src/app/core/hotkeys/hotkeys.component.scss | |||
@@ -1,3 +1,6 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
1 | .cfp-hotkeys-container { | 4 | .cfp-hotkeys-container { |
2 | display: flex !important; | 5 | display: flex !important; |
3 | align-items: center; | 6 | align-items: center; |
@@ -23,7 +26,7 @@ | |||
23 | } | 26 | } |
24 | 27 | ||
25 | .cfp-hotkeys-container.fade.in { | 28 | .cfp-hotkeys-container.fade.in { |
26 | z-index: 10002; | 29 | z-index: z(hotkeys); |
27 | visibility: visible; | 30 | visibility: visible; |
28 | opacity: 1; | 31 | opacity: 1; |
29 | } | 32 | } |
@@ -91,7 +94,7 @@ | |||
91 | cursor: pointer; | 94 | cursor: pointer; |
92 | } | 95 | } |
93 | 96 | ||
94 | @media all and (max-width: 500px) { | 97 | @media all and (max-width: $mobile-view) { |
95 | .cfp-hotkeys { | 98 | .cfp-hotkeys { |
96 | font-size: 0.8em; | 99 | font-size: 0.8em; |
97 | } | 100 | } |
diff --git a/client/src/app/core/menu/index.ts b/client/src/app/core/menu/index.ts new file mode 100644 index 000000000..516a49aca --- /dev/null +++ b/client/src/app/core/menu/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './menu.service' | |||
diff --git a/client/src/app/core/menu/menu.service.ts b/client/src/app/core/menu/menu.service.ts new file mode 100644 index 000000000..ecb2bceb7 --- /dev/null +++ b/client/src/app/core/menu/menu.service.ts | |||
@@ -0,0 +1,32 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
3 | import { fromEvent } from 'rxjs' | ||
4 | import { debounceTime } from 'rxjs/operators' | ||
5 | |||
6 | @Injectable() | ||
7 | export class MenuService { | ||
8 | isMenuDisplayed = true | ||
9 | isMenuChangedByUser = false | ||
10 | |||
11 | constructor ( | ||
12 | private screenService: ScreenService | ||
13 | ) { | ||
14 | // Do not display menu on small screens | ||
15 | if (this.screenService.isInSmallView()) { | ||
16 | this.isMenuDisplayed = false | ||
17 | } | ||
18 | |||
19 | fromEvent(window, 'resize') | ||
20 | .pipe(debounceTime(200)) | ||
21 | .subscribe(() => this.onResize()) | ||
22 | } | ||
23 | |||
24 | toggleMenu () { | ||
25 | this.isMenuDisplayed = !this.isMenuDisplayed | ||
26 | this.isMenuChangedByUser = true | ||
27 | } | ||
28 | |||
29 | onResize () { | ||
30 | this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser | ||
31 | } | ||
32 | } | ||
diff --git a/client/src/app/core/plugins/hooks.service.ts b/client/src/app/core/plugins/hooks.service.ts index a6a444c32..2fbf406d1 100644 --- a/client/src/app/core/plugins/hooks.service.ts +++ b/client/src/app/core/plugins/hooks.service.ts | |||
@@ -1,9 +1,8 @@ | |||
1 | import { from, Observable } from 'rxjs' | ||
2 | import { mergeMap, switchMap } from 'rxjs/operators' | ||
1 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
2 | import { PluginService } from '@app/core/plugins/plugin.service' | 4 | import { PluginService } from '@app/core/plugins/plugin.service' |
3 | import { ClientActionHookName, ClientFilterHookName } from '@shared/models/plugins/client-hook.model' | 5 | import { ClientActionHookName, ClientFilterHookName } from '@shared/models/plugins/client-hook.model' |
4 | import { from, Observable } from 'rxjs' | ||
5 | import { mergeMap, switchMap } from 'rxjs/operators' | ||
6 | import { ServerService } from '@app/core/server' | ||
7 | import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.type' | 6 | import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.type' |
8 | 7 | ||
9 | type RawFunction<U, T> = (params: U) => T | 8 | type RawFunction<U, T> = (params: U) => T |
@@ -11,10 +10,7 @@ type ObservableFunction<U, T> = RawFunction<U, Observable<T>> | |||
11 | 10 | ||
12 | @Injectable() | 11 | @Injectable() |
13 | export class HooksService { | 12 | export class HooksService { |
14 | constructor ( | 13 | constructor (private pluginService: PluginService) { } |
15 | private server: ServerService, | ||
16 | private pluginService: PluginService | ||
17 | ) { } | ||
18 | 14 | ||
19 | wrapObsFun | 15 | wrapObsFun |
20 | <P, R, H1 extends ClientFilterHookName, H2 extends ClientFilterHookName> | 16 | <P, R, H1 extends ClientFilterHookName, H2 extends ClientFilterHookName> |
diff --git a/client/src/app/core/plugins/plugin.service.ts b/client/src/app/core/plugins/plugin.service.ts index da5114048..c6efcac6d 100644 --- a/client/src/app/core/plugins/plugin.service.ts +++ b/client/src/app/core/plugins/plugin.service.ts | |||
@@ -13,13 +13,16 @@ import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.ty | |||
13 | import { RegisterClientHookOptions } from '@shared/models/plugins/register-client-hook.model' | 13 | import { RegisterClientHookOptions } from '@shared/models/plugins/register-client-hook.model' |
14 | import { HttpClient } from '@angular/common/http' | 14 | import { HttpClient } from '@angular/common/http' |
15 | import { AuthService } from '@app/core/auth' | 15 | import { AuthService } from '@app/core/auth' |
16 | import { Notifier } from '@app/core/notification' | ||
16 | import { RestExtractor } from '@app/shared/rest' | 17 | import { RestExtractor } from '@app/shared/rest' |
18 | import { MarkdownService } from '@app/shared/renderer' | ||
17 | import { PluginType } from '@shared/models/plugins/plugin.type' | 19 | import { PluginType } from '@shared/models/plugins/plugin.type' |
18 | import { PublicServerSetting } from '@shared/models/plugins/public-server.setting' | 20 | import { PublicServerSetting } from '@shared/models/plugins/public-server.setting' |
19 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' | 21 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' |
20 | import { RegisterClientHelpers } from '../../../types/register-client-option.model' | 22 | import { RegisterClientHelpers } from '../../../types/register-client-option.model' |
21 | import { PluginTranslation } from '@shared/models/plugins/plugin-translation.model' | 23 | import { PluginTranslation } from '@shared/models/plugins/plugin-translation.model' |
22 | import { importModule } from '@app/shared/misc/utils' | 24 | import { importModule } from '@app/shared/misc/utils' |
25 | import { CustomModalComponent } from '@app/modal/custom-modal.component' | ||
23 | 26 | ||
24 | interface HookStructValue extends RegisterClientHookOptions { | 27 | interface HookStructValue extends RegisterClientHookOptions { |
25 | plugin: ServerConfigPlugin | 28 | plugin: ServerConfigPlugin |
@@ -44,11 +47,14 @@ export class PluginService implements ClientHook { | |||
44 | common: new ReplaySubject<boolean>(1), | 47 | common: new ReplaySubject<boolean>(1), |
45 | search: new ReplaySubject<boolean>(1), | 48 | search: new ReplaySubject<boolean>(1), |
46 | 'video-watch': new ReplaySubject<boolean>(1), | 49 | 'video-watch': new ReplaySubject<boolean>(1), |
47 | signup: new ReplaySubject<boolean>(1) | 50 | signup: new ReplaySubject<boolean>(1), |
51 | login: new ReplaySubject<boolean>(1) | ||
48 | } | 52 | } |
49 | 53 | ||
50 | translationsObservable: Observable<PluginTranslation> | 54 | translationsObservable: Observable<PluginTranslation> |
51 | 55 | ||
56 | customModal: CustomModalComponent | ||
57 | |||
52 | private plugins: ServerConfigPlugin[] = [] | 58 | private plugins: ServerConfigPlugin[] = [] |
53 | private scopes: { [ scopeName: string ]: PluginInfo[] } = {} | 59 | private scopes: { [ scopeName: string ]: PluginInfo[] } = {} |
54 | private loadedScripts: { [ script: string ]: boolean } = {} | 60 | private loadedScripts: { [ script: string ]: boolean } = {} |
@@ -60,6 +66,8 @@ export class PluginService implements ClientHook { | |||
60 | constructor ( | 66 | constructor ( |
61 | private router: Router, | 67 | private router: Router, |
62 | private authService: AuthService, | 68 | private authService: AuthService, |
69 | private notifier: Notifier, | ||
70 | private markdownRenderer: MarkdownService, | ||
63 | private server: ServerService, | 71 | private server: ServerService, |
64 | private zone: NgZone, | 72 | private zone: NgZone, |
65 | private authHttp: HttpClient, | 73 | private authHttp: HttpClient, |
@@ -80,6 +88,10 @@ export class PluginService implements ClientHook { | |||
80 | }) | 88 | }) |
81 | } | 89 | } |
82 | 90 | ||
91 | initializeCustomModal (customModal: CustomModalComponent) { | ||
92 | this.customModal = customModal | ||
93 | } | ||
94 | |||
83 | ensurePluginsAreBuilt () { | 95 | ensurePluginsAreBuilt () { |
84 | return this.pluginsBuilt.asObservable() | 96 | return this.pluginsBuilt.asObservable() |
85 | .pipe(first(), shareReplay()) | 97 | .pipe(first(), shareReplay()) |
@@ -272,6 +284,32 @@ export class PluginService implements ClientHook { | |||
272 | return this.authService.isLoggedIn() | 284 | return this.authService.isLoggedIn() |
273 | }, | 285 | }, |
274 | 286 | ||
287 | notifier: { | ||
288 | info: (text: string, title?: string, timeout?: number) => this.notifier.info(text, title, timeout), | ||
289 | error: (text: string, title?: string, timeout?: number) => this.notifier.error(text, title, timeout), | ||
290 | success: (text: string, title?: string, timeout?: number) => this.notifier.success(text, title, timeout) | ||
291 | }, | ||
292 | |||
293 | showModal: (input: { | ||
294 | title: string, | ||
295 | content: string, | ||
296 | close?: boolean, | ||
297 | cancel?: { value: string, action?: () => void }, | ||
298 | confirm?: { value: string, action?: () => void } | ||
299 | }) => { | ||
300 | this.customModal.show(input) | ||
301 | }, | ||
302 | |||
303 | markdownRenderer: { | ||
304 | textMarkdownToHTML: (textMarkdown: string) => { | ||
305 | return this.markdownRenderer.textMarkdownToHTML(textMarkdown) | ||
306 | }, | ||
307 | |||
308 | enhancedMarkdownToHTML: (enhancedMarkdown: string) => { | ||
309 | return this.markdownRenderer.enhancedMarkdownToHTML(enhancedMarkdown) | ||
310 | } | ||
311 | }, | ||
312 | |||
275 | translate: (value: string) => { | 313 | translate: (value: string) => { |
276 | return this.translationsObservable | 314 | return this.translationsObservable |
277 | .pipe(map(allTranslations => allTranslations[npmName])) | 315 | .pipe(map(allTranslations => allTranslations[npmName])) |
diff --git a/client/src/app/core/routing/custom-reuse-strategy.ts b/client/src/app/core/routing/custom-reuse-strategy.ts index a9f61acec..c0f9f04e0 100644 --- a/client/src/app/core/routing/custom-reuse-strategy.ts +++ b/client/src/app/core/routing/custom-reuse-strategy.ts | |||
@@ -1,5 +1,7 @@ | |||
1 | import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router' | 1 | import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router' |
2 | import { Injectable } from '@angular/core' | ||
2 | 3 | ||
4 | @Injectable() | ||
3 | export class CustomReuseStrategy implements RouteReuseStrategy { | 5 | export class CustomReuseStrategy implements RouteReuseStrategy { |
4 | storedRouteHandles = new Map<string, DetachedRouteHandle>() | 6 | storedRouteHandles = new Map<string, DetachedRouteHandle>() |
5 | recentlyUsed: string | 7 | recentlyUsed: string |
@@ -76,6 +78,6 @@ export class CustomReuseStrategy implements RouteReuseStrategy { | |||
76 | } | 78 | } |
77 | 79 | ||
78 | private isReuseEnabled (route: ActivatedRouteSnapshot) { | 80 | private isReuseEnabled (route: ActivatedRouteSnapshot) { |
79 | return route.data.reuse && route.data.reuse.enabled && route.queryParams['a-state'] | 81 | return route.data.reuse && route.data.reuse.enabled && route.queryParams[ 'a-state' ] |
80 | } | 82 | } |
81 | } | 83 | } |
diff --git a/client/src/app/core/routing/index.ts b/client/src/app/core/routing/index.ts index 9f0b4eac5..58b83bb2a 100644 --- a/client/src/app/core/routing/index.ts +++ b/client/src/app/core/routing/index.ts | |||
@@ -2,3 +2,4 @@ export * from './login-guard.service' | |||
2 | export * from './user-right-guard.service' | 2 | export * from './user-right-guard.service' |
3 | export * from './preload-selected-modules-list' | 3 | export * from './preload-selected-modules-list' |
4 | export * from './redirect.service' | 4 | export * from './redirect.service' |
5 | export * from './menu-guard.service' | ||
diff --git a/client/src/app/core/routing/menu-guard.service.ts b/client/src/app/core/routing/menu-guard.service.ts new file mode 100644 index 000000000..907d145fd --- /dev/null +++ b/client/src/app/core/routing/menu-guard.service.ts | |||
@@ -0,0 +1,48 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { CanActivate, CanDeactivate } from '@angular/router' | ||
3 | import { MenuService } from '@app/core/menu' | ||
4 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
5 | |||
6 | abstract class MenuGuard implements CanActivate, CanDeactivate<any> { | ||
7 | display = true | ||
8 | canDeactivate = this.canActivate | ||
9 | |||
10 | constructor (protected menu: MenuService, protected screen: ScreenService, display: boolean) { | ||
11 | this.display = display | ||
12 | } | ||
13 | |||
14 | canActivate (): boolean { | ||
15 | // small screens already have the site-wide onResize from screenService | ||
16 | // > medium screens have enough space to fit the administrative menus | ||
17 | if (!this.screen.isInMobileView() && this.screen.isInMediumView()) { | ||
18 | this.menu.isMenuDisplayed = this.display | ||
19 | } | ||
20 | return true | ||
21 | } | ||
22 | } | ||
23 | |||
24 | @Injectable() | ||
25 | export class OpenMenuGuard extends MenuGuard { | ||
26 | constructor (menu: MenuService, screen: ScreenService) { super(menu, screen, true) } | ||
27 | } | ||
28 | |||
29 | @Injectable() | ||
30 | export class CloseMenuGuard extends MenuGuard { | ||
31 | constructor (menu: MenuService, screen: ScreenService) { super(menu, screen, false) } | ||
32 | } | ||
33 | |||
34 | @Injectable() | ||
35 | export class MenuGuards { | ||
36 | public static guards = [ | ||
37 | OpenMenuGuard, | ||
38 | CloseMenuGuard | ||
39 | ] | ||
40 | |||
41 | static open () { | ||
42 | return OpenMenuGuard | ||
43 | } | ||
44 | |||
45 | static close () { | ||
46 | return CloseMenuGuard | ||
47 | } | ||
48 | } | ||
diff --git a/client/src/app/core/routing/preload-selected-modules-list.ts b/client/src/app/core/routing/preload-selected-modules-list.ts index 3bca60317..64af68225 100644 --- a/client/src/app/core/routing/preload-selected-modules-list.ts +++ b/client/src/app/core/routing/preload-selected-modules-list.ts | |||
@@ -1,7 +1,9 @@ | |||
1 | import { Observable, timer as observableTimer, of as ofObservable } from 'rxjs' | 1 | import { Observable, of as ofObservable, timer as observableTimer } from 'rxjs' |
2 | import { switchMap } from 'rxjs/operators' | 2 | import { switchMap } from 'rxjs/operators' |
3 | import { PreloadingStrategy, Route } from '@angular/router' | 3 | import { PreloadingStrategy, Route } from '@angular/router' |
4 | import { Injectable } from '@angular/core' | ||
4 | 5 | ||
6 | @Injectable() | ||
5 | export class PreloadSelectedModulesList implements PreloadingStrategy { | 7 | export class PreloadSelectedModulesList implements PreloadingStrategy { |
6 | preload (route: Route, load: Function): Observable<any> { | 8 | preload (route: Route, load: Function): Observable<any> { |
7 | if (!route.data || !route.data.preload) return ofObservable(null) | 9 | if (!route.data || !route.data.preload) return ofObservable(null) |
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index 3997ce6db..eac8f85e4 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts | |||
@@ -9,6 +9,7 @@ import { VideoConstant } from '../../../../../shared/models/videos' | |||
9 | import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' | 9 | import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' |
10 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' | 10 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' |
11 | import { sortBy } from '@app/shared/misc/utils' | 11 | import { sortBy } from '@app/shared/misc/utils' |
12 | import { ServerStats } from '@shared/models/server' | ||
12 | 13 | ||
13 | @Injectable() | 14 | @Injectable() |
14 | export class ServerService { | 15 | export class ServerService { |
@@ -16,6 +17,8 @@ export class ServerService { | |||
16 | private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' | 17 | private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' |
17 | private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' | 18 | private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' |
18 | private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' | 19 | private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' |
20 | private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats' | ||
21 | |||
19 | private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' | 22 | private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' |
20 | 23 | ||
21 | configReloaded = new Subject<void>() | 24 | configReloaded = new Subject<void>() |
@@ -44,8 +47,16 @@ export class ServerService { | |||
44 | css: '' | 47 | css: '' |
45 | } | 48 | } |
46 | }, | 49 | }, |
50 | search: { | ||
51 | remoteUri: { | ||
52 | users: true, | ||
53 | anonymous: false | ||
54 | } | ||
55 | }, | ||
47 | plugin: { | 56 | plugin: { |
48 | registered: [] | 57 | registered: [], |
58 | registeredExternalAuths: [], | ||
59 | registeredIdAndPassAuths: [] | ||
49 | }, | 60 | }, |
50 | theme: { | 61 | theme: { |
51 | registered: [], | 62 | registered: [], |
@@ -238,6 +249,10 @@ export class ServerService { | |||
238 | return this.localeObservable.pipe(first()) | 249 | return this.localeObservable.pipe(first()) |
239 | } | 250 | } |
240 | 251 | ||
252 | getServerStats () { | ||
253 | return this.http.get<ServerStats>(ServerService.BASE_STATS_URL) | ||
254 | } | ||
255 | |||
241 | private loadAttributeEnum <T extends string | number> ( | 256 | private loadAttributeEnum <T extends string | number> ( |
242 | baseUrl: string, | 257 | baseUrl: string, |
243 | attributeName: 'categories' | 'licences' | 'languages' | 'privacies', | 258 | attributeName: 'categories' | 'licences' | 'languages' | 'privacies', |
@@ -250,17 +265,19 @@ export class ServerService { | |||
250 | .pipe(map(data => ({ data, translations }))) | 265 | .pipe(map(data => ({ data, translations }))) |
251 | }), | 266 | }), |
252 | map(({ data, translations }) => { | 267 | map(({ data, translations }) => { |
253 | const hashToPopulate: VideoConstant<T>[] = [] | 268 | const hashToPopulate: VideoConstant<T>[] = Object.keys(data) |
254 | 269 | .map(dataKey => { | |
255 | Object.keys(data) | 270 | const label = data[ dataKey ] |
256 | .forEach(dataKey => { | 271 | |
257 | const label = data[ dataKey ] | 272 | const id = attributeName === 'languages' |
258 | 273 | ? dataKey as T | |
259 | hashToPopulate.push({ | 274 | : parseInt(dataKey, 10) as T |
260 | id: (attributeName === 'languages' ? dataKey : parseInt(dataKey, 10)) as T, | 275 | |
261 | label: peertubeTranslate(label, translations) | 276 | return { |
262 | }) | 277 | id, |
263 | }) | 278 | label: peertubeTranslate(label, translations) |
279 | } | ||
280 | }) | ||
264 | 281 | ||
265 | if (sort === true) sortBy(hashToPopulate, 'label') | 282 | if (sort === true) sortBy(hashToPopulate, 'label') |
266 | 283 | ||
diff --git a/client/src/app/core/theme/theme.service.ts b/client/src/app/core/theme/theme.service.ts index 2c5873cb3..c0189ad32 100644 --- a/client/src/app/core/theme/theme.service.ts +++ b/client/src/app/core/theme/theme.service.ts | |||
@@ -4,16 +4,14 @@ import { ServerService } from '@app/core/server' | |||
4 | import { environment } from '../../../environments/environment' | 4 | import { environment } from '../../../environments/environment' |
5 | import { PluginService } from '@app/core/plugins/plugin.service' | 5 | import { PluginService } from '@app/core/plugins/plugin.service' |
6 | import { ServerConfig, ServerConfigTheme } from '@shared/models' | 6 | import { ServerConfig, ServerConfigTheme } from '@shared/models' |
7 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' | ||
8 | import { first } from 'rxjs/operators' | 7 | import { first } from 'rxjs/operators' |
8 | import { User } from '@app/shared/users/user.model' | ||
9 | import { UserService } from '@app/shared/users/user.service' | ||
10 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
9 | 11 | ||
10 | @Injectable() | 12 | @Injectable() |
11 | export class ThemeService { | 13 | export class ThemeService { |
12 | 14 | ||
13 | private static KEYS = { | ||
14 | LAST_ACTIVE_THEME: 'last_active_theme' | ||
15 | } | ||
16 | |||
17 | private oldThemeName: string | 15 | private oldThemeName: string |
18 | private themes: ServerConfigTheme[] = [] | 16 | private themes: ServerConfigTheme[] = [] |
19 | 17 | ||
@@ -24,8 +22,10 @@ export class ThemeService { | |||
24 | 22 | ||
25 | constructor ( | 23 | constructor ( |
26 | private auth: AuthService, | 24 | private auth: AuthService, |
25 | private userService: UserService, | ||
27 | private pluginService: PluginService, | 26 | private pluginService: PluginService, |
28 | private server: ServerService | 27 | private server: ServerService, |
28 | private localStorageService: LocalStorageService | ||
29 | ) {} | 29 | ) {} |
30 | 30 | ||
31 | initialize () { | 31 | initialize () { |
@@ -77,11 +77,11 @@ export class ThemeService { | |||
77 | private getCurrentTheme () { | 77 | private getCurrentTheme () { |
78 | if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name | 78 | if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name |
79 | 79 | ||
80 | if (this.auth.isLoggedIn()) { | 80 | const theme = this.auth.isLoggedIn() |
81 | const theme = this.auth.getUser().theme | 81 | ? this.auth.getUser().theme |
82 | if (theme !== 'instance-default') return theme | 82 | : this.userService.getAnonymousUser().theme |
83 | } | ||
84 | 83 | ||
84 | if (theme !== 'instance-default') return theme | ||
85 | return this.serverConfig.theme.default | 85 | return this.serverConfig.theme.default |
86 | } | 86 | } |
87 | 87 | ||
@@ -111,9 +111,9 @@ export class ThemeService { | |||
111 | 111 | ||
112 | this.pluginService.reloadLoadedScopes() | 112 | this.pluginService.reloadLoadedScopes() |
113 | 113 | ||
114 | peertubeLocalStorage.setItem(ThemeService.KEYS.LAST_ACTIVE_THEME, JSON.stringify(theme)) | 114 | this.localStorageService.setItem(User.KEYS.THEME, JSON.stringify(theme), false) |
115 | } else { | 115 | } else { |
116 | peertubeLocalStorage.removeItem(ThemeService.KEYS.LAST_ACTIVE_THEME) | 116 | this.localStorageService.removeItem(User.KEYS.THEME, false) |
117 | } | 117 | } |
118 | 118 | ||
119 | this.oldThemeName = currentTheme | 119 | this.oldThemeName = currentTheme |
@@ -126,6 +126,10 @@ export class ThemeService { | |||
126 | 126 | ||
127 | if (!this.auth.isLoggedIn()) { | 127 | if (!this.auth.isLoggedIn()) { |
128 | this.updateCurrentTheme() | 128 | this.updateCurrentTheme() |
129 | |||
130 | this.localStorageService.watch([User.KEYS.THEME]).subscribe( | ||
131 | () => this.updateCurrentTheme() | ||
132 | ) | ||
129 | } | 133 | } |
130 | 134 | ||
131 | this.auth.userInformationLoaded | 135 | this.auth.userInformationLoaded |
@@ -134,7 +138,7 @@ export class ThemeService { | |||
134 | } | 138 | } |
135 | 139 | ||
136 | private loadAndSetFromLocalStorage () { | 140 | private loadAndSetFromLocalStorage () { |
137 | const lastActiveThemeString = peertubeLocalStorage.getItem(ThemeService.KEYS.LAST_ACTIVE_THEME) | 141 | const lastActiveThemeString = this.localStorageService.getItem(User.KEYS.THEME) |
138 | if (!lastActiveThemeString) return | 142 | if (!lastActiveThemeString) return |
139 | 143 | ||
140 | try { | 144 | try { |
diff --git a/client/src/app/header/header.component.html b/client/src/app/header/header.component.html index 4fd18f9bd..49e219187 100644 --- a/client/src/app/header/header.component.html +++ b/client/src/app/header/header.component.html | |||
@@ -1,8 +1,4 @@ | |||
1 | <input | 1 | <my-search-typeahead class="w-100 d-flex justify-content-end"></my-search-typeahead> |
2 | type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch" i18n-placeholder placeholder="Search videos, channels…" | ||
3 | [(ngModel)]="searchValue" (keyup.enter)="doSearch()" | ||
4 | > | ||
5 | <span (click)="doSearch()" class="icon icon-search"></span> | ||
6 | 2 | ||
7 | <a class="upload-button" routerLink="/videos/upload"> | 3 | <a class="upload-button" routerLink="/videos/upload"> |
8 | <my-global-icon iconName="upload"></my-global-icon> | 4 | <my-global-icon iconName="upload"></my-global-icon> |
diff --git a/client/src/app/header/header.component.scss b/client/src/app/header/header.component.scss index 2bbde74bc..91b390773 100644 --- a/client/src/app/header/header.component.scss +++ b/client/src/app/header/header.component.scss | |||
@@ -1,51 +1,8 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | #search-video { | 4 | my-search-typeahead { |
5 | @include peertube-input-text($search-input-width); | ||
6 | padding-left: 10px; | ||
7 | margin-right: 15px; | 5 | margin-right: 15px; |
8 | padding-right: 40px; // For the search icon | ||
9 | font-size: 14px; | ||
10 | |||
11 | transition: box-shadow .3s ease; | ||
12 | |||
13 | /* light border style */ | ||
14 | border: 1px solid var(--mainBackgroundColor); | ||
15 | box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px; | ||
16 | |||
17 | &:focus { | ||
18 | box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px; | ||
19 | } | ||
20 | |||
21 | &::placeholder { | ||
22 | color: var(--inputPlaceholderColor); | ||
23 | } | ||
24 | |||
25 | &:focus::placeholder { | ||
26 | opacity: 0 !important; | ||
27 | } | ||
28 | |||
29 | @media screen and (max-width: 800px) { | ||
30 | width: calc(100% - 150px); | ||
31 | } | ||
32 | |||
33 | @media screen and (max-width: 600px) { | ||
34 | width: calc(100% - 70px); | ||
35 | } | ||
36 | } | ||
37 | |||
38 | .icon.icon-search { | ||
39 | @include icon(25px); | ||
40 | height: 21px; | ||
41 | |||
42 | background-color: var(--mainForegroundColor); | ||
43 | mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%; | ||
44 | |||
45 | // yolo | ||
46 | position: absolute; | ||
47 | margin-left: -50px; | ||
48 | margin-top: 5px; | ||
49 | } | 6 | } |
50 | 7 | ||
51 | .upload-button { | 8 | .upload-button { |
@@ -56,10 +13,6 @@ | |||
56 | color: var(--mainBackgroundColor) !important; | 13 | color: var(--mainBackgroundColor) !important; |
57 | margin-right: 25px; | 14 | margin-right: 25px; |
58 | 15 | ||
59 | @media screen and (max-width: 800px) { | ||
60 | margin-right: 0; | ||
61 | } | ||
62 | |||
63 | @media screen and (max-width: 600px) { | 16 | @media screen and (max-width: 600px) { |
64 | margin-right: 10px; | 17 | margin-right: 10px; |
65 | padding: 0 10px; | 18 | padding: 0 10px; |
diff --git a/client/src/app/header/header.component.ts b/client/src/app/header/header.component.ts index 92a7eded6..cce76b0d1 100644 --- a/client/src/app/header/header.component.ts +++ b/client/src/app/header/header.component.ts | |||
@@ -1,10 +1,4 @@ | |||
1 | import { filter, first, map, tap } from 'rxjs/operators' | 1 | import { Component } from '@angular/core' |
2 | import { Component, OnInit } from '@angular/core' | ||
3 | import { ActivatedRoute, NavigationEnd, Params, Router } from '@angular/router' | ||
4 | import { getParameterByName } from '../shared/misc/utils' | ||
5 | import { AuthService, Notifier, ServerService } from '@app/core' | ||
6 | import { of } from 'rxjs' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | 2 | ||
9 | @Component({ | 3 | @Component({ |
10 | selector: 'my-header', | 4 | selector: 'my-header', |
@@ -12,54 +6,4 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
12 | styleUrls: [ './header.component.scss' ] | 6 | styleUrls: [ './header.component.scss' ] |
13 | }) | 7 | }) |
14 | 8 | ||
15 | export class HeaderComponent implements OnInit { | 9 | export class HeaderComponent {} |
16 | searchValue = '' | ||
17 | ariaLabelTextForSearch = '' | ||
18 | |||
19 | constructor ( | ||
20 | private router: Router, | ||
21 | private route: ActivatedRoute, | ||
22 | private auth: AuthService, | ||
23 | private serverService: ServerService, | ||
24 | private authService: AuthService, | ||
25 | private notifier: Notifier, | ||
26 | private i18n: I18n | ||
27 | ) {} | ||
28 | |||
29 | ngOnInit () { | ||
30 | this.ariaLabelTextForSearch = this.i18n('Search videos, channels') | ||
31 | |||
32 | this.router.events | ||
33 | .pipe( | ||
34 | filter(e => e instanceof NavigationEnd), | ||
35 | map(() => getParameterByName('search', window.location.href)) | ||
36 | ) | ||
37 | .subscribe(searchQuery => this.searchValue = searchQuery || '') | ||
38 | } | ||
39 | |||
40 | doSearch () { | ||
41 | const queryParams: Params = {} | ||
42 | |||
43 | if (window.location.pathname === '/search' && this.route.snapshot.queryParams) { | ||
44 | Object.assign(queryParams, this.route.snapshot.queryParams) | ||
45 | } | ||
46 | |||
47 | Object.assign(queryParams, { search: this.searchValue }) | ||
48 | |||
49 | const o = this.auth.isLoggedIn() | ||
50 | ? this.loadUserLanguagesIfNeeded(queryParams) | ||
51 | : of(true) | ||
52 | |||
53 | o.subscribe(() => this.router.navigate([ '/search' ], { queryParams })) | ||
54 | } | ||
55 | |||
56 | private loadUserLanguagesIfNeeded (queryParams: any) { | ||
57 | if (queryParams && queryParams.languageOneOf) return of(queryParams) | ||
58 | |||
59 | return this.auth.userInformationLoaded | ||
60 | .pipe( | ||
61 | first(), | ||
62 | tap(() => Object.assign(queryParams, { languageOneOf: this.auth.getUser().videoLanguages })) | ||
63 | ) | ||
64 | } | ||
65 | } | ||
diff --git a/client/src/app/header/index.ts b/client/src/app/header/index.ts index d98d2d00a..a882d4d1f 100644 --- a/client/src/app/header/index.ts +++ b/client/src/app/header/index.ts | |||
@@ -1 +1,4 @@ | |||
1 | export * from './header.component' | 1 | export * from './header.component' |
2 | export * from './search-typeahead.component' | ||
3 | export * from './suggestions.component' | ||
4 | export * from './suggestion.component' | ||
diff --git a/client/src/app/header/search-typeahead.component.html b/client/src/app/header/search-typeahead.component.html new file mode 100644 index 000000000..710268664 --- /dev/null +++ b/client/src/app/header/search-typeahead.component.html | |||
@@ -0,0 +1,53 @@ | |||
1 | <div class="d-inline-flex position-relative" id="typeahead-container"> | ||
2 | <input | ||
3 | type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…" | ||
4 | [(ngModel)]="search" (ngModelChange)="onSearchChange()" (keyup)="handleKey($event)" (keydown.enter)="doSearch()" | ||
5 | > | ||
6 | <span class="icon icon-search" (click)="doSearch()"></span> | ||
7 | |||
8 | <div class="position-absolute jump-to-suggestions"> | ||
9 | <!-- suggestions --> | ||
10 | <my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></my-suggestions> | ||
11 | |||
12 | <!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion --> | ||
13 | <div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden"> | ||
14 | <ng-container *ngIf="activeResult.type === 'search-global'"> | ||
15 | <div class="d-flex justify-content-between"> | ||
16 | <label class="small-title" i18n>GLOBAL SEARCH</label> | ||
17 | <div class="advanced-search-status text-muted"> | ||
18 | <span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span> | ||
19 | <i class="glyphicon glyphicon-globe"></i> | ||
20 | </div> | ||
21 | </div> | ||
22 | <div class="text-muted" i18n>Results will be augmented with those of a third-party index. Only data necessary to make the query will be sent.</div> | ||
23 | </ng-container> | ||
24 | </div> | ||
25 | |||
26 | <!-- search instructions, when search input is empty --> | ||
27 | <div *ngIf="areInstructionsDisplayed" id="typeahead-instructions" class="overflow-hidden"> | ||
28 | <div class="d-flex justify-content-between"> | ||
29 | <label class="small-title" i18n>ADVANCED SEARCH</label> | ||
30 | <div class="advanced-search-status c-help"> | ||
31 | <span [ngClass]="canSearchAnyURI ? 'text-success' : 'text-muted'" i18n-title title="Determines whether you can resolve any distant content, or if this instance only allows doing so for instances it follows."> | ||
32 | <span *ngIf="canSearchAnyURI" class="mr-1" i18n>any instance</span> | ||
33 | <span *ngIf="!canSearchAnyURI" class="mr-1" i18n>only followed instances</span> | ||
34 | <i [ngClass]="canSearchAnyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i> | ||
35 | </span> | ||
36 | </div> | ||
37 | </div> | ||
38 | <ul> | ||
39 | <li> | ||
40 | <em>@channel_id@domain</em> <span class="flex-auto text-muted" i18n>channel</span> | ||
41 | </li> | ||
42 | <li> | ||
43 | <em>URL</em> <span class="text-muted" i18n>channel</span> | ||
44 | </li> | ||
45 | <li> | ||
46 | <em>UUID</em> <span class="text-muted" i18n>video</span> | ||
47 | </li> | ||
48 | </ul> | ||
49 | <span class="text-muted" i18n>Any other text will return matching video or channel names.</span> | ||
50 | </div> | ||
51 | </div> | ||
52 | |||
53 | </div> | ||
diff --git a/client/src/app/header/search-typeahead.component.scss b/client/src/app/header/search-typeahead.component.scss new file mode 100644 index 000000000..33b88825f --- /dev/null +++ b/client/src/app/header/search-typeahead.component.scss | |||
@@ -0,0 +1,145 @@ | |||
1 | @import '_mixins'; | ||
2 | @import '_variables'; | ||
3 | @import '_bootstrap-variables'; | ||
4 | @import '~bootstrap/scss/mixins/_breakpoints'; | ||
5 | |||
6 | #search-video { | ||
7 | @include peertube-input-text($search-input-width); | ||
8 | padding-left: 10px; | ||
9 | padding-right: 40px; // For the search icon | ||
10 | font-size: 14px; | ||
11 | |||
12 | &::placeholder { | ||
13 | color: var(--inputPlaceholderColor); | ||
14 | } | ||
15 | } | ||
16 | |||
17 | .icon.icon-search { | ||
18 | @include icon(25px); | ||
19 | height: 21px; | ||
20 | |||
21 | background-color: var(--mainForegroundColor); | ||
22 | mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%; | ||
23 | |||
24 | // yolo | ||
25 | position: absolute; | ||
26 | margin-left: -35px; | ||
27 | margin-top: 5px; | ||
28 | } | ||
29 | |||
30 | .jump-to-suggestions { | ||
31 | top: 100%; | ||
32 | left: 0; | ||
33 | z-index: z(typeahead); | ||
34 | width: 100%; | ||
35 | } | ||
36 | |||
37 | #typeahead-help, | ||
38 | #typeahead-instructions, | ||
39 | my-suggestions ::ng-deep ul { | ||
40 | border: 1px solid var(--mainBackgroundColor); | ||
41 | border-bottom-right-radius: 3px; | ||
42 | border-bottom-left-radius: 3px; | ||
43 | background: var(--mainBackgroundColor); | ||
44 | transition: .3s ease; | ||
45 | transition-property: box-shadow; | ||
46 | } | ||
47 | |||
48 | #typeahead-help, | ||
49 | #typeahead-instructions { | ||
50 | margin-top: 10px; | ||
51 | width: 100%; | ||
52 | padding: .5rem 1rem; | ||
53 | white-space: normal; | ||
54 | |||
55 | ul { | ||
56 | list-style: none; | ||
57 | padding: 0; | ||
58 | margin-bottom: .5rem; | ||
59 | |||
60 | em { | ||
61 | font-weight: 600; | ||
62 | margin-right: 0.2rem; | ||
63 | font-style: normal; | ||
64 | } | ||
65 | } | ||
66 | } | ||
67 | |||
68 | #typeahead-container { | ||
69 | input { | ||
70 | border: 1px solid var(--mainBackgroundColor) !important; | ||
71 | box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px; | ||
72 | flex-grow: 1; | ||
73 | transition: box-shadow .3s ease, width .2s ease; | ||
74 | } | ||
75 | |||
76 | @media screen and (min-width: $mobile-view) { | ||
77 | margin-left: 10px; | ||
78 | } | ||
79 | |||
80 | @media screen and (max-width: $small-view) { | ||
81 | flex: 1; | ||
82 | |||
83 | input { | ||
84 | width: unset; | ||
85 | } | ||
86 | } | ||
87 | |||
88 | span { | ||
89 | right: 10px; | ||
90 | } | ||
91 | |||
92 | & > div:last-child { | ||
93 | // we have to switch the display and not the opacity, | ||
94 | // to avoid clashing with the rest of the interface. | ||
95 | display: none; | ||
96 | } | ||
97 | |||
98 | &:focus, | ||
99 | ::ng-deep &:focus-within { | ||
100 | & > div:last-child { | ||
101 | @media screen and (min-width: $mobile-view) { | ||
102 | display: initial !important; | ||
103 | } | ||
104 | |||
105 | #typeahead-help, | ||
106 | #typeahead-instructions, | ||
107 | my-suggestions ::ng-deep ul { | ||
108 | box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 20px -5px; | ||
109 | } | ||
110 | } | ||
111 | |||
112 | ::ng-deep input { | ||
113 | box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 20px 0px; | ||
114 | border-end-start-radius: 0; | ||
115 | border-end-end-radius: 0; | ||
116 | |||
117 | @include media-breakpoint-up(lg) { | ||
118 | width: 500px; | ||
119 | } | ||
120 | } | ||
121 | } | ||
122 | } | ||
123 | |||
124 | .glyphicon { | ||
125 | top: 3px; | ||
126 | } | ||
127 | |||
128 | .advanced-search-status { | ||
129 | height: max-content; | ||
130 | cursor: default; | ||
131 | |||
132 | &.c-help { | ||
133 | cursor: help; | ||
134 | } | ||
135 | } | ||
136 | |||
137 | .small-title { | ||
138 | @include in-content-small-title; | ||
139 | |||
140 | margin-bottom: .5rem; | ||
141 | } | ||
142 | |||
143 | ::ng-deep my-suggestion { | ||
144 | width: 100%; | ||
145 | } | ||
diff --git a/client/src/app/header/search-typeahead.component.ts b/client/src/app/header/search-typeahead.component.ts new file mode 100644 index 000000000..2bf1072f4 --- /dev/null +++ b/client/src/app/header/search-typeahead.component.ts | |||
@@ -0,0 +1,179 @@ | |||
1 | import { Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild } from '@angular/core' | ||
2 | import { ActivatedRoute, Params, Router } from '@angular/router' | ||
3 | import { AuthService, ServerService } from '@app/core' | ||
4 | import { first, tap } from 'rxjs/operators' | ||
5 | import { ListKeyManager } from '@angular/cdk/a11y' | ||
6 | import { Result, SuggestionComponent } from './suggestion.component' | ||
7 | import { of } from 'rxjs' | ||
8 | import { ServerConfig } from '@shared/models' | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-search-typeahead', | ||
12 | templateUrl: './search-typeahead.component.html', | ||
13 | styleUrls: [ './search-typeahead.component.scss' ] | ||
14 | }) | ||
15 | export class SearchTypeaheadComponent implements OnInit, OnDestroy { | ||
16 | @ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement> | ||
17 | |||
18 | hasChannel = false | ||
19 | inChannel = false | ||
20 | newSearch = true | ||
21 | |||
22 | search = '' | ||
23 | serverConfig: ServerConfig | ||
24 | |||
25 | inThisChannelText: string | ||
26 | |||
27 | keyboardEventsManager: ListKeyManager<SuggestionComponent> | ||
28 | results: Result[] = [] | ||
29 | |||
30 | constructor ( | ||
31 | private authService: AuthService, | ||
32 | private router: Router, | ||
33 | private route: ActivatedRoute, | ||
34 | private serverService: ServerService | ||
35 | ) {} | ||
36 | |||
37 | ngOnInit () { | ||
38 | this.route.queryParams | ||
39 | .pipe(first(params => this.isOnSearch() && params.search !== undefined && params.search !== null)) | ||
40 | .subscribe(params => this.search = params.search) | ||
41 | this.serverService.getConfig() | ||
42 | .subscribe(config => this.serverConfig = config) | ||
43 | } | ||
44 | |||
45 | ngOnDestroy () { | ||
46 | if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() | ||
47 | } | ||
48 | |||
49 | get activeResult () { | ||
50 | return this.keyboardEventsManager?.activeItem?.result | ||
51 | } | ||
52 | |||
53 | get areInstructionsDisplayed () { | ||
54 | return !this.search | ||
55 | } | ||
56 | |||
57 | get showHelp () { | ||
58 | return this.search && this.newSearch && this.activeResult?.type === 'search-global' | ||
59 | } | ||
60 | |||
61 | get canSearchAnyURI () { | ||
62 | if (!this.serverConfig) return false | ||
63 | return this.authService.isLoggedIn() | ||
64 | ? this.serverConfig.search.remoteUri.users | ||
65 | : this.serverConfig.search.remoteUri.anonymous | ||
66 | } | ||
67 | |||
68 | onSearchChange () { | ||
69 | this.computeResults() | ||
70 | } | ||
71 | |||
72 | computeResults () { | ||
73 | this.newSearch = true | ||
74 | let results: Result[] = [] | ||
75 | |||
76 | if (this.search) { | ||
77 | results = [ | ||
78 | /* Channel search is still unimplemented. Uncomment when it is. | ||
79 | { | ||
80 | text: this.search, | ||
81 | type: 'search-channel' | ||
82 | }, | ||
83 | */ | ||
84 | { | ||
85 | text: this.search, | ||
86 | type: 'search-instance', | ||
87 | default: true | ||
88 | }, | ||
89 | /* Global search is still unimplemented. Uncomment when it is. | ||
90 | { | ||
91 | text: this.search, | ||
92 | type: 'search-global' | ||
93 | }, | ||
94 | */ | ||
95 | ...results | ||
96 | ] | ||
97 | } | ||
98 | |||
99 | this.results = results.filter( | ||
100 | (result: Result) => { | ||
101 | // if we're not in a channel or one of its videos/playlits, show all channel-related results | ||
102 | if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel') | ||
103 | // if we're in a channel, show all channel-related results except for the channel redirection itself | ||
104 | if (this.inChannel) return result.type !== 'channel' | ||
105 | // all other result types are kept | ||
106 | return true | ||
107 | } | ||
108 | ) | ||
109 | } | ||
110 | |||
111 | setEventItems (event: { items: QueryList<SuggestionComponent>, index?: number }) { | ||
112 | event.items.forEach(e => { | ||
113 | if (this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem === e) { | ||
114 | this.keyboardEventsManager.activeItem.active = true | ||
115 | } else { | ||
116 | e.active = false | ||
117 | } | ||
118 | }) | ||
119 | } | ||
120 | |||
121 | initKeyboardEventsManager (event: { items: QueryList<SuggestionComponent>, index?: number }) { | ||
122 | if (this.keyboardEventsManager) this.keyboardEventsManager.change.unsubscribe() | ||
123 | |||
124 | this.keyboardEventsManager = new ListKeyManager(event.items) | ||
125 | |||
126 | if (event.index !== undefined) { | ||
127 | this.keyboardEventsManager.setActiveItem(event.index) | ||
128 | } else { | ||
129 | this.keyboardEventsManager.setFirstItemActive() | ||
130 | } | ||
131 | |||
132 | this.keyboardEventsManager.change.subscribe( | ||
133 | _ => this.setEventItems(event) | ||
134 | ) | ||
135 | } | ||
136 | |||
137 | handleKey (event: KeyboardEvent) { | ||
138 | event.stopImmediatePropagation() | ||
139 | if (!this.keyboardEventsManager) return | ||
140 | |||
141 | switch (event.key) { | ||
142 | case 'ArrowDown': | ||
143 | case 'ArrowUp': | ||
144 | this.keyboardEventsManager.onKeydown(event) | ||
145 | break | ||
146 | } | ||
147 | } | ||
148 | |||
149 | isOnSearch () { | ||
150 | return window.location.pathname === '/search' | ||
151 | } | ||
152 | |||
153 | doSearch () { | ||
154 | this.newSearch = false | ||
155 | const queryParams: Params = {} | ||
156 | |||
157 | if (this.isOnSearch() && this.route.snapshot.queryParams) { | ||
158 | Object.assign(queryParams, this.route.snapshot.queryParams) | ||
159 | } | ||
160 | |||
161 | Object.assign(queryParams, { search: this.search }) | ||
162 | |||
163 | const o = this.authService.isLoggedIn() | ||
164 | ? this.loadUserLanguagesIfNeeded(queryParams) | ||
165 | : of(true) | ||
166 | |||
167 | o.subscribe(() => this.router.navigate([ '/search' ], { queryParams })) | ||
168 | } | ||
169 | |||
170 | private loadUserLanguagesIfNeeded (queryParams: any) { | ||
171 | if (queryParams && queryParams.languageOneOf) return of(queryParams) | ||
172 | |||
173 | return this.authService.userInformationLoaded | ||
174 | .pipe( | ||
175 | first(), | ||
176 | tap(() => Object.assign(queryParams, { languageOneOf: this.authService.getUser().videoLanguages })) | ||
177 | ) | ||
178 | } | ||
179 | } | ||
diff --git a/client/src/app/header/suggestion.component.html b/client/src/app/header/suggestion.component.html new file mode 100644 index 000000000..d7ae3450a --- /dev/null +++ b/client/src/app/header/suggestion.component.html | |||
@@ -0,0 +1,22 @@ | |||
1 | <a tabindex="-1" class="d-flex flex-auto flex-items-center p-2" [class.focus-visible]="active"> | ||
2 | <div class="flex-shrink-0 mr-2 text-center"> | ||
3 | <my-global-icon *ngIf="result.type !== 'channel'" iconName="search"></my-global-icon> | ||
4 | <my-global-icon *ngIf="result.type === 'channel'" iconName="folder"></my-global-icon> | ||
5 | </div> | ||
6 | |||
7 | <img class="avatar mr-2 flex-shrink-0 d-none" alt="" aria-label="Team" src="" width="28" height="28"> | ||
8 | |||
9 | <div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"></div> | ||
10 | |||
11 | <div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6"> | ||
12 | <span *ngIf="result.type === 'search-channel'" i18n>In this channel</span> | ||
13 | <span *ngIf="result.type === 'search-instance'" i18n>In this instance</span> | ||
14 | <span *ngIf="result.type === 'search-global'" i18n>In the vidiverse</span> | ||
15 | <span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle">↵</span> | ||
16 | </div> | ||
17 | |||
18 | <div *ngIf="result.type === 'channel'" aria-hidden="true" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6 d-on-nav-focus" i18n> | ||
19 | Jump to channel | ||
20 | <span class="d-inline-block ml-1 v-align-middle">↵</span> | ||
21 | </div> | ||
22 | </a> \ No newline at end of file | ||
diff --git a/client/src/app/header/suggestion.component.scss b/client/src/app/header/suggestion.component.scss new file mode 100644 index 000000000..1de2f43bd --- /dev/null +++ b/client/src/app/header/suggestion.component.scss | |||
@@ -0,0 +1,32 @@ | |||
1 | @import '_mixins'; | ||
2 | |||
3 | a { | ||
4 | @include disable-default-a-behaviour; | ||
5 | width: 100%; | ||
6 | |||
7 | &, &:hover { | ||
8 | color: var(--mainForegroundColor); | ||
9 | |||
10 | &.focus-visible { | ||
11 | background-color: var(--mainHoverColor); | ||
12 | color: var(--mainBackgroundColor); | ||
13 | } | ||
14 | } | ||
15 | } | ||
16 | |||
17 | .bg-gray { | ||
18 | background-color: var(--mainBackgroundColor); | ||
19 | } | ||
20 | |||
21 | .text-gray-light { | ||
22 | color: var(--mainForegroundColor); | ||
23 | } | ||
24 | |||
25 | my-global-icon { | ||
26 | width: 17px; | ||
27 | position: relative; | ||
28 | top: -2px; | ||
29 | margin: 5px; | ||
30 | |||
31 | @include apply-svg-color(var(--mainForegroundColor)); | ||
32 | } | ||
diff --git a/client/src/app/header/suggestion.component.ts b/client/src/app/header/suggestion.component.ts new file mode 100644 index 000000000..69641b511 --- /dev/null +++ b/client/src/app/header/suggestion.component.ts | |||
@@ -0,0 +1,37 @@ | |||
1 | import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core' | ||
2 | import { RouterLink } from '@angular/router' | ||
3 | import { ListKeyManagerOption } from '@angular/cdk/a11y' | ||
4 | |||
5 | export type Result = { | ||
6 | text: string | ||
7 | type: 'channel' | 'suggestion' | 'search-channel' | 'search-instance' | 'search-global' | 'search-any' | ||
8 | routerLink?: RouterLink, | ||
9 | default?: boolean | ||
10 | } | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-suggestion', | ||
14 | templateUrl: './suggestion.component.html', | ||
15 | styleUrls: [ './suggestion.component.scss' ], | ||
16 | changeDetection: ChangeDetectionStrategy.OnPush | ||
17 | }) | ||
18 | export class SuggestionComponent implements OnInit, ListKeyManagerOption { | ||
19 | @Input() result: Result | ||
20 | @Input() highlight: string | ||
21 | @Output() selected = new EventEmitter() | ||
22 | |||
23 | disabled = false | ||
24 | active = false | ||
25 | |||
26 | getLabel () { | ||
27 | return this.result.text | ||
28 | } | ||
29 | |||
30 | ngOnInit () { | ||
31 | if (this.result.default) this.active = true | ||
32 | } | ||
33 | |||
34 | selectItem () { | ||
35 | this.selected.emit(this.result) | ||
36 | } | ||
37 | } | ||
diff --git a/client/src/app/header/suggestions.component.html b/client/src/app/header/suggestions.component.html new file mode 100644 index 000000000..8d017d78d --- /dev/null +++ b/client/src/app/header/suggestions.component.html | |||
@@ -0,0 +1,6 @@ | |||
1 | <ul role="listbox" class="p-0 m-0"> | ||
2 | <li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5" | ||
3 | role="option" aria-selected="true" (mouseenter)="hoverItem(i)"> | ||
4 | <my-suggestion [result]="result" [highlight]="highlight"></my-suggestion> | ||
5 | </li> | ||
6 | </ul> \ No newline at end of file | ||
diff --git a/client/src/app/header/suggestions.component.ts b/client/src/app/header/suggestions.component.ts new file mode 100644 index 000000000..ee3ef73c2 --- /dev/null +++ b/client/src/app/header/suggestions.component.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core' | ||
2 | import { SuggestionComponent } from './suggestion.component' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-suggestions', | ||
6 | templateUrl: './suggestions.component.html', | ||
7 | changeDetection: ChangeDetectionStrategy.OnPush | ||
8 | }) | ||
9 | export class SuggestionsComponent implements AfterViewInit { | ||
10 | @Input() results: any[] | ||
11 | @Input() highlight: string | ||
12 | @ViewChildren(SuggestionComponent) listItems: QueryList<SuggestionComponent> | ||
13 | @Output() init = new EventEmitter() | ||
14 | |||
15 | ngAfterViewInit () { | ||
16 | this.listItems.changes.subscribe( | ||
17 | _ => this.init.emit({ items: this.listItems }) | ||
18 | ) | ||
19 | } | ||
20 | |||
21 | hoverItem (index: number) { | ||
22 | this.init.emit({ items: this.listItems, index: index }) | ||
23 | } | ||
24 | } | ||
diff --git a/client/src/app/login/login.component.html b/client/src/app/login/login.component.html index 0b0bacff0..599b203ae 100644 --- a/client/src/app/login/login.component.html +++ b/client/src/app/login/login.component.html | |||
@@ -3,59 +3,79 @@ | |||
3 | Login | 3 | Login |
4 | </div> | 4 | </div> |
5 | 5 | ||
6 | <div class="alert alert-info" *ngIf="signupAllowed === false" role="alert"> | 6 | <div class="alert alert-danger" i18n *ngIf="externalAuthError"> |
7 | <h6 class="alert-heading" i18n> | 7 | Sorry but there was an issue with the external login process. Please <a routerLink="/about">contact an administrator</a>. |
8 | If you are looking for an account… | 8 | </div> |
9 | </h6> | ||
10 | 9 | ||
11 | <div i18n> | 10 | <ng-container *ngIf="!externalAuthError && !isAuthenticatedWithExternalAuth"> |
12 | Currently this instance doesn't allow for user registration, but you can find an instance | 11 | <div class="looking-for-account alert alert-info" *ngIf="signupAllowed === false" role="alert"> |
13 | that gives you the possibility to sign up for an account and upload your videos there. | 12 | <h6 class="alert-heading" i18n> |
13 | If you are looking for an account… | ||
14 | </h6> | ||
14 | 15 | ||
15 | <br /> | 16 | <div i18n> |
17 | Currently this instance doesn't allow for user registration, but you can find an instance | ||
18 | that gives you the possibility to sign up for an account and upload your videos there. | ||
16 | 19 | ||
17 | Find yours among multiple instances at <a class="alert-link" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer">https://joinpeertube.org/instances</a>. | 20 | <br /> |
18 | </div> | ||
19 | </div> | ||
20 | |||
21 | <div *ngIf="error" class="alert alert-danger">{{ error }} | ||
22 | <span *ngIf="error === 'User email is not verified.'"> <a i18n routerLink="/verify-account/ask-send-email">Request new verification email.</a></span> | ||
23 | </div> | ||
24 | 21 | ||
25 | <form role="form" (ngSubmit)="login()" [formGroup]="form"> | 22 | Find yours among multiple instances at <a class="alert-link" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer">https://joinpeertube.org/instances</a>. |
26 | <div class="form-group"> | ||
27 | <div> | ||
28 | <label i18n for="username">User</label> | ||
29 | <input | ||
30 | type="text" id="username" i18n-placeholder placeholder="Username or email address" required tabindex="1" | ||
31 | formControlName="username" [ngClass]="{ 'input-error': formErrors['username'] }" #emailInput | ||
32 | > | ||
33 | <a i18n *ngIf="signupAllowed === true" routerLink="/signup" class="create-an-account"> | ||
34 | or create an account | ||
35 | </a> | ||
36 | </div> | 23 | </div> |
24 | </div> | ||
37 | 25 | ||
38 | <div *ngIf="formErrors.username" class="form-error"> | 26 | <div *ngIf="error" class="alert alert-danger">{{ error }} |
39 | {{ formErrors.username }} | 27 | <span *ngIf="error === 'User email is not verified.'"> <a i18n routerLink="/verify-account/ask-send-email">Request new verification email.</a></span> |
40 | </div> | ||
41 | </div> | 28 | </div> |
42 | 29 | ||
43 | <div class="form-group"> | 30 | <div class="login-form-and-externals"> |
44 | <label i18n for="password">Password</label> | 31 | |
45 | <div> | 32 | <form role="form" (ngSubmit)="login()" [formGroup]="form"> |
46 | <input | 33 | <div class="form-group"> |
47 | type="password" name="password" id="password" i18n-placeholder placeholder="Password" required tabindex="2" autocomplete="current-password" | 34 | <div> |
48 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | 35 | <label i18n for="username">User</label> |
49 | > | 36 | <input |
50 | <a i18n class="forgot-password-button" (click)="openForgotPasswordModal()" title="Click here to reset your password">I forgot my password</a> | 37 | type="text" id="username" i18n-placeholder placeholder="Username or email address" required tabindex="1" |
51 | </div> | 38 | formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" #usernameInput |
52 | <div *ngIf="formErrors.password" class="form-error"> | 39 | > |
53 | {{ formErrors.password }} | 40 | <a i18n *ngIf="signupAllowed === true" routerLink="/signup" class="create-an-account"> |
41 | or create an account | ||
42 | </a> | ||
43 | </div> | ||
44 | |||
45 | <div *ngIf="formErrors.username" class="form-error"> | ||
46 | {{ formErrors.username }} | ||
47 | </div> | ||
48 | </div> | ||
49 | |||
50 | <div class="form-group"> | ||
51 | <label i18n for="password">Password</label> | ||
52 | <div> | ||
53 | <input | ||
54 | type="password" name="password" id="password" i18n-placeholder placeholder="Password" required tabindex="2" autocomplete="current-password" | ||
55 | formControlName="password" class="form-control" [ngClass]="{ 'input-error': formErrors['password'] }" | ||
56 | > | ||
57 | <a i18n-title class="forgot-password-button" (click)="openForgotPasswordModal()" title="Click here to reset your password">I forgot my password</a> | ||
58 | </div> | ||
59 | <div *ngIf="formErrors.password" class="form-error"> | ||
60 | {{ formErrors.password }} | ||
61 | </div> | ||
62 | </div> | ||
63 | |||
64 | <input type="submit" i18n-value value="Login" [disabled]="!form.valid"> | ||
65 | </form> | ||
66 | |||
67 | <div class="external-login-blocks" *ngIf="getExternalLogins().length !== 0"> | ||
68 | <div class="block-title" i18n>Or sign in with</div> | ||
69 | |||
70 | <div> | ||
71 | <a class="external-login-block" *ngFor="let auth of getExternalLogins()" [href]="getAuthHref(auth)" role="button"> | ||
72 | {{ auth.authDisplayName }} | ||
73 | </a> | ||
74 | </div> | ||
54 | </div> | 75 | </div> |
55 | </div> | 76 | </div> |
56 | 77 | ||
57 | <input type="submit" i18n-value value="Login" [disabled]="!form.valid"> | 78 | </ng-container> |
58 | </form> | ||
59 | </div> | 79 | </div> |
60 | 80 | ||
61 | <ng-template #forgotPasswordModal> | 81 | <ng-template #forgotPasswordModal> |
@@ -81,7 +101,10 @@ | |||
81 | </div> | 101 | </div> |
82 | 102 | ||
83 | <div class="modal-footer inputs"> | 103 | <div class="modal-footer inputs"> |
84 | <span i18n class="action-button action-button-cancel" (click)="hideForgotPasswordModal()">Cancel</span> | 104 | <input |
105 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | ||
106 | (click)="hideForgotPasswordModal()" (key.enter)="hideForgotPasswordModal()" | ||
107 | > | ||
85 | 108 | ||
86 | <input | 109 | <input |
87 | type="submit" i18n-value value="Send me an email to reset my password" class="action-button-submit" | 110 | type="submit" i18n-value value="Send me an email to reset my password" class="action-button-submit" |
diff --git a/client/src/app/login/login.component.scss b/client/src/app/login/login.component.scss index 8ac231475..db9f78f7c 100644 --- a/client/src/app/login/login.component.scss +++ b/client/src/app/login/login.component.scss | |||
@@ -21,9 +21,46 @@ input[type=submit] { | |||
21 | color: var(--mainForegroundColor); | 21 | color: var(--mainForegroundColor); |
22 | cursor: pointer; | 22 | cursor: pointer; |
23 | transition: opacity cubic-bezier(0.39, 0.575, 0.565, 1); | 23 | transition: opacity cubic-bezier(0.39, 0.575, 0.565, 1); |
24 | 24 | ||
25 | &:hover { | 25 | &:hover { |
26 | text-decoration: none !important; | 26 | text-decoration: none !important; |
27 | opacity: .7 !important; | 27 | opacity: .7 !important; |
28 | } | 28 | } |
29 | } | 29 | } |
30 | |||
31 | .login-form-and-externals { | ||
32 | display: flex; | ||
33 | flex-wrap: wrap; | ||
34 | font-size: 15px; | ||
35 | |||
36 | form { | ||
37 | margin: 0 50px 20px 0; | ||
38 | } | ||
39 | |||
40 | .external-login-blocks { | ||
41 | min-width: 200px; | ||
42 | |||
43 | .block-title { | ||
44 | font-weight: $font-semibold; | ||
45 | } | ||
46 | |||
47 | .external-login-block { | ||
48 | @include disable-default-a-behaviour; | ||
49 | |||
50 | cursor: pointer; | ||
51 | border: 1px solid #d1d7e0; | ||
52 | border-radius: 5px; | ||
53 | color: var(--mainForegroundColor); | ||
54 | margin: 10px 10px 0 0; | ||
55 | display: flex; | ||
56 | justify-content: center; | ||
57 | align-items: center; | ||
58 | min-height: 35px; | ||
59 | min-width: 100px; | ||
60 | |||
61 | &:hover { | ||
62 | background-color: rgba(209, 215, 224, 0.5) | ||
63 | } | ||
64 | } | ||
65 | } | ||
66 | } | ||
diff --git a/client/src/app/login/login.component.ts b/client/src/app/login/login.component.ts index 580f28822..fff4b43f6 100644 --- a/client/src/app/login/login.component.ts +++ b/client/src/app/login/login.component.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { Component, ElementRef, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, OnInit, ViewChild, AfterViewInit } from '@angular/core' |
2 | import { Notifier, RedirectService } from '@app/core' | 2 | import { Notifier, RedirectService } from '@app/core' |
3 | import { UserService } from '@app/shared' | 3 | import { UserService } from '@app/shared' |
4 | import { AuthService } from '../core' | 4 | import { AuthService } from '../core' |
@@ -8,7 +8,9 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val | |||
8 | import { LoginValidatorsService } from '@app/shared/forms/form-validators/login-validators.service' | 8 | import { LoginValidatorsService } from '@app/shared/forms/form-validators/login-validators.service' |
9 | import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' | 9 | import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' |
10 | import { ActivatedRoute } from '@angular/router' | 10 | import { ActivatedRoute } from '@angular/router' |
11 | import { ServerConfig } from '@shared/models/server/server-config.model' | 11 | import { ServerConfig, RegisteredExternalAuthConfig } from '@shared/models/server/server-config.model' |
12 | import { environment } from 'src/environments/environment' | ||
13 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
12 | 14 | ||
13 | @Component({ | 15 | @Component({ |
14 | selector: 'my-login', | 16 | selector: 'my-login', |
@@ -16,13 +18,17 @@ import { ServerConfig } from '@shared/models/server/server-config.model' | |||
16 | styleUrls: [ './login.component.scss' ] | 18 | styleUrls: [ './login.component.scss' ] |
17 | }) | 19 | }) |
18 | 20 | ||
19 | export class LoginComponent extends FormReactive implements OnInit { | 21 | export class LoginComponent extends FormReactive implements OnInit, AfterViewInit { |
20 | @ViewChild('emailInput', { static: true }) input: ElementRef | 22 | @ViewChild('usernameInput', { static: false }) usernameInput: ElementRef |
21 | @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef | 23 | @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef |
22 | 24 | ||
23 | error: string = null | 25 | error: string = null |
24 | forgotPasswordEmail = '' | 26 | forgotPasswordEmail = '' |
25 | 27 | ||
28 | isAuthenticatedWithExternalAuth = false | ||
29 | externalAuthError = false | ||
30 | externalLogins: string[] = [] | ||
31 | |||
26 | private openedForgotPasswordModal: NgbModalRef | 32 | private openedForgotPasswordModal: NgbModalRef |
27 | private serverConfig: ServerConfig | 33 | private serverConfig: ServerConfig |
28 | 34 | ||
@@ -35,6 +41,7 @@ export class LoginComponent extends FormReactive implements OnInit { | |||
35 | private userService: UserService, | 41 | private userService: UserService, |
36 | private redirectService: RedirectService, | 42 | private redirectService: RedirectService, |
37 | private notifier: Notifier, | 43 | private notifier: Notifier, |
44 | private hooks: HooksService, | ||
38 | private i18n: I18n | 45 | private i18n: I18n |
39 | ) { | 46 | ) { |
40 | super() | 47 | super() |
@@ -49,14 +56,40 @@ export class LoginComponent extends FormReactive implements OnInit { | |||
49 | } | 56 | } |
50 | 57 | ||
51 | ngOnInit () { | 58 | ngOnInit () { |
52 | this.serverConfig = this.route.snapshot.data.serverConfig | 59 | const snapshot = this.route.snapshot |
60 | |||
61 | this.serverConfig = snapshot.data.serverConfig | ||
62 | |||
63 | if (snapshot.queryParams.externalAuthToken) { | ||
64 | this.loadExternalAuthToken(snapshot.queryParams.username, snapshot.queryParams.externalAuthToken) | ||
65 | return | ||
66 | } | ||
67 | |||
68 | if (snapshot.queryParams.externalAuthError) { | ||
69 | this.externalAuthError = true | ||
70 | return | ||
71 | } | ||
53 | 72 | ||
54 | this.buildForm({ | 73 | this.buildForm({ |
55 | username: this.loginValidatorsService.LOGIN_USERNAME, | 74 | username: this.loginValidatorsService.LOGIN_USERNAME, |
56 | password: this.loginValidatorsService.LOGIN_PASSWORD | 75 | password: this.loginValidatorsService.LOGIN_PASSWORD |
57 | }) | 76 | }) |
77 | } | ||
78 | |||
79 | ngAfterViewInit () { | ||
80 | if (this.usernameInput) { | ||
81 | this.usernameInput.nativeElement.focus() | ||
82 | } | ||
83 | |||
84 | this.hooks.runAction('action:login.init', 'login') | ||
85 | } | ||
58 | 86 | ||
59 | this.input.nativeElement.focus() | 87 | getExternalLogins () { |
88 | return this.serverConfig.plugin.registeredExternalAuths | ||
89 | } | ||
90 | |||
91 | getAuthHref (auth: RegisteredExternalAuthConfig) { | ||
92 | return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` | ||
60 | } | 93 | } |
61 | 94 | ||
62 | login () { | 95 | login () { |
@@ -68,11 +101,7 @@ export class LoginComponent extends FormReactive implements OnInit { | |||
68 | .subscribe( | 101 | .subscribe( |
69 | () => this.redirectService.redirectToPreviousRoute(), | 102 | () => this.redirectService.redirectToPreviousRoute(), |
70 | 103 | ||
71 | err => { | 104 | err => this.handleError(err) |
72 | if (err.message.indexOf('credentials are invalid') !== -1) this.error = this.i18n('Incorrect username or password.') | ||
73 | else if (err.message.indexOf('blocked') !== -1) this.error = this.i18n('You account is blocked.') | ||
74 | else this.error = err.message | ||
75 | } | ||
76 | ) | 105 | ) |
77 | } | 106 | } |
78 | 107 | ||
@@ -99,4 +128,24 @@ export class LoginComponent extends FormReactive implements OnInit { | |||
99 | hideForgotPasswordModal () { | 128 | hideForgotPasswordModal () { |
100 | this.openedForgotPasswordModal.close() | 129 | this.openedForgotPasswordModal.close() |
101 | } | 130 | } |
131 | |||
132 | private loadExternalAuthToken (username: string, token: string) { | ||
133 | this.isAuthenticatedWithExternalAuth = true | ||
134 | |||
135 | this.authService.login(username, null, token) | ||
136 | .subscribe( | ||
137 | () => this.redirectService.redirectToPreviousRoute(), | ||
138 | |||
139 | err => { | ||
140 | this.handleError(err) | ||
141 | this.isAuthenticatedWithExternalAuth = false | ||
142 | } | ||
143 | ) | ||
144 | } | ||
145 | |||
146 | private handleError (err: any) { | ||
147 | if (err.message.indexOf('credentials are invalid') !== -1) this.error = this.i18n('Incorrect username or password.') | ||
148 | else if (err.message.indexOf('blocked') !== -1) this.error = this.i18n('You account is blocked.') | ||
149 | else this.error = err.message | ||
150 | } | ||
102 | } | 151 | } |
diff --git a/client/src/app/menu/avatar-notification.component.html b/client/src/app/menu/avatar-notification.component.html index 7975afba5..df2a102a3 100644 --- a/client/src/app/menu/avatar-notification.component.html +++ b/client/src/app/menu/avatar-notification.component.html | |||
@@ -30,7 +30,7 @@ | |||
30 | </div> | 30 | </div> |
31 | 31 | ||
32 | <my-user-notifications | 32 | <my-user-notifications |
33 | [ignoreLoadingBar]="true" [infiniteScroll]="false" itemsPerPage="10" | 33 | [ignoreLoadingBar]="true" [infiniteScroll]="false" [itemsPerPage]="10" |
34 | [markAllAsReadSubject]="markAllAsReadSubject" (notificationsLoaded)="onNotificationLoaded()" | 34 | [markAllAsReadSubject]="markAllAsReadSubject" (notificationsLoaded)="onNotificationLoaded()" |
35 | ></my-user-notifications> | 35 | ></my-user-notifications> |
36 | 36 | ||
diff --git a/client/src/app/menu/avatar-notification.component.ts b/client/src/app/menu/avatar-notification.component.ts index 989a11849..c447f031c 100644 --- a/client/src/app/menu/avatar-notification.component.ts +++ b/client/src/app/menu/avatar-notification.component.ts | |||
@@ -6,7 +6,6 @@ import { Notifier, UserNotificationSocket } from '@app/core' | |||
6 | import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' | 6 | import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' |
7 | import { NavigationEnd, Router } from '@angular/router' | 7 | import { NavigationEnd, Router } from '@angular/router' |
8 | import { filter } from 'rxjs/operators' | 8 | import { filter } from 'rxjs/operators' |
9 | import { UserNotificationsComponent } from '@app/shared' | ||
10 | 9 | ||
11 | @Component({ | 10 | @Component({ |
12 | selector: 'my-avatar-notification', | 11 | selector: 'my-avatar-notification', |
diff --git a/client/src/app/menu/language-chooser.component.scss b/client/src/app/menu/language-chooser.component.scss index 72deb3952..6226a85cb 100644 --- a/client/src/app/menu/language-chooser.component.scss +++ b/client/src/app/menu/language-chooser.component.scss | |||
@@ -4,6 +4,13 @@ | |||
4 | .help-to-translate { | 4 | .help-to-translate { |
5 | @include peertube-button-link; | 5 | @include peertube-button-link; |
6 | @include orange-button; | 6 | @include orange-button; |
7 | |||
8 | &.focus-visible, | ||
9 | &:focus { | ||
10 | box-shadow: none; | ||
11 | } | ||
12 | |||
13 | border-radius: 0; | ||
7 | } | 14 | } |
8 | 15 | ||
9 | .modal-body { | 16 | .modal-body { |
diff --git a/client/src/app/menu/language-chooser.component.ts b/client/src/app/menu/language-chooser.component.ts index 4a6e4c75a..9bc934ad4 100644 --- a/client/src/app/menu/language-chooser.component.ts +++ b/client/src/app/menu/language-chooser.component.ts | |||
@@ -1,7 +1,9 @@ | |||
1 | import { Component, ElementRef, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, ViewChild, Inject, LOCALE_ID } from '@angular/core' |
2 | import { I18N_LOCALES } from '../../../../shared' | 2 | import { I18N_LOCALES } from '../../../../shared' |
3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
4 | import { sortBy } from '@app/shared/misc/utils' | 4 | import { sortBy } from '@app/shared/misc/utils' |
5 | import { getCompleteLocale } from '@shared/models/i18n' | ||
6 | import { isOnDevLocale, getDevLocale } from '@app/shared/i18n/i18n-utils' | ||
5 | 7 | ||
6 | @Component({ | 8 | @Component({ |
7 | selector: 'my-language-chooser', | 9 | selector: 'my-language-chooser', |
@@ -13,7 +15,10 @@ export class LanguageChooserComponent { | |||
13 | 15 | ||
14 | languages: { id: string, label: string }[] = [] | 16 | languages: { id: string, label: string }[] = [] |
15 | 17 | ||
16 | constructor (private modalService: NgbModal) { | 18 | constructor ( |
19 | private modalService: NgbModal, | ||
20 | @Inject(LOCALE_ID) private localeId: string | ||
21 | ) { | ||
17 | const l = Object.keys(I18N_LOCALES) | 22 | const l = Object.keys(I18N_LOCALES) |
18 | .map(k => ({ id: k, label: I18N_LOCALES[k] })) | 23 | .map(k => ({ id: k, label: I18N_LOCALES[k] })) |
19 | 24 | ||
@@ -21,11 +26,18 @@ export class LanguageChooserComponent { | |||
21 | } | 26 | } |
22 | 27 | ||
23 | show () { | 28 | show () { |
24 | this.modalService.open(this.modal) | 29 | this.modalService.open(this.modal, { centered: true }) |
25 | } | 30 | } |
26 | 31 | ||
27 | buildLanguageLink (lang: { id: string }) { | 32 | buildLanguageLink (lang: { id: string }) { |
28 | return window.location.origin + '/' + lang.id | 33 | return window.location.origin + '/' + lang.id |
29 | } | 34 | } |
30 | 35 | ||
36 | getCurrentLanguage () { | ||
37 | const english = 'English' | ||
38 | const locale = isOnDevLocale() ? getDevLocale() : getCompleteLocale(this.localeId) | ||
39 | |||
40 | if (locale) return I18N_LOCALES[locale] || english | ||
41 | return english | ||
42 | } | ||
31 | } | 43 | } |
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index 675fb597d..1cb51ef55 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html | |||
@@ -8,34 +8,65 @@ | |||
8 | <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="logged-in-display-name">{{ user.account?.displayName }}</a> | 8 | <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="logged-in-display-name">{{ user.account?.displayName }}</a> |
9 | <a *ngIf="!user.account" routerLink="/my-account/settings" class="logged-in-display-name">{{ user.account?.displayName }}</a> | 9 | <a *ngIf="!user.account" routerLink="/my-account/settings" class="logged-in-display-name">{{ user.account?.displayName }}</a> |
10 | 10 | ||
11 | <div ngxClipboard [cbContent]="user.account?.nameWithHost" class="logged-in-username">{{ user.username }}</div> | 11 | <div class="logged-in-username">{{ user.username }}</div> |
12 | </div> | 12 | </div> |
13 | 13 | ||
14 | <div class="logged-in-more" ngbDropdown placement="bottom-right auto"> | 14 | <div class="logged-in-more" ngbDropdown [placement]="placement" container="body" autoClose="outside"> |
15 | <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button"></my-global-icon> | 15 | <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button"></my-global-icon> |
16 | 16 | ||
17 | <div ngbDropdownMenu> | 17 | <div ngbDropdownMenu> |
18 | <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="dropdown-item"> | 18 | <a *ngIf="user.account" ngbDropdownItem ngbDropdownToggle class="dropdown-item" [routerLink]="[ '/accounts', user.account.nameWithHost ]"> |
19 | <my-global-icon iconName="go"></my-global-icon> <ng-container i18n>Public profile</ng-container> | 19 | <my-global-icon iconName="go"></my-global-icon> <ng-container i18n>Public profile</ng-container> |
20 | </a> | 20 | </a> |
21 | 21 | ||
22 | <div class="dropdown-divider"></div> | 22 | <div class="dropdown-divider"></div> |
23 | 23 | ||
24 | <a routerLink="/my-account" class="dropdown-item"> | 24 | <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account"> |
25 | <my-global-icon iconName="user"></my-global-icon> <ng-container i18n>Account settings</ng-container> | 25 | <my-global-icon iconName="user"></my-global-icon> <ng-container i18n>Account settings</ng-container> |
26 | </a> | 26 | </a> |
27 | 27 | ||
28 | <a routerLink="/my-account/video-channels" class="dropdown-item"> | 28 | <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account/video-channels"> |
29 | <my-global-icon iconName="folder"></my-global-icon> <ng-container i18n>Channels settings</ng-container> | 29 | <my-global-icon iconName="folder"></my-global-icon> <ng-container i18n>Channels settings</ng-container> |
30 | </a> | 30 | </a> |
31 | 31 | ||
32 | <div class="dropdown-divider"></div> | 32 | <div class="dropdown-divider"></div> |
33 | 33 | ||
34 | <a class="dropdown-item" href="https://joinpeertube.org/help" target="_blank" rel="noopener noreferrer"> | 34 | <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" (click)="openLanguageChooser()"> |
35 | <my-global-icon iconName="help"></my-global-icon> <ng-container i18n>Help</ng-container> | 35 | <my-global-icon iconName="language"></my-global-icon> |
36 | <ng-container i18n>Interface: {{ language }}</ng-container> | ||
37 | <i class="ml-auto glyphicon glyphicon-menu-right"></i> | ||
36 | </a> | 38 | </a> |
37 | 39 | ||
38 | <a (click)="logout($event)" class="dropdown-item" href="#"> | 40 | <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account/settings" fragment="video-settings"> |
41 | <my-global-icon iconName="video-lang"></my-global-icon> | ||
42 | <ng-container i18n>Videos: {{ videoLanguages.join(', ') }}</ng-container> | ||
43 | <i class="ml-auto glyphicon glyphicon-menu-right"></i> | ||
44 | </a> | ||
45 | |||
46 | <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account/settings" fragment="video-settings"> | ||
47 | <my-global-icon class="hover-display-toggle" [ngClass]="{ 'not-displayed': user.nsfwPolicy === 'display' }" iconName="sensitive"></my-global-icon> | ||
48 | <my-global-icon class="hover-display-toggle" [ngClass]="{ 'not-displayed': user.nsfwPolicy !== 'display' }" iconName="unsensitive"></my-global-icon> | ||
49 | <ng-container i18n>Sensitive: {{ nsfwPolicy }}</ng-container> | ||
50 | <i class="ml-auto glyphicon glyphicon-menu-right"></i> | ||
51 | </a> | ||
52 | |||
53 | <a ngbDropdownItem class="dropdown-item" (click)="toggleUseP2P()"> | ||
54 | <my-global-icon iconName="p2p"></my-global-icon> | ||
55 | <ng-container i18n>Help share videos</ng-container> | ||
56 | <input type="checkbox" [checked]="user.webTorrentEnabled"/><label class="ml-auto" for="switch">Toggle p2p</label> | ||
57 | </a> | ||
58 | |||
59 | <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account"> | ||
60 | <my-global-icon iconName="more-horizontal"></my-global-icon> <ng-container i18n>More account settings</ng-container> | ||
61 | </a> | ||
62 | |||
63 | <div class="dropdown-divider"></div> | ||
64 | |||
65 | <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" (click)="openHotkeysCheatSheet()"> | ||
66 | <i class="icon icon-shortcuts"></i> <ng-container i18n>Keyboard shortcuts</ng-container> | ||
67 | </a> | ||
68 | |||
69 | <a ngbDropdownItem ngbDropdownToggle (click)="logout($event)" class="dropdown-item" href="#"> | ||
39 | <my-global-icon iconName="sign-out"></my-global-icon> <ng-container i18n>Log out</ng-container> | 70 | <my-global-icon iconName="sign-out"></my-global-icon> <ng-container i18n>Log out</ng-container> |
40 | </a> | 71 | </a> |
41 | </div> | 72 | </div> |
@@ -48,7 +79,7 @@ | |||
48 | </div> | 79 | </div> |
49 | 80 | ||
50 | <div *ngIf="isLoggedIn" class="panel-block"> | 81 | <div *ngIf="isLoggedIn" class="panel-block"> |
51 | <div i18n class="block-title">My library</div> | 82 | <div i18n class="block-title">MY LIBRARY</div> |
52 | 83 | ||
53 | <a routerLink="/my-account/videos" routerLinkActive="active"> | 84 | <a routerLink="/my-account/videos" routerLinkActive="active"> |
54 | <my-global-icon iconName="videos"></my-global-icon> | 85 | <my-global-icon iconName="videos"></my-global-icon> |
@@ -73,7 +104,7 @@ | |||
73 | </div> | 104 | </div> |
74 | 105 | ||
75 | <div class="panel-block"> | 106 | <div class="panel-block"> |
76 | <div i18n class="block-title">Videos</div> | 107 | <div i18n class="block-title">VIDEOS</div> |
77 | 108 | ||
78 | <a routerLink="/videos/overview" routerLinkActive="active"> | 109 | <a routerLink="/videos/overview" routerLinkActive="active"> |
79 | <my-global-icon iconName="globe"></my-global-icon> | 110 | <my-global-icon iconName="globe"></my-global-icon> |
@@ -100,32 +131,56 @@ | |||
100 | <ng-container i18n>Local</ng-container> | 131 | <ng-container i18n>Local</ng-container> |
101 | </a> | 132 | </a> |
102 | </div> | 133 | </div> |
134 | </div> | ||
103 | 135 | ||
136 | <div class="footer"> | ||
104 | <div class="panel-block"> | 137 | <div class="panel-block"> |
105 | <div class="block-title" i18n>More</div> | ||
106 | |||
107 | <a *ngIf="userHasAdminAccess" [routerLink]="getFirstAdminRouteAvailable()" routerLinkActive="active"> | 138 | <a *ngIf="userHasAdminAccess" [routerLink]="getFirstAdminRouteAvailable()" routerLinkActive="active"> |
108 | <my-global-icon iconName="administration"></my-global-icon> | 139 | <my-global-icon iconName="cog"></my-global-icon> |
109 | <ng-container i18n>Administration</ng-container> | 140 | <ng-container i18n>Administration</ng-container> |
110 | </a> | 141 | </a> |
111 | 142 | <a *ngIf="!isLoggedIn" (click)="openQuickSettings()"> | |
112 | <a routerLink="/about" routerLinkActive="active"> | 143 | <my-global-icon iconName="cog"></my-global-icon> |
113 | <my-global-icon iconName="about"></my-global-icon> | 144 | <ng-container i18n>Settings</ng-container> |
145 | </a> | ||
146 | <a routerLink="/about/instance"> | ||
147 | <my-global-icon iconName="help"></my-global-icon> | ||
114 | <ng-container i18n>About</ng-container> | 148 | <ng-container i18n>About</ng-container> |
115 | </a> | 149 | </a> |
116 | </div> | 150 | </div> |
117 | </div> | ||
118 | 151 | ||
119 | <div class="footer d-flex justify-content-between"> | 152 | <div class="bottom-links"> |
120 | <span class="language"> | ||
121 | <span tabindex="0" role="button" (keyup.enter)="openLanguageChooser()" (click)="openLanguageChooser()" i18n-title title="Change the language" class="icon icon-language"></span> | ||
122 | </span> | ||
123 | 153 | ||
124 | <span class="shortcuts"> | 154 | <div class="footer-links"> |
125 | <span tabindex="0" role="button" (keyup.enter)="openHotkeysCheatSheet()" (click)="openHotkeysCheatSheet()" i18n-title title="Show keyboard shortcuts" class="icon icon-shortcuts"></span> | 155 | <div *ngIf="isLoggedIn === false"> |
126 | </span> | 156 | <span role="button" (click)="openLanguageChooser()" class="c-hand" i18n>Interface: {{ language }}</span> |
157 | </div> | ||
158 | |||
159 | <div> | ||
160 | <a i18n routerLink="/about/instance">Contact</a> | ||
161 | <a i18n href="https://joinpeertube.org/help" i18n-title title="Get help using PeerTube" target="_blank" rel="noopener noreferrer">Help</a> | ||
162 | <a i18n href="https://joinpeertube.org/faq" i18n-title title="Frequently asked questions about PeerTube" target="_blank" rel="noopener noreferrer">FAQ</a> | ||
163 | <a i18n routerLink="/about/instance" fragment="statistics">Stats</a> | ||
164 | <a i18n href="https://docs.joinpeertube.org/api-rest-reference.html" i18n-title title="API documentation" target="_blank" rel="noopener noreferrer">API</a> | ||
165 | <a (click)="openHotkeysCheatSheet()" class="c-hand" i18n>Shortcuts</a> | ||
166 | </div> | ||
167 | </div> | ||
168 | |||
169 | <div class="footer-copyleft"> | ||
170 | <small class="d-inline" i18n-title title="powered by PeerTube - CopyLeft 2015-2020"> | ||
171 | <a href="https://joinpeertube.org" i18n-title title="PeerTube website" target="_blank" rel="noopener noreferrer" i18n> | ||
172 | powered by PeerTube | ||
173 | </a> | ||
174 | |||
175 | <a href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE" i18n-title title="PeerTube license" target="_blank" rel="noopener noreferrer"> | ||
176 | <span aria-label="copyleft" class="d-inline-block" style="transform: rotateY(180deg)">©</span> 2015-2020 | ||
177 | </a> | ||
178 | </small> | ||
179 | </div> | ||
180 | </div> | ||
127 | </div> | 181 | </div> |
128 | </menu> | 182 | </menu> |
129 | </div> | 183 | </div> |
130 | 184 | ||
131 | <my-language-chooser #languageChooserModal></my-language-chooser> | 185 | <my-language-chooser #languageChooserModal></my-language-chooser> |
186 | <my-quick-settings #quickSettingsModal></my-quick-settings> | ||
diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss index b05173751..5bff0c328 100644 --- a/client/src/app/menu/menu.component.scss +++ b/client/src/app/menu/menu.component.scss | |||
@@ -6,7 +6,8 @@ | |||
6 | height: calc(100vh - #{$header-height}); | 6 | height: calc(100vh - #{$header-height}); |
7 | padding: 0; | 7 | padding: 0; |
8 | width: $menu-width; | 8 | width: $menu-width; |
9 | z-index: 10000; | 9 | z-index: z(menu); |
10 | scrollbar-color: var(--actionButtonColor) var(--menuBackgroundColor); | ||
10 | } | 11 | } |
11 | 12 | ||
12 | menu { | 13 | menu { |
@@ -26,9 +27,13 @@ menu { | |||
26 | overflow-y: auto; | 27 | overflow-y: auto; |
27 | } | 28 | } |
28 | 29 | ||
30 | @media not all and (hover: hover) and (pointer: fine) { | ||
31 | overflow-y: auto; | ||
32 | } | ||
33 | |||
29 | &.logged-in { | 34 | &.logged-in { |
30 | .panel-block { | 35 | .panel-block { |
31 | margin-bottom: 25px; | 36 | margin-bottom: 20px; |
32 | } | 37 | } |
33 | 38 | ||
34 | .block-title { | 39 | .block-title { |
@@ -87,22 +92,6 @@ menu { | |||
87 | @include apply-svg-color(var(--menuForegroundColor)); | 92 | @include apply-svg-color(var(--menuForegroundColor)); |
88 | } | 93 | } |
89 | } | 94 | } |
90 | |||
91 | .dropdown-item { | ||
92 | @include dropdown-with-icon-item; | ||
93 | |||
94 | my-global-icon { | ||
95 | width: 22px; | ||
96 | height: 22px; | ||
97 | |||
98 | &[iconName="sign-out"] { | ||
99 | position: relative; | ||
100 | right: -1px; | ||
101 | height: 21px; | ||
102 | width: 21px; | ||
103 | } | ||
104 | } | ||
105 | } | ||
106 | } | 95 | } |
107 | } | 96 | } |
108 | 97 | ||
@@ -142,7 +131,7 @@ menu { | |||
142 | } | 131 | } |
143 | 132 | ||
144 | .panel-block { | 133 | .panel-block { |
145 | margin-bottom: 45px; | 134 | margin-bottom: 15px; |
146 | 135 | ||
147 | a { | 136 | a { |
148 | @include disable-default-a-behaviour; | 137 | @include disable-default-a-behaviour; |
@@ -197,58 +186,160 @@ menu { | |||
197 | } | 186 | } |
198 | 187 | ||
199 | .footer { | 188 | .footer { |
200 | padding-bottom: 15px; | ||
201 | padding-left: $menu-lateral-padding; | ||
202 | padding-right: $menu-lateral-padding; | ||
203 | width: $menu-width; | 189 | width: $menu-width; |
190 | padding-bottom: 15px; | ||
204 | 191 | ||
205 | .language, .shortcuts, .color-palette { | 192 | .bottom-links { |
206 | display: inline-block; | 193 | display: flex; |
207 | color: $menu-bottom-color; | 194 | flex-direction: column; |
208 | cursor: pointer; | 195 | padding: 0 $menu-lateral-padding; |
209 | font-size: 12px; | 196 | } |
210 | font-weight: $font-semibold; | ||
211 | 197 | ||
212 | .icon { | 198 | $footer-links-base-opacity: .8; |
213 | @include disable-outline; | ||
214 | @include icon(28px); | ||
215 | opacity: 0.9; | ||
216 | 199 | ||
217 | &.icon-language { | 200 | .footer-links { |
218 | position: relative; | 201 | &, > div { |
219 | top: -1px; | 202 | display: flex; |
220 | width: 28px; | 203 | flex-wrap: wrap; |
221 | height: 24px; | 204 | } |
222 | 205 | ||
223 | background-image: url('../../assets/images/menu/language.png'); | 206 | a, span[role=button] { |
207 | display: inline-block; | ||
208 | text-decoration: none; | ||
209 | color: var(--mainBackgroundColor); | ||
210 | opacity: $footer-links-base-opacity; | ||
211 | white-space: nowrap; | ||
212 | font-size: 90%; | ||
213 | font-weight: 500; | ||
214 | line-height: 1.4rem; | ||
215 | margin-right: 8px; | ||
216 | |||
217 | &.inline-global-icon { | ||
218 | display: inline-flex; | ||
219 | align-items: center; | ||
220 | white-space: nowrap; | ||
221 | height: 1.4rem; | ||
222 | |||
223 | my-global-icon { | ||
224 | @include apply-svg-color(var(--mainBackgroundColor)); | ||
225 | |||
226 | display: flex; | ||
227 | width: auto; | ||
228 | height: 90%; | ||
229 | margin-right: .2rem; | ||
230 | } | ||
224 | } | 231 | } |
232 | } | ||
233 | } | ||
225 | 234 | ||
226 | &.icon-shortcuts { | 235 | .footer-copyleft small a { |
227 | position: relative; | 236 | @include disable-default-a-behaviour; |
228 | top: -1px; | ||
229 | width: 24px; | ||
230 | height: 24px; | ||
231 | 237 | ||
232 | background-image: url('../../assets/images/menu/keyboard.png'); | 238 | color: var(--mainBackgroundColor); |
233 | filter: invert(100%); | 239 | opacity: $footer-links-base-opacity - .2; |
234 | } | 240 | } |
241 | } | ||
242 | } | ||
235 | 243 | ||
236 | &.icon-moonsun { | 244 | .dropdown-menu { |
237 | margin-left: 10px; | 245 | width: calc(100% + 40px); |
238 | position: relative; | 246 | } |
239 | top: -1px; | ||
240 | width: 24px; | ||
241 | height: 24px; | ||
242 | 247 | ||
243 | background-image: url('../../assets/images/menu/moonsun.svg'); | 248 | .dropdown-item { |
244 | } | 249 | @include dropdown-with-icon-item; |
245 | 250 | ||
246 | &:hover { | 251 | cursor: pointer; |
247 | opacity: 1; | 252 | display: flex; |
248 | } | 253 | align-items: center; |
249 | } | 254 | |
255 | i.glyphicon-menu-right { | ||
256 | opacity: .4; | ||
257 | } | ||
258 | |||
259 | my-global-icon { | ||
260 | &[iconName="cog"], | ||
261 | &[iconName="sign-out"] { | ||
262 | position: relative; | ||
263 | right: -2px; | ||
264 | height: 20px; | ||
265 | width: 20px; | ||
250 | } | 266 | } |
251 | } | 267 | } |
268 | |||
269 | my-global-icon.not-displayed { | ||
270 | display: none; | ||
271 | } | ||
272 | |||
273 | &:hover { | ||
274 | my-global-icon.hover-display-toggle.not-displayed { | ||
275 | display: inherit; | ||
276 | } | ||
277 | my-global-icon.hover-display-toggle { | ||
278 | display: none; | ||
279 | } | ||
280 | } | ||
281 | } | ||
282 | |||
283 | .more-settings { | ||
284 | text-transform: uppercase; | ||
285 | font-size: 80%; | ||
286 | color: #6c757d; | ||
287 | } | ||
288 | |||
289 | .icon { | ||
290 | @include disable-outline; | ||
291 | @include icon(22px); | ||
292 | opacity: 0.8; | ||
293 | |||
294 | &.icon-shortcuts { | ||
295 | position: relative; | ||
296 | top: -1px; | ||
297 | margin-right: 10px; | ||
298 | |||
299 | background-image: url('../../assets/images/menu/keyboard.png'); | ||
300 | } | ||
301 | } | ||
302 | |||
303 | input[type=checkbox]{ | ||
304 | position: absolute; | ||
305 | visibility: hidden; | ||
306 | } | ||
307 | |||
308 | label { | ||
309 | cursor: pointer; | ||
310 | text-indent: -9999px; | ||
311 | width: 35px; | ||
312 | height: 20px; | ||
313 | background: #cccccc; | ||
314 | display: block; | ||
315 | border-radius: 100px; | ||
316 | position: relative; | ||
317 | margin: 0; | ||
318 | |||
319 | &:after { | ||
320 | content: ''; | ||
321 | position: absolute; | ||
322 | top: 3px; | ||
323 | left: 3px; | ||
324 | width: 14px; | ||
325 | height: 14px; | ||
326 | background: var(--mainBackgroundColor); | ||
327 | border-radius: 50%; | ||
328 | transition: 0.3s ease-out; | ||
329 | } | ||
330 | |||
331 | &:active:after { | ||
332 | width: 40px; | ||
333 | } | ||
334 | } | ||
335 | |||
336 | input:checked + label { | ||
337 | background: var(--mainColor); | ||
338 | |||
339 | &:after { | ||
340 | left: calc(100% - 3px); | ||
341 | transform: translateX(-100%); | ||
342 | } | ||
252 | } | 343 | } |
253 | 344 | ||
254 | @media screen and (max-width: $mobile-view) { | 345 | @media screen and (max-width: $mobile-view) { |
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts index 1d7651e78..015c14bce 100644 --- a/client/src/app/menu/menu.component.ts +++ b/client/src/app/menu/menu.component.ts | |||
@@ -1,10 +1,14 @@ | |||
1 | import { Component, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, OnInit, ViewChild } from '@angular/core' |
2 | import { UserRight } from '../../../../shared/models/users/user-right.enum' | 2 | import { UserRight } from '../../../../shared/models/users/user-right.enum' |
3 | import { AuthService, AuthStatus, RedirectService, ServerService, ThemeService } from '../core' | 3 | import { AuthService, AuthStatus, RedirectService, ServerService } from '../core' |
4 | import { User } from '../shared/users/user.model' | 4 | import { User } from '@app/shared/users/user.model' |
5 | import { UserService } from '@app/shared/users/user.service' | ||
5 | import { LanguageChooserComponent } from '@app/menu/language-chooser.component' | 6 | import { LanguageChooserComponent } from '@app/menu/language-chooser.component' |
6 | import { HotkeysService } from 'angular2-hotkeys' | 7 | import { HotkeysService } from 'angular2-hotkeys' |
7 | import { ServerConfig } from '@shared/models' | 8 | import { ServerConfig, VideoConstant } from '@shared/models' |
9 | import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component' | ||
10 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
11 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
8 | 12 | ||
9 | @Component({ | 13 | @Component({ |
10 | selector: 'my-menu', | 14 | selector: 'my-menu', |
@@ -13,12 +17,17 @@ import { ServerConfig } from '@shared/models' | |||
13 | }) | 17 | }) |
14 | export class MenuComponent implements OnInit { | 18 | export class MenuComponent implements OnInit { |
15 | @ViewChild('languageChooserModal', { static: true }) languageChooserModal: LanguageChooserComponent | 19 | @ViewChild('languageChooserModal', { static: true }) languageChooserModal: LanguageChooserComponent |
20 | @ViewChild('quickSettingsModal', { static: true }) quickSettingsModal: QuickSettingsModalComponent | ||
16 | 21 | ||
17 | user: User | 22 | user: User |
18 | isLoggedIn: boolean | 23 | isLoggedIn: boolean |
24 | |||
19 | userHasAdminAccess = false | 25 | userHasAdminAccess = false |
20 | helpVisible = false | 26 | helpVisible = false |
21 | 27 | ||
28 | videoLanguages: string[] = [] | ||
29 | |||
30 | private languages: VideoConstant<string>[] = [] | ||
22 | private serverConfig: ServerConfig | 31 | private serverConfig: ServerConfig |
23 | private routesPerRight: { [ role in UserRight ]?: string } = { | 32 | private routesPerRight: { [ role in UserRight ]?: string } = { |
24 | [UserRight.MANAGE_USERS]: '/admin/users', | 33 | [UserRight.MANAGE_USERS]: '/admin/users', |
@@ -31,10 +40,25 @@ export class MenuComponent implements OnInit { | |||
31 | 40 | ||
32 | constructor ( | 41 | constructor ( |
33 | private authService: AuthService, | 42 | private authService: AuthService, |
43 | private userService: UserService, | ||
34 | private serverService: ServerService, | 44 | private serverService: ServerService, |
35 | private redirectService: RedirectService, | 45 | private redirectService: RedirectService, |
36 | private hotkeysService: HotkeysService | 46 | private hotkeysService: HotkeysService, |
37 | ) {} | 47 | private screenService: ScreenService, |
48 | private i18n: I18n | ||
49 | ) { } | ||
50 | |||
51 | get isInMobileView () { | ||
52 | return this.screenService.isInMobileView() | ||
53 | } | ||
54 | |||
55 | get placement () { | ||
56 | if (this.isInMobileView) { | ||
57 | return 'left-top auto' | ||
58 | } else { | ||
59 | return 'right-top auto' | ||
60 | } | ||
61 | } | ||
38 | 62 | ||
39 | ngOnInit () { | 63 | ngOnInit () { |
40 | this.serverConfig = this.serverService.getTmpConfig() | 64 | this.serverConfig = this.serverService.getTmpConfig() |
@@ -63,9 +87,35 @@ export class MenuComponent implements OnInit { | |||
63 | } | 87 | } |
64 | ) | 88 | ) |
65 | 89 | ||
66 | this.hotkeysService.cheatSheetToggle.subscribe(isOpen => { | 90 | this.hotkeysService.cheatSheetToggle |
67 | this.helpVisible = isOpen | 91 | .subscribe(isOpen => this.helpVisible = isOpen) |
68 | }) | 92 | |
93 | this.serverService.getVideoLanguages() | ||
94 | .subscribe(languages => { | ||
95 | this.languages = languages | ||
96 | |||
97 | this.authService.userInformationLoaded | ||
98 | .subscribe(() => this.buildUserLanguages()) | ||
99 | }) | ||
100 | } | ||
101 | |||
102 | get language () { | ||
103 | return this.languageChooserModal.getCurrentLanguage() | ||
104 | } | ||
105 | |||
106 | get nsfwPolicy () { | ||
107 | if (!this.user) return | ||
108 | |||
109 | switch (this.user.nsfwPolicy) { | ||
110 | case 'do_not_list': | ||
111 | return this.i18n('hide') | ||
112 | |||
113 | case 'blur': | ||
114 | return this.i18n('blur') | ||
115 | |||
116 | case 'display': | ||
117 | return this.i18n('display') | ||
118 | } | ||
69 | } | 119 | } |
70 | 120 | ||
71 | isRegistrationAllowed () { | 121 | isRegistrationAllowed () { |
@@ -117,6 +167,40 @@ export class MenuComponent implements OnInit { | |||
117 | this.hotkeysService.cheatSheetToggle.next(!this.helpVisible) | 167 | this.hotkeysService.cheatSheetToggle.next(!this.helpVisible) |
118 | } | 168 | } |
119 | 169 | ||
170 | openQuickSettings () { | ||
171 | this.quickSettingsModal.show() | ||
172 | } | ||
173 | |||
174 | toggleUseP2P () { | ||
175 | if (!this.user) return | ||
176 | this.user.webTorrentEnabled = !this.user.webTorrentEnabled | ||
177 | |||
178 | this.userService.updateMyProfile({ webTorrentEnabled: this.user.webTorrentEnabled }) | ||
179 | .subscribe(() => this.authService.refreshUserInformation()) | ||
180 | } | ||
181 | |||
182 | langForLocale (localeId: string) { | ||
183 | if (localeId === '_unknown') return this.i18n('Unknown') | ||
184 | |||
185 | return this.languages.find(lang => lang.id === localeId).label | ||
186 | } | ||
187 | |||
188 | private buildUserLanguages () { | ||
189 | if (!this.user) { | ||
190 | this.videoLanguages = [] | ||
191 | return | ||
192 | } | ||
193 | |||
194 | if (!this.user.videoLanguages) { | ||
195 | this.videoLanguages = [ this.i18n('any language') ] | ||
196 | return | ||
197 | } | ||
198 | |||
199 | this.videoLanguages = this.user.videoLanguages | ||
200 | .map(locale => this.langForLocale(locale)) | ||
201 | .map(value => value === undefined ? '?' : value) | ||
202 | } | ||
203 | |||
120 | private computeIsUserHasAdminAccess () { | 204 | private computeIsUserHasAdminAccess () { |
121 | const right = this.getFirstAdminRightAvailable() | 205 | const right = this.getFirstAdminRightAvailable() |
122 | 206 | ||
diff --git a/client/src/app/modal/custom-modal.component.html b/client/src/app/modal/custom-modal.component.html new file mode 100644 index 000000000..06ecc2743 --- /dev/null +++ b/client/src/app/modal/custom-modal.component.html | |||
@@ -0,0 +1,20 @@ | |||
1 | <ng-template #modal let-hide="close"> | ||
2 | <div class="modal-header"> | ||
3 | <h4 class="modal-title">{{title}}</h4> | ||
4 | <my-global-icon *ngIf="close" iconName="cross" aria-label="Close" role="button" (click)="onCloseClick()"></my-global-icon> | ||
5 | </div> | ||
6 | |||
7 | <div class="modal-body" [innerHTML]="content"></div> | ||
8 | |||
9 | <div *ngIf="hasCancel() || hasConfirm()" class="modal-footer inputs"> | ||
10 | <input | ||
11 | *ngIf="hasCancel()" type="button" role="button" value="{{cancel.value}}" class="action-button action-button-cancel" | ||
12 | (click)="onCancelClick()" (key.enter)="onCancelClick()" | ||
13 | > | ||
14 | |||
15 | <input | ||
16 | *ngIf="hasConfirm()" type="button" role="button" value="{{confirm.value}}" class="action-button action-button-confirm" | ||
17 | (click)="onConfirmClick()" (key.enter)="onConfirmClick()" | ||
18 | > | ||
19 | </div> | ||
20 | </ng-template> | ||
diff --git a/client/src/app/modal/custom-modal.component.scss b/client/src/app/modal/custom-modal.component.scss new file mode 100644 index 000000000..a7fa30cf5 --- /dev/null +++ b/client/src/app/modal/custom-modal.component.scss | |||
@@ -0,0 +1,20 @@ | |||
1 | @import '_mixins'; | ||
2 | @import '_variables'; | ||
3 | |||
4 | .modal-body { | ||
5 | font-size: 15px; | ||
6 | } | ||
7 | |||
8 | li { | ||
9 | margin-bottom: 10px; | ||
10 | } | ||
11 | |||
12 | .action-button-cancel { | ||
13 | @include peertube-button; | ||
14 | @include grey-button; | ||
15 | } | ||
16 | |||
17 | .action-button-confirm { | ||
18 | @include peertube-button; | ||
19 | @include orange-button; | ||
20 | } | ||
diff --git a/client/src/app/modal/custom-modal.component.ts b/client/src/app/modal/custom-modal.component.ts new file mode 100644 index 000000000..a98579085 --- /dev/null +++ b/client/src/app/modal/custom-modal.component.ts | |||
@@ -0,0 +1,93 @@ | |||
1 | import { Component, ElementRef, ViewChild, Input } from '@angular/core' | ||
2 | import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-custom-modal', | ||
6 | templateUrl: './custom-modal.component.html', | ||
7 | styleUrls: [ './custom-modal.component.scss' ] | ||
8 | }) | ||
9 | export class CustomModalComponent { | ||
10 | @ViewChild('modal', { static: true }) modal: ElementRef | ||
11 | |||
12 | @Input() title: string | ||
13 | @Input() content: string | ||
14 | @Input() close?: boolean | ||
15 | @Input() cancel?: { value: string, action?: () => void } | ||
16 | @Input() confirm?: { value: string, action?: () => void } | ||
17 | |||
18 | private modalRef: NgbModalRef | ||
19 | |||
20 | constructor ( | ||
21 | private modalService: NgbModal | ||
22 | ) { } | ||
23 | |||
24 | show (input: { | ||
25 | title: string, | ||
26 | content: string, | ||
27 | close?: boolean, | ||
28 | cancel?: { value: string, action?: () => void }, | ||
29 | confirm?: { value: string, action?: () => void } | ||
30 | }) { | ||
31 | if (this.modalRef instanceof NgbModalRef && this.modalService.hasOpenModals()) { | ||
32 | console.error('Cannot open another custom modal, one is already opened.') | ||
33 | return | ||
34 | } | ||
35 | |||
36 | const { title, content, close, cancel, confirm } = input | ||
37 | |||
38 | this.title = title | ||
39 | this.content = content | ||
40 | this.close = close | ||
41 | this.cancel = cancel | ||
42 | this.confirm = confirm | ||
43 | |||
44 | this.modalRef = this.modalService.open(this.modal, { | ||
45 | centered: true, | ||
46 | backdrop: 'static', | ||
47 | keyboard: false, | ||
48 | size: 'lg' | ||
49 | }) | ||
50 | } | ||
51 | |||
52 | onCancelClick () { | ||
53 | this.modalRef.close() | ||
54 | |||
55 | if (typeof this.cancel.action === 'function') { | ||
56 | this.cancel.action() | ||
57 | } | ||
58 | |||
59 | this.destroy() | ||
60 | } | ||
61 | |||
62 | onCloseClick () { | ||
63 | this.modalRef.close() | ||
64 | this.destroy() | ||
65 | } | ||
66 | |||
67 | onConfirmClick () { | ||
68 | this.modalRef.close() | ||
69 | |||
70 | if (typeof this.confirm.action === 'function') { | ||
71 | this.confirm.action() | ||
72 | } | ||
73 | |||
74 | this.destroy() | ||
75 | } | ||
76 | |||
77 | hasCancel () { | ||
78 | return typeof this.cancel !== 'undefined' | ||
79 | } | ||
80 | |||
81 | hasConfirm () { | ||
82 | return typeof this.confirm !== 'undefined' | ||
83 | } | ||
84 | |||
85 | private destroy () { | ||
86 | delete this.modalRef | ||
87 | delete this.title | ||
88 | delete this.content | ||
89 | delete this.close | ||
90 | delete this.cancel | ||
91 | delete this.confirm | ||
92 | } | ||
93 | } | ||
diff --git a/client/src/app/modal/instance-config-warning-modal.component.html b/client/src/app/modal/instance-config-warning-modal.component.html index 93391c0a8..44c994bc8 100644 --- a/client/src/app/modal/instance-config-warning-modal.component.html +++ b/client/src/app/modal/instance-config-warning-modal.component.html | |||
@@ -21,7 +21,7 @@ | |||
21 | <li i18n *ngIf="!about.instance.terms">Instance terms</li> | 21 | <li i18n *ngIf="!about.instance.terms">Instance terms</li> |
22 | </ul> | 22 | </ul> |
23 | 23 | ||
24 | <p> | 24 | <p i18n> |
25 | Please consider to configure these fields to help people to choose <strong>the appropriate instance</strong>. | 25 | Please consider to configure these fields to help people to choose <strong>the appropriate instance</strong>. |
26 | Without them, your instance may not be referenced on <a target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">JoinPeerTube website</a>. | 26 | Without them, your instance may not be referenced on <a target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">JoinPeerTube website</a>. |
27 | </p> | 27 | </p> |
@@ -40,7 +40,10 @@ | |||
40 | 40 | ||
41 | </my-peertube-checkbox> | 41 | </my-peertube-checkbox> |
42 | 42 | ||
43 | <span i18n class="action-button action-button-cancel" (click)="hide()">Close</span> | 43 | <input |
44 | type="button" role="button" i18n-value value="Close" class="action-button action-button-cancel" | ||
45 | (click)="hide()" (key.enter)="hide()" | ||
46 | > | ||
44 | </div> | 47 | </div> |
45 | 48 | ||
46 | </ng-template> | 49 | </ng-template> |
diff --git a/client/src/app/modal/instance-config-warning-modal.component.ts b/client/src/app/modal/instance-config-warning-modal.component.ts index 742a7dd41..5e1433548 100644 --- a/client/src/app/modal/instance-config-warning-modal.component.ts +++ b/client/src/app/modal/instance-config-warning-modal.component.ts | |||
@@ -24,7 +24,7 @@ export class InstanceConfigWarningModalComponent { | |||
24 | show (about: About) { | 24 | show (about: About) { |
25 | this.about = about | 25 | this.about = about |
26 | 26 | ||
27 | const ref = this.modalService.open(this.modal) | 27 | const ref = this.modalService.open(this.modal, { centered: true }) |
28 | 28 | ||
29 | ref.result.finally(() => { | 29 | ref.result.finally(() => { |
30 | if (this.stopDisplayModal === true) this.doNotOpenAgain() | 30 | if (this.stopDisplayModal === true) this.doNotOpenAgain() |
diff --git a/client/src/app/modal/quick-settings-modal.component.html b/client/src/app/modal/quick-settings-modal.component.html new file mode 100644 index 000000000..e2ea51b92 --- /dev/null +++ b/client/src/app/modal/quick-settings-modal.component.html | |||
@@ -0,0 +1,20 @@ | |||
1 | <ng-template #modal let-hide="close"> | ||
2 | <div class="modal-header"> | ||
3 | <h4 i18n class="modal-title">Settings</h4> | ||
4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> | ||
5 | </div> | ||
6 | |||
7 | <div class="modal-body"> | ||
8 | <div i18n class="mb-4 quick-settings-title">Display settings</div> | ||
9 | |||
10 | <my-account-video-settings *ngIf="!isUserLoggedIn()" [user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true"> | ||
11 | <ng-container ngProjectAs="inner-title"> | ||
12 | <div i18n class="mb-4 mt-4 quick-settings-title">Video settings</div> | ||
13 | </ng-container> | ||
14 | </my-account-video-settings> | ||
15 | |||
16 | <div i18n class="mb-4 mt-4 quick-settings-title">Interface settings</div> | ||
17 | |||
18 | <my-account-interface-settings *ngIf="!isUserLoggedIn()" [user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true"></my-account-interface-settings> | ||
19 | </div> | ||
20 | </ng-template> | ||
diff --git a/client/src/app/modal/quick-settings-modal.component.scss b/client/src/app/modal/quick-settings-modal.component.scss new file mode 100644 index 000000000..ef21542f3 --- /dev/null +++ b/client/src/app/modal/quick-settings-modal.component.scss | |||
@@ -0,0 +1,39 @@ | |||
1 | @import '_mixins'; | ||
2 | |||
3 | .modal-button { | ||
4 | @include disable-default-a-behaviour; | ||
5 | transform: translateY(2px); | ||
6 | |||
7 | button { | ||
8 | @include peertube-button; | ||
9 | @include grey-button; | ||
10 | @include button-with-icon(18px, 4px, -1px); | ||
11 | |||
12 | my-global-icon { | ||
13 | @include apply-svg-color(#585858); | ||
14 | } | ||
15 | } | ||
16 | |||
17 | & + .modal-button { | ||
18 | margin-left: 1rem; | ||
19 | } | ||
20 | } | ||
21 | |||
22 | .icon { | ||
23 | @include disable-outline; | ||
24 | @include icon(22px); | ||
25 | opacity: 0.6; | ||
26 | margin-left: -1px; | ||
27 | |||
28 | &.icon-shortcuts { | ||
29 | position: relative; | ||
30 | top: -1px; | ||
31 | margin-right: 4px; | ||
32 | |||
33 | background-image: url('../../assets/images/menu/keyboard.png'); | ||
34 | } | ||
35 | } | ||
36 | |||
37 | .quick-settings-title { | ||
38 | @include in-content-small-title; | ||
39 | } \ No newline at end of file | ||
diff --git a/client/src/app/modal/quick-settings-modal.component.ts b/client/src/app/modal/quick-settings-modal.component.ts new file mode 100644 index 000000000..41d6c9f47 --- /dev/null +++ b/client/src/app/modal/quick-settings-modal.component.ts | |||
@@ -0,0 +1,62 @@ | |||
1 | import { Component, ViewChild, OnInit } from '@angular/core' | ||
2 | import { AuthService, AuthStatus } from '@app/core' | ||
3 | import { FormReactive, FormValidatorService, UserService, User } from '@app/shared' | ||
4 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
5 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | ||
6 | import { ReplaySubject } from 'rxjs' | ||
7 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
8 | import { filter } from 'rxjs/operators' | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-quick-settings', | ||
12 | templateUrl: './quick-settings-modal.component.html', | ||
13 | styleUrls: [ './quick-settings-modal.component.scss' ] | ||
14 | }) | ||
15 | export class QuickSettingsModalComponent extends FormReactive implements OnInit { | ||
16 | @ViewChild('modal', { static: true }) modal: NgbModal | ||
17 | |||
18 | user: User | ||
19 | userInformationLoaded = new ReplaySubject<boolean>(1) | ||
20 | |||
21 | private openedModal: NgbModalRef | ||
22 | |||
23 | constructor ( | ||
24 | protected formValidatorService: FormValidatorService, | ||
25 | private modalService: NgbModal, | ||
26 | private userService: UserService, | ||
27 | private authService: AuthService, | ||
28 | private localStorageService: LocalStorageService | ||
29 | ) { | ||
30 | super() | ||
31 | } | ||
32 | |||
33 | ngOnInit () { | ||
34 | this.user = this.userService.getAnonymousUser() | ||
35 | this.localStorageService.watch().subscribe( | ||
36 | () => this.user = this.userService.getAnonymousUser() | ||
37 | ) | ||
38 | this.userInformationLoaded.next(true) | ||
39 | |||
40 | this.authService.loginChangedSource | ||
41 | .pipe(filter(status => status !== AuthStatus.LoggedIn)) | ||
42 | .subscribe( | ||
43 | () => { | ||
44 | this.user = this.userService.getAnonymousUser() | ||
45 | this.userInformationLoaded.next(true) | ||
46 | } | ||
47 | ) | ||
48 | } | ||
49 | |||
50 | isUserLoggedIn () { | ||
51 | return this.authService.isLoggedIn() | ||
52 | } | ||
53 | |||
54 | show () { | ||
55 | this.openedModal = this.modalService.open(this.modal, { centered: true }) | ||
56 | } | ||
57 | |||
58 | hide () { | ||
59 | this.openedModal.close() | ||
60 | this.form.reset() | ||
61 | } | ||
62 | } | ||
diff --git a/client/src/app/modal/welcome-modal.component.html b/client/src/app/modal/welcome-modal.component.html index 9b210eb4d..8bfcc4bf6 100644 --- a/client/src/app/modal/welcome-modal.component.html +++ b/client/src/app/modal/welcome-modal.component.html | |||
@@ -76,10 +76,14 @@ | |||
76 | </div> | 76 | </div> |
77 | 77 | ||
78 | <div class="modal-footer inputs"> | 78 | <div class="modal-footer inputs"> |
79 | <span i18n class="action-button action-button-understood" (click)="hide()">Remind me later</span> | 79 | <input |
80 | 80 | type="button" role="button" i18n-value value="Remind me later" class="action-button action-button-understood" | |
81 | <a i18n (click)="doNotOpenAgain(); hide()" class="configure-instance-button" href="/admin/config/edit-custom" target="_blank" | 81 | (click)="hide()" (key.enter)="hide()" |
82 | rel="noopener noreferrer"> | 82 | > |
83 | |||
84 | <a i18n (click)="doNotOpenAgain(); hide()" (key.enter)="doNotOpenAgain(); hide()" | ||
85 | class="configure-instance-button" href="/admin/config/edit-custom" target="_blank" | ||
86 | rel="noopener noreferrer" ngbAutofocus> | ||
83 | Configure my instance | 87 | Configure my instance |
84 | </a> | 88 | </a> |
85 | </div> | 89 | </div> |
diff --git a/client/src/app/modal/welcome-modal.component.ts b/client/src/app/modal/welcome-modal.component.ts index 19a147b85..e022776e3 100644 --- a/client/src/app/modal/welcome-modal.component.ts +++ b/client/src/app/modal/welcome-modal.component.ts | |||
@@ -18,7 +18,8 @@ export class WelcomeModalComponent { | |||
18 | ) { } | 18 | ) { } |
19 | 19 | ||
20 | show () { | 20 | show () { |
21 | this.modalService.open(this.modal,{ | 21 | this.modalService.open(this.modal, { |
22 | centered: true, | ||
22 | backdrop: 'static', | 23 | backdrop: 'static', |
23 | keyboard: false, | 24 | keyboard: false, |
24 | size: 'lg' | 25 | size: 'lg' |
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html index 07fb2c048..54fc7338f 100644 --- a/client/src/app/search/search-filters.component.html +++ b/client/src/app/search/search-filters.component.html | |||
@@ -46,6 +46,7 @@ | |||
46 | type="text" id="original-publication-after" name="original-publication-after" | 46 | type="text" id="original-publication-after" name="original-publication-after" |
47 | i18n-placeholder placeholder="After..." | 47 | i18n-placeholder placeholder="After..." |
48 | [(ngModel)]="originallyPublishedStartYear" | 48 | [(ngModel)]="originallyPublishedStartYear" |
49 | class="form-control" | ||
49 | > | 50 | > |
50 | </div> | 51 | </div> |
51 | <div class="col-sm-6"> | 52 | <div class="col-sm-6"> |
@@ -55,6 +56,7 @@ | |||
55 | type="text" id="original-publication-before" name="original-publication-before" | 56 | type="text" id="original-publication-before" name="original-publication-before" |
56 | i18n-placeholder placeholder="Before..." | 57 | i18n-placeholder placeholder="Before..." |
57 | [(ngModel)]="originallyPublishedEndYear" | 58 | [(ngModel)]="originallyPublishedEndYear" |
59 | class="form-control" | ||
58 | > | 60 | > |
59 | </div> | 61 | </div> |
60 | </div> | 62 | </div> |
@@ -102,8 +104,8 @@ | |||
102 | Reset | 104 | Reset |
103 | </button> | 105 | </button> |
104 | <div class="peertube-select-container"> | 106 | <div class="peertube-select-container"> |
105 | <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf"> | 107 | <select id="category" name="category" [(ngModel)]="advancedSearch.categoryOneOf" class="form-control"> |
106 | <option [value]="undefined" i18n>Any or no category set</option> | 108 | <option [value]="undefined" i18n>Display all categories</option> |
107 | <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option> | 109 | <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option> |
108 | </select> | 110 | </select> |
109 | </div> | 111 | </div> |
@@ -115,8 +117,8 @@ | |||
115 | Reset | 117 | Reset |
116 | </button> | 118 | </button> |
117 | <div class="peertube-select-container"> | 119 | <div class="peertube-select-container"> |
118 | <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf"> | 120 | <select id="licence" name="licence" [(ngModel)]="advancedSearch.licenceOneOf" class="form-control"> |
119 | <option [value]="undefined" i18n>Any or no license set</option> | 121 | <option [value]="undefined" i18n>Display all licenses</option> |
120 | <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option> | 122 | <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option> |
121 | </select> | 123 | </select> |
122 | </div> | 124 | </div> |
@@ -128,8 +130,8 @@ | |||
128 | Reset | 130 | Reset |
129 | </button> | 131 | </button> |
130 | <div class="peertube-select-container"> | 132 | <div class="peertube-select-container"> |
131 | <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf"> | 133 | <select id="language" name="language" [(ngModel)]="advancedSearch.languageOneOf" class="form-control"> |
132 | <option [value]="undefined" i18n>Any or no language set</option> | 134 | <option [value]="undefined" i18n>Display all languages</option> |
133 | <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option> | 135 | <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option> |
134 | </select> | 136 | </select> |
135 | </div> | 137 | </div> |
@@ -146,7 +148,7 @@ | |||
146 | [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf" | 148 | [(ngModel)]="advancedSearch.tagsAllOf" name="tagsAllOf" id="tagsAllOf" |
147 | [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" | 149 | [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" |
148 | i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag" | 150 | i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag" |
149 | maxItems="5" modelAsStrings="true" | 151 | [maxItems]="5" [modelAsStrings]="true" |
150 | ></tag-input> | 152 | ></tag-input> |
151 | </div> | 153 | </div> |
152 | 154 | ||
@@ -159,7 +161,7 @@ | |||
159 | [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf" | 161 | [(ngModel)]="advancedSearch.tagsOneOf" name="tagsOneOf" id="tagsOneOf" |
160 | [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" | 162 | [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" |
161 | i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag" | 163 | i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a tag" |
162 | maxItems="5" modelAsStrings="true" | 164 | [maxItems]="5" [modelAsStrings]="true" |
163 | ></tag-input> | 165 | ></tag-input> |
164 | </div> | 166 | </div> |
165 | </div> | 167 | </div> |
diff --git a/client/src/app/search/search-filters.component.scss b/client/src/app/search/search-filters.component.scss index 99af2e4c5..a88a1c0b0 100644 --- a/client/src/app/search/search-filters.component.scss +++ b/client/src/app/search/search-filters.component.scss | |||
@@ -66,65 +66,4 @@ input[type=submit] { | |||
66 | white-space: nowrap; | 66 | white-space: nowrap; |
67 | } | 67 | } |
68 | 68 | ||
69 | ::ng-deep { | 69 | @include ng2-tags; |
70 | .ng2-tag-input { | ||
71 | border: none !important; | ||
72 | } | ||
73 | |||
74 | .ng2-tags-container { | ||
75 | display: flex; | ||
76 | align-items: center; | ||
77 | border: 1px solid #C6C6C6; | ||
78 | border-radius: 3px; | ||
79 | padding: 5px !important; | ||
80 | height: max-content; | ||
81 | } | ||
82 | |||
83 | tag-input-form { | ||
84 | input { | ||
85 | height: 30px !important; | ||
86 | font-size: 12px !important; | ||
87 | |||
88 | background-color: var(--mainBackgroundColor) !important; | ||
89 | color: var(--mainForegroundColor) !important; | ||
90 | } | ||
91 | } | ||
92 | |||
93 | tag { | ||
94 | background-color: $grey-background-color !important; | ||
95 | color: #000 !important; | ||
96 | border-radius: 3px !important; | ||
97 | font-size: 12px !important; | ||
98 | height: 30px !important; | ||
99 | line-height: 30px !important; | ||
100 | margin: 0 5px 0 0 !important; | ||
101 | cursor: default !important; | ||
102 | padding: 0 8px 0 10px !important; | ||
103 | |||
104 | div { | ||
105 | height: 100% !important; | ||
106 | } | ||
107 | } | ||
108 | |||
109 | delete-icon { | ||
110 | cursor: pointer !important; | ||
111 | height: auto !important; | ||
112 | vertical-align: middle !important; | ||
113 | padding-left: 6px !important; | ||
114 | |||
115 | svg { | ||
116 | position: relative; | ||
117 | top: -1px; | ||
118 | height: auto !important; | ||
119 | vertical-align: middle !important; | ||
120 | |||
121 | path { | ||
122 | fill: $grey-foreground-color !important; | ||
123 | } | ||
124 | } | ||
125 | |||
126 | &:hover { | ||
127 | transform: none !important; | ||
128 | } | ||
129 | } | ||
130 | } | ||
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss index d4d8bbcf7..641647e2e 100644 --- a/client/src/app/search/search.component.scss +++ b/client/src/app/search/search.component.scss | |||
@@ -82,11 +82,35 @@ | |||
82 | } | 82 | } |
83 | 83 | ||
84 | @media screen and (max-width: $small-view) { | 84 | @media screen and (max-width: $small-view) { |
85 | .video-channel-names { | 85 | .search-result { |
86 | flex-direction: column !important; | 86 | .entry.video-channel, |
87 | .entry.video { | ||
88 | flex-direction: column; | ||
89 | height: auto; | ||
90 | justify-content: center; | ||
91 | align-items: center; | ||
92 | text-align: center; | ||
93 | |||
94 | img { | ||
95 | margin: 0; | ||
96 | } | ||
97 | |||
98 | img { | ||
99 | margin: 0; | ||
100 | } | ||
87 | 101 | ||
88 | .video-channel-name { | 102 | .video-channel-info .video-channel-names { |
89 | margin-left: 0 !important; | 103 | align-items: center; |
104 | flex-direction: column !important; | ||
105 | |||
106 | .video-channel-name { | ||
107 | margin-left: 0 !important; | ||
108 | } | ||
109 | } | ||
110 | |||
111 | my-subscribe-button { | ||
112 | margin-top: 5px; | ||
113 | } | ||
90 | } | 114 | } |
91 | } | 115 | } |
92 | } | 116 | } |
@@ -100,12 +124,6 @@ | |||
100 | } | 124 | } |
101 | 125 | ||
102 | .entry { | 126 | .entry { |
103 | flex-direction: column; | ||
104 | height: auto; | ||
105 | justify-content: center; | ||
106 | align-items: center; | ||
107 | text-align: center; | ||
108 | |||
109 | &.video { | 127 | &.video { |
110 | .video-info-name, | 128 | .video-info-name, |
111 | .video-info-account { | 129 | .video-info-account { |
@@ -126,16 +144,6 @@ | |||
126 | } | 144 | } |
127 | } | 145 | } |
128 | } | 146 | } |
129 | |||
130 | &.video-channel { | ||
131 | .video-channel-info .video-channel-names { | ||
132 | align-items: center; | ||
133 | } | ||
134 | |||
135 | my-subscribe-button { | ||
136 | margin-top: 5px; | ||
137 | } | ||
138 | } | ||
139 | } | 147 | } |
140 | } | 148 | } |
141 | } | 149 | } |
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts index dfd8d8823..075994dd3 100644 --- a/client/src/app/search/search.component.ts +++ b/client/src/app/search/search.component.ts | |||
@@ -141,7 +141,8 @@ export class SearchComponent implements OnInit, OnDestroy { | |||
141 | return this.advancedSearch.size() | 141 | return this.advancedSearch.size() |
142 | } | 142 | } |
143 | 143 | ||
144 | removeVideoFromArray (video: Video) { | 144 | // Add VideoChannel for typings, but the template already checks "video" argument is a video |
145 | removeVideoFromArray (video: Video | VideoChannel) { | ||
145 | this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) | 146 | this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) |
146 | } | 147 | } |
147 | 148 | ||
diff --git a/client/src/app/search/search.service.ts b/client/src/app/search/search.service.ts index 7610fee8c..3cad5aaa7 100644 --- a/client/src/app/search/search.service.ts +++ b/client/src/app/search/search.service.ts | |||
@@ -11,6 +11,7 @@ import { Video } from '@app/shared/video/video.model' | |||
11 | import { AdvancedSearch } from '@app/search/advanced-search.model' | 11 | import { AdvancedSearch } from '@app/search/advanced-search.model' |
12 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 12 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
13 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 13 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
14 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' | ||
14 | 15 | ||
15 | @Injectable() | 16 | @Injectable() |
16 | export class SearchService { | 17 | export class SearchService { |
@@ -21,7 +22,11 @@ export class SearchService { | |||
21 | private restExtractor: RestExtractor, | 22 | private restExtractor: RestExtractor, |
22 | private restService: RestService, | 23 | private restService: RestService, |
23 | private videoService: VideoService | 24 | private videoService: VideoService |
24 | ) {} | 25 | ) { |
26 | // Add ability to override search endpoint if the user updated this local storage key | ||
27 | const searchUrl = peertubeLocalStorage.getItem('search-url') | ||
28 | if (searchUrl) SearchService.BASE_SEARCH_URL = searchUrl | ||
29 | } | ||
25 | 30 | ||
26 | searchVideos (parameters: { | 31 | searchVideos (parameters: { |
27 | search: string, | 32 | search: string, |
diff --git a/client/src/app/shared/angular/from-now.pipe.ts b/client/src/app/shared/angular/from-now.pipe.ts index 3a9a76411..9851468ee 100644 --- a/client/src/app/shared/angular/from-now.pipe.ts +++ b/client/src/app/shared/angular/from-now.pipe.ts | |||
@@ -12,9 +12,8 @@ export class FromNowPipe implements PipeTransform { | |||
12 | const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000) | 12 | const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000) |
13 | 13 | ||
14 | let interval = Math.floor(seconds / 31536000) | 14 | let interval = Math.floor(seconds / 31536000) |
15 | if (interval > 1) { | 15 | if (interval > 1) return this.i18n('{{interval}} years ago', { interval }) |
16 | return this.i18n('{{interval}} years ago', { interval }) | 16 | if (interval === 1) return this.i18n('{{interval}} year ago', { interval }) |
17 | } | ||
18 | 17 | ||
19 | interval = Math.floor(seconds / 2592000) | 18 | interval = Math.floor(seconds / 2592000) |
20 | if (interval > 1) return this.i18n('{{interval}} months ago', { interval }) | 19 | if (interval > 1) return this.i18n('{{interval}} months ago', { interval }) |
@@ -35,6 +34,6 @@ export class FromNowPipe implements PipeTransform { | |||
35 | interval = Math.floor(seconds / 60) | 34 | interval = Math.floor(seconds / 60) |
36 | if (interval >= 1) return this.i18n('{{interval}} min ago', { interval }) | 35 | if (interval >= 1) return this.i18n('{{interval}} min ago', { interval }) |
37 | 36 | ||
38 | return this.i18n('{{interval}} sec ago', { interval: Math.max(0, seconds) }) | 37 | return this.i18n('just now') |
39 | } | 38 | } |
40 | } | 39 | } |
diff --git a/client/src/app/shared/angular/highlight.pipe.ts b/client/src/app/shared/angular/highlight.pipe.ts new file mode 100644 index 000000000..fb6042280 --- /dev/null +++ b/client/src/app/shared/angular/highlight.pipe.ts | |||
@@ -0,0 +1,54 @@ | |||
1 | import { PipeTransform, Pipe } from '@angular/core' | ||
2 | import { SafeHtml } from '@angular/platform-browser' | ||
3 | |||
4 | // Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369 | ||
5 | @Pipe({ name: 'highlight' }) | ||
6 | export class HighlightPipe implements PipeTransform { | ||
7 | /* use this for single match search */ | ||
8 | static SINGLE_MATCH = 'Single-Match' | ||
9 | /* use this for single match search with a restriction that target should start with search string */ | ||
10 | static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match' | ||
11 | /* use this for global search */ | ||
12 | static MULTI_MATCH = 'Multi-Match' | ||
13 | |||
14 | // tslint:disable-next-line:no-empty | ||
15 | constructor () {} | ||
16 | |||
17 | transform ( | ||
18 | contentString: string = null, | ||
19 | stringToHighlight: string = null, | ||
20 | option = 'Single-And-StartsWith-Match', | ||
21 | caseSensitive = false, | ||
22 | highlightStyleName = 'search-highlight' | ||
23 | ): SafeHtml { | ||
24 | if (stringToHighlight && contentString && option) { | ||
25 | let regex: any = '' | ||
26 | const caseFlag: string = !caseSensitive ? 'i' : '' | ||
27 | switch (option) { | ||
28 | case 'Single-Match': { | ||
29 | regex = new RegExp(stringToHighlight, caseFlag) | ||
30 | break | ||
31 | } | ||
32 | case 'Single-And-StartsWith-Match': { | ||
33 | regex = new RegExp('^' + stringToHighlight, caseFlag) | ||
34 | break | ||
35 | } | ||
36 | case 'Multi-Match': { | ||
37 | regex = new RegExp(stringToHighlight, 'g' + caseFlag) | ||
38 | break | ||
39 | } | ||
40 | default: { | ||
41 | // default will be a global case-insensitive match | ||
42 | regex = new RegExp(stringToHighlight, 'gi') | ||
43 | } | ||
44 | } | ||
45 | const replaced = contentString.replace( | ||
46 | regex, | ||
47 | (match) => `<span class="${highlightStyleName}">${match}</span>` | ||
48 | ) | ||
49 | return replaced | ||
50 | } else { | ||
51 | return contentString | ||
52 | } | ||
53 | } | ||
54 | } | ||
diff --git a/client/src/app/shared/angular/timestamp-route-transformer.directive.ts b/client/src/app/shared/angular/timestamp-route-transformer.directive.ts index f4d9aeb1f..45e023695 100644 --- a/client/src/app/shared/angular/timestamp-route-transformer.directive.ts +++ b/client/src/app/shared/angular/timestamp-route-transformer.directive.ts | |||
@@ -10,31 +10,30 @@ export class TimestampRouteTransformerDirective { | |||
10 | public onClick ($event: Event) { | 10 | public onClick ($event: Event) { |
11 | const target = $event.target as HTMLLinkElement | 11 | const target = $event.target as HTMLLinkElement |
12 | 12 | ||
13 | if (target.hasAttribute('href')) { | 13 | if (target.hasAttribute('href') !== true) return |
14 | const ngxLink = document.createElement('a') | 14 | |
15 | ngxLink.href = target.getAttribute('href') | 15 | const ngxLink = document.createElement('a') |
16 | 16 | ngxLink.href = target.getAttribute('href') | |
17 | // we only care about reflective links | 17 | |
18 | if (ngxLink.host !== window.location.host) return | 18 | // we only care about reflective links |
19 | 19 | if (ngxLink.host !== window.location.host) return | |
20 | const ngxLinkParams = new URLSearchParams(ngxLink.search) | 20 | |
21 | if (ngxLinkParams.has('start')) { | 21 | const ngxLinkParams = new URLSearchParams(ngxLink.search) |
22 | const separators = ['h', 'm', 's'] | 22 | if (ngxLinkParams.has('start') !== true) return |
23 | const start = ngxLinkParams | 23 | |
24 | .get('start') | 24 | const separators = ['h', 'm', 's'] |
25 | .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator | 25 | const start = ngxLinkParams |
26 | .map(t => { | 26 | .get('start') |
27 | if (t.includes('h')) return parseInt(t, 10) * 3600 | 27 | .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator |
28 | if (t.includes('m')) return parseInt(t, 10) * 60 | 28 | .map(t => { |
29 | return parseInt(t, 10) | 29 | if (t.includes('h')) return parseInt(t, 10) * 3600 |
30 | }) | 30 | if (t.includes('m')) return parseInt(t, 10) * 60 |
31 | .reduce((acc, t) => acc + t) | 31 | return parseInt(t, 10) |
32 | this.timestampClicked.emit(start) | 32 | }) |
33 | } | 33 | .reduce((acc, t) => acc + t) |
34 | 34 | ||
35 | $event.preventDefault() | 35 | this.timestampClicked.emit(start) |
36 | } | 36 | |
37 | 37 | $event.preventDefault() | |
38 | return | ||
39 | } | 38 | } |
40 | } | 39 | } |
diff --git a/client/src/app/shared/angular/video-duration-formatter.pipe.ts b/client/src/app/shared/angular/video-duration-formatter.pipe.ts index c92631a75..4b6767415 100644 --- a/client/src/app/shared/angular/video-duration-formatter.pipe.ts +++ b/client/src/app/shared/angular/video-duration-formatter.pipe.ts | |||
@@ -1,19 +1,28 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | 1 | import { Pipe, PipeTransform } from '@angular/core' |
2 | 2 | import { I18n } from '@ngx-translate/i18n-polyfill' | |
3 | // Thanks: https://stackoverflow.com/a/46055604 | ||
4 | 3 | ||
5 | @Pipe({ | 4 | @Pipe({ |
6 | name: 'myVideoDurationFormatter' | 5 | name: 'myVideoDurationFormatter' |
7 | }) | 6 | }) |
8 | export class VideoDurationPipe implements PipeTransform { | 7 | export class VideoDurationPipe implements PipeTransform { |
8 | |||
9 | constructor (private i18n: I18n) { | ||
10 | |||
11 | } | ||
12 | |||
9 | transform (value: number): string { | 13 | transform (value: number): string { |
10 | const minutes = Math.floor(value / 60) | 14 | const hours = Math.floor(value / 3600) |
11 | const hours = Math.floor(minutes / 60) | 15 | const minutes = Math.floor((value % 3600) / 60) |
16 | const seconds = value % 60 | ||
12 | 17 | ||
13 | if (hours > 0) { | 18 | if (hours > 0) { |
14 | return hours + ' h ' + (minutes - hours * 60) + ' min ' + (value - (minutes - hours * 60) * 60) + ' sec' | 19 | return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds }) |
20 | } | ||
21 | |||
22 | if (minutes > 0) { | ||
23 | return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds }) | ||
15 | } | 24 | } |
16 | 25 | ||
17 | return minutes + ' min ' + (value - minutes * 60) + ' sec' | 26 | return this.i18n('{{seconds}} sec', { seconds }) |
18 | } | 27 | } |
19 | } | 28 | } |
diff --git a/client/src/app/shared/blocklist/blocklist.service.ts b/client/src/app/shared/blocklist/blocklist.service.ts index c1f7312f0..5cf265bc1 100644 --- a/client/src/app/shared/blocklist/blocklist.service.ts +++ b/client/src/app/shared/blocklist/blocklist.service.ts | |||
@@ -76,10 +76,14 @@ export class BlocklistService { | |||
76 | 76 | ||
77 | /*********************** Instance -> Account blocklist ***********************/ | 77 | /*********************** Instance -> Account blocklist ***********************/ |
78 | 78 | ||
79 | getInstanceAccountBlocklist (pagination: RestPagination, sort: SortMeta) { | 79 | getInstanceAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search: string }) { |
80 | const { pagination, sort, search } = options | ||
81 | |||
80 | let params = new HttpParams() | 82 | let params = new HttpParams() |
81 | params = this.restService.addRestGetParams(params, pagination, sort) | 83 | params = this.restService.addRestGetParams(params, pagination, sort) |
82 | 84 | ||
85 | if (search) params = params.append('search', search) | ||
86 | |||
83 | return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params }) | 87 | return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params }) |
84 | .pipe( | 88 | .pipe( |
85 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | 89 | map(res => this.restExtractor.convertResultListDateToHuman(res)), |
@@ -104,10 +108,14 @@ export class BlocklistService { | |||
104 | 108 | ||
105 | /*********************** Instance -> Server blocklist ***********************/ | 109 | /*********************** Instance -> Server blocklist ***********************/ |
106 | 110 | ||
107 | getInstanceServerBlocklist (pagination: RestPagination, sort: SortMeta) { | 111 | getInstanceServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search: string }) { |
112 | const { pagination, sort, search } = options | ||
113 | |||
108 | let params = new HttpParams() | 114 | let params = new HttpParams() |
109 | params = this.restService.addRestGetParams(params, pagination, sort) | 115 | params = this.restService.addRestGetParams(params, pagination, sort) |
110 | 116 | ||
117 | if (search) params = params.append('search', search) | ||
118 | |||
111 | return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params }) | 119 | return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params }) |
112 | .pipe( | 120 | .pipe( |
113 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | 121 | map(res => this.restExtractor.convertResultListDateToHuman(res)), |
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html index cd993db9f..952b3b6f8 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.html +++ b/client/src/app/shared/buttons/action-dropdown.component.html | |||
@@ -1,4 +1,4 @@ | |||
1 | <div class="dropdown-root" ngbDropdown [placement]="placement" *ngIf="areActionsDisplayed(actions, entry)"> | 1 | <div class="dropdown-root" ngbDropdown [placement]="placement" [container]="container" *ngIf="areActionsDisplayed(actions, entry)"> |
2 | <div | 2 | <div |
3 | class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }" | 3 | class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }" |
4 | ngbDropdownToggle role="button" | 4 | ngbDropdownToggle role="button" |
@@ -24,17 +24,27 @@ | |||
24 | </div> | 24 | </div> |
25 | </ng-template> | 25 | </ng-template> |
26 | 26 | ||
27 | <a *ngIf="action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" class="dropdown-item" [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"> | 27 | <a |
28 | *ngIf="action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }" | ||
29 | class="dropdown-item" [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''" | ||
30 | > | ||
28 | <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container> | 31 | <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container> |
29 | </a> | 32 | </a> |
30 | 33 | ||
31 | <span | 34 | <span |
32 | *ngIf="!action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" (click)="action.handler(entry)" | 35 | *ngIf="!action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }" |
33 | class="custom-action dropdown-item" role="button" [title]="action.title || ''" | 36 | class="custom-action dropdown-item" role="button" [title]="action.title || ''" (click)="action.handler(entry)" |
34 | > | 37 | > |
35 | <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container> | 38 | <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container> |
36 | </span> | 39 | </span> |
37 | 40 | ||
41 | <h6 | ||
42 | *ngIf="!action.linkBuilder && action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }" | ||
43 | class="dropdown-header" role="button" [title]="action.title || ''" (click)="action.handler(entry)" | ||
44 | > | ||
45 | <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container> | ||
46 | </h6> | ||
47 | |||
38 | </ng-container> | 48 | </ng-container> |
39 | </ng-container> | 49 | </ng-container> |
40 | 50 | ||
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss index 442c90984..7a030f32c 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.scss +++ b/client/src/app/shared/buttons/action-dropdown.component.scss | |||
@@ -51,6 +51,10 @@ | |||
51 | } | 51 | } |
52 | 52 | ||
53 | .dropdown-menu { | 53 | .dropdown-menu { |
54 | .dropdown-header { | ||
55 | padding: 0.2rem 1rem; | ||
56 | } | ||
57 | |||
54 | .dropdown-item { | 58 | .dropdown-item { |
55 | display: flex; | 59 | display: flex; |
56 | cursor: pointer; | 60 | cursor: pointer; |
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts index a8b3ab16c..15f9556dc 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.ts +++ b/client/src/app/shared/buttons/action-dropdown.component.ts | |||
@@ -9,6 +9,7 @@ export type DropdownAction<T> = { | |||
9 | handler?: (a: T) => any | 9 | handler?: (a: T) => any |
10 | linkBuilder?: (a: T) => (string | number)[] | 10 | linkBuilder?: (a: T) => (string | number)[] |
11 | isDisplayed?: (a: T) => boolean | 11 | isDisplayed?: (a: T) => boolean |
12 | isHeader?: boolean | ||
12 | } | 13 | } |
13 | 14 | ||
14 | export type DropdownButtonSize = 'normal' | 'small' | 15 | export type DropdownButtonSize = 'normal' | 'small' |
@@ -26,6 +27,7 @@ export class ActionDropdownComponent<T> { | |||
26 | @Input() entry: T | 27 | @Input() entry: T |
27 | 28 | ||
28 | @Input() placement = 'bottom-left auto' | 29 | @Input() placement = 'bottom-left auto' |
30 | @Input() container: null | 'body' | ||
29 | 31 | ||
30 | @Input() buttonSize: DropdownButtonSize = 'normal' | 32 | @Input() buttonSize: DropdownButtonSize = 'normal' |
31 | @Input() buttonDirection: DropdownDirection = 'horizontal' | 33 | @Input() buttonDirection: DropdownDirection = 'horizontal' |
@@ -34,10 +36,10 @@ export class ActionDropdownComponent<T> { | |||
34 | @Input() label: string | 36 | @Input() label: string |
35 | @Input() theme: DropdownTheme = 'grey' | 37 | @Input() theme: DropdownTheme = 'grey' |
36 | 38 | ||
37 | getActions () { | 39 | getActions (): DropdownAction<T>[][] { |
38 | if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions | 40 | if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction<T>[][] |
39 | 41 | ||
40 | return [ this.actions ] | 42 | return [ this.actions as DropdownAction<T>[] ] |
41 | } | 43 | } |
42 | 44 | ||
43 | areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean { | 45 | areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean { |
diff --git a/client/src/app/shared/buttons/button.component.scss b/client/src/app/shared/buttons/button.component.scss index 2a8cfc748..3ccfefd7e 100644 --- a/client/src/app/shared/buttons/button.component.scss +++ b/client/src/app/shared/buttons/button.component.scss | |||
@@ -10,11 +10,26 @@ my-small-loader ::ng-deep .root { | |||
10 | .action-button { | 10 | .action-button { |
11 | @include peertube-button-link; | 11 | @include peertube-button-link; |
12 | @include button-with-icon(21px, 0, -2px); | 12 | @include button-with-icon(21px, 0, -2px); |
13 | } | ||
13 | 14 | ||
14 | // FIXME: Firefox does not apply global .orange-button icon color | 15 | .orange-button { |
15 | &.orange-button { | 16 | @include peertube-button; |
16 | @include apply-svg-color(#fff) | 17 | @include orange-button; |
17 | } | 18 | } |
19 | |||
20 | .orange-button-link { | ||
21 | @include peertube-button-link; | ||
22 | @include orange-button; | ||
23 | } | ||
24 | |||
25 | .grey-button { | ||
26 | @include peertube-button; | ||
27 | @include grey-button; | ||
28 | } | ||
29 | |||
30 | .grey-button-link { | ||
31 | @include peertube-button-link; | ||
32 | @include grey-button; | ||
18 | } | 33 | } |
19 | 34 | ||
20 | // In a table, try to minimize the space taken by this button | 35 | // In a table, try to minimize the space taken by this button |
diff --git a/client/src/app/shared/buttons/edit-button.component.ts b/client/src/app/shared/buttons/edit-button.component.ts index 1fe4f7b30..9cfe1a3bb 100644 --- a/client/src/app/shared/buttons/edit-button.component.ts +++ b/client/src/app/shared/buttons/edit-button.component.ts | |||
@@ -8,5 +8,5 @@ import { Component, Input } from '@angular/core' | |||
8 | 8 | ||
9 | export class EditButtonComponent { | 9 | export class EditButtonComponent { |
10 | @Input() label: string | 10 | @Input() label: string |
11 | @Input() routerLink: string[] = [] | 11 | @Input() routerLink: string[] | string = [] |
12 | } | 12 | } |
diff --git a/client/src/app/shared/confirm/confirm.component.html b/client/src/app/shared/confirm/confirm.component.html index 65df1cd4d..dbc8c23e3 100644 --- a/client/src/app/shared/confirm/confirm.component.html +++ b/client/src/app/shared/confirm/confirm.component.html | |||
@@ -16,11 +16,15 @@ | |||
16 | </div> | 16 | </div> |
17 | 17 | ||
18 | <div class="modal-footer inputs"> | 18 | <div class="modal-footer inputs"> |
19 | <span i18n class="action-button action-button-cancel" (click)="dismiss()" role="button">Cancel</span> | 19 | <input |
20 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | ||
21 | (click)="dismiss()" (key.enter)="dismiss()" | ||
22 | > | ||
20 | 23 | ||
21 | <input | 24 | <input |
25 | ngbAutofocus | ||
22 | type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()" | 26 | type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()" |
23 | (click)="close()" | 27 | (click)="close()" (key.enter)="confirm()" |
24 | > | 28 | > |
25 | </div> | 29 | </div> |
26 | </ng-template> | 30 | </ng-template> |
diff --git a/client/src/app/shared/confirm/confirm.component.ts b/client/src/app/shared/confirm/confirm.component.ts index 763454c4f..c6e40fe72 100644 --- a/client/src/app/shared/confirm/confirm.component.ts +++ b/client/src/app/shared/confirm/confirm.component.ts | |||
@@ -45,7 +45,6 @@ export class ConfirmComponent implements OnInit { | |||
45 | ) | 45 | ) |
46 | } | 46 | } |
47 | 47 | ||
48 | @HostListener('document:keydown.enter') | ||
49 | confirm () { | 48 | confirm () { |
50 | if (this.openedModal) this.openedModal.close() | 49 | if (this.openedModal) this.openedModal.close() |
51 | } | 50 | } |
@@ -60,7 +59,7 @@ export class ConfirmComponent implements OnInit { | |||
60 | showModal () { | 59 | showModal () { |
61 | this.inputValue = '' | 60 | this.inputValue = '' |
62 | 61 | ||
63 | this.openedModal = this.modalService.open(this.confirmModal) | 62 | this.openedModal = this.modalService.open(this.confirmModal, { centered: true }) |
64 | 63 | ||
65 | this.openedModal.result | 64 | this.openedModal.result |
66 | .then(() => this.confirmService.confirmResponse.next(true)) | 65 | .then(() => this.confirmService.confirmResponse.next(true)) |
diff --git a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts index 767e3f026..d20754d11 100644 --- a/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts +++ b/client/src/app/shared/forms/form-validators/custom-config-validators.service.ts | |||
@@ -56,7 +56,7 @@ export class CustomConfigValidatorsService { | |||
56 | } | 56 | } |
57 | 57 | ||
58 | this.SIGNUP_LIMIT = { | 58 | this.SIGNUP_LIMIT = { |
59 | VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], | 59 | VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ], |
60 | MESSAGES: { | 60 | MESSAGES: { |
61 | 'required': this.i18n('Signup limit is required.'), | 61 | 'required': this.i18n('Signup limit is required.'), |
62 | 'min': this.i18n('Signup limit must be greater than 1.'), | 62 | 'min': this.i18n('Signup limit must be greater than 1.'), |
diff --git a/client/src/app/shared/forms/form-validators/user-validators.service.ts b/client/src/app/shared/forms/form-validators/user-validators.service.ts index 4dff3e422..13b9228d4 100644 --- a/client/src/app/shared/forms/form-validators/user-validators.service.ts +++ b/client/src/app/shared/forms/form-validators/user-validators.service.ts | |||
@@ -8,6 +8,7 @@ export class UserValidatorsService { | |||
8 | readonly USER_USERNAME: BuildFormValidator | 8 | readonly USER_USERNAME: BuildFormValidator |
9 | readonly USER_EMAIL: BuildFormValidator | 9 | readonly USER_EMAIL: BuildFormValidator |
10 | readonly USER_PASSWORD: BuildFormValidator | 10 | readonly USER_PASSWORD: BuildFormValidator |
11 | readonly USER_PASSWORD_OPTIONAL: BuildFormValidator | ||
11 | readonly USER_CONFIRM_PASSWORD: BuildFormValidator | 12 | readonly USER_CONFIRM_PASSWORD: BuildFormValidator |
12 | readonly USER_VIDEO_QUOTA: BuildFormValidator | 13 | readonly USER_VIDEO_QUOTA: BuildFormValidator |
13 | readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator | 14 | readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator |
@@ -56,6 +57,17 @@ export class UserValidatorsService { | |||
56 | } | 57 | } |
57 | } | 58 | } |
58 | 59 | ||
60 | this.USER_PASSWORD_OPTIONAL = { | ||
61 | VALIDATORS: [ | ||
62 | Validators.minLength(6), | ||
63 | Validators.maxLength(255) | ||
64 | ], | ||
65 | MESSAGES: { | ||
66 | 'minlength': this.i18n('Password must be at least 6 characters long.'), | ||
67 | 'maxlength': this.i18n('Password cannot be more than 255 characters long.') | ||
68 | } | ||
69 | } | ||
70 | |||
59 | this.USER_CONFIRM_PASSWORD = { | 71 | this.USER_CONFIRM_PASSWORD = { |
60 | VALIDATORS: [], | 72 | VALIDATORS: [], |
61 | MESSAGES: { | 73 | MESSAGES: { |
diff --git a/client/src/app/shared/forms/input-readonly-copy.component.html b/client/src/app/shared/forms/input-readonly-copy.component.html index 27571b63f..9566e9741 100644 --- a/client/src/app/shared/forms/input-readonly-copy.component.html +++ b/client/src/app/shared/forms/input-readonly-copy.component.html | |||
@@ -1,8 +1,8 @@ | |||
1 | <div class="input-group"> | 1 | <div class="input-group input-group-sm"> |
2 | <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" /> | 2 | <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" /> |
3 | 3 | ||
4 | <div class="input-group-append"> | 4 | <div class="input-group-append"> |
5 | <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> | 5 | <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> |
6 | <span class="glyphicon glyphicon-copy"></span> | 6 | <span class="glyphicon glyphicon-copy"></span> |
7 | </button> | 7 | </button> |
8 | </div> | 8 | </div> |
diff --git a/client/src/app/shared/forms/markdown-textarea.component.html b/client/src/app/shared/forms/markdown-textarea.component.html index 0925b9ad5..a519f3e0a 100644 --- a/client/src/app/shared/forms/markdown-textarea.component.html +++ b/client/src/app/shared/forms/markdown-textarea.component.html | |||
@@ -1,17 +1,36 @@ | |||
1 | <div class="root" [ngStyle]="{ 'flex-direction': flexDirection }"> | 1 | <div class="root" [ngClass]="{ 'maximized': isMaximized }" [ngStyle]="{ 'max-width': textareaMaxWidth }"> |
2 | <textarea | 2 | <textarea #textarea |
3 | [(ngModel)]="content" (ngModelChange)="onModelChange()" | 3 | [(ngModel)]="content" (ngModelChange)="onModelChange()" |
4 | [ngClass]="classes" [ngStyle]="{ width: textareaWidth, height: textareaHeight, 'margin-right': textareaMarginRight }" | 4 | class="form-control" [ngClass]="classes" |
5 | [ngStyle]="{ height: textareaHeight }" | ||
5 | [id]="name" [name]="name"> | 6 | [id]="name" [name]="name"> |
6 | </textarea> | 7 | </textarea> |
7 | 8 | ||
8 | <ngb-tabset *ngIf="arePreviewsDisplayed()" class="previews" type="pills"> | 9 | <div ngbNav #nav="ngbNav" class="nav-pills nav-preview"> |
9 | <ngb-tab *ngIf="truncate !== undefined" i18n-title title="Truncated preview"> | 10 | <ng-container ngbNavItem *ngIf="truncate !== undefined"> |
10 | <ng-template ngbTabContent><div [innerHTML]="truncatedPreviewHTML"></div></ng-template> | 11 | <a ngbNavLink i18n>Truncated preview</a> |
11 | </ngb-tab> | ||
12 | 12 | ||
13 | <ngb-tab i18n-title title="Complete preview"> | 13 | <ng-template ngbNavContent> |
14 | <ng-template ngbTabContent><div [innerHTML]="previewHTML"></div></ng-template> | 14 | <div [innerHTML]="truncatedPreviewHTML"></div> |
15 | </ngb-tab> | 15 | </ng-template> |
16 | </ngb-tabset> | 16 | </ng-container> |
17 | |||
18 | <ng-container ngbNavItem> | ||
19 | <a ngbNavLink i18n>Complete preview</a> | ||
20 | |||
21 | <ng-template ngbNavContent> | ||
22 | <div [innerHTML]="previewHTML"></div> | ||
23 | </ng-template> | ||
24 | </ng-container> | ||
25 | |||
26 | <my-button | ||
27 | *ngIf="!isMaximized" icon="fullscreen" (click)="onMaximizeClick()" | ||
28 | ></my-button> | ||
29 | |||
30 | <my-button | ||
31 | *ngIf="isMaximized" icon="exit-fullscreen" (click)="onMaximizeClick()" | ||
32 | ></my-button> | ||
33 | </div> | ||
34 | |||
35 | <div [ngbNavOutlet]="nav"></div> | ||
17 | </div> | 36 | </div> |
diff --git a/client/src/app/shared/forms/markdown-textarea.component.scss b/client/src/app/shared/forms/markdown-textarea.component.scss index eacaf36a2..8e5739e45 100644 --- a/client/src/app/shared/forms/markdown-textarea.component.scss +++ b/client/src/app/shared/forms/markdown-textarea.component.scss | |||
@@ -1,34 +1,250 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .root { | 4 | $nav-preview-tab-height: 30px; |
5 | display: flex; | 5 | $base-padding: 15px; |
6 | $input-border-color: #C6C6C6; | ||
7 | $input-border-radius: 3px; | ||
8 | |||
9 | @mixin in-small-view { | ||
10 | .root { | ||
11 | display: flex; | ||
12 | flex-direction: column; | ||
13 | |||
14 | textarea { | ||
15 | @include peertube-textarea(100%, 150px); | ||
16 | |||
17 | background-color: var(--textareaBackgroundColor); | ||
18 | font-family: monospace; | ||
19 | font-size: 13px; | ||
20 | border-bottom: none; | ||
21 | border-bottom-left-radius: unset; | ||
22 | border-bottom-right-radius: unset; | ||
23 | } | ||
6 | 24 | ||
7 | textarea { | 25 | .nav-preview { |
8 | @include peertube-textarea(100%, 150px); | 26 | display: block; |
27 | text-align: right; | ||
28 | padding-top: 10px; | ||
29 | padding-bottom: 10px; | ||
30 | padding-left: 10px; | ||
31 | padding-right: 10px; | ||
32 | border-top: 1px dashed $input-border-color; | ||
33 | border-left: 1px solid $input-border-color; | ||
34 | border-right: 1px solid $input-border-color; | ||
35 | border-bottom: 1px solid $input-border-color; | ||
36 | border-bottom-right-radius: $input-border-radius; | ||
9 | 37 | ||
10 | margin-bottom: 15px; | 38 | border-bottom-left-radius: $input-border-radius; |
39 | ::ng-deep { | ||
40 | .nav-link { | ||
41 | display: none !important; | ||
42 | } | ||
43 | |||
44 | .grey-button { | ||
45 | padding: 0 12px 0 12px; | ||
46 | } | ||
47 | } | ||
48 | } | ||
49 | |||
50 | ::ng-deep { | ||
51 | .tab-content { | ||
52 | display: none; | ||
53 | } | ||
54 | } | ||
11 | } | 55 | } |
56 | } | ||
57 | |||
58 | @mixin nav-preview-medium { | ||
59 | display: flex; | ||
60 | flex-grow: 1; | ||
61 | border-bottom-left-radius: unset; | ||
62 | border-bottom-right-radius: unset; | ||
63 | border-bottom: 2px solid var(--mainColor); | ||
12 | 64 | ||
13 | .previews { | 65 | :first-child { |
14 | max-height: 150px; | 66 | margin-left: auto; |
15 | overflow-y: auto; | ||
16 | flex-grow: 1; | ||
17 | } | 67 | } |
18 | 68 | ||
19 | ::ng-deep { | 69 | ::ng-deep { |
20 | .nav-link { | 70 | .nav-link { |
21 | display: flex !important; | 71 | display: flex !important; |
22 | align-items: center; | 72 | align-items: center; |
23 | height: 30px !important; | 73 | height: $nav-preview-tab-height !important; |
24 | padding: 0 15px !important; | 74 | padding: 0 15px !important; |
75 | font-size: 85% !important; | ||
76 | opacity: .7; | ||
77 | } | ||
78 | |||
79 | .grey-button { | ||
80 | margin-left: 5px; | ||
81 | } | ||
82 | } | ||
83 | } | ||
84 | |||
85 | @mixin content-preview-base { | ||
86 | display: block; | ||
87 | min-height: 75px; | ||
88 | padding: $base-padding; | ||
89 | overflow-y: auto; | ||
90 | font-size: 15px; | ||
91 | word-wrap: break-word; | ||
92 | } | ||
93 | |||
94 | @mixin maximized-base { | ||
95 | flex-direction: row; | ||
96 | z-index: #{z(header) - 1}; | ||
97 | position: fixed; | ||
98 | top: $header-height; | ||
99 | left: $menu-width; | ||
100 | max-height: none !important; | ||
101 | max-width: none !important; | ||
102 | width: calc(100% - #{$menu-width}); | ||
103 | height: calc(100vh - #{$header-height}) !important; | ||
104 | |||
105 | $nav-preview-vertical-padding: 40px; | ||
106 | |||
107 | .nav-preview { | ||
108 | @include nav-preview-medium(); | ||
109 | padding-top: #{$nav-preview-vertical-padding / 2}; | ||
110 | padding-bottom: #{$nav-preview-vertical-padding / 2}; | ||
111 | padding-left: 0px; | ||
112 | padding-right: 0px; | ||
113 | position: absolute; | ||
114 | background-color: var(--mainBackgroundColor); | ||
115 | width: 100% !important; | ||
116 | border-top: none; | ||
117 | border-left: none; | ||
118 | border-right: none; | ||
119 | |||
120 | :last-child { | ||
121 | margin-right: $not-expanded-horizontal-margins; | ||
25 | } | 122 | } |
123 | } | ||
124 | |||
125 | ::ng-deep .tab-content { | ||
126 | @include content-preview-base(); | ||
127 | background-color: var(--mainBackgroundColor); | ||
128 | scrollbar-color: var(--actionButtonColor) var(--mainBackgroundColor); | ||
129 | } | ||
26 | 130 | ||
27 | .tab-content { | 131 | textarea, |
28 | min-height: 75px; | 132 | ::ng-deep .tab-content { |
29 | padding: 15px; | 133 | max-height: none !important; |
30 | font-size: 15px; | 134 | max-width: none !important; |
31 | word-wrap: break-word; | 135 | margin-top: #{$nav-preview-tab-height + $nav-preview-vertical-padding} !important; |
136 | height: calc(100vh - #{$header-height + $nav-preview-tab-height + $nav-preview-vertical-padding}) !important; | ||
137 | width: 50% !important; | ||
138 | border: none !important; | ||
139 | border-radius: unset !important; | ||
140 | } | ||
141 | |||
142 | :host-context(.expanded) { | ||
143 | .root.maximized { | ||
144 | left: 0; | ||
145 | width: 100%; | ||
146 | } | ||
147 | } | ||
148 | } | ||
149 | |||
150 | @mixin maximized-in-small-view { | ||
151 | .root.maximized { | ||
152 | @include maximized-base(); | ||
153 | |||
154 | textarea { | ||
155 | display: none; | ||
32 | } | 156 | } |
157 | |||
158 | ::ng-deep .tab-content { | ||
159 | width: 100% !important; | ||
160 | } | ||
161 | } | ||
162 | } | ||
163 | |||
164 | @mixin maximized-tabs-in-mobile-view { | ||
165 | // Ellipsis on tabs for mobile view | ||
166 | .root.maximized { | ||
167 | .nav-preview { | ||
168 | ::ng-deep .nav-link { | ||
169 | @include ellipsis(); | ||
170 | |||
171 | display: block !important; | ||
172 | max-width: 45% !important; | ||
173 | padding: 5px 0 !important; | ||
174 | margin-right: 10px !important; | ||
175 | text-align: center; | ||
176 | |||
177 | &:not(.active) { | ||
178 | max-width: 15% !important; | ||
179 | } | ||
180 | |||
181 | &.active { | ||
182 | padding: 5px 15px !important; | ||
183 | } | ||
184 | } | ||
185 | } | ||
186 | } | ||
187 | } | ||
188 | |||
189 | @mixin in-medium-view { | ||
190 | .root { | ||
191 | .nav-preview { | ||
192 | @include nav-preview-medium(); | ||
193 | } | ||
194 | |||
195 | ::ng-deep .tab-content { | ||
196 | @include content-preview-base(); | ||
197 | max-height: 210px; | ||
198 | border-bottom: 1px solid $input-border-color; | ||
199 | border-left: 1px solid $input-border-color; | ||
200 | border-right: 1px solid $input-border-color; | ||
201 | border-bottom-left-radius: $input-border-radius; | ||
202 | border-bottom-right-radius: $input-border-radius; | ||
203 | } | ||
204 | } | ||
205 | } | ||
206 | |||
207 | @mixin maximized-in-medium-view { | ||
208 | .root.maximized { | ||
209 | @include maximized-base(); | ||
210 | |||
211 | textarea { | ||
212 | display: block; | ||
213 | padding: $base-padding; | ||
214 | border-right: 1px dashed $input-border-color !important; | ||
215 | resize: none; | ||
216 | scrollbar-color: var(--actionButtonColor) var(--textareaBackgroundColor); | ||
217 | |||
218 | &:focus { | ||
219 | box-shadow: none; | ||
220 | } | ||
221 | } | ||
222 | } | ||
223 | } | ||
224 | |||
225 | @include in-small-view(); | ||
226 | @include maximized-in-small-view(); | ||
227 | |||
228 | @media only screen and (max-width: $mobile-view) { | ||
229 | @include maximized-tabs-in-mobile-view(); | ||
230 | } | ||
231 | |||
232 | @media only screen and (max-width: #{$mobile-view + $menu-width}) { | ||
233 | :host-context(.main-col:not(.expanded)) { | ||
234 | @include maximized-tabs-in-mobile-view(); | ||
235 | } | ||
236 | } | ||
237 | |||
238 | @media only screen and (min-width: $small-view) { | ||
239 | :host-context(.expanded) { | ||
240 | @include in-medium-view(); | ||
241 | } | ||
242 | |||
243 | @include maximized-in-medium-view(); | ||
244 | } | ||
245 | |||
246 | @media only screen and (min-width: #{$small-view + $menu-width}) { | ||
247 | :host-context(.main-col:not(.expanded)) { | ||
248 | @include in-medium-view(); | ||
33 | } | 249 | } |
34 | } | 250 | } |
diff --git a/client/src/app/shared/forms/markdown-textarea.component.ts b/client/src/app/shared/forms/markdown-textarea.component.ts index 19cd37573..dde7b4d98 100644 --- a/client/src/app/shared/forms/markdown-textarea.component.ts +++ b/client/src/app/shared/forms/markdown-textarea.component.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | 1 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' |
2 | import { Component, forwardRef, Input, OnInit } from '@angular/core' | 2 | import { Component, forwardRef, Input, OnInit, ViewChild, ElementRef } from '@angular/core' |
3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
4 | import { Subject } from 'rxjs' | 4 | import { Subject } from 'rxjs' |
5 | import truncate from 'lodash-es/truncate' | 5 | import truncate from 'lodash-es/truncate' |
@@ -21,19 +21,19 @@ import { MarkdownService } from '@app/shared/renderer' | |||
21 | 21 | ||
22 | export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { | 22 | export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { |
23 | @Input() content = '' | 23 | @Input() content = '' |
24 | @Input() classes: string[] = [] | 24 | @Input() classes: string[] | { [klass: string]: any[] | any } = [] |
25 | @Input() textareaWidth = '100%' | 25 | @Input() textareaMaxWidth = '100%' |
26 | @Input() textareaHeight = '150px' | 26 | @Input() textareaHeight = '150px' |
27 | @Input() previewColumn = false | ||
28 | @Input() truncate: number | 27 | @Input() truncate: number |
29 | @Input() markdownType: 'text' | 'enhanced' = 'text' | 28 | @Input() markdownType: 'text' | 'enhanced' = 'text' |
30 | @Input() markdownVideo = false | 29 | @Input() markdownVideo = false |
31 | @Input() name = 'description' | 30 | @Input() name = 'description' |
32 | 31 | ||
33 | textareaMarginRight = '0' | 32 | @ViewChild('textarea') textareaElement: ElementRef |
34 | flexDirection = 'column' | 33 | |
35 | truncatedPreviewHTML = '' | 34 | truncatedPreviewHTML = '' |
36 | previewHTML = '' | 35 | previewHTML = '' |
36 | isMaximized = false | ||
37 | 37 | ||
38 | private contentChanged = new Subject<string>() | 38 | private contentChanged = new Subject<string>() |
39 | 39 | ||
@@ -51,11 +51,6 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { | |||
51 | .subscribe(() => this.updatePreviews()) | 51 | .subscribe(() => this.updatePreviews()) |
52 | 52 | ||
53 | this.contentChanged.next(this.content) | 53 | this.contentChanged.next(this.content) |
54 | |||
55 | if (this.previewColumn) { | ||
56 | this.flexDirection = 'row' | ||
57 | this.textareaMarginRight = '15px' | ||
58 | } | ||
59 | } | 54 | } |
60 | 55 | ||
61 | propagateChange = (_: any) => { /* empty */ } | 56 | propagateChange = (_: any) => { /* empty */ } |
@@ -80,8 +75,26 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { | |||
80 | this.contentChanged.next(this.content) | 75 | this.contentChanged.next(this.content) |
81 | } | 76 | } |
82 | 77 | ||
83 | arePreviewsDisplayed () { | 78 | onMaximizeClick () { |
84 | return this.screenService.isInSmallView() === false | 79 | this.isMaximized = !this.isMaximized |
80 | |||
81 | // Make sure textarea have the focus | ||
82 | this.textareaElement.nativeElement.focus() | ||
83 | |||
84 | // Make sure the window has no scrollbars | ||
85 | if (!this.isMaximized) { | ||
86 | this.unlockBodyScroll() | ||
87 | } else { | ||
88 | this.lockBodyScroll() | ||
89 | } | ||
90 | } | ||
91 | |||
92 | private lockBodyScroll () { | ||
93 | document.getElementById('content').classList.add('lock-scroll') | ||
94 | } | ||
95 | |||
96 | private unlockBodyScroll () { | ||
97 | document.getElementById('content').classList.remove('lock-scroll') | ||
85 | } | 98 | } |
86 | 99 | ||
87 | private async updatePreviews () { | 100 | private async updatePreviews () { |
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.html b/client/src/app/shared/forms/peertube-checkbox.component.html index c740d852c..704f3e696 100644 --- a/client/src/app/shared/forms/peertube-checkbox.component.html +++ b/client/src/app/shared/forms/peertube-checkbox.component.html | |||
@@ -29,6 +29,8 @@ | |||
29 | <ng-template *ngTemplateOutlet="helpTemplate"></ng-template> | 29 | <ng-template *ngTemplateOutlet="helpTemplate"></ng-template> |
30 | </ng-template> | 30 | </ng-template> |
31 | </my-help> | 31 | </my-help> |
32 | |||
33 | <div *ngIf="recommended" class="recommended" i18n>Recommended</div> | ||
32 | </div> | 34 | </div> |
33 | 35 | ||
34 | <div class="ml-4 d-flex flex-column"> | 36 | <div class="ml-4 d-flex flex-column"> |
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss index 3120509b3..c1233e8a5 100644 --- a/client/src/app/shared/forms/peertube-checkbox.component.scss +++ b/client/src/app/shared/forms/peertube-checkbox.component.scss | |||
@@ -34,4 +34,19 @@ | |||
34 | .wrapper:empty { | 34 | .wrapper:empty { |
35 | display: none; | 35 | display: none; |
36 | } | 36 | } |
37 | |||
38 | .recommended { | ||
39 | margin-left: .5rem; | ||
40 | align-self: baseline; | ||
41 | display: inline-block; | ||
42 | padding: 4px 6px; | ||
43 | cursor: default; | ||
44 | border-radius: 3px; | ||
45 | font-size: 12px; | ||
46 | line-height: 12px; | ||
47 | font-weight: 500; | ||
48 | color: var(--inputPlaceholderColor); | ||
49 | background-color: rgba(217,225,232,.1); | ||
50 | border: 1px solid rgba(217,225,232,.5); | ||
51 | } | ||
37 | } \ No newline at end of file | 52 | } \ No newline at end of file |
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.ts b/client/src/app/shared/forms/peertube-checkbox.component.ts index cb7ec8eda..89e79fecd 100644 --- a/client/src/app/shared/forms/peertube-checkbox.component.ts +++ b/client/src/app/shared/forms/peertube-checkbox.component.ts | |||
@@ -21,6 +21,7 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterCon | |||
21 | @Input() labelInnerHTML: string | 21 | @Input() labelInnerHTML: string |
22 | @Input() helpPlacement = 'top auto' | 22 | @Input() helpPlacement = 'top auto' |
23 | @Input() disabled = false | 23 | @Input() disabled = false |
24 | @Input() recommended = false | ||
24 | 25 | ||
25 | @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'label' | 'help'>> | 26 | @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'label' | 'help'>> |
26 | 27 | ||
diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss index e7dbcd997..9671cc65f 100644 --- a/client/src/app/shared/forms/timestamp-input.component.scss +++ b/client/src/app/shared/forms/timestamp-input.component.scss | |||
@@ -1,8 +1,15 @@ | |||
1 | @import 'variables'; | ||
2 | |||
1 | p-inputmask { | 3 | p-inputmask { |
2 | ::ng-deep input { | 4 | ::ng-deep input { |
3 | width: 80px; | 5 | width: 80px; |
4 | font-size: 15px; | 6 | font-size: 15px; |
5 | 7 | ||
6 | border: none; | 8 | border: none; |
9 | |||
10 | &:focus-within, | ||
11 | &:focus { | ||
12 | box-shadow: #{$focus-box-shadow-form} var(--mainColorLightest); | ||
13 | } | ||
7 | } | 14 | } |
8 | } | 15 | } |
diff --git a/client/src/app/shared/images/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts index 806aca347..d2700f6c3 100644 --- a/client/src/app/shared/images/global-icon.component.ts +++ b/client/src/app/shared/images/global-icon.component.ts | |||
@@ -1,57 +1,62 @@ | |||
1 | import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' | 1 | import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' |
2 | import { HooksService } from '@app/core/plugins/hooks.service' | 2 | import { HooksService } from '@app/core/plugins/hooks.service' |
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | 3 | ||
5 | const icons = { | 4 | const icons = { |
6 | 'add': require('!!raw-loader?!../../../assets/images/global/add.svg'), | 5 | 'add': require('!!raw-loader?!../../../assets/images/global/add.svg').default, |
7 | 'user': require('!!raw-loader?!../../../assets/images/global/user.svg'), | 6 | 'user': require('!!raw-loader?!../../../assets/images/global/user.svg').default, |
8 | 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg'), | 7 | 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg').default, |
9 | 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg'), | 8 | 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg').default, |
10 | 'help': require('!!raw-loader?!../../../assets/images/global/help.svg'), | 9 | 'help': require('!!raw-loader?!../../../assets/images/global/help.svg').default, |
11 | 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg'), | 10 | 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg').default, |
12 | 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg'), | 11 | 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg').default, |
13 | 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg'), | 12 | 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg').default, |
14 | 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg'), | 13 | 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg').default, |
15 | 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg'), | 14 | 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg').default, |
16 | 'no': require('!!raw-loader?!../../../assets/images/global/no.svg'), | 15 | 'no': require('!!raw-loader?!../../../assets/images/global/no.svg').default, |
17 | 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg'), | 16 | 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg').default, |
18 | 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg'), | 17 | 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg').default, |
19 | 'history': require('!!raw-loader?!../../../assets/images/global/history.svg'), | 18 | 'history': require('!!raw-loader?!../../../assets/images/global/history.svg').default, |
20 | 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg'), | 19 | 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg').default, |
21 | 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg'), | 20 | 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg').default, |
22 | 'download': require('!!raw-loader?!../../../assets/images/global/download.svg'), | 21 | 'download': require('!!raw-loader?!../../../assets/images/global/download.svg').default, |
23 | 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg'), | 22 | 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg').default, |
24 | 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg'), | 23 | 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg').default, |
25 | 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg'), | 24 | 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg').default, |
26 | 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg'), | 25 | 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg').default, |
27 | 'server': require('!!raw-loader?!../../../assets/images/global/server.svg'), | 26 | 'server': require('!!raw-loader?!../../../assets/images/global/server.svg').default, |
28 | 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg'), | 27 | 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg').default, |
29 | 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg'), | 28 | 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg').default, |
30 | 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg'), | 29 | 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg').default, |
31 | 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg'), | 30 | 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg').default, |
32 | 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg'), | 31 | 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg').default, |
33 | 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg'), | 32 | 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg').default, |
34 | 'support': require('!!raw-loader?!../../../assets/images/video/support.svg'), | 33 | 'support': require('!!raw-loader?!../../../assets/images/video/support.svg').default, |
35 | 'like': require('!!raw-loader?!../../../assets/images/video/like.svg'), | 34 | 'like': require('!!raw-loader?!../../../assets/images/video/like.svg').default, |
36 | 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg'), | 35 | 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg').default, |
37 | 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg'), | 36 | 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg').default, |
38 | 'share': require('!!raw-loader?!../../../assets/images/video/share.svg'), | 37 | 'share': require('!!raw-loader?!../../../assets/images/video/share.svg').default, |
39 | 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg'), | 38 | 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg').default, |
40 | 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg'), | 39 | 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg').default, |
41 | 'play': require('!!raw-loader?!../../../assets/images/global/play.svg'), | 40 | 'play': require('!!raw-loader?!../../../assets/images/global/play.svg').default, |
42 | 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg'), | 41 | 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg').default, |
43 | 'about': require('!!raw-loader?!../../../assets/images/menu/about.svg'), | 42 | 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg').default, |
44 | 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg'), | 43 | 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg').default, |
45 | 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg'), | 44 | 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg').default, |
46 | 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg'), | 45 | 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg').default, |
47 | 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg'), | 46 | 'video-lang': require('!!raw-loader?!../../../assets/images/global/video-lang.svg').default, |
48 | 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg'), | 47 | 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg').default, |
49 | 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg'), | 48 | 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg').default, |
50 | 'administration': require('!!raw-loader?!../../../assets/images/menu/administration.svg'), | 49 | 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg').default, |
51 | 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg'), | 50 | 'language': require('!!raw-loader?!../../../assets/images/menu/language.svg').default, |
52 | 'users': require('!!raw-loader?!../../../assets/images/global/users.svg'), | 51 | 'unsensitive': require('!!raw-loader?!../../../assets/images/menu/eye.svg').default, |
53 | 'search': require('!!raw-loader?!../../../assets/images/global/search.svg'), | 52 | 'sensitive': require('!!raw-loader?!../../../assets/images/menu/eye-closed.svg').default, |
54 | 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg') | 53 | 'p2p': require('!!raw-loader?!../../../assets/images/menu/p2p.svg').default, |
54 | 'users': require('!!raw-loader?!../../../assets/images/global/users.svg').default, | ||
55 | 'search': require('!!raw-loader?!../../../assets/images/global/search.svg').default, | ||
56 | 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg').default, | ||
57 | 'npm': require('!!raw-loader?!../../../assets/images/global/npm.svg').default, | ||
58 | 'fullscreen': require('!!raw-loader?!../../../assets/images/global/fullscreen.svg').default, | ||
59 | 'exit-fullscreen': require('!!raw-loader?!../../../assets/images/global/exit-fullscreen.svg').default | ||
55 | } | 60 | } |
56 | 61 | ||
57 | export type GlobalIconName = keyof typeof icons | 62 | export type GlobalIconName = keyof typeof icons |
diff --git a/client/src/app/shared/images/preview-upload.component.html b/client/src/app/shared/images/preview-upload.component.html index 5e1d5211b..7c3a2b588 100644 --- a/client/src/app/shared/images/preview-upload.component.html +++ b/client/src/app/shared/images/preview-upload.component.html | |||
@@ -1,13 +1,11 @@ | |||
1 | <div class="root"> | 1 | <div class="root"> |
2 | <div class="preview-container"> | 2 | <div class="preview-container"> |
3 | <my-reactive-file | 3 | <my-reactive-file |
4 | [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" | 4 | [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right" |
5 | icon="edit" (fileChanged)="onFileChanged($event)" | 5 | icon="edit" (fileChanged)="onFileChanged($event)" [ngbTooltip]="'(extensions: '+ videoImageExtensions +', '+ maxSizeText +': '+ maxVideoImageSizeInBytes +')'" |
6 | ></my-reactive-file> | 6 | ></my-reactive-file> |
7 | 7 | ||
8 | <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" /> | 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> | 9 | <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div> |
10 | </div> | 10 | </div> |
11 | |||
12 | <div i18n class="file-constraints">(extensions: {{ allowedExtensionsMessage }}, max size: {{ maxVideoImageSize | bytes }})</div> | ||
13 | </div> | 11 | </div> |
diff --git a/client/src/app/shared/images/preview-upload.component.scss b/client/src/app/shared/images/preview-upload.component.scss index 257060239..8f3522115 100644 --- a/client/src/app/shared/images/preview-upload.component.scss +++ b/client/src/app/shared/images/preview-upload.component.scss | |||
@@ -16,11 +16,13 @@ | |||
16 | } | 16 | } |
17 | 17 | ||
18 | .preview { | 18 | .preview { |
19 | border: 2px solid grey; | 19 | object-fit: cover; |
20 | border-radius: 4px; | 20 | border-radius: 4px; |
21 | max-width: 100%; | ||
21 | 22 | ||
22 | &.no-image { | 23 | &.no-image { |
23 | background-color: #ececec; | 24 | border: 2px solid grey; |
25 | background-color: var(--mainBackgroundColor); | ||
24 | } | 26 | } |
25 | } | 27 | } |
26 | } | 28 | } |
diff --git a/client/src/app/shared/images/preview-upload.component.ts b/client/src/app/shared/images/preview-upload.component.ts index f56f5b1f8..7519734ba 100644 --- a/client/src/app/shared/images/preview-upload.component.ts +++ b/client/src/app/shared/images/preview-upload.component.ts | |||
@@ -3,6 +3,8 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | |||
3 | import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' | 3 | import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' |
4 | import { ServerService } from '@app/core' | 4 | import { ServerService } from '@app/core' |
5 | import { ServerConfig } from '@shared/models' | 5 | import { ServerConfig } from '@shared/models' |
6 | import { BytesPipe } from 'ngx-pipes' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
6 | 8 | ||
7 | @Component({ | 9 | @Component({ |
8 | selector: 'my-preview-upload', | 10 | selector: 'my-preview-upload', |
@@ -24,14 +26,20 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor { | |||
24 | 26 | ||
25 | imageSrc: SafeResourceUrl | 27 | imageSrc: SafeResourceUrl |
26 | allowedExtensionsMessage = '' | 28 | allowedExtensionsMessage = '' |
29 | maxSizeText: string | ||
27 | 30 | ||
28 | private serverConfig: ServerConfig | 31 | private serverConfig: ServerConfig |
29 | private file: File | 32 | private bytesPipe: BytesPipe |
33 | private file: Blob | ||
30 | 34 | ||
31 | constructor ( | 35 | constructor ( |
32 | private sanitizer: DomSanitizer, | 36 | private sanitizer: DomSanitizer, |
33 | private serverService: ServerService | 37 | private serverService: ServerService, |
34 | ) {} | 38 | private i18n: I18n |
39 | ) { | ||
40 | this.bytesPipe = new BytesPipe() | ||
41 | this.maxSizeText = this.i18n('max size') | ||
42 | } | ||
35 | 43 | ||
36 | get videoImageExtensions () { | 44 | get videoImageExtensions () { |
37 | return this.serverConfig.video.image.extensions | 45 | return this.serverConfig.video.image.extensions |
@@ -41,6 +49,10 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor { | |||
41 | return this.serverConfig.video.image.size.max | 49 | return this.serverConfig.video.image.size.max |
42 | } | 50 | } |
43 | 51 | ||
52 | get maxVideoImageSizeInBytes () { | ||
53 | return this.bytesPipe.transform(this.maxVideoImageSize) | ||
54 | } | ||
55 | |||
44 | ngOnInit () { | 56 | ngOnInit () { |
45 | this.serverConfig = this.serverService.getTmpConfig() | 57 | this.serverConfig = this.serverService.getTmpConfig() |
46 | this.serverService.getConfig() | 58 | this.serverService.getConfig() |
@@ -49,7 +61,7 @@ export class PreviewUploadComponent implements OnInit, ControlValueAccessor { | |||
49 | this.allowedExtensionsMessage = this.videoImageExtensions.join(', ') | 61 | this.allowedExtensionsMessage = this.videoImageExtensions.join(', ') |
50 | } | 62 | } |
51 | 63 | ||
52 | onFileChanged (file: File) { | 64 | onFileChanged (file: Blob) { |
53 | this.file = file | 65 | this.file = file |
54 | 66 | ||
55 | this.propagateChange(this.file) | 67 | this.propagateChange(this.file) |
diff --git a/client/src/app/shared/instance/instance-features-table.component.html b/client/src/app/shared/instance/instance-features-table.component.html index fd8b3354f..99b854d13 100644 --- a/client/src/app/shared/instance/instance-features-table.component.html +++ b/client/src/app/shared/instance/instance-features-table.component.html | |||
@@ -37,8 +37,8 @@ | |||
37 | <tr> | 37 | <tr> |
38 | <td i18n class="sub-label">Video uploads</td> | 38 | <td i18n class="sub-label">Video uploads</td> |
39 | <td> | 39 | <td> |
40 | <span *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span> | 40 | <span i18n *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span> |
41 | <span *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span> | 41 | <span i18n *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span> |
42 | </td> | 42 | </td> |
43 | </tr> | 43 | </tr> |
44 | 44 | ||
@@ -91,5 +91,16 @@ | |||
91 | <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean> | 91 | <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean> |
92 | </td> | 92 | </td> |
93 | </tr> | 93 | </tr> |
94 | |||
95 | <tr> | ||
96 | <td i18n class="label" colspan="2">Search</td> | ||
97 | </tr> | ||
98 | |||
99 | <tr> | ||
100 | <td i18n class="sub-label">Users can resolve distant content</td> | ||
101 | <td> | ||
102 | <my-feature-boolean [value]="serverConfig.search.remoteUri.users"></my-feature-boolean> | ||
103 | </td> | ||
104 | </tr> | ||
94 | </table> | 105 | </table> |
95 | </div> | 106 | </div> |
diff --git a/client/src/app/shared/instance/instance-statistics.component.ts b/client/src/app/shared/instance/instance-statistics.component.ts index 8ec728f05..40aa8a4c0 100644 --- a/client/src/app/shared/instance/instance-statistics.component.ts +++ b/client/src/app/shared/instance/instance-statistics.component.ts | |||
@@ -1,9 +1,6 @@ | |||
1 | import { map } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
5 | import { ServerStats } from '@shared/models/server' | 2 | import { ServerStats } from '@shared/models/server' |
6 | import { environment } from '../../../environments/environment' | 3 | import { ServerService } from '@app/core' |
7 | 4 | ||
8 | @Component({ | 5 | @Component({ |
9 | selector: 'my-instance-statistics', | 6 | selector: 'my-instance-statistics', |
@@ -11,27 +8,15 @@ import { environment } from '../../../environments/environment' | |||
11 | styleUrls: [ './instance-statistics.component.scss' ] | 8 | styleUrls: [ './instance-statistics.component.scss' ] |
12 | }) | 9 | }) |
13 | export class InstanceStatisticsComponent implements OnInit { | 10 | export class InstanceStatisticsComponent implements OnInit { |
14 | private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats' | ||
15 | |||
16 | serverStats: ServerStats = null | 11 | serverStats: ServerStats = null |
17 | 12 | ||
18 | constructor ( | 13 | constructor ( |
19 | private http: HttpClient, | 14 | private serverService: ServerService |
20 | private i18n: I18n | ||
21 | ) { | 15 | ) { |
22 | } | 16 | } |
23 | 17 | ||
24 | ngOnInit () { | 18 | ngOnInit () { |
25 | this.getStats() | 19 | this.serverService.getServerStats() |
26 | .subscribe( | 20 | .subscribe(res => this.serverStats = res) |
27 | res => { | ||
28 | this.serverStats = res | ||
29 | } | ||
30 | ) | ||
31 | } | ||
32 | |||
33 | getStats () { | ||
34 | return this.http | ||
35 | .get<ServerStats>(InstanceStatisticsComponent.BASE_STATS_URL) | ||
36 | } | 21 | } |
37 | } | 22 | } |
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.html b/client/src/app/shared/menu/top-menu-dropdown.component.html index 35511ee62..d577f757d 100644 --- a/client/src/app/shared/menu/top-menu-dropdown.component.html +++ b/client/src/app/shared/menu/top-menu-dropdown.component.html | |||
@@ -1,12 +1,22 @@ | |||
1 | <div class="sub-menu"> | 1 | <div class="sub-menu" [ngClass]="{ 'no-scroll': isModalOpened }"> |
2 | <ng-container *ngFor="let menuEntry of menuEntries"> | 2 | <ng-container *ngFor="let menuEntry of menuEntries; index as id"> |
3 | 3 | ||
4 | <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page">{{ menuEntry.label }}</a> | 4 | <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page title-page-settings">{{ menuEntry.label }}</a> |
5 | 5 | ||
6 | <div *ngIf="!menuEntry.routerLink" ngbDropdown [container]="container" class="parent-entry" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)"> | 6 | <div *ngIf="!menuEntry.routerLink" ngbDropdown [container]="container" class="parent-entry" |
7 | #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)"> | ||
7 | <span | 8 | <span |
9 | *ngIf="isInSmallView" | ||
10 | [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" | ||
11 | (click)="openModal(id)" role="button" class="title-page title-page-settings"> | ||
12 | <ng-container i18n>{{ menuEntry.label }}</ng-container> | ||
13 | <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container> | ||
14 | </span> | ||
15 | |||
16 | <span | ||
17 | *ngIf="!isInSmallView" | ||
8 | (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor | 18 | (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor |
9 | (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page" | 19 | (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page title-page-settings" |
10 | > | 20 | > |
11 | <ng-container i18n>{{ menuEntry.label }}</ng-container> | 21 | <ng-container i18n>{{ menuEntry.label }}</ng-container> |
12 | <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container> | 22 | <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container> |
@@ -20,6 +30,21 @@ | |||
20 | </a> | 30 | </a> |
21 | </div> | 31 | </div> |
22 | </div> | 32 | </div> |
23 | |||
24 | </ng-container> | 33 | </ng-container> |
25 | </div> | 34 | </div> |
35 | |||
36 | <ng-template #modal let-close="close" let-dismiss="dismiss"> | ||
37 | <div class="modal-body"> | ||
38 | <ng-container *ngFor="let menuEntry of menuEntries; index as id"> | ||
39 | <div [ngClass]="{ hidden: id !== currentMenuEntryIndex }"> | ||
40 | <a *ngFor="let menuChild of menuEntry.children" | ||
41 | [ngClass]="{ icon: hasIcons }" | ||
42 | [routerLink]="menuChild.routerLink" routerLinkActive="active" (click)="dismissOtherModals()"> | ||
43 | <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName"></my-global-icon> | ||
44 | |||
45 | {{ menuChild.label }} | ||
46 | </a> | ||
47 | </div> | ||
48 | </ng-container> | ||
49 | </div> | ||
50 | </ng-template> | ||
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.scss b/client/src/app/shared/menu/top-menu-dropdown.component.scss index 1be699a88..5f90dcf80 100644 --- a/client/src/app/shared/menu/top-menu-dropdown.component.scss +++ b/client/src/app/shared/menu/top-menu-dropdown.component.scss | |||
@@ -25,3 +25,32 @@ | |||
25 | 25 | ||
26 | top: -1px; | 26 | top: -1px; |
27 | } | 27 | } |
28 | |||
29 | .sub-menu.no-scroll { | ||
30 | overflow-x: hidden; | ||
31 | } | ||
32 | |||
33 | .modal-body { | ||
34 | .hidden { | ||
35 | display: none; | ||
36 | } | ||
37 | |||
38 | a { | ||
39 | @include disable-default-a-behaviour; | ||
40 | |||
41 | color: currentColor; | ||
42 | box-sizing: border-box; | ||
43 | display: block; | ||
44 | font-size: 1.2rem; | ||
45 | padding: 9px 12px; | ||
46 | text-align: initial; | ||
47 | text-transform: unset; | ||
48 | width: 100%; | ||
49 | |||
50 | &.active { | ||
51 | color: var(--mainBackgroundColor) !important; | ||
52 | background-color: var(--mainHoverColor); | ||
53 | opacity: .9; | ||
54 | } | ||
55 | } | ||
56 | } | ||
diff --git a/client/src/app/shared/menu/top-menu-dropdown.component.ts b/client/src/app/shared/menu/top-menu-dropdown.component.ts index 5ccdafb54..f98240804 100644 --- a/client/src/app/shared/menu/top-menu-dropdown.component.ts +++ b/client/src/app/shared/menu/top-menu-dropdown.component.ts | |||
@@ -1,8 +1,14 @@ | |||
1 | import { Component, Input, OnDestroy, OnInit } from '@angular/core' | 1 | import { |
2 | Component, | ||
3 | Input, | ||
4 | OnDestroy, | ||
5 | OnInit, | ||
6 | ViewChild | ||
7 | } from '@angular/core' | ||
2 | import { filter, take } from 'rxjs/operators' | 8 | import { filter, take } from 'rxjs/operators' |
3 | import { NavigationEnd, Router } from '@angular/router' | 9 | import { NavigationEnd, Router } from '@angular/router' |
4 | import { Subscription } from 'rxjs' | 10 | import { Subscription } from 'rxjs' |
5 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | 11 | import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap' |
6 | import { GlobalIconName } from '@app/shared/images/global-icon.component' | 12 | import { GlobalIconName } from '@app/shared/images/global-icon.component' |
7 | import { ScreenService } from '@app/shared/misc/screen.service' | 13 | import { ScreenService } from '@app/shared/misc/screen.service' |
8 | 14 | ||
@@ -26,32 +32,40 @@ export type TopMenuDropdownParam = { | |||
26 | export class TopMenuDropdownComponent implements OnInit, OnDestroy { | 32 | export class TopMenuDropdownComponent implements OnInit, OnDestroy { |
27 | @Input() menuEntries: TopMenuDropdownParam[] = [] | 33 | @Input() menuEntries: TopMenuDropdownParam[] = [] |
28 | 34 | ||
35 | @ViewChild('modal', { static: true }) modal: NgbModal | ||
36 | |||
29 | suffixLabels: { [ parentLabel: string ]: string } | 37 | suffixLabels: { [ parentLabel: string ]: string } |
30 | hasIcons = false | 38 | hasIcons = false |
31 | container: undefined | 'body' = undefined | 39 | container: undefined | 'body' = undefined |
40 | isModalOpened = false | ||
41 | currentMenuEntryIndex: number | ||
32 | 42 | ||
33 | private openedOnHover = false | 43 | private openedOnHover = false |
34 | private routeSub: Subscription | 44 | private routeSub: Subscription |
35 | 45 | ||
36 | constructor ( | 46 | constructor ( |
37 | private router: Router, | 47 | private router: Router, |
48 | private modalService: NgbModal, | ||
38 | private screen: ScreenService | 49 | private screen: ScreenService |
39 | ) {} | 50 | ) { } |
51 | |||
52 | get isInSmallView () { | ||
53 | return this.screen.isInSmallView() | ||
54 | } | ||
40 | 55 | ||
41 | ngOnInit () { | 56 | ngOnInit () { |
42 | this.updateChildLabels(window.location.pathname) | 57 | this.updateChildLabels(window.location.pathname) |
43 | 58 | ||
44 | this.routeSub = this.router.events | 59 | this.routeSub = this.router.events |
45 | .pipe(filter(event => event instanceof NavigationEnd)) | 60 | .pipe(filter(event => event instanceof NavigationEnd)) |
46 | .subscribe(() => this.updateChildLabels(window.location.pathname)) | 61 | .subscribe(() => this.updateChildLabels(window.location.pathname)) |
47 | 62 | ||
48 | this.hasIcons = this.menuEntries.some( | 63 | this.hasIcons = this.menuEntries.some( |
49 | e => e.children && e.children.some(c => !!c.iconName) | 64 | e => e.children && e.children.some(c => !!c.iconName) |
50 | ) | 65 | ) |
51 | 66 | ||
52 | // FIXME: We have to set body for the container to avoid because of scroll overflow on mobile view | 67 | // We have to set body for the container to avoid scroll overflow on mobile and small views |
53 | // But this break our hovering system | 68 | if (this.isInSmallView) { |
54 | if (this.screen.isInMobileView()) { | ||
55 | this.container = 'body' | 69 | this.container = 'body' |
56 | } | 70 | } |
57 | } | 71 | } |
@@ -86,6 +100,27 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy { | |||
86 | this.openedOnHover = false | 100 | this.openedOnHover = false |
87 | } | 101 | } |
88 | 102 | ||
103 | openModal (index: number) { | ||
104 | this.currentMenuEntryIndex = index | ||
105 | this.isModalOpened = true | ||
106 | |||
107 | this.modalService.open(this.modal, { | ||
108 | centered: true, | ||
109 | beforeDismiss: async () => { | ||
110 | this.onModalDismiss() | ||
111 | return true | ||
112 | } | ||
113 | }) | ||
114 | } | ||
115 | |||
116 | onModalDismiss () { | ||
117 | this.isModalOpened = false | ||
118 | } | ||
119 | |||
120 | dismissOtherModals () { | ||
121 | this.modalService.dismissAll() | ||
122 | } | ||
123 | |||
89 | private updateChildLabels (path: string) { | 124 | private updateChildLabels (path: string) { |
90 | this.suffixLabels = {} | 125 | this.suffixLabels = {} |
91 | 126 | ||
diff --git a/client/src/app/shared/misc/help.component.scss b/client/src/app/shared/misc/help.component.scss index f55a516e4..3c8b66cd5 100644 --- a/client/src/app/shared/misc/help.component.scss +++ b/client/src/app/shared/misc/help.component.scss | |||
@@ -17,6 +17,7 @@ | |||
17 | 17 | ||
18 | ::ng-deep { | 18 | ::ng-deep { |
19 | .help-popover { | 19 | .help-popover { |
20 | z-index: z(help-popover) !important; | ||
20 | max-width: 300px; | 21 | max-width: 300px; |
21 | 22 | ||
22 | .popover-body { | 23 | .popover-body { |
@@ -26,7 +27,7 @@ | |||
26 | font-size: 13px; | 27 | font-size: 13px; |
27 | background-color: var(--mainBackgroundColor); | 28 | background-color: var(--mainBackgroundColor); |
28 | color: var(--mainForegroundColor); | 29 | color: var(--mainForegroundColor); |
29 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2); | 30 | border-radius: 3px; |
30 | 31 | ||
31 | p { | 32 | p { |
32 | margin-bottom: 0; | 33 | margin-bottom: 0; |
diff --git a/client/src/app/shared/misc/list-overflow.component.html b/client/src/app/shared/misc/list-overflow.component.html new file mode 100644 index 000000000..986572801 --- /dev/null +++ b/client/src/app/shared/misc/list-overflow.component.html | |||
@@ -0,0 +1,35 @@ | |||
1 | <div #itemsParent class="d-flex align-items-center text-nowrap w-100 list-overflow-parent"> | ||
2 | <span [id]="getId(id)" #itemsRendered *ngFor="let item of items; index as id"> | ||
3 | <ng-container *ngTemplateOutlet="itemTemplate; context: {item: item}"></ng-container> | ||
4 | </span> | ||
5 | |||
6 | <ng-container *ngIf="isMenuDisplayed()"> | ||
7 | <button *ngIf="isInMobileView" class="btn btn-outline-secondary btn-sm list-overflow-menu" (click)="toggleModal()"> | ||
8 | <span class="glyphicon glyphicon-chevron-down"></span> | ||
9 | </button> | ||
10 | |||
11 | <div *ngIf="!isInMobileView" class="list-overflow-menu" ngbDropdown container="body" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)" (mouseenter)="openDropdownOnHover(dropdown)"> | ||
12 | <button class="btn btn-outline-secondary btn-sm" [ngClass]="{ routeActive: active }" | ||
13 | ngbDropdownAnchor (click)="dropdownAnchorClicked(dropdown)" role="button" | ||
14 | > | ||
15 | <span class="glyphicon glyphicon-chevron-down"></span> | ||
16 | </button> | ||
17 | |||
18 | <div ngbDropdownMenu> | ||
19 | <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length" | ||
20 | [routerLink]="item.routerLink" routerLinkActive="active" class="dropdown-item"> | ||
21 | {{ item.label }} | ||
22 | </a> | ||
23 | </div> | ||
24 | </div> | ||
25 | </ng-container> | ||
26 | </div > | ||
27 | |||
28 | <ng-template #modal let-close="close" let-dismiss="dismiss"> | ||
29 | <div class="modal-body"> | ||
30 | <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length" | ||
31 | [routerLink]="item.routerLink" routerLinkActive="active" (click)="dismissOtherModals()"> | ||
32 | {{ item.label }} | ||
33 | </a> | ||
34 | </div> | ||
35 | </ng-template> | ||
diff --git a/client/src/app/shared/misc/list-overflow.component.scss b/client/src/app/shared/misc/list-overflow.component.scss new file mode 100644 index 000000000..1e5fe4c10 --- /dev/null +++ b/client/src/app/shared/misc/list-overflow.component.scss | |||
@@ -0,0 +1,61 @@ | |||
1 | @import '_mixins'; | ||
2 | |||
3 | :host { | ||
4 | width: 100%; | ||
5 | } | ||
6 | |||
7 | .list-overflow-parent { | ||
8 | overflow: hidden; | ||
9 | } | ||
10 | |||
11 | .list-overflow-menu { | ||
12 | position: absolute; | ||
13 | right: 25px; | ||
14 | } | ||
15 | |||
16 | button { | ||
17 | width: 30px; | ||
18 | border: none; | ||
19 | |||
20 | &::after { | ||
21 | display: none; | ||
22 | } | ||
23 | |||
24 | &.routeActive { | ||
25 | &::after { | ||
26 | display: inherit; | ||
27 | border: 2px solid var(--mainColor); | ||
28 | position: relative; | ||
29 | right: 95%; | ||
30 | top: 50%; | ||
31 | } | ||
32 | } | ||
33 | } | ||
34 | |||
35 | ::ng-deep .dropdown-menu { | ||
36 | margin-top: 0 !important; | ||
37 | position: static; | ||
38 | right: auto; | ||
39 | bottom: auto | ||
40 | } | ||
41 | |||
42 | .modal-body { | ||
43 | a { | ||
44 | @include disable-default-a-behaviour; | ||
45 | |||
46 | color: currentColor; | ||
47 | box-sizing: border-box; | ||
48 | display: block; | ||
49 | font-size: 1.2rem; | ||
50 | padding: 9px 12px; | ||
51 | text-align: initial; | ||
52 | text-transform: unset; | ||
53 | width: 100%; | ||
54 | |||
55 | &.active { | ||
56 | color: var(--mainBackgroundColor) !important; | ||
57 | background-color: var(--mainHoverColor); | ||
58 | opacity: .9; | ||
59 | } | ||
60 | } | ||
61 | } | ||
diff --git a/client/src/app/shared/misc/list-overflow.component.ts b/client/src/app/shared/misc/list-overflow.component.ts new file mode 100644 index 000000000..30f43ba43 --- /dev/null +++ b/client/src/app/shared/misc/list-overflow.component.ts | |||
@@ -0,0 +1,120 @@ | |||
1 | import { | ||
2 | AfterViewInit, | ||
3 | ChangeDetectionStrategy, | ||
4 | ChangeDetectorRef, | ||
5 | Component, | ||
6 | ElementRef, | ||
7 | HostListener, | ||
8 | Input, | ||
9 | QueryList, | ||
10 | TemplateRef, | ||
11 | ViewChild, | ||
12 | ViewChildren | ||
13 | } from '@angular/core' | ||
14 | import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
15 | import { lowerFirst, uniqueId } from 'lodash-es' | ||
16 | import { ScreenService } from './screen.service' | ||
17 | import { take } from 'rxjs/operators' | ||
18 | |||
19 | export interface ListOverflowItem { | ||
20 | label: string | ||
21 | routerLink: string | any[] | ||
22 | } | ||
23 | |||
24 | @Component({ | ||
25 | selector: 'list-overflow', | ||
26 | templateUrl: './list-overflow.component.html', | ||
27 | styleUrls: [ './list-overflow.component.scss' ], | ||
28 | changeDetection: ChangeDetectionStrategy.OnPush | ||
29 | }) | ||
30 | export class ListOverflowComponent<T extends ListOverflowItem> implements AfterViewInit { | ||
31 | @Input() items: T[] | ||
32 | @Input() itemTemplate: TemplateRef<{item: T}> | ||
33 | |||
34 | @ViewChild('modal', { static: true }) modal: ElementRef | ||
35 | @ViewChild('itemsParent', { static: true }) parent: ElementRef<HTMLDivElement> | ||
36 | @ViewChildren('itemsRendered') itemsRendered: QueryList<ElementRef> | ||
37 | |||
38 | showItemsUntilIndexExcluded: number | ||
39 | active = false | ||
40 | isInTouchScreen = false | ||
41 | isInMobileView = false | ||
42 | |||
43 | private openedOnHover = false | ||
44 | |||
45 | constructor ( | ||
46 | private cdr: ChangeDetectorRef, | ||
47 | private modalService: NgbModal, | ||
48 | private screenService: ScreenService | ||
49 | ) {} | ||
50 | |||
51 | ngAfterViewInit () { | ||
52 | setTimeout(() => this.onWindowResize(), 0) | ||
53 | } | ||
54 | |||
55 | isMenuDisplayed () { | ||
56 | return !!this.showItemsUntilIndexExcluded | ||
57 | } | ||
58 | |||
59 | @HostListener('window:resize') | ||
60 | onWindowResize () { | ||
61 | this.isInTouchScreen = !!this.screenService.isInTouchScreen() | ||
62 | this.isInMobileView = !!this.screenService.isInMobileView() | ||
63 | |||
64 | const parentWidth = this.parent.nativeElement.getBoundingClientRect().width | ||
65 | let showItemsUntilIndexExcluded: number | ||
66 | let accWidth = 0 | ||
67 | |||
68 | for (const [index, el] of this.itemsRendered.toArray().entries()) { | ||
69 | accWidth += el.nativeElement.getBoundingClientRect().width | ||
70 | if (showItemsUntilIndexExcluded === undefined) { | ||
71 | showItemsUntilIndexExcluded = (parentWidth < accWidth) ? index : undefined | ||
72 | } | ||
73 | |||
74 | const e = document.getElementById(this.getId(index)) | ||
75 | const shouldBeVisible = showItemsUntilIndexExcluded ? index < showItemsUntilIndexExcluded : true | ||
76 | e.style.visibility = shouldBeVisible ? 'inherit' : 'hidden' | ||
77 | } | ||
78 | |||
79 | this.showItemsUntilIndexExcluded = showItemsUntilIndexExcluded | ||
80 | this.cdr.markForCheck() | ||
81 | } | ||
82 | |||
83 | openDropdownOnHover (dropdown: NgbDropdown) { | ||
84 | this.openedOnHover = true | ||
85 | dropdown.open() | ||
86 | |||
87 | // Menu was closed | ||
88 | dropdown.openChange | ||
89 | .pipe(take(1)) | ||
90 | .subscribe(() => this.openedOnHover = false) | ||
91 | } | ||
92 | |||
93 | dropdownAnchorClicked (dropdown: NgbDropdown) { | ||
94 | if (this.openedOnHover) { | ||
95 | this.openedOnHover = false | ||
96 | return | ||
97 | } | ||
98 | |||
99 | return dropdown.toggle() | ||
100 | } | ||
101 | |||
102 | closeDropdownIfHovered (dropdown: NgbDropdown) { | ||
103 | if (this.openedOnHover === false) return | ||
104 | |||
105 | dropdown.close() | ||
106 | this.openedOnHover = false | ||
107 | } | ||
108 | |||
109 | toggleModal () { | ||
110 | this.modalService.open(this.modal, { centered: true }) | ||
111 | } | ||
112 | |||
113 | dismissOtherModals () { | ||
114 | this.modalService.dismissAll() | ||
115 | } | ||
116 | |||
117 | getId (id: number | string = uniqueId()): string { | ||
118 | return lowerFirst(this.constructor.name) + '_' + id | ||
119 | } | ||
120 | } | ||
diff --git a/client/src/app/shared/misc/screen.service.ts b/client/src/app/shared/misc/screen.service.ts index 220d41d59..9c71a8c83 100644 --- a/client/src/app/shared/misc/screen.service.ts +++ b/client/src/app/shared/misc/screen.service.ts | |||
@@ -14,6 +14,10 @@ export class ScreenService { | |||
14 | return this.getWindowInnerWidth() < 800 | 14 | return this.getWindowInnerWidth() < 800 |
15 | } | 15 | } |
16 | 16 | ||
17 | isInMediumView () { | ||
18 | return this.getWindowInnerWidth() < 1100 | ||
19 | } | ||
20 | |||
17 | isInMobileView () { | 21 | isInMobileView () { |
18 | return this.getWindowInnerWidth() < 500 | 22 | return this.getWindowInnerWidth() < 500 |
19 | } | 23 | } |
diff --git a/client/src/app/shared/misc/storage.service.ts b/client/src/app/shared/misc/storage.service.ts new file mode 100644 index 000000000..0d4a8ab53 --- /dev/null +++ b/client/src/app/shared/misc/storage.service.ts | |||
@@ -0,0 +1,40 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { Observable, Subject } from 'rxjs' | ||
3 | import { | ||
4 | peertubeLocalStorage, | ||
5 | peertubeSessionStorage | ||
6 | } from './peertube-web-storage' | ||
7 | import { filter } from 'rxjs/operators' | ||
8 | |||
9 | abstract class StorageService { | ||
10 | protected instance: Storage | ||
11 | static storageSub = new Subject<string>() | ||
12 | |||
13 | watch (keys?: string[]): Observable<string> { | ||
14 | return StorageService.storageSub.asObservable().pipe(filter(val => keys ? keys.includes(val) : true)) | ||
15 | } | ||
16 | |||
17 | getItem (key: string) { | ||
18 | return this.instance.getItem(key) | ||
19 | } | ||
20 | |||
21 | setItem (key: string, data: any, notifyOfUpdate = true) { | ||
22 | this.instance.setItem(key, data) | ||
23 | if (notifyOfUpdate) StorageService.storageSub.next(key) | ||
24 | } | ||
25 | |||
26 | removeItem (key: string, notifyOfUpdate = true) { | ||
27 | this.instance.removeItem(key) | ||
28 | if (notifyOfUpdate) StorageService.storageSub.next(key) | ||
29 | } | ||
30 | } | ||
31 | |||
32 | @Injectable() | ||
33 | export class LocalStorageService extends StorageService { | ||
34 | protected instance: Storage = peertubeLocalStorage | ||
35 | } | ||
36 | |||
37 | @Injectable() | ||
38 | export class SessionStorageService extends StorageService { | ||
39 | protected instance: Storage = peertubeSessionStorage | ||
40 | } | ||
diff --git a/client/src/app/shared/moderation/user-ban-modal.component.html b/client/src/app/shared/moderation/user-ban-modal.component.html index f38ea543d..365eb1938 100644 --- a/client/src/app/shared/moderation/user-ban-modal.component.html +++ b/client/src/app/shared/moderation/user-ban-modal.component.html | |||
@@ -8,8 +8,10 @@ | |||
8 | <div class="modal-body"> | 8 | <div class="modal-body"> |
9 | <form novalidate [formGroup]="form" (ngSubmit)="banUser()"> | 9 | <form novalidate [formGroup]="form" (ngSubmit)="banUser()"> |
10 | <div class="form-group"> | 10 | <div class="form-group"> |
11 | <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> | 11 | <textarea |
12 | </textarea> | 12 | i18n-placeholder placeholder="Reason..." formControlName="reason" |
13 | class="form-control" [ngClass]="{ 'input-error': formErrors['reason'] }" | ||
14 | ></textarea> | ||
13 | <div *ngIf="formErrors.reason" class="form-error"> | 15 | <div *ngIf="formErrors.reason" class="form-error"> |
14 | {{ formErrors.reason }} | 16 | {{ formErrors.reason }} |
15 | </div> | 17 | </div> |
@@ -20,7 +22,10 @@ | |||
20 | </div> | 22 | </div> |
21 | 23 | ||
22 | <div class="form-group inputs"> | 24 | <div class="form-group inputs"> |
23 | <span i18n class="action-button action-button-cancel" (click)="hide()">Cancel</span> | 25 | <input |
26 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | ||
27 | (click)="hide()" (key.enter)="hide()" | ||
28 | > | ||
24 | 29 | ||
25 | <input | 30 | <input |
26 | type="submit" i18n-value value="Ban this user" class="action-button-submit" | 31 | type="submit" i18n-value value="Ban this user" class="action-button-submit" |
diff --git a/client/src/app/shared/moderation/user-ban-modal.component.ts b/client/src/app/shared/moderation/user-ban-modal.component.ts index cf0e1577a..1647e3691 100644 --- a/client/src/app/shared/moderation/user-ban-modal.component.ts +++ b/client/src/app/shared/moderation/user-ban-modal.component.ts | |||
@@ -39,7 +39,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit { | |||
39 | 39 | ||
40 | openModal (user: User | User[]) { | 40 | openModal (user: User | User[]) { |
41 | this.usersToBan = user | 41 | this.usersToBan = user |
42 | this.openedModal = this.modalService.open(this.modal) | 42 | this.openedModal = this.modalService.open(this.modal, { centered: true }) |
43 | } | 43 | } |
44 | 44 | ||
45 | hide () { | 45 | hide () { |
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts index 11d8588f4..c8ccaa800 100644 --- a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts +++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts | |||
@@ -14,13 +14,13 @@ import { ServerConfig } from '@shared/models' | |||
14 | templateUrl: './user-moderation-dropdown.component.html' | 14 | templateUrl: './user-moderation-dropdown.component.html' |
15 | }) | 15 | }) |
16 | export class UserModerationDropdownComponent implements OnInit, OnChanges { | 16 | export class UserModerationDropdownComponent implements OnInit, OnChanges { |
17 | @ViewChild('userBanModal', { static: false }) userBanModal: UserBanModalComponent | 17 | @ViewChild('userBanModal') userBanModal: UserBanModalComponent |
18 | 18 | ||
19 | @Input() user: User | 19 | @Input() user: User |
20 | @Input() account: Account | 20 | @Input() account: Account |
21 | 21 | ||
22 | @Input() buttonSize: 'normal' | 'small' = 'normal' | 22 | @Input() buttonSize: 'normal' | 'small' = 'normal' |
23 | @Input() placement = 'left' | 23 | @Input() placement = 'left-top left-bottom auto' |
24 | @Input() label: string | 24 | @Input() label: string |
25 | 25 | ||
26 | @Output() userChanged = new EventEmitter() | 26 | @Output() userChanged = new EventEmitter() |
diff --git a/client/src/app/shared/overview/overview.service.ts b/client/src/app/shared/overview/overview.service.ts index 79cb781f7..6d8af8052 100644 --- a/client/src/app/shared/overview/overview.service.ts +++ b/client/src/app/shared/overview/overview.service.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { catchError, map, switchMap, tap } from 'rxjs/operators' | 1 | import { catchError, map, switchMap, tap } from 'rxjs/operators' |
2 | import { HttpClient } from '@angular/common/http' | 2 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { forkJoin, Observable, of } from 'rxjs' | 4 | import { forkJoin, Observable, of } from 'rxjs' |
5 | import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models' | 5 | import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models' |
@@ -21,9 +21,12 @@ export class OverviewService { | |||
21 | private serverService: ServerService | 21 | private serverService: ServerService |
22 | ) {} | 22 | ) {} |
23 | 23 | ||
24 | getVideosOverview (): Observable<VideosOverview> { | 24 | getVideosOverview (page: number): Observable<VideosOverview> { |
25 | let params = new HttpParams() | ||
26 | params = params.append('page', page + '') | ||
27 | |||
25 | return this.authHttp | 28 | return this.authHttp |
26 | .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos') | 29 | .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params }) |
27 | .pipe( | 30 | .pipe( |
28 | switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)), | 31 | switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)), |
29 | catchError(err => this.restExtractor.handleError(err)) | 32 | catchError(err => this.restExtractor.handleError(err)) |
diff --git a/client/src/app/shared/renderer/html-renderer.service.ts b/client/src/app/shared/renderer/html-renderer.service.ts index 94a8aa4c6..1ddd8fe2f 100644 --- a/client/src/app/shared/renderer/html-renderer.service.ts +++ b/client/src/app/shared/renderer/html-renderer.service.ts | |||
@@ -19,15 +19,18 @@ export class HtmlRendererService { | |||
19 | allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], | 19 | allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ], |
20 | allowedSchemes: [ 'http', 'https' ], | 20 | allowedSchemes: [ 'http', 'https' ], |
21 | allowedAttributes: { | 21 | allowedAttributes: { |
22 | 'a': [ 'href', 'class', 'target' ] | 22 | 'a': [ 'href', 'class', 'target', 'rel' ] |
23 | }, | 23 | }, |
24 | transformTags: { | 24 | transformTags: { |
25 | a: (tagName, attribs) => { | 25 | a: (tagName, attribs) => { |
26 | let rel = 'noopener noreferrer' | ||
27 | if (attribs.rel === 'me') rel += ' me' | ||
28 | |||
26 | return { | 29 | return { |
27 | tagName, | 30 | tagName, |
28 | attribs: Object.assign(attribs, { | 31 | attribs: Object.assign(attribs, { |
29 | target: '_blank', | 32 | target: '_blank', |
30 | rel: 'noopener noreferrer' | 33 | rel |
31 | }) | 34 | }) |
32 | } | 35 | } |
33 | } | 36 | } |
diff --git a/client/src/app/shared/renderer/markdown.service.ts b/client/src/app/shared/renderer/markdown.service.ts index 0d3fde537..f0c87326f 100644 --- a/client/src/app/shared/renderer/markdown.service.ts +++ b/client/src/app/shared/renderer/markdown.service.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { MarkdownIt } from 'markdown-it' | ||
3 | import { buildVideoLink } from '../../../assets/player/utils' | 2 | import { buildVideoLink } from '../../../assets/player/utils' |
4 | import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service' | 3 | import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service' |
4 | import * as MarkdownIt from 'markdown-it' | ||
5 | 5 | ||
6 | type MarkdownParsers = { | 6 | type MarkdownParsers = { |
7 | textMarkdownIt: MarkdownIt | 7 | textMarkdownIt: MarkdownIt |
@@ -100,7 +100,7 @@ export class MarkdownService { | |||
100 | } | 100 | } |
101 | 101 | ||
102 | private async createMarkdownIt (config: MarkdownConfig) { | 102 | private async createMarkdownIt (config: MarkdownConfig) { |
103 | // FIXME: import('...') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function | 103 | // FIXME: import('...') returns a struct module, containing a "default" field |
104 | const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default | 104 | const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default |
105 | 105 | ||
106 | const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html }) | 106 | const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html }) |
diff --git a/client/src/app/shared/rest/rest-table.ts b/client/src/app/shared/rest/rest-table.ts index c180346af..d4e6cf5f2 100644 --- a/client/src/app/shared/rest/rest-table.ts +++ b/client/src/app/shared/rest/rest-table.ts | |||
@@ -1,6 +1,5 @@ | |||
1 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' | 1 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' |
2 | import { LazyLoadEvent } from 'primeng/components/common/lazyloadevent' | 2 | import { LazyLoadEvent, SortMeta } from 'primeng/api' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | ||
4 | import { RestPagination } from './rest-pagination' | 3 | import { RestPagination } from './rest-pagination' |
5 | import { Subject } from 'rxjs' | 4 | import { Subject } from 'rxjs' |
6 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | 5 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' |
@@ -8,13 +7,17 @@ import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | |||
8 | export abstract class RestTable { | 7 | export abstract class RestTable { |
9 | 8 | ||
10 | abstract totalRecords: number | 9 | abstract totalRecords: number |
11 | abstract rowsPerPage: number | ||
12 | abstract sort: SortMeta | 10 | abstract sort: SortMeta |
13 | abstract pagination: RestPagination | 11 | abstract pagination: RestPagination |
14 | 12 | ||
15 | protected search: string | 13 | search: string |
14 | rowsPerPageOptions = [ 10, 20, 50, 100 ] | ||
15 | rowsPerPage = this.rowsPerPageOptions[0] | ||
16 | expandedRows = {} | ||
17 | |||
16 | private searchStream: Subject<string> | 18 | private searchStream: Subject<string> |
17 | private sortLocalStorageKey = 'rest-table-sort-' + this.constructor.name | 19 | |
20 | abstract getIdentifier (): string | ||
18 | 21 | ||
19 | initialize () { | 22 | initialize () { |
20 | this.loadSort() | 23 | this.loadSort() |
@@ -22,13 +25,13 @@ export abstract class RestTable { | |||
22 | } | 25 | } |
23 | 26 | ||
24 | loadSort () { | 27 | loadSort () { |
25 | const result = peertubeLocalStorage.getItem(this.sortLocalStorageKey) | 28 | const result = peertubeLocalStorage.getItem(this.getSortLocalStorageKey()) |
26 | 29 | ||
27 | if (result) { | 30 | if (result) { |
28 | try { | 31 | try { |
29 | this.sort = JSON.parse(result) | 32 | this.sort = JSON.parse(result) |
30 | } catch (err) { | 33 | } catch (err) { |
31 | console.error('Cannot load sort of local storage key ' + this.sortLocalStorageKey, err) | 34 | console.error('Cannot load sort of local storage key ' + this.getSortLocalStorageKey(), err) |
32 | } | 35 | } |
33 | } | 36 | } |
34 | } | 37 | } |
@@ -49,7 +52,7 @@ export abstract class RestTable { | |||
49 | } | 52 | } |
50 | 53 | ||
51 | saveSort () { | 54 | saveSort () { |
52 | peertubeLocalStorage.setItem(this.sortLocalStorageKey, JSON.stringify(this.sort)) | 55 | peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort)) |
53 | } | 56 | } |
54 | 57 | ||
55 | initSearch () { | 58 | initSearch () { |
@@ -66,9 +69,37 @@ export abstract class RestTable { | |||
66 | }) | 69 | }) |
67 | } | 70 | } |
68 | 71 | ||
69 | onSearch (search: string) { | 72 | onSearch (event: Event) { |
70 | this.searchStream.next(search) | 73 | const target = event.target as HTMLInputElement |
74 | this.searchStream.next(target.value) | ||
75 | } | ||
76 | |||
77 | onPage (event: { first: number, rows: number }) { | ||
78 | if (this.rowsPerPage !== event.rows) { | ||
79 | this.rowsPerPage = event.rows | ||
80 | this.pagination = { | ||
81 | start: event.first, | ||
82 | count: this.rowsPerPage | ||
83 | } | ||
84 | this.loadData() | ||
85 | } | ||
86 | this.expandedRows = {} | ||
87 | } | ||
88 | |||
89 | setTableFilter (filter: string) { | ||
90 | // FIXME: cannot use ViewChild, so create a component for the filter input | ||
91 | const filterInput = document.getElementById('table-filter') as HTMLInputElement | ||
92 | if (filterInput) filterInput.value = filter | ||
93 | } | ||
94 | |||
95 | resetSearch () { | ||
96 | this.searchStream.next('') | ||
97 | this.setTableFilter('') | ||
71 | } | 98 | } |
72 | 99 | ||
73 | protected abstract loadData (): void | 100 | protected abstract loadData (): void |
101 | |||
102 | private getSortLocalStorageKey () { | ||
103 | return 'rest-table-sort-' + this.getIdentifier() | ||
104 | } | ||
74 | } | 105 | } |
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts index 16bb6d82c..cd6db1f3c 100644 --- a/client/src/app/shared/rest/rest.service.ts +++ b/client/src/app/shared/rest/rest.service.ts | |||
@@ -1,10 +1,21 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { SortMeta } from 'primeng/api' |
2 | import { HttpParams } from '@angular/common/http' | 2 | import { HttpParams } from '@angular/common/http' |
3 | import { SortMeta } from 'primeng/components/common/sortmeta' | 3 | import { Injectable } from '@angular/core' |
4 | import { ComponentPagination, ComponentPaginationLight } from './component-pagination.model' | 4 | import { ComponentPaginationLight } from './component-pagination.model' |
5 | |||
6 | import { RestPagination } from './rest-pagination' | 5 | import { RestPagination } from './rest-pagination' |
7 | 6 | ||
7 | interface QueryStringFilterPrefixes { | ||
8 | [key: string]: { | ||
9 | prefix: string | ||
10 | handler?: (v: string) => string | number | ||
11 | multiple?: boolean | ||
12 | } | ||
13 | } | ||
14 | |||
15 | type ParseQueryStringFilterResult = { | ||
16 | [key: string]: string | number | (string | number)[] | ||
17 | } | ||
18 | |||
8 | @Injectable() | 19 | @Injectable() |
9 | export class RestService { | 20 | export class RestService { |
10 | 21 | ||
@@ -53,4 +64,48 @@ export class RestService { | |||
53 | 64 | ||
54 | return { start, count } | 65 | return { start, count } |
55 | } | 66 | } |
67 | |||
68 | parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes): ParseQueryStringFilterResult { | ||
69 | if (!q) return {} | ||
70 | |||
71 | // Tokenize the strings using spaces | ||
72 | const tokens = q.split(' ').filter(token => !!token) | ||
73 | |||
74 | // Build prefix array | ||
75 | const prefixeStrings = Object.values(prefixes) | ||
76 | .map(p => p.prefix) | ||
77 | |||
78 | // Search is the querystring minus defined filters | ||
79 | const searchTokens = tokens.filter(t => { | ||
80 | return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false) | ||
81 | }) | ||
82 | |||
83 | const additionalFilters: ParseQueryStringFilterResult = {} | ||
84 | |||
85 | for (const prefixKey of Object.keys(prefixes)) { | ||
86 | const prefixObj = prefixes[prefixKey] | ||
87 | const prefix = prefixObj.prefix | ||
88 | |||
89 | const matchedTokens = tokens.filter(t => t.startsWith(prefix)) | ||
90 | .map(t => t.slice(prefix.length)) // Keep the value filter | ||
91 | .map(t => { | ||
92 | if (prefixObj.handler) return prefixObj.handler(t) | ||
93 | |||
94 | return t | ||
95 | }) | ||
96 | .filter(t => !!t) | ||
97 | |||
98 | if (matchedTokens.length === 0) continue | ||
99 | |||
100 | additionalFilters[prefixKey] = prefixObj.multiple === true | ||
101 | ? matchedTokens | ||
102 | : matchedTokens[0] | ||
103 | } | ||
104 | |||
105 | return { | ||
106 | search: searchTokens.join(' '), | ||
107 | |||
108 | ...additionalFilters | ||
109 | } | ||
110 | } | ||
56 | } | 111 | } |
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index b2eb13f73..01735c187 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts | |||
@@ -5,9 +5,10 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms' | |||
5 | import { RouterModule } from '@angular/router' | 5 | import { RouterModule } from '@angular/router' |
6 | import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' | 6 | import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component' |
7 | import { HelpComponent } from '@app/shared/misc/help.component' | 7 | import { HelpComponent } from '@app/shared/misc/help.component' |
8 | import { ListOverflowComponent } from '@app/shared/misc/list-overflow.component' | ||
8 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' | 9 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' |
9 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' | 10 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' |
10 | import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared' | 11 | import { SharedModule as PrimeSharedModule } from 'primeng/api' |
11 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | 12 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' |
12 | import { ButtonComponent } from './buttons/button.component' | 13 | import { ButtonComponent } from './buttons/button.component' |
13 | import { DeleteButtonComponent } from './buttons/delete-button.component' | 14 | import { DeleteButtonComponent } from './buttons/delete-button.component' |
@@ -46,6 +47,7 @@ import { | |||
46 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' | 47 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' |
47 | import { InputMaskModule } from 'primeng/inputmask' | 48 | import { InputMaskModule } from 'primeng/inputmask' |
48 | import { ScreenService } from '@app/shared/misc/screen.service' | 49 | import { ScreenService } from '@app/shared/misc/screen.service' |
50 | import { LocalStorageService, SessionStorageService } from '@app/shared/misc/storage.service' | ||
49 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' | 51 | import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service' |
50 | import { VideoCaptionService } from '@app/shared/video-caption' | 52 | import { VideoCaptionService } from '@app/shared/video-caption' |
51 | import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' | 53 | import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component' |
@@ -56,7 +58,7 @@ import { | |||
56 | NgbDropdownModule, | 58 | NgbDropdownModule, |
57 | NgbModalModule, | 59 | NgbModalModule, |
58 | NgbPopoverModule, | 60 | NgbPopoverModule, |
59 | NgbTabsetModule, | 61 | NgbNavModule, |
60 | NgbTooltipModule | 62 | NgbTooltipModule |
61 | } from '@ng-bootstrap/ng-bootstrap' | 63 | } from '@ng-bootstrap/ng-bootstrap' |
62 | import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' | 64 | import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription' |
@@ -88,16 +90,24 @@ import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' | |||
88 | import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe' | 90 | import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe' |
89 | import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' | 91 | import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' |
90 | import { FromNowPipe } from '@app/shared/angular/from-now.pipe' | 92 | import { FromNowPipe } from '@app/shared/angular/from-now.pipe' |
93 | import { HighlightPipe } from '@app/shared/angular/highlight.pipe' | ||
91 | import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' | 94 | import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' |
92 | import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' | 95 | import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' |
93 | import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' | 96 | import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' |
94 | import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' | 97 | import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' |
95 | import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' | 98 | import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' |
96 | import { ClipboardModule } from 'ngx-clipboard' | ||
97 | import { FollowService } from '@app/shared/instance/follow.service' | 99 | import { FollowService } from '@app/shared/instance/follow.service' |
98 | import { MultiSelectModule } from 'primeng/multiselect' | 100 | import { MultiSelectModule } from 'primeng/multiselect' |
99 | import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component' | 101 | import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component' |
100 | import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component' | 102 | import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component' |
103 | import { RedundancyService } from '@app/shared/video/redundancy.service' | ||
104 | import { ClipboardModule } from '@angular/cdk/clipboard' | ||
105 | import { InputSwitchModule } from 'primeng/inputswitch' | ||
106 | |||
107 | import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings' | ||
108 | import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface' | ||
109 | import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component' | ||
110 | import { BatchDomainsValidatorsService } from '@app/+admin/config/shared/batch-domains-validators.service' | ||
101 | 111 | ||
102 | @NgModule({ | 112 | @NgModule({ |
103 | imports: [ | 113 | imports: [ |
@@ -110,7 +120,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
110 | NgbDropdownModule, | 120 | NgbDropdownModule, |
111 | NgbModalModule, | 121 | NgbModalModule, |
112 | NgbPopoverModule, | 122 | NgbPopoverModule, |
113 | NgbTabsetModule, | 123 | NgbNavModule, |
114 | NgbTooltipModule, | 124 | NgbTooltipModule, |
115 | NgbCollapseModule, | 125 | NgbCollapseModule, |
116 | 126 | ||
@@ -119,7 +129,8 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
119 | PrimeSharedModule, | 129 | PrimeSharedModule, |
120 | InputMaskModule, | 130 | InputMaskModule, |
121 | NgPipesModule, | 131 | NgPipesModule, |
122 | MultiSelectModule | 132 | MultiSelectModule, |
133 | InputSwitchModule | ||
123 | ], | 134 | ], |
124 | 135 | ||
125 | declarations: [ | 136 | declarations: [ |
@@ -147,6 +158,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
147 | NumberFormatterPipe, | 158 | NumberFormatterPipe, |
148 | ObjectLengthPipe, | 159 | ObjectLengthPipe, |
149 | FromNowPipe, | 160 | FromNowPipe, |
161 | HighlightPipe, | ||
150 | PeerTubeTemplateDirective, | 162 | PeerTubeTemplateDirective, |
151 | VideoDurationPipe, | 163 | VideoDurationPipe, |
152 | 164 | ||
@@ -155,6 +167,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
155 | InfiniteScrollerDirective, | 167 | InfiniteScrollerDirective, |
156 | TextareaAutoResizeDirective, | 168 | TextareaAutoResizeDirective, |
157 | HelpComponent, | 169 | HelpComponent, |
170 | ListOverflowComponent, | ||
158 | 171 | ||
159 | ReactiveFileComponent, | 172 | ReactiveFileComponent, |
160 | PeertubeCheckboxComponent, | 173 | PeertubeCheckboxComponent, |
@@ -175,7 +188,11 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
175 | DateToggleComponent, | 188 | DateToggleComponent, |
176 | 189 | ||
177 | GlobalIconComponent, | 190 | GlobalIconComponent, |
178 | PreviewUploadComponent | 191 | PreviewUploadComponent, |
192 | |||
193 | MyAccountVideoSettingsComponent, | ||
194 | MyAccountInterfaceSettingsComponent, | ||
195 | ActorAvatarInfoComponent | ||
179 | ], | 196 | ], |
180 | 197 | ||
181 | exports: [ | 198 | exports: [ |
@@ -188,7 +205,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
188 | NgbDropdownModule, | 205 | NgbDropdownModule, |
189 | NgbModalModule, | 206 | NgbModalModule, |
190 | NgbPopoverModule, | 207 | NgbPopoverModule, |
191 | NgbTabsetModule, | 208 | NgbNavModule, |
192 | NgbTooltipModule, | 209 | NgbTooltipModule, |
193 | NgbCollapseModule, | 210 | NgbCollapseModule, |
194 | 211 | ||
@@ -226,6 +243,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
226 | InfiniteScrollerDirective, | 243 | InfiniteScrollerDirective, |
227 | TextareaAutoResizeDirective, | 244 | TextareaAutoResizeDirective, |
228 | HelpComponent, | 245 | HelpComponent, |
246 | ListOverflowComponent, | ||
229 | InputReadonlyCopyComponent, | 247 | InputReadonlyCopyComponent, |
230 | 248 | ||
231 | ReactiveFileComponent, | 249 | ReactiveFileComponent, |
@@ -250,8 +268,13 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
250 | NumberFormatterPipe, | 268 | NumberFormatterPipe, |
251 | ObjectLengthPipe, | 269 | ObjectLengthPipe, |
252 | FromNowPipe, | 270 | FromNowPipe, |
271 | HighlightPipe, | ||
253 | PeerTubeTemplateDirective, | 272 | PeerTubeTemplateDirective, |
254 | VideoDurationPipe | 273 | VideoDurationPipe, |
274 | |||
275 | MyAccountVideoSettingsComponent, | ||
276 | MyAccountInterfaceSettingsComponent, | ||
277 | ActorAvatarInfoComponent | ||
255 | ], | 278 | ], |
256 | 279 | ||
257 | providers: [ | 280 | providers: [ |
@@ -275,6 +298,7 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
275 | LoginValidatorsService, | 298 | LoginValidatorsService, |
276 | ResetPasswordValidatorsService, | 299 | ResetPasswordValidatorsService, |
277 | UserValidatorsService, | 300 | UserValidatorsService, |
301 | BatchDomainsValidatorsService, | ||
278 | VideoPlaylistValidatorsService, | 302 | VideoPlaylistValidatorsService, |
279 | VideoAbuseValidatorsService, | 303 | VideoAbuseValidatorsService, |
280 | VideoChannelValidatorsService, | 304 | VideoChannelValidatorsService, |
@@ -296,10 +320,12 @@ import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-cop | |||
296 | 320 | ||
297 | I18nPrimengCalendarService, | 321 | I18nPrimengCalendarService, |
298 | ScreenService, | 322 | ScreenService, |
323 | LocalStorageService, SessionStorageService, | ||
299 | 324 | ||
300 | UserNotificationService, | 325 | UserNotificationService, |
301 | 326 | ||
302 | FollowService, | 327 | FollowService, |
328 | RedundancyService, | ||
303 | 329 | ||
304 | I18n | 330 | I18n |
305 | ] | 331 | ] |
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.html b/client/src/app/shared/user-subscription/subscribe-button.component.html index f08c88f3c..85b3d1fdb 100644 --- a/client/src/app/shared/user-subscription/subscribe-button.component.html +++ b/client/src/app/shared/user-subscription/subscribe-button.component.html | |||
@@ -55,7 +55,7 @@ | |||
55 | </button> | 55 | </button> |
56 | 56 | ||
57 | <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button> | 57 | <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button> |
58 | <my-remote-subscribe showHelp="true" [uri]="uri"></my-remote-subscribe> | 58 | <my-remote-subscribe [showHelp]="true" [uri]="uri"></my-remote-subscribe> |
59 | 59 | ||
60 | <div class="dropdown-divider"></div> | 60 | <div class="dropdown-divider"></div> |
61 | 61 | ||
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.scss b/client/src/app/shared/user-subscription/subscribe-button.component.scss index 114a12f06..b739c5ae2 100644 --- a/client/src/app/shared/user-subscription/subscribe-button.component.scss +++ b/client/src/app/shared/user-subscription/subscribe-button.component.scss | |||
@@ -13,9 +13,17 @@ | |||
13 | font-size: 15px; | 13 | font-size: 15px; |
14 | } | 14 | } |
15 | 15 | ||
16 | &:not(.big) { | ||
17 | white-space: nowrap; | ||
18 | } | ||
19 | |||
16 | &.big { | 20 | &.big { |
17 | height: 35px; | 21 | height: 35px; |
18 | 22 | ||
23 | & > button:first-child { | ||
24 | width: 175px; | ||
25 | } | ||
26 | |||
19 | button .extra-text { | 27 | button .extra-text { |
20 | span:first-child { | 28 | span:first-child { |
21 | line-height: 80%; | 29 | line-height: 80%; |
@@ -80,10 +88,6 @@ | |||
80 | } | 88 | } |
81 | } | 89 | } |
82 | 90 | ||
83 | .dropdown-header { | ||
84 | padding-left: 1rem; | ||
85 | } | ||
86 | |||
87 | ::ng-deep form { | 91 | ::ng-deep form { |
88 | padding: 0.25rem 1rem; | 92 | padding: 0.25rem 1rem; |
89 | } | 93 | } |
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts index 7707d7dda..3348fe75f 100644 --- a/client/src/app/shared/users/user.model.ts +++ b/client/src/app/shared/users/user.model.ts | |||
@@ -1,10 +1,32 @@ | |||
1 | import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRight, UserRole, VideoChannel } from '../../../../../shared' | 1 | import { |
2 | hasUserRight, | ||
3 | User as UserServerModel, | ||
4 | UserNotificationSetting, | ||
5 | UserRight, | ||
6 | UserRole | ||
7 | } from '../../../../../shared/models/users' | ||
8 | import { VideoChannel } from '../../../../../shared/models/videos' | ||
2 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' | 9 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' |
3 | import { Account } from '@app/shared/account/account.model' | 10 | import { Account } from '@app/shared/account/account.model' |
4 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 11 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
5 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' | 12 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' |
6 | 13 | ||
7 | export class User implements UserServerModel { | 14 | export class User implements UserServerModel { |
15 | static KEYS = { | ||
16 | ID: 'id', | ||
17 | ROLE: 'role', | ||
18 | EMAIL: 'email', | ||
19 | VIDEOS_HISTORY_ENABLED: 'videos-history-enabled', | ||
20 | USERNAME: 'username', | ||
21 | NSFW_POLICY: 'nsfw_policy', | ||
22 | WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled', | ||
23 | AUTO_PLAY_VIDEO: 'auto_play_video', | ||
24 | SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO: 'auto_play_next_video', | ||
25 | AUTO_PLAY_VIDEO_PLAYLIST: 'auto_play_video_playlist', | ||
26 | THEME: 'last_active_theme', | ||
27 | VIDEO_LANGUAGES: 'video_languages' | ||
28 | } | ||
29 | |||
8 | id: number | 30 | id: number |
9 | username: string | 31 | username: string |
10 | email: string | 32 | email: string |
@@ -29,6 +51,11 @@ export class User implements UserServerModel { | |||
29 | videoQuotaDaily: number | 51 | videoQuotaDaily: number |
30 | videoQuotaUsed?: number | 52 | videoQuotaUsed?: number |
31 | videoQuotaUsedDaily?: number | 53 | videoQuotaUsedDaily?: number |
54 | videosCount?: number | ||
55 | videoAbusesCount?: number | ||
56 | videoAbusesAcceptedCount?: number | ||
57 | videoAbusesCreatedCount?: number | ||
58 | videoCommentsCount?: number | ||
32 | 59 | ||
33 | theme: string | 60 | theme: string |
34 | 61 | ||
@@ -42,6 +69,10 @@ export class User implements UserServerModel { | |||
42 | noInstanceConfigWarningModal: boolean | 69 | noInstanceConfigWarningModal: boolean |
43 | noWelcomeModal: boolean | 70 | noWelcomeModal: boolean |
44 | 71 | ||
72 | pluginAuth: string | null | ||
73 | |||
74 | lastLoginDate: Date | null | ||
75 | |||
45 | createdAt: Date | 76 | createdAt: Date |
46 | 77 | ||
47 | constructor (hash: Partial<UserServerModel>) { | 78 | constructor (hash: Partial<UserServerModel>) { |
@@ -57,11 +88,19 @@ export class User implements UserServerModel { | |||
57 | this.videoQuotaDaily = hash.videoQuotaDaily | 88 | this.videoQuotaDaily = hash.videoQuotaDaily |
58 | this.videoQuotaUsed = hash.videoQuotaUsed | 89 | this.videoQuotaUsed = hash.videoQuotaUsed |
59 | this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily | 90 | this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily |
91 | this.videosCount = hash.videosCount | ||
92 | this.videoAbusesCount = hash.videoAbusesCount | ||
93 | this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount | ||
94 | this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount | ||
95 | this.videoCommentsCount = hash.videoCommentsCount | ||
60 | 96 | ||
61 | this.nsfwPolicy = hash.nsfwPolicy | 97 | this.nsfwPolicy = hash.nsfwPolicy |
62 | this.webTorrentEnabled = hash.webTorrentEnabled | 98 | this.webTorrentEnabled = hash.webTorrentEnabled |
63 | this.videosHistoryEnabled = hash.videosHistoryEnabled | ||
64 | this.autoPlayVideo = hash.autoPlayVideo | 99 | this.autoPlayVideo = hash.autoPlayVideo |
100 | this.autoPlayNextVideo = hash.autoPlayNextVideo | ||
101 | this.autoPlayNextVideoPlaylist = hash.autoPlayNextVideoPlaylist | ||
102 | this.videosHistoryEnabled = hash.videosHistoryEnabled | ||
103 | this.videoLanguages = hash.videoLanguages | ||
65 | 104 | ||
66 | this.theme = hash.theme | 105 | this.theme = hash.theme |
67 | 106 | ||
@@ -77,6 +116,9 @@ export class User implements UserServerModel { | |||
77 | 116 | ||
78 | this.createdAt = hash.createdAt | 117 | this.createdAt = hash.createdAt |
79 | 118 | ||
119 | this.pluginAuth = hash.pluginAuth | ||
120 | this.lastLoginDate = hash.lastLoginDate | ||
121 | |||
80 | if (hash.account !== undefined) { | 122 | if (hash.account !== undefined) { |
81 | this.account = new Account(hash.account) | 123 | this.account = new Account(hash.account) |
82 | } | 124 | } |
diff --git a/client/src/app/shared/users/user.service.ts b/client/src/app/shared/users/user.service.ts index e24d91df3..abb4088b5 100644 --- a/client/src/app/shared/users/user.service.ts +++ b/client/src/app/shared/users/user.service.ts | |||
@@ -1,8 +1,8 @@ | |||
1 | import { from, Observable, of } from 'rxjs' | 1 | import { from, Observable } from 'rxjs' |
2 | import { catchError, concatMap, map, share, shareReplay, tap, toArray } from 'rxjs/operators' | 2 | import { catchError, concatMap, map, shareReplay, toArray } from 'rxjs/operators' |
3 | import { HttpClient, HttpParams } from '@angular/common/http' | 3 | import { HttpClient, HttpParams } from '@angular/common/http' |
4 | import { Injectable } from '@angular/core' | 4 | import { Injectable } from '@angular/core' |
5 | import { ResultList, User, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' | 5 | import { ResultList, User as UserServerModel, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared' |
6 | import { environment } from '../../../environments/environment' | 6 | import { environment } from '../../../environments/environment' |
7 | import { RestExtractor, RestPagination, RestService } from '../rest' | 7 | import { RestExtractor, RestPagination, RestService } from '../rest' |
8 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 8 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
@@ -10,6 +10,10 @@ import { SortMeta } from 'primeng/api' | |||
10 | import { BytesPipe } from 'ngx-pipes' | 10 | import { BytesPipe } from 'ngx-pipes' |
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | 11 | import { I18n } from '@ngx-translate/i18n-polyfill' |
12 | import { UserRegister } from '@shared/models/users/user-register.model' | 12 | import { UserRegister } from '@shared/models/users/user-register.model' |
13 | import { User } from './user.model' | ||
14 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | ||
15 | import { has } from 'lodash-es' | ||
16 | import { LocalStorageService, SessionStorageService } from '../misc/storage.service' | ||
13 | 17 | ||
14 | @Injectable() | 18 | @Injectable() |
15 | export class UserService { | 19 | export class UserService { |
@@ -17,12 +21,14 @@ export class UserService { | |||
17 | 21 | ||
18 | private bytesPipe = new BytesPipe() | 22 | private bytesPipe = new BytesPipe() |
19 | 23 | ||
20 | private userCache: { [ id: number ]: Observable<User> } = {} | 24 | private userCache: { [ id: number ]: Observable<UserServerModel> } = {} |
21 | 25 | ||
22 | constructor ( | 26 | constructor ( |
23 | private authHttp: HttpClient, | 27 | private authHttp: HttpClient, |
24 | private restExtractor: RestExtractor, | 28 | private restExtractor: RestExtractor, |
25 | private restService: RestService, | 29 | private restService: RestService, |
30 | private localStorageService: LocalStorageService, | ||
31 | private sessionStorageService: SessionStorageService, | ||
26 | private i18n: I18n | 32 | private i18n: I18n |
27 | ) { } | 33 | ) { } |
28 | 34 | ||
@@ -64,6 +70,30 @@ export class UserService { | |||
64 | ) | 70 | ) |
65 | } | 71 | } |
66 | 72 | ||
73 | updateMyAnonymousProfile (profile: UserUpdateMe) { | ||
74 | const supportedKeys = { | ||
75 | // local storage keys | ||
76 | nsfwPolicy: (val: NSFWPolicyType) => this.localStorageService.setItem(User.KEYS.NSFW_POLICY, val), | ||
77 | webTorrentEnabled: (val: boolean) => this.localStorageService.setItem(User.KEYS.WEBTORRENT_ENABLED, String(val)), | ||
78 | autoPlayVideo: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO, String(val)), | ||
79 | autoPlayNextVideoPlaylist: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST, String(val)), | ||
80 | theme: (val: string) => this.localStorageService.setItem(User.KEYS.THEME, val), | ||
81 | videoLanguages: (val: string[]) => this.localStorageService.setItem(User.KEYS.VIDEO_LANGUAGES, JSON.stringify(val)), | ||
82 | |||
83 | // session storage keys | ||
84 | autoPlayNextVideo: (val: boolean) => | ||
85 | this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, String(val)) | ||
86 | } | ||
87 | |||
88 | for (const key of Object.keys(profile)) { | ||
89 | try { | ||
90 | if (has(supportedKeys, key)) supportedKeys[key](profile[key]) | ||
91 | } catch (err) { | ||
92 | console.error(`Cannot set item ${key} in localStorage. Likely due to a value impossible to stringify.`, err) | ||
93 | } | ||
94 | } | ||
95 | } | ||
96 | |||
67 | deleteMe () { | 97 | deleteMe () { |
68 | const url = UserService.BASE_USERS_URL + 'me' | 98 | const url = UserService.BASE_USERS_URL + 'me' |
69 | 99 | ||
@@ -187,7 +217,7 @@ export class UserService { | |||
187 | ) | 217 | ) |
188 | } | 218 | } |
189 | 219 | ||
190 | updateUsers (users: User[], userUpdate: UserUpdate) { | 220 | updateUsers (users: UserServerModel[], userUpdate: UserUpdate) { |
191 | return from(users) | 221 | return from(users) |
192 | .pipe( | 222 | .pipe( |
193 | concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)), | 223 | concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)), |
@@ -204,18 +234,44 @@ export class UserService { | |||
204 | return this.userCache[userId] | 234 | return this.userCache[userId] |
205 | } | 235 | } |
206 | 236 | ||
207 | getUser (userId: number) { | 237 | getUser (userId: number, withStats = false) { |
208 | return this.authHttp.get<User>(UserService.BASE_USERS_URL + userId) | 238 | const params = new HttpParams().append('withStats', withStats + '') |
239 | return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId, { params }) | ||
209 | .pipe(catchError(err => this.restExtractor.handleError(err))) | 240 | .pipe(catchError(err => this.restExtractor.handleError(err))) |
210 | } | 241 | } |
211 | 242 | ||
212 | getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<User>> { | 243 | getAnonymousUser () { |
244 | let videoLanguages | ||
245 | |||
246 | try { | ||
247 | videoLanguages = JSON.parse(this.localStorageService.getItem(User.KEYS.VIDEO_LANGUAGES)) | ||
248 | } catch (err) { | ||
249 | videoLanguages = null | ||
250 | console.error('Cannot parse desired video languages from localStorage.', err) | ||
251 | } | ||
252 | |||
253 | return new User({ | ||
254 | // local storage keys | ||
255 | nsfwPolicy: this.localStorageService.getItem(User.KEYS.NSFW_POLICY) as NSFWPolicyType, | ||
256 | webTorrentEnabled: this.localStorageService.getItem(User.KEYS.WEBTORRENT_ENABLED) !== 'false', | ||
257 | theme: this.localStorageService.getItem(User.KEYS.THEME) || 'default', | ||
258 | videoLanguages, | ||
259 | |||
260 | autoPlayNextVideoPlaylist: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST) !== 'false', | ||
261 | autoPlayVideo: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO) === 'true', | ||
262 | |||
263 | // session storage keys | ||
264 | autoPlayNextVideo: this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' | ||
265 | }) | ||
266 | } | ||
267 | |||
268 | getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<UserServerModel>> { | ||
213 | let params = new HttpParams() | 269 | let params = new HttpParams() |
214 | params = this.restService.addRestGetParams(params, pagination, sort) | 270 | params = this.restService.addRestGetParams(params, pagination, sort) |
215 | 271 | ||
216 | if (search) params = params.append('search', search) | 272 | if (search) params = params.append('search', search) |
217 | 273 | ||
218 | return this.authHttp.get<ResultList<User>>(UserService.BASE_USERS_URL, { params }) | 274 | return this.authHttp.get<ResultList<UserServerModel>>(UserService.BASE_USERS_URL, { params }) |
219 | .pipe( | 275 | .pipe( |
220 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | 276 | map(res => this.restExtractor.convertResultListDateToHuman(res)), |
221 | map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))), | 277 | map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))), |
@@ -223,7 +279,7 @@ export class UserService { | |||
223 | ) | 279 | ) |
224 | } | 280 | } |
225 | 281 | ||
226 | removeUser (usersArg: User | User[]) { | 282 | removeUser (usersArg: UserServerModel | UserServerModel[]) { |
227 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] | 283 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] |
228 | 284 | ||
229 | return from(users) | 285 | return from(users) |
@@ -234,7 +290,7 @@ export class UserService { | |||
234 | ) | 290 | ) |
235 | } | 291 | } |
236 | 292 | ||
237 | banUsers (usersArg: User | User[], reason?: string) { | 293 | banUsers (usersArg: UserServerModel | UserServerModel[], reason?: string) { |
238 | const body = reason ? { reason } : {} | 294 | const body = reason ? { reason } : {} |
239 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] | 295 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] |
240 | 296 | ||
@@ -246,7 +302,7 @@ export class UserService { | |||
246 | ) | 302 | ) |
247 | } | 303 | } |
248 | 304 | ||
249 | unbanUsers (usersArg: User | User[]) { | 305 | unbanUsers (usersArg: UserServerModel | UserServerModel[]) { |
250 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] | 306 | const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] |
251 | 307 | ||
252 | return from(users) | 308 | return from(users) |
@@ -257,7 +313,7 @@ export class UserService { | |||
257 | ) | 313 | ) |
258 | } | 314 | } |
259 | 315 | ||
260 | private formatUser (user: User) { | 316 | private formatUser (user: UserServerModel) { |
261 | let videoQuota | 317 | let videoQuota |
262 | if (user.videoQuota === -1) { | 318 | if (user.videoQuota === -1) { |
263 | videoQuota = this.i18n('Unlimited') | 319 | videoQuota = this.i18n('Unlimited') |
diff --git a/client/src/app/shared/video-abuse/video-abuse.service.ts b/client/src/app/shared/video-abuse/video-abuse.service.ts index b0b59ea0c..700a30239 100644 --- a/client/src/app/shared/video-abuse/video-abuse.service.ts +++ b/client/src/app/shared/video-abuse/video-abuse.service.ts | |||
@@ -1,9 +1,9 @@ | |||
1 | import { catchError, map } from 'rxjs/operators' | 1 | import { catchError, map } from 'rxjs/operators' |
2 | import { HttpClient, HttpParams } from '@angular/common/http' | 2 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { SortMeta } from 'primeng/components/common/sortmeta' | 4 | import { SortMeta } from 'primeng/api' |
5 | import { Observable } from 'rxjs' | 5 | import { Observable } from 'rxjs' |
6 | import { ResultList, VideoAbuse, VideoAbuseUpdate } from '../../../../../shared' | 6 | import { ResultList, VideoAbuse, VideoAbuseUpdate, VideoAbuseState } from '../../../../../shared' |
7 | import { environment } from '../../../environments/environment' | 7 | import { environment } from '../../../environments/environment' |
8 | import { RestExtractor, RestPagination, RestService } from '../rest' | 8 | import { RestExtractor, RestPagination, RestService } from '../rest' |
9 | 9 | ||
@@ -17,15 +17,48 @@ export class VideoAbuseService { | |||
17 | private restExtractor: RestExtractor | 17 | private restExtractor: RestExtractor |
18 | ) {} | 18 | ) {} |
19 | 19 | ||
20 | getVideoAbuses (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoAbuse>> { | 20 | getVideoAbuses (options: { |
21 | pagination: RestPagination, | ||
22 | sort: SortMeta, | ||
23 | search?: string | ||
24 | }): Observable<ResultList<VideoAbuse>> { | ||
25 | const { pagination, sort, search } = options | ||
21 | const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse' | 26 | const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse' |
22 | 27 | ||
23 | let params = new HttpParams() | 28 | let params = new HttpParams() |
24 | params = this.restService.addRestGetParams(params, pagination, sort) | 29 | params = this.restService.addRestGetParams(params, pagination, sort) |
25 | 30 | ||
31 | if (search) { | ||
32 | const filters = this.restService.parseQueryStringFilter(search, { | ||
33 | id: { prefix: '#' }, | ||
34 | state: { | ||
35 | prefix: 'state:', | ||
36 | handler: v => { | ||
37 | if (v === 'accepted') return VideoAbuseState.ACCEPTED | ||
38 | if (v === 'pending') return VideoAbuseState.PENDING | ||
39 | if (v === 'rejected') return VideoAbuseState.REJECTED | ||
40 | |||
41 | return undefined | ||
42 | } | ||
43 | }, | ||
44 | videoIs: { | ||
45 | prefix: 'videoIs:', | ||
46 | handler: v => { | ||
47 | if (v === 'deleted') return v | ||
48 | if (v === 'blacklisted') return v | ||
49 | |||
50 | return undefined | ||
51 | } | ||
52 | }, | ||
53 | searchReporter: { prefix: 'reporter:' }, | ||
54 | searchReportee: { prefix: 'reportee:' } | ||
55 | }) | ||
56 | |||
57 | params = this.restService.addObjectParams(params, filters) | ||
58 | } | ||
59 | |||
26 | return this.authHttp.get<ResultList<VideoAbuse>>(url, { params }) | 60 | return this.authHttp.get<ResultList<VideoAbuse>>(url, { params }) |
27 | .pipe( | 61 | .pipe( |
28 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | ||
29 | catchError(res => this.restExtractor.handleError(res)) | 62 | catchError(res => this.restExtractor.handleError(res)) |
30 | ) | 63 | ) |
31 | } | 64 | } |
diff --git a/client/src/app/shared/video-blacklist/video-blacklist.service.ts b/client/src/app/shared/video-blacklist/video-blacklist.service.ts index 491fa698b..c0e13a651 100644 --- a/client/src/app/shared/video-blacklist/video-blacklist.service.ts +++ b/client/src/app/shared/video-blacklist/video-blacklist.service.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { catchError, map, concatMap, toArray } from 'rxjs/operators' | 1 | import { catchError, map, concatMap, toArray } from 'rxjs/operators' |
2 | import { HttpClient, HttpParams } from '@angular/common/http' | 2 | import { HttpClient, HttpParams } from '@angular/common/http' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { SortMeta } from 'primeng/components/common/sortmeta' | 4 | import { SortMeta } from 'primeng/api' |
5 | import { from as observableFrom, Observable } from 'rxjs' | 5 | import { from as observableFrom, Observable } from 'rxjs' |
6 | import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' | 6 | import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' |
7 | import { Video } from '../video/video.model' | 7 | import { Video } from '../video/video.model' |
@@ -19,13 +19,19 @@ export class VideoBlacklistService { | |||
19 | private restExtractor: RestExtractor | 19 | private restExtractor: RestExtractor |
20 | ) {} | 20 | ) {} |
21 | 21 | ||
22 | listBlacklist (pagination: RestPagination, sort: SortMeta, type?: VideoBlacklistType): Observable<ResultList<VideoBlacklist>> { | 22 | listBlacklist (options: { |
23 | pagination: RestPagination, | ||
24 | sort: SortMeta, | ||
25 | search?: string | ||
26 | type?: VideoBlacklistType | ||
27 | }): Observable<ResultList<VideoBlacklist>> { | ||
28 | const { pagination, sort, search, type } = options | ||
29 | |||
23 | let params = new HttpParams() | 30 | let params = new HttpParams() |
24 | params = this.restService.addRestGetParams(params, pagination, sort) | 31 | params = this.restService.addRestGetParams(params, pagination, sort) |
25 | 32 | ||
26 | if (type) { | 33 | if (search) params = params.append('search', search) |
27 | params = params.set('type', type.toString()) | 34 | if (type) params = params.append('type', type.toString()) |
28 | } | ||
29 | 35 | ||
30 | return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params }) | 36 | return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params }) |
31 | .pipe( | 37 | .pipe( |
diff --git a/client/src/app/shared/video-channel/video-channel.model.ts b/client/src/app/shared/video-channel/video-channel.model.ts index 309b614ae..617d6d44d 100644 --- a/client/src/app/shared/video-channel/video-channel.model.ts +++ b/client/src/app/shared/video-channel/video-channel.model.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { VideoChannel as ServerVideoChannel } from '../../../../../shared/models/videos' | 1 | import { VideoChannel as ServerVideoChannel, ViewsPerDate } from '../../../../../shared/models/videos' |
2 | import { Actor } from '../actor/actor.model' | 2 | import { Actor } from '../actor/actor.model' |
3 | import { Account } from '../../../../../shared/models/actors' | 3 | import { Account } from '../../../../../shared/models/actors' |
4 | 4 | ||
@@ -8,9 +8,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel { | |||
8 | support: string | 8 | support: string |
9 | isLocal: boolean | 9 | isLocal: boolean |
10 | nameWithHost: string | 10 | nameWithHost: string |
11 | nameWithHostForced: string | ||
11 | ownerAccount?: Account | 12 | ownerAccount?: Account |
12 | ownerBy?: string | 13 | ownerBy?: string |
13 | ownerAvatarUrl?: string | 14 | ownerAvatarUrl?: string |
15 | viewsPerDay?: ViewsPerDate[] | ||
14 | 16 | ||
15 | constructor (hash: ServerVideoChannel) { | 17 | constructor (hash: ServerVideoChannel) { |
16 | super(hash) | 18 | super(hash) |
@@ -20,6 +22,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel { | |||
20 | this.support = hash.support | 22 | this.support = hash.support |
21 | this.isLocal = hash.isLocal | 23 | this.isLocal = hash.isLocal |
22 | this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) | 24 | this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host) |
25 | this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true) | ||
26 | |||
27 | if (hash.viewsPerDay) { | ||
28 | this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) })) | ||
29 | } | ||
23 | 30 | ||
24 | if (hash.ownerAccount) { | 31 | if (hash.ownerAccount) { |
25 | this.ownerAccount = hash.ownerAccount | 32 | this.ownerAccount = hash.ownerAccount |
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 adb4f4819..0e036bda7 100644 --- a/client/src/app/shared/video-channel/video-channel.service.ts +++ b/client/src/app/shared/video-channel/video-channel.service.ts | |||
@@ -44,13 +44,18 @@ export class VideoChannelService { | |||
44 | ) | 44 | ) |
45 | } | 45 | } |
46 | 46 | ||
47 | listAccountVideoChannels (account: Account, componentPagination?: ComponentPaginationLight): Observable<ResultList<VideoChannel>> { | 47 | listAccountVideoChannels ( |
48 | account: Account, | ||
49 | componentPagination?: ComponentPaginationLight, | ||
50 | withStats = false | ||
51 | ): Observable<ResultList<VideoChannel>> { | ||
48 | const pagination = componentPagination | 52 | const pagination = componentPagination |
49 | ? this.restService.componentPaginationToRestPagination(componentPagination) | 53 | ? this.restService.componentPaginationToRestPagination(componentPagination) |
50 | : { start: 0, count: 20 } | 54 | : { start: 0, count: 20 } |
51 | 55 | ||
52 | let params = new HttpParams() | 56 | let params = new HttpParams() |
53 | params = this.restService.addRestGetParams(params, pagination) | 57 | params = this.restService.addRestGetParams(params, pagination) |
58 | params = params.set('withStats', withStats + '') | ||
54 | 59 | ||
55 | const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels' | 60 | const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels' |
56 | return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params }) | 61 | return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params }) |
diff --git a/client/src/app/shared/video-import/video-import.service.ts b/client/src/app/shared/video-import/video-import.service.ts index 3e3fb7dfb..afd9e3fb5 100644 --- a/client/src/app/shared/video-import/video-import.service.ts +++ b/client/src/app/shared/video-import/video-import.service.ts | |||
@@ -9,7 +9,7 @@ import { VideoImportCreate, VideoUpdate } from '../../../../../shared/models/vid | |||
9 | import { objectToFormData } from '@app/shared/misc/utils' | 9 | import { objectToFormData } from '@app/shared/misc/utils' |
10 | import { ResultList } from '../../../../../shared/models/result-list.model' | 10 | import { ResultList } from '../../../../../shared/models/result-list.model' |
11 | import { UserService } from '@app/shared/users/user.service' | 11 | import { UserService } from '@app/shared/users/user.service' |
12 | import { SortMeta } from 'primeng/components/common/sortmeta' | 12 | import { SortMeta } from 'primeng/api' |
13 | import { RestPagination } from '@app/shared/rest' | 13 | import { RestPagination } from '@app/shared/rest' |
14 | import { ServerService } from '@app/core' | 14 | import { ServerService } from '@app/core' |
15 | 15 | ||
diff --git a/client/src/app/shared/video-ownership/video-ownership.service.ts b/client/src/app/shared/video-ownership/video-ownership.service.ts index aa9e4839a..b95d5b792 100644 --- a/client/src/app/shared/video-ownership/video-ownership.service.ts +++ b/client/src/app/shared/video-ownership/video-ownership.service.ts | |||
@@ -5,7 +5,7 @@ import { environment } from '../../../environments/environment' | |||
5 | import { RestExtractor, RestService } from '../rest' | 5 | import { RestExtractor, RestService } from '../rest' |
6 | import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos' | 6 | import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos' |
7 | import { Observable } from 'rxjs/index' | 7 | import { Observable } from 'rxjs/index' |
8 | import { SortMeta } from 'primeng/components/common/sortmeta' | 8 | import { SortMeta } from 'primeng/api' |
9 | import { ResultList, VideoChangeOwnership } from '../../../../../shared' | 9 | import { ResultList, VideoChangeOwnership } from '../../../../../shared' |
10 | import { RestPagination } from '@app/shared/rest' | 10 | import { RestPagination } from '@app/shared/rest' |
11 | import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model' | 11 | import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model' |
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html index 6e0989227..58108584b 100644 --- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.html +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html | |||
@@ -62,7 +62,7 @@ | |||
62 | <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened"> | 62 | <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened"> |
63 | <my-global-icon iconName="add"></my-global-icon> | 63 | <my-global-icon iconName="add"></my-global-icon> |
64 | 64 | ||
65 | Create a private playlist | 65 | <span i18n>Create a private playlist</span> |
66 | </div> | 66 | </div> |
67 | 67 | ||
68 | <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form"> | 68 | <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form"> |
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss index f1b6cd601..1724449e8 100644 --- a/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss | |||
@@ -4,7 +4,7 @@ | |||
4 | .header, | 4 | .header, |
5 | .dropdown-item, | 5 | .dropdown-item, |
6 | .input-container { | 6 | .input-container { |
7 | padding: 6px 24px 10px 24px; | 7 | padding: 8px 24px; |
8 | } | 8 | } |
9 | 9 | ||
10 | .header { | 10 | .header { |
@@ -54,11 +54,12 @@ | |||
54 | } | 54 | } |
55 | 55 | ||
56 | .playlist { | 56 | .playlist { |
57 | display: flex; | 57 | display: inline-flex; |
58 | cursor: pointer; | 58 | cursor: pointer; |
59 | 59 | ||
60 | my-peertube-checkbox { | 60 | my-peertube-checkbox { |
61 | margin-right: 10px; | 61 | margin-right: 10px; |
62 | align-self: center; | ||
62 | } | 63 | } |
63 | 64 | ||
64 | .display-name { | 65 | .display-name { |
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts index 4864581b5..a2c0724cd 100644 --- a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts +++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts | |||
@@ -18,7 +18,7 @@ import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist- | |||
18 | changeDetection: ChangeDetectionStrategy.OnPush | 18 | changeDetection: ChangeDetectionStrategy.OnPush |
19 | }) | 19 | }) |
20 | export class VideoPlaylistElementMiniatureComponent implements OnInit { | 20 | export class VideoPlaylistElementMiniatureComponent implements OnInit { |
21 | @ViewChild('moreDropdown', { static: false }) moreDropdown: NgbDropdown | 21 | @ViewChild('moreDropdown') moreDropdown: NgbDropdown |
22 | 22 | ||
23 | @Input() playlist: VideoPlaylist | 23 | @Input() playlist: VideoPlaylist |
24 | @Input() playlistElement: VideoPlaylistElement | 24 | @Input() playlistElement: VideoPlaylistElement |
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss index 3c7a4b1fc..44b629542 100644 --- a/client/src/app/shared/video/abstract-video-list.scss +++ b/client/src/app/shared/video/abstract-video-list.scss | |||
@@ -15,6 +15,11 @@ | |||
15 | top: 1px; | 15 | top: 1px; |
16 | margin-left: 5px; | 16 | margin-left: 5px; |
17 | width: max-content; | 17 | width: max-content; |
18 | opacity: 0; | ||
19 | transition: ease-in .2s opacity; | ||
20 | } | ||
21 | &:hover my-feed { | ||
22 | opacity: 1; | ||
18 | } | 23 | } |
19 | } | 24 | } |
20 | 25 | ||
@@ -50,3 +55,15 @@ | |||
50 | @include adapt-margin-content-width; | 55 | @include adapt-margin-content-width; |
51 | } | 56 | } |
52 | 57 | ||
58 | @media screen and (max-width: $mobile-view) { | ||
59 | .videos-header { | ||
60 | flex-direction: column; | ||
61 | align-items: center; | ||
62 | height: auto; | ||
63 | |||
64 | .title-page { | ||
65 | margin-bottom: 10px; | ||
66 | margin-right: 0px; | ||
67 | } | ||
68 | } | ||
69 | } | ||
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts index c2fe6f754..b146d7014 100644 --- a/client/src/app/shared/video/abstract-video-list.ts +++ b/client/src/app/shared/video/abstract-video-list.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { debounceTime, first, tap } from 'rxjs/operators' | 1 | import { debounceTime, first, tap, throttleTime } from 'rxjs/operators' |
2 | import { OnDestroy, OnInit } from '@angular/core' | 2 | import { OnDestroy, OnInit } from '@angular/core' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' | 4 | import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs' |
@@ -14,6 +14,9 @@ import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | |||
14 | import { I18n } from '@ngx-translate/i18n-polyfill' | 14 | import { I18n } from '@ngx-translate/i18n-polyfill' |
15 | import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' | 15 | import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date' |
16 | import { ServerConfig } from '@shared/models' | 16 | import { ServerConfig } from '@shared/models' |
17 | import { GlobalIconName } from '@app/shared/images/global-icon.component' | ||
18 | import { UserService, User } from '../users' | ||
19 | import { LocalStorageService } from '../misc/storage.service' | ||
17 | 20 | ||
18 | enum GroupDate { | 21 | enum GroupDate { |
19 | UNKNOWN = 0, | 22 | UNKNOWN = 0, |
@@ -61,7 +64,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor | |||
61 | 64 | ||
62 | actions: { | 65 | actions: { |
63 | routerLink: string | 66 | routerLink: string |
64 | iconName: string | 67 | iconName: GlobalIconName |
65 | label: string | 68 | label: string |
66 | }[] = [] | 69 | }[] = [] |
67 | 70 | ||
@@ -71,9 +74,11 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor | |||
71 | 74 | ||
72 | protected abstract notifier: Notifier | 75 | protected abstract notifier: Notifier |
73 | protected abstract authService: AuthService | 76 | protected abstract authService: AuthService |
77 | protected abstract userService: UserService | ||
74 | protected abstract route: ActivatedRoute | 78 | protected abstract route: ActivatedRoute |
75 | protected abstract serverService: ServerService | 79 | protected abstract serverService: ServerService |
76 | protected abstract screenService: ScreenService | 80 | protected abstract screenService: ScreenService |
81 | protected abstract storageService: LocalStorageService | ||
77 | protected abstract router: Router | 82 | protected abstract router: Router |
78 | protected abstract i18n: I18n | 83 | protected abstract i18n: I18n |
79 | abstract titlePage: string | 84 | abstract titlePage: string |
@@ -123,6 +128,16 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor | |||
123 | if (this.loadOnInit === true) { | 128 | if (this.loadOnInit === true) { |
124 | loadUserObservable.subscribe(() => this.loadMoreVideos()) | 129 | loadUserObservable.subscribe(() => this.loadMoreVideos()) |
125 | } | 130 | } |
131 | |||
132 | this.storageService.watch([ | ||
133 | User.KEYS.NSFW_POLICY, | ||
134 | User.KEYS.VIDEO_LANGUAGES | ||
135 | ]).pipe(throttleTime(200)).subscribe( | ||
136 | () => { | ||
137 | this.loadUserVideoLanguagesIfNeeded() | ||
138 | if (this.hasDoneFirstQuery) this.reloadVideos() | ||
139 | } | ||
140 | ) | ||
126 | } | 141 | } |
127 | 142 | ||
128 | ngOnDestroy () { | 143 | ngOnDestroy () { |
@@ -278,7 +293,12 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor | |||
278 | } | 293 | } |
279 | 294 | ||
280 | private loadUserVideoLanguagesIfNeeded () { | 295 | private loadUserVideoLanguagesIfNeeded () { |
281 | if (!this.authService.isLoggedIn() || !this.useUserVideoLanguagePreferences) { | 296 | if (!this.useUserVideoLanguagePreferences) { |
297 | return of(true) | ||
298 | } | ||
299 | |||
300 | if (!this.authService.isLoggedIn()) { | ||
301 | this.languageOneOf = this.userService.getAnonymousUser().videoLanguages | ||
282 | return of(true) | 302 | return of(true) |
283 | } | 303 | } |
284 | 304 | ||
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts index 9f613c5fa..f09c3d1fc 100644 --- a/client/src/app/shared/video/infinite-scroller.directive.ts +++ b/client/src/app/shared/video/infinite-scroller.directive.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { distinctUntilChanged, filter, map, share, startWith, tap, throttleTime } from 'rxjs/operators' | 1 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' |
2 | import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | 2 | import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' |
3 | import { fromEvent, Observable, Subscription } from 'rxjs' | 3 | import { fromEvent, Observable, Subscription } from 'rxjs' |
4 | 4 | ||
@@ -53,7 +53,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterConten | |||
53 | const scrollableElement = this.onItself ? this.container : window | 53 | const scrollableElement = this.onItself ? this.container : window |
54 | const scrollObservable = fromEvent(scrollableElement, 'scroll') | 54 | const scrollObservable = fromEvent(scrollableElement, 'scroll') |
55 | .pipe( | 55 | .pipe( |
56 | startWith(null as string), // FIXME: typings | 56 | startWith(true), |
57 | throttleTime(200, undefined, throttleOptions), | 57 | throttleTime(200, undefined, throttleOptions), |
58 | map(() => this.getScrollInfo()), | 58 | map(() => this.getScrollInfo()), |
59 | distinctUntilChanged((o1, o2) => o1.current === o2.current), | 59 | distinctUntilChanged((o1, o2) => o1.current === o2.current), |
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.html b/client/src/app/shared/video/modals/video-blacklist.component.html index 1a87bdcd4..8f06a6b02 100644 --- a/client/src/app/shared/video/modals/video-blacklist.component.html +++ b/client/src/app/shared/video/modals/video-blacklist.component.html | |||
@@ -8,8 +8,10 @@ | |||
8 | 8 | ||
9 | <form novalidate [formGroup]="form" (ngSubmit)="blacklist()"> | 9 | <form novalidate [formGroup]="form" (ngSubmit)="blacklist()"> |
10 | <div class="form-group"> | 10 | <div class="form-group"> |
11 | <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> | 11 | <textarea |
12 | </textarea> | 12 | i18n-placeholder placeholder="Reason..." formControlName="reason" |
13 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" | ||
14 | ></textarea> | ||
13 | <div *ngIf="formErrors.reason" class="form-error"> | 15 | <div *ngIf="formErrors.reason" class="form-error"> |
14 | {{ formErrors.reason }} | 16 | {{ formErrors.reason }} |
15 | </div> | 17 | </div> |
@@ -18,14 +20,19 @@ | |||
18 | <div class="form-group" *ngIf="video.isLocal"> | 20 | <div class="form-group" *ngIf="video.isLocal"> |
19 | <my-peertube-checkbox | 21 | <my-peertube-checkbox |
20 | inputName="unfederate" formControlName="unfederate" | 22 | inputName="unfederate" formControlName="unfederate" |
21 | i18n-labelText labelText="Unfederate the video (ask for its deletion from the remote instances)" | 23 | i18n-labelText labelText="Unfederate the video" |
22 | ></my-peertube-checkbox> | 24 | > |
25 | <ng-container ngProjectAs="description"> | ||
26 | <span i18n>This will ask remote instances to delete it</span> | ||
27 | </ng-container> | ||
28 | </my-peertube-checkbox> | ||
23 | </div> | 29 | </div> |
24 | 30 | ||
25 | <div class="form-group inputs"> | 31 | <div class="form-group inputs"> |
26 | <span i18n class="action-button action-button-cancel" (click)="hide()"> | 32 | <input |
27 | Cancel | 33 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" |
28 | </span> | 34 | (click)="hide()" (key.enter)="hide()" |
35 | > | ||
29 | 36 | ||
30 | <input | 37 | <input |
31 | type="submit" i18n-value value="Submit" class="action-button-submit" | 38 | type="submit" i18n-value value="Submit" class="action-button-submit" |
diff --git a/client/src/app/shared/video/modals/video-blacklist.component.ts b/client/src/app/shared/video/modals/video-blacklist.component.ts index f0c70a365..6ef9c250b 100644 --- a/client/src/app/shared/video/modals/video-blacklist.component.ts +++ b/client/src/app/shared/video/modals/video-blacklist.component.ts | |||
@@ -1,12 +1,12 @@ | |||
1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' |
2 | import { Notifier, RedirectService } from '@app/core' | 2 | import { Notifier, RedirectService } from '@app/core' |
3 | import { VideoBlacklistService } from '../../../shared/video-blacklist' | 3 | import { VideoBlacklistService } from '../../../shared/video-blacklist' |
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 5 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 6 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 7 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
9 | import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' | 8 | import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' |
9 | import { Video } from '@app/shared/video/video.model' | ||
10 | 10 | ||
11 | @Component({ | 11 | @Component({ |
12 | selector: 'my-video-blacklist', | 12 | selector: 'my-video-blacklist', |
@@ -14,7 +14,7 @@ import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms | |||
14 | styleUrls: [ './video-blacklist.component.scss' ] | 14 | styleUrls: [ './video-blacklist.component.scss' ] |
15 | }) | 15 | }) |
16 | export class VideoBlacklistComponent extends FormReactive implements OnInit { | 16 | export class VideoBlacklistComponent extends FormReactive implements OnInit { |
17 | @Input() video: VideoDetails = null | 17 | @Input() video: Video = null |
18 | 18 | ||
19 | @ViewChild('modal', { static: true }) modal: NgbModal | 19 | @ViewChild('modal', { static: true }) modal: NgbModal |
20 | 20 | ||
@@ -46,7 +46,7 @@ export class VideoBlacklistComponent extends FormReactive implements OnInit { | |||
46 | } | 46 | } |
47 | 47 | ||
48 | show () { | 48 | show () { |
49 | this.openedModal = this.modalService.open(this.modal, { keyboard: false }) | 49 | this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) |
50 | } | 50 | } |
51 | 51 | ||
52 | hide () { | 52 | hide () { |
diff --git a/client/src/app/shared/video/modals/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html index 8cca985b1..c65e371ee 100644 --- a/client/src/app/shared/video/modals/video-download.component.html +++ b/client/src/app/shared/video/modals/video-download.component.html | |||
@@ -1,7 +1,7 @@ | |||
1 | <ng-template #modal let-hide="close"> | 1 | <ng-template #modal let-hide="close"> |
2 | <div class="modal-header"> | 2 | <div class="modal-header"> |
3 | <h4 class="modal-title">Download | 3 | <h4 class="modal-title"> |
4 | <span *ngIf="!videoCaptions" i18n>video</span> | 4 | <ng-container i18n>Download</ng-container> |
5 | 5 | ||
6 | <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block"> | 6 | <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block"> |
7 | <span id="dropdownDownloadType" ngbDropdownToggle> | 7 | <span id="dropdownDownloadType" ngbDropdownToggle> |
@@ -20,22 +20,67 @@ | |||
20 | <div class="form-group"> | 20 | <div class="form-group"> |
21 | <div class="input-group input-group-sm"> | 21 | <div class="input-group input-group-sm"> |
22 | <div class="input-group-prepend peertube-select-container"> | 22 | <div class="input-group-prepend peertube-select-container"> |
23 | <select *ngIf="type === 'video'" [(ngModel)]="resolutionId"> | 23 | <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()"> |
24 | <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option> | 24 | <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option> |
25 | </select> | 25 | </select> |
26 | |||
26 | <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId"> | 27 | <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId"> |
27 | <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option> | 28 | <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option> |
28 | </select> | 29 | </select> |
29 | </div> | 30 | </div> |
31 | |||
30 | <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" /> | 32 | <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" /> |
31 | <div class="input-group-append"> | 33 | <div class="input-group-append"> |
32 | <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> | 34 | <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary"> |
33 | <span class="glyphicon glyphicon-copy"></span> | 35 | <span class="glyphicon glyphicon-copy"></span> |
34 | </button> | 36 | </button> |
35 | </div> | 37 | </div> |
36 | </div> | 38 | </div> |
37 | </div> | 39 | </div> |
38 | 40 | ||
41 | <ng-container *ngIf="type === 'video' && videoFile?.metadata"> | ||
42 | <div ngbNav #nav="ngbNav" class="nav-tabs"> | ||
43 | |||
44 | <ng-container ngbNavItem> | ||
45 | <a ngbNavLink i18n>Format</a> | ||
46 | <ng-template ngbNavContent> | ||
47 | <div class="file-metadata"> | ||
48 | <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue"> | ||
49 | <span i18n class="metadata-attribute-label">{{ item.value.label }}</span> | ||
50 | <span class="metadata-attribute-value">{{ item.value.value }}</span> | ||
51 | </div> | ||
52 | </div> | ||
53 | </ng-template> | ||
54 | </ng-container> | ||
55 | |||
56 | <ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined"> | ||
57 | <a ngbNavLink i18n>Video stream</a> | ||
58 | <ng-template ngbNavContent> | ||
59 | <div class="file-metadata"> | ||
60 | <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue"> | ||
61 | <span i18n class="metadata-attribute-label">{{ item.value.label }}</span> | ||
62 | <span class="metadata-attribute-value">{{ item.value.value }}</span> | ||
63 | </div> | ||
64 | </div> | ||
65 | </ng-template> | ||
66 | </ng-container> | ||
67 | |||
68 | <ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined"> | ||
69 | <a ngbNavLink i18n>Audio stream</a> | ||
70 | <ng-template ngbNavContent> | ||
71 | <div class="file-metadata"> | ||
72 | <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue"> | ||
73 | <span i18n class="metadata-attribute-label">{{ item.value.label }}</span> | ||
74 | <span class="metadata-attribute-value">{{ item.value.value }}</span> | ||
75 | </div> | ||
76 | </div> | ||
77 | </ng-template> | ||
78 | </ng-container> | ||
79 | </div> | ||
80 | |||
81 | <div [ngbNavOutlet]="nav"></div> | ||
82 | </ng-container> | ||
83 | |||
39 | <div class="download-type" *ngIf="type === 'video'"> | 84 | <div class="download-type" *ngIf="type === 'video'"> |
40 | <div class="peertube-radio-container"> | 85 | <div class="peertube-radio-container"> |
41 | <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct"> | 86 | <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct"> |
@@ -50,9 +95,10 @@ | |||
50 | </div> | 95 | </div> |
51 | 96 | ||
52 | <div class="modal-footer inputs"> | 97 | <div class="modal-footer inputs"> |
53 | <span i18n class="action-button action-button-cancel" (click)="hide()"> | 98 | <input |
54 | Cancel | 99 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" |
55 | </span> | 100 | (click)="hide()" (key.enter)="hide()" |
101 | > | ||
56 | 102 | ||
57 | <input | 103 | <input |
58 | type="submit" i18n-value value="Download" class="action-button-submit" | 104 | type="submit" i18n-value value="Download" class="action-button-submit" |
diff --git a/client/src/app/shared/video/modals/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss index 09dd91aa9..f28bc34ed 100644 --- a/client/src/app/shared/video/modals/video-download.component.scss +++ b/client/src/app/shared/video/modals/video-download.component.scss | |||
@@ -27,3 +27,38 @@ | |||
27 | margin-right: 30px; | 27 | margin-right: 30px; |
28 | } | 28 | } |
29 | } | 29 | } |
30 | |||
31 | .file-metadata { | ||
32 | padding: 1rem; | ||
33 | } | ||
34 | |||
35 | .file-metadata .metadata-attribute { | ||
36 | font-size: 13px; | ||
37 | display: block; | ||
38 | margin-bottom: 12px; | ||
39 | |||
40 | .metadata-attribute-label { | ||
41 | min-width: 142px; | ||
42 | padding-right: 5px; | ||
43 | display: inline-block; | ||
44 | color: $grey-foreground-color; | ||
45 | font-weight: $font-bold; | ||
46 | } | ||
47 | |||
48 | a.metadata-attribute-value { | ||
49 | @include disable-default-a-behaviour; | ||
50 | color: var(--mainForegroundColor); | ||
51 | |||
52 | &:hover { | ||
53 | opacity: 0.9; | ||
54 | } | ||
55 | } | ||
56 | |||
57 | &.metadata-attribute-tags { | ||
58 | .metadata-attribute-value:not(:nth-child(2)) { | ||
59 | &::before { | ||
60 | content: ', ' | ||
61 | } | ||
62 | } | ||
63 | } | ||
64 | } | ||
diff --git a/client/src/app/shared/video/modals/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts index c1ceca263..d77187821 100644 --- a/client/src/app/shared/video/modals/video-download.component.ts +++ b/client/src/app/shared/video/modals/video-download.component.ts | |||
@@ -3,9 +3,15 @@ import { VideoDetails } from '../../../shared/video/video-details.model' | |||
3 | import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' | 3 | import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
5 | import { AuthService, Notifier } from '@app/core' | 5 | import { AuthService, Notifier } from '@app/core' |
6 | import { VideoPrivacy, VideoCaption } from '@shared/models' | 6 | import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models' |
7 | import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg' | ||
8 | import { mapValues, pick } from 'lodash-es' | ||
9 | import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' | ||
10 | import { BytesPipe } from 'ngx-pipes' | ||
11 | import { VideoService } from '../video.service' | ||
7 | 12 | ||
8 | type DownloadType = 'video' | 'subtitles' | 13 | type DownloadType = 'video' | 'subtitles' |
14 | type FileMetadata = { [key: string]: { label: string, value: string }} | ||
9 | 15 | ||
10 | @Component({ | 16 | @Component({ |
11 | selector: 'my-video-download', | 17 | selector: 'my-video-download', |
@@ -20,17 +26,28 @@ export class VideoDownloadComponent { | |||
20 | subtitleLanguageId: string | 26 | subtitleLanguageId: string |
21 | 27 | ||
22 | video: VideoDetails | 28 | video: VideoDetails |
29 | videoFile: VideoFile | ||
30 | videoFileMetadataFormat: FileMetadata | ||
31 | videoFileMetadataVideoStream: FileMetadata | undefined | ||
32 | videoFileMetadataAudioStream: FileMetadata | undefined | ||
23 | videoCaptions: VideoCaption[] | 33 | videoCaptions: VideoCaption[] |
24 | activeModal: NgbActiveModal | 34 | activeModal: NgbActiveModal |
25 | 35 | ||
26 | type: DownloadType = 'video' | 36 | type: DownloadType = 'video' |
27 | 37 | ||
38 | private bytesPipe: BytesPipe | ||
39 | private numbersPipe: NumberFormatterPipe | ||
40 | |||
28 | constructor ( | 41 | constructor ( |
29 | private notifier: Notifier, | 42 | private notifier: Notifier, |
30 | private modalService: NgbModal, | 43 | private modalService: NgbModal, |
44 | private videoService: VideoService, | ||
31 | private auth: AuthService, | 45 | private auth: AuthService, |
32 | private i18n: I18n | 46 | private i18n: I18n |
33 | ) { } | 47 | ) { |
48 | this.bytesPipe = new BytesPipe() | ||
49 | this.numbersPipe = new NumberFormatterPipe() | ||
50 | } | ||
34 | 51 | ||
35 | get typeText () { | 52 | get typeText () { |
36 | return this.type === 'video' | 53 | return this.type === 'video' |
@@ -48,9 +65,10 @@ export class VideoDownloadComponent { | |||
48 | this.video = video | 65 | this.video = video |
49 | this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined | 66 | this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined |
50 | 67 | ||
51 | this.activeModal = this.modalService.open(this.modal) | 68 | this.activeModal = this.modalService.open(this.modal, { centered: true }) |
52 | 69 | ||
53 | this.resolutionId = this.getVideoFiles()[0].resolution.id | 70 | this.resolutionId = this.getVideoFiles()[0].resolution.id |
71 | this.onResolutionIdChange() | ||
54 | if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id | 72 | if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id |
55 | } | 73 | } |
56 | 74 | ||
@@ -67,10 +85,27 @@ export class VideoDownloadComponent { | |||
67 | getLink () { | 85 | getLink () { |
68 | return this.type === 'subtitles' && this.videoCaptions | 86 | return this.type === 'subtitles' && this.videoCaptions |
69 | ? this.getSubtitlesLink() | 87 | ? this.getSubtitlesLink() |
70 | : this.getVideoLink() | 88 | : this.getVideoFileLink() |
71 | } | 89 | } |
72 | 90 | ||
73 | getVideoLink () { | 91 | async onResolutionIdChange () { |
92 | this.videoFile = this.getVideoFile() | ||
93 | if (this.videoFile.metadata || !this.videoFile.metadataUrl) return | ||
94 | |||
95 | await this.hydrateMetadataFromMetadataUrl(this.videoFile) | ||
96 | |||
97 | this.videoFileMetadataFormat = this.videoFile | ||
98 | ? this.getMetadataFormat(this.videoFile.metadata.format) | ||
99 | : undefined | ||
100 | this.videoFileMetadataVideoStream = this.videoFile | ||
101 | ? this.getMetadataStream(this.videoFile.metadata.streams, 'video') | ||
102 | : undefined | ||
103 | this.videoFileMetadataAudioStream = this.videoFile | ||
104 | ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio') | ||
105 | : undefined | ||
106 | } | ||
107 | |||
108 | getVideoFile () { | ||
74 | // HTML select send us a string, so convert it to a number | 109 | // HTML select send us a string, so convert it to a number |
75 | this.resolutionId = parseInt(this.resolutionId.toString(), 10) | 110 | this.resolutionId = parseInt(this.resolutionId.toString(), 10) |
76 | 111 | ||
@@ -79,6 +114,12 @@ export class VideoDownloadComponent { | |||
79 | console.error('Could not find file with resolution %d.', this.resolutionId) | 114 | console.error('Could not find file with resolution %d.', this.resolutionId) |
80 | return | 115 | return |
81 | } | 116 | } |
117 | return file | ||
118 | } | ||
119 | |||
120 | getVideoFileLink () { | ||
121 | const file = this.videoFile | ||
122 | if (!file) return | ||
82 | 123 | ||
83 | const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL | 124 | const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL |
84 | ? '?access_token=' + this.auth.getAccessToken() | 125 | ? '?access_token=' + this.auth.getAccessToken() |
@@ -104,4 +145,64 @@ export class VideoDownloadComponent { | |||
104 | switchToType (type: DownloadType) { | 145 | switchToType (type: DownloadType) { |
105 | this.type = type | 146 | this.type = type |
106 | } | 147 | } |
148 | |||
149 | getMetadataFormat (format: FfprobeFormat) { | ||
150 | const keyToTranslateFunction = { | ||
151 | 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }), | ||
152 | 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }), | ||
153 | 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }), | ||
154 | 'bit_rate': (value: number) => ({ | ||
155 | label: this.i18n('Bitrate'), | ||
156 | value: `${this.numbersPipe.transform(value)}bps` | ||
157 | }) | ||
158 | } | ||
159 | |||
160 | // flattening format | ||
161 | const sanitizedFormat = Object.assign(format, format.tags) | ||
162 | delete sanitizedFormat.tags | ||
163 | |||
164 | return mapValues( | ||
165 | pick(sanitizedFormat, Object.keys(keyToTranslateFunction)), | ||
166 | (val, key) => keyToTranslateFunction[key](val) | ||
167 | ) | ||
168 | } | ||
169 | |||
170 | getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') { | ||
171 | const stream = streams.find(s => s.codec_type === type) | ||
172 | if (!stream) return undefined | ||
173 | |||
174 | let keyToTranslateFunction = { | ||
175 | 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }), | ||
176 | 'profile': (value: string) => ({ label: this.i18n('Profile'), value }), | ||
177 | 'bit_rate': (value: number) => ({ | ||
178 | label: this.i18n('Bitrate'), | ||
179 | value: `${this.numbersPipe.transform(value)}bps` | ||
180 | }) | ||
181 | } | ||
182 | |||
183 | if (type === 'video') { | ||
184 | keyToTranslateFunction = Object.assign(keyToTranslateFunction, { | ||
185 | 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }), | ||
186 | 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }), | ||
187 | 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }), | ||
188 | 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value }) | ||
189 | }) | ||
190 | } else { | ||
191 | keyToTranslateFunction = Object.assign(keyToTranslateFunction, { | ||
192 | 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }), | ||
193 | 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value }) | ||
194 | }) | ||
195 | } | ||
196 | |||
197 | return mapValues( | ||
198 | pick(stream, Object.keys(keyToTranslateFunction)), | ||
199 | (val, key) => keyToTranslateFunction[key](val) | ||
200 | ) | ||
201 | } | ||
202 | |||
203 | private hydrateMetadataFromMetadataUrl (file: VideoFile) { | ||
204 | const observable = this.videoService.getVideoFileMetadata(file.metadataUrl) | ||
205 | observable.subscribe(res => file.metadata = res) | ||
206 | return observable.toPromise() | ||
207 | } | ||
107 | } | 208 | } |
diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html index b9434da26..e336b6660 100644 --- a/client/src/app/shared/video/modals/video-report.component.html +++ b/client/src/app/shared/video/modals/video-report.component.html | |||
@@ -7,23 +7,25 @@ | |||
7 | <div class="modal-body"> | 7 | <div class="modal-body"> |
8 | 8 | ||
9 | <div i18n class="information"> | 9 | <div i18n class="information"> |
10 | Your report will be sent to moderators of {{ currentHost }}. | 10 | Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>. |
11 | <ng-container *ngIf="isRemoteVideo()"> It will be forwarded to origin instance {{ originHost }} too.</ng-container> | ||
12 | </div> | 11 | </div> |
13 | 12 | ||
14 | <form novalidate [formGroup]="form" (ngSubmit)="report()"> | 13 | <form novalidate [formGroup]="form" (ngSubmit)="report()"> |
15 | <div class="form-group"> | 14 | <div class="form-group"> |
16 | <textarea i18n-placeholder placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }"> | 15 | <textarea |
17 | </textarea> | 16 | i18n-placeholder placeholder="Reason..." formControlName="reason" |
17 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" | ||
18 | ></textarea> | ||
18 | <div *ngIf="formErrors.reason" class="form-error"> | 19 | <div *ngIf="formErrors.reason" class="form-error"> |
19 | {{ formErrors.reason }} | 20 | {{ formErrors.reason }} |
20 | </div> | 21 | </div> |
21 | </div> | 22 | </div> |
22 | 23 | ||
23 | <div class="form-group inputs"> | 24 | <div class="form-group inputs"> |
24 | <span i18n class="action-button action-button-cancel" (click)="hide()"> | 25 | <input |
25 | Cancel | 26 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" |
26 | </span> | 27 | (click)="hide()" (key.enter)="hide()" |
28 | > | ||
27 | 29 | ||
28 | <input | 30 | <input |
29 | type="submit" i18n-value value="Submit" class="action-button-submit" | 31 | type="submit" i18n-value value="Submit" class="action-button-submit" |
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts index 1d368ff17..988fa03d4 100644 --- a/client/src/app/shared/video/modals/video-report.component.ts +++ b/client/src/app/shared/video/modals/video-report.component.ts | |||
@@ -1,13 +1,13 @@ | |||
1 | import { Component, Input, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, Input, OnInit, ViewChild } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { FormReactive } from '../../../shared/forms' | 3 | import { FormReactive } from '../../../shared/forms' |
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 5 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
7 | import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' | 6 | import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' |
8 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
9 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
10 | import { VideoAbuseService } from '@app/shared/video-abuse' | 9 | import { VideoAbuseService } from '@app/shared/video-abuse' |
10 | import { Video } from '@app/shared/video/video.model' | ||
11 | 11 | ||
12 | @Component({ | 12 | @Component({ |
13 | selector: 'my-video-report', | 13 | selector: 'my-video-report', |
@@ -15,7 +15,7 @@ import { VideoAbuseService } from '@app/shared/video-abuse' | |||
15 | styleUrls: [ './video-report.component.scss' ] | 15 | styleUrls: [ './video-report.component.scss' ] |
16 | }) | 16 | }) |
17 | export class VideoReportComponent extends FormReactive implements OnInit { | 17 | export class VideoReportComponent extends FormReactive implements OnInit { |
18 | @Input() video: VideoDetails = null | 18 | @Input() video: Video = null |
19 | 19 | ||
20 | @ViewChild('modal', { static: true }) modal: NgbModal | 20 | @ViewChild('modal', { static: true }) modal: NgbModal |
21 | 21 | ||
@@ -53,7 +53,7 @@ export class VideoReportComponent extends FormReactive implements OnInit { | |||
53 | } | 53 | } |
54 | 54 | ||
55 | show () { | 55 | show () { |
56 | this.openedModal = this.modalService.open(this.modal, { keyboard: false }) | 56 | this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) |
57 | } | 57 | } |
58 | 58 | ||
59 | hide () { | 59 | hide () { |
diff --git a/client/src/app/shared/video/redundancy.service.ts b/client/src/app/shared/video/redundancy.service.ts new file mode 100644 index 000000000..fb918d73b --- /dev/null +++ b/client/src/app/shared/video/redundancy.service.ts | |||
@@ -0,0 +1,73 @@ | |||
1 | import { catchError, map, toArray } from 'rxjs/operators' | ||
2 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { RestExtractor, RestPagination, RestService } from '@app/shared/rest' | ||
5 | import { SortMeta } from 'primeng/api' | ||
6 | import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models' | ||
7 | import { concat, Observable } from 'rxjs' | ||
8 | import { environment } from '../../../environments/environment' | ||
9 | |||
10 | @Injectable() | ||
11 | export class RedundancyService { | ||
12 | static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy' | ||
13 | |||
14 | constructor ( | ||
15 | private authHttp: HttpClient, | ||
16 | private restService: RestService, | ||
17 | private restExtractor: RestExtractor | ||
18 | ) { } | ||
19 | |||
20 | updateRedundancy (host: string, redundancyAllowed: boolean) { | ||
21 | const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host | ||
22 | |||
23 | const body = { redundancyAllowed } | ||
24 | |||
25 | return this.authHttp.put(url, body) | ||
26 | .pipe( | ||
27 | map(this.restExtractor.extractDataBool), | ||
28 | catchError(err => this.restExtractor.handleError(err)) | ||
29 | ) | ||
30 | } | ||
31 | |||
32 | listVideoRedundancies (options: { | ||
33 | pagination: RestPagination, | ||
34 | sort: SortMeta, | ||
35 | target?: VideoRedundanciesTarget | ||
36 | }): Observable<ResultList<VideoRedundancy>> { | ||
37 | const { pagination, sort, target } = options | ||
38 | |||
39 | let params = new HttpParams() | ||
40 | params = this.restService.addRestGetParams(params, pagination, sort) | ||
41 | |||
42 | if (target) params = params.append('target', target) | ||
43 | |||
44 | return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params }) | ||
45 | .pipe( | ||
46 | catchError(res => this.restExtractor.handleError(res)) | ||
47 | ) | ||
48 | } | ||
49 | |||
50 | addVideoRedundancy (video: Video) { | ||
51 | return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id }) | ||
52 | .pipe( | ||
53 | catchError(res => this.restExtractor.handleError(res)) | ||
54 | ) | ||
55 | } | ||
56 | |||
57 | removeVideoRedundancies (redundancy: VideoRedundancy) { | ||
58 | const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id) | ||
59 | .concat(redundancy.redundancies.files.map(r => r.id)) | ||
60 | .map(id => this.removeRedundancy(id)) | ||
61 | |||
62 | return concat(...observables) | ||
63 | .pipe(toArray()) | ||
64 | } | ||
65 | |||
66 | private removeRedundancy (redundancyId: number) { | ||
67 | return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId) | ||
68 | .pipe( | ||
69 | map(this.restExtractor.extractDataBool), | ||
70 | catchError(res => this.restExtractor.handleError(res)) | ||
71 | ) | ||
72 | } | ||
73 | } | ||
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts index afdeab18d..4e5fc6476 100644 --- a/client/src/app/shared/video/video-actions-dropdown.component.ts +++ b/client/src/app/shared/video/video-actions-dropdown.component.ts | |||
@@ -1,8 +1,7 @@ | |||
1 | import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' |
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | 2 | import { I18n } from '@ngx-translate/i18n-polyfill' |
3 | import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' | 3 | import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' |
4 | import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' | 4 | import { AuthService, ConfirmService, Notifier } from '@app/core' |
5 | import { BlocklistService } from '@app/shared/blocklist' | ||
6 | import { Video } from '@app/shared/video/video.model' | 5 | import { Video } from '@app/shared/video/video.model' |
7 | import { VideoService } from '@app/shared/video/video.service' | 6 | import { VideoService } from '@app/shared/video/video.service' |
8 | import { VideoDetails } from '@app/shared/video/video-details.model' | 7 | import { VideoDetails } from '@app/shared/video/video-details.model' |
@@ -14,6 +13,7 @@ import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklis | |||
14 | import { VideoBlacklistService } from '@app/shared/video-blacklist' | 13 | import { VideoBlacklistService } from '@app/shared/video-blacklist' |
15 | import { ScreenService } from '@app/shared/misc/screen.service' | 14 | import { ScreenService } from '@app/shared/misc/screen.service' |
16 | import { VideoCaption } from '@shared/models' | 15 | import { VideoCaption } from '@shared/models' |
16 | import { RedundancyService } from '@app/shared/video/redundancy.service' | ||
17 | 17 | ||
18 | export type VideoActionsDisplayType = { | 18 | export type VideoActionsDisplayType = { |
19 | playlist?: boolean | 19 | playlist?: boolean |
@@ -22,6 +22,7 @@ export type VideoActionsDisplayType = { | |||
22 | blacklist?: boolean | 22 | blacklist?: boolean |
23 | delete?: boolean | 23 | delete?: boolean |
24 | report?: boolean | 24 | report?: boolean |
25 | duplicate?: boolean | ||
25 | } | 26 | } |
26 | 27 | ||
27 | @Component({ | 28 | @Component({ |
@@ -30,12 +31,12 @@ export type VideoActionsDisplayType = { | |||
30 | styleUrls: [ './video-actions-dropdown.component.scss' ] | 31 | styleUrls: [ './video-actions-dropdown.component.scss' ] |
31 | }) | 32 | }) |
32 | export class VideoActionsDropdownComponent implements OnChanges { | 33 | export class VideoActionsDropdownComponent implements OnChanges { |
33 | @ViewChild('playlistDropdown', { static: false }) playlistDropdown: NgbDropdown | 34 | @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown |
34 | @ViewChild('playlistAdd', { static: false }) playlistAdd: VideoAddToPlaylistComponent | 35 | @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent |
35 | 36 | ||
36 | @ViewChild('videoDownloadModal', { static: false }) videoDownloadModal: VideoDownloadComponent | 37 | @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent |
37 | @ViewChild('videoReportModal', { static: false }) videoReportModal: VideoReportComponent | 38 | @ViewChild('videoReportModal') videoReportModal: VideoReportComponent |
38 | @ViewChild('videoBlacklistModal', { static: false }) videoBlacklistModal: VideoBlacklistComponent | 39 | @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent |
39 | 40 | ||
40 | @Input() video: Video | VideoDetails | 41 | @Input() video: Video | VideoDetails |
41 | @Input() videoCaptions: VideoCaption[] = [] | 42 | @Input() videoCaptions: VideoCaption[] = [] |
@@ -46,7 +47,8 @@ export class VideoActionsDropdownComponent implements OnChanges { | |||
46 | update: true, | 47 | update: true, |
47 | blacklist: true, | 48 | blacklist: true, |
48 | delete: true, | 49 | delete: true, |
49 | report: true | 50 | report: true, |
51 | duplicate: true | ||
50 | } | 52 | } |
51 | @Input() placement = 'left' | 53 | @Input() placement = 'left' |
52 | 54 | ||
@@ -70,10 +72,9 @@ export class VideoActionsDropdownComponent implements OnChanges { | |||
70 | private notifier: Notifier, | 72 | private notifier: Notifier, |
71 | private confirmService: ConfirmService, | 73 | private confirmService: ConfirmService, |
72 | private videoBlacklistService: VideoBlacklistService, | 74 | private videoBlacklistService: VideoBlacklistService, |
73 | private serverService: ServerService, | ||
74 | private screenService: ScreenService, | 75 | private screenService: ScreenService, |
75 | private videoService: VideoService, | 76 | private videoService: VideoService, |
76 | private blocklistService: BlocklistService, | 77 | private redundancyService: RedundancyService, |
77 | private i18n: I18n | 78 | private i18n: I18n |
78 | ) { } | 79 | ) { } |
79 | 80 | ||
@@ -144,6 +145,10 @@ export class VideoActionsDropdownComponent implements OnChanges { | |||
144 | return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled | 145 | return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled |
145 | } | 146 | } |
146 | 147 | ||
148 | canVideoBeDuplicated () { | ||
149 | return this.video.canBeDuplicatedBy(this.user) | ||
150 | } | ||
151 | |||
147 | /* Action handlers */ | 152 | /* Action handlers */ |
148 | 153 | ||
149 | async unblacklistVideo () { | 154 | async unblacklistVideo () { |
@@ -186,6 +191,18 @@ export class VideoActionsDropdownComponent implements OnChanges { | |||
186 | ) | 191 | ) |
187 | } | 192 | } |
188 | 193 | ||
194 | duplicateVideo () { | ||
195 | this.redundancyService.addVideoRedundancy(this.video) | ||
196 | .subscribe( | ||
197 | () => { | ||
198 | const message = this.i18n('This video will be duplicated by your instance.') | ||
199 | this.notifier.success(message) | ||
200 | }, | ||
201 | |||
202 | err => this.notifier.error(err.message) | ||
203 | ) | ||
204 | } | ||
205 | |||
189 | onVideoBlacklisted () { | 206 | onVideoBlacklisted () { |
190 | this.videoBlacklisted.emit() | 207 | this.videoBlacklisted.emit() |
191 | } | 208 | } |
@@ -234,6 +251,12 @@ export class VideoActionsDropdownComponent implements OnChanges { | |||
234 | isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable() | 251 | isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblacklistable() |
235 | }, | 252 | }, |
236 | { | 253 | { |
254 | label: this.i18n('Mirror'), | ||
255 | handler: () => this.duplicateVideo(), | ||
256 | isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(), | ||
257 | iconName: 'cloud-download' | ||
258 | }, | ||
259 | { | ||
237 | label: this.i18n('Delete'), | 260 | label: this.i18n('Delete'), |
238 | handler: () => this.removeVideo(), | 261 | handler: () => this.removeVideo(), |
239 | isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(), | 262 | isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(), |
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html index 46c49c15b..8e948ce42 100644 --- a/client/src/app/shared/video/video-miniature.component.html +++ b/client/src/app/shared/video/video-miniature.component.html | |||
@@ -2,7 +2,10 @@ | |||
2 | <my-video-thumbnail | 2 | <my-video-thumbnail |
3 | [video]="video" [nsfw]="isVideoBlur" | 3 | [video]="video" [nsfw]="isVideoBlur" |
4 | [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)" | 4 | [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)" |
5 | ></my-video-thumbnail> | 5 | > |
6 | <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container> | ||
7 | <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container> | ||
8 | </my-video-thumbnail> | ||
6 | 9 | ||
7 | <div class="video-bottom"> | 10 | <div class="video-bottom"> |
8 | <div class="video-miniature-information"> | 11 | <div class="video-miniature-information"> |
@@ -19,11 +22,6 @@ | |||
19 | <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container> | 22 | <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container> |
20 | <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container> | 23 | <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container> |
21 | </span> | 24 | </span> |
22 | |||
23 | <ng-container *ngIf="displayOptions.privacyLabel"> | ||
24 | <span *ngIf="isUnlistedVideo()" class="badge badge-warning ml-1" i18n>Unlisted</span> | ||
25 | <span *ngIf="isPrivateVideo()" class="badge badge-danger ml-1" i18n>Private</span> | ||
26 | </ng-container> | ||
27 | </span> | 25 | </span> |
28 | 26 | ||
29 | <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]"> | 27 | <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]"> |
@@ -50,9 +48,9 @@ | |||
50 | </div> | 48 | </div> |
51 | 49 | ||
52 | <div class="video-actions"> | 50 | <div class="video-actions"> |
53 | <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown --> | 51 | <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 --> |
54 | <my-video-actions-dropdown | 52 | <my-video-actions-dropdown |
55 | *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left" | 53 | *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left auto" |
56 | (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()" | 54 | (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()" |
57 | ></my-video-actions-dropdown> | 55 | ></my-video-actions-dropdown> |
58 | </div> | 56 | </div> |
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss index b63fd2989..f27800a24 100644 --- a/client/src/app/shared/video/video-miniature.component.scss +++ b/client/src/app/shared/video/video-miniature.component.scss | |||
@@ -85,8 +85,12 @@ $more-margin-right: 15px; | |||
85 | } | 85 | } |
86 | 86 | ||
87 | @media screen and (max-width: $small-view) { | 87 | @media screen and (max-width: $small-view) { |
88 | .video-miniature-information .video-miniature-name { | 88 | .video-miniature-information { |
89 | margin-top: 0; | 89 | margin: 0 10px; |
90 | |||
91 | .video-miniature-name { | ||
92 | margin-top: 0; | ||
93 | } | ||
90 | } | 94 | } |
91 | 95 | ||
92 | .video-actions { | 96 | .video-actions { |
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts index 598a7a983..72b652448 100644 --- a/client/src/app/shared/video/video-miniature.component.ts +++ b/client/src/app/shared/video/video-miniature.component.ts | |||
@@ -64,7 +64,8 @@ export class VideoMiniatureComponent implements OnInit { | |||
64 | update: true, | 64 | update: true, |
65 | blacklist: true, | 65 | blacklist: true, |
66 | delete: true, | 66 | delete: true, |
67 | report: true | 67 | report: true, |
68 | duplicate: true | ||
68 | } | 69 | } |
69 | showActions = false | 70 | showActions = false |
70 | serverConfig: ServerConfig | 71 | serverConfig: ServerConfig |
@@ -199,7 +200,7 @@ export class VideoMiniatureComponent implements OnInit { | |||
199 | } | 200 | } |
200 | 201 | ||
201 | isWatchLaterPlaylistDisplayed () { | 202 | isWatchLaterPlaylistDisplayed () { |
202 | return this.inWatchLaterPlaylist !== undefined | 203 | return this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined |
203 | } | 204 | } |
204 | 205 | ||
205 | private setUpBy () { | 206 | private setUpBy () { |
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html index b63085b81..fe5510c56 100644 --- a/client/src/app/shared/video/video-thumbnail.component.html +++ b/client/src/app/shared/video/video-thumbnail.component.html | |||
@@ -1,5 +1,5 @@ | |||
1 | <a | 1 | <a |
2 | [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [title]="video.name" | 2 | [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" |
3 | class="video-thumbnail" | 3 | class="video-thumbnail" |
4 | > | 4 | > |
5 | <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> | 5 | <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> |
@@ -18,6 +18,9 @@ | |||
18 | </ng-container> | 18 | </ng-container> |
19 | </div> | 19 | </div> |
20 | 20 | ||
21 | <div class="video-thumbnail-label-overlay warning"><ng-content select="label-warning"></ng-content></div> | ||
22 | <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div> | ||
23 | |||
21 | <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div> | 24 | <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div> |
22 | 25 | ||
23 | <div class="play-overlay"> | 26 | <div class="play-overlay"> |
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss index 573a64987..5fca916f0 100644 --- a/client/src/app/shared/video/video-thumbnail.component.scss +++ b/client/src/app/shared/video/video-thumbnail.component.scss | |||
@@ -19,13 +19,24 @@ | |||
19 | } | 19 | } |
20 | 20 | ||
21 | .video-thumbnail-watch-later-overlay, | 21 | .video-thumbnail-watch-later-overlay, |
22 | .video-thumbnail-label-overlay, | ||
22 | .video-thumbnail-duration-overlay { | 23 | .video-thumbnail-duration-overlay { |
23 | @include static-thumbnail-overlay; | 24 | @include static-thumbnail-overlay; |
24 | 25 | ||
25 | border-radius: 3px; | 26 | border-radius: 3px; |
26 | font-size: 12px; | 27 | font-size: 12px; |
27 | font-weight: $font-bold; | 28 | font-weight: $font-bold; |
28 | z-index: 1; | 29 | z-index: z(miniature); |
30 | } | ||
31 | |||
32 | .video-thumbnail-label-overlay { | ||
33 | position: absolute; | ||
34 | padding: 0 5px; | ||
35 | left: 5px; | ||
36 | top: 5px; | ||
37 | |||
38 | &.warning { background-color: orange; } | ||
39 | &.danger { background-color: red; } | ||
29 | } | 40 | } |
30 | 41 | ||
31 | .video-thumbnail-duration-overlay { | 42 | .video-thumbnail-duration-overlay { |
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts index 2420ec715..111b4c8bb 100644 --- a/client/src/app/shared/video/video-thumbnail.component.ts +++ b/client/src/app/shared/video/video-thumbnail.component.ts | |||
@@ -12,7 +12,7 @@ export class VideoThumbnailComponent { | |||
12 | @Input() video: Video | 12 | @Input() video: Video |
13 | @Input() nsfw = false | 13 | @Input() nsfw = false |
14 | @Input() routerLink: any[] | 14 | @Input() routerLink: any[] |
15 | @Input() queryParams: any[] | 15 | @Input() queryParams: { [ p: string ]: any } |
16 | 16 | ||
17 | @Input() displayWatchLaterPlaylist: boolean | 17 | @Input() displayWatchLaterPlaylist: boolean |
18 | @Input() inWatchLaterPlaylist: boolean | 18 | @Input() inWatchLaterPlaylist: boolean |
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index fb98d5382..546518cca 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts | |||
@@ -42,6 +42,9 @@ export class Video implements VideoServerModel { | |||
42 | dislikes: number | 42 | dislikes: number |
43 | nsfw: boolean | 43 | nsfw: boolean |
44 | 44 | ||
45 | originInstanceUrl: string | ||
46 | originInstanceHost: string | ||
47 | |||
45 | waitTranscoding?: boolean | 48 | waitTranscoding?: boolean |
46 | state?: VideoConstant<VideoState> | 49 | state?: VideoConstant<VideoState> |
47 | scheduledUpdate?: VideoScheduleUpdate | 50 | scheduledUpdate?: VideoScheduleUpdate |
@@ -86,22 +89,31 @@ export class Video implements VideoServerModel { | |||
86 | this.waitTranscoding = hash.waitTranscoding | 89 | this.waitTranscoding = hash.waitTranscoding |
87 | this.state = hash.state | 90 | this.state = hash.state |
88 | this.description = hash.description | 91 | this.description = hash.description |
92 | |||
89 | this.duration = hash.duration | 93 | this.duration = hash.duration |
90 | this.durationLabel = durationToString(hash.duration) | 94 | this.durationLabel = durationToString(hash.duration) |
95 | |||
91 | this.id = hash.id | 96 | this.id = hash.id |
92 | this.uuid = hash.uuid | 97 | this.uuid = hash.uuid |
98 | |||
93 | this.isLocal = hash.isLocal | 99 | this.isLocal = hash.isLocal |
94 | this.name = hash.name | 100 | this.name = hash.name |
101 | |||
95 | this.thumbnailPath = hash.thumbnailPath | 102 | this.thumbnailPath = hash.thumbnailPath |
96 | this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath | 103 | this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath |
104 | |||
97 | this.previewPath = hash.previewPath | 105 | this.previewPath = hash.previewPath |
98 | this.previewUrl = absoluteAPIUrl + hash.previewPath | 106 | this.previewUrl = absoluteAPIUrl + hash.previewPath |
107 | |||
99 | this.embedPath = hash.embedPath | 108 | this.embedPath = hash.embedPath |
100 | this.embedUrl = absoluteAPIUrl + hash.embedPath | 109 | this.embedUrl = absoluteAPIUrl + hash.embedPath |
110 | |||
101 | this.views = hash.views | 111 | this.views = hash.views |
102 | this.likes = hash.likes | 112 | this.likes = hash.likes |
103 | this.dislikes = hash.dislikes | 113 | this.dislikes = hash.dislikes |
114 | |||
104 | this.nsfw = hash.nsfw | 115 | this.nsfw = hash.nsfw |
116 | |||
105 | this.account = hash.account | 117 | this.account = hash.account |
106 | this.channel = hash.channel | 118 | this.channel = hash.channel |
107 | 119 | ||
@@ -124,6 +136,9 @@ export class Video implements VideoServerModel { | |||
124 | this.blacklistedReason = hash.blacklistedReason | 136 | this.blacklistedReason = hash.blacklistedReason |
125 | 137 | ||
126 | this.userHistory = hash.userHistory | 138 | this.userHistory = hash.userHistory |
139 | |||
140 | this.originInstanceHost = this.account.host | ||
141 | this.originInstanceUrl = 'https://' + this.originInstanceHost | ||
127 | } | 142 | } |
128 | 143 | ||
129 | isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { | 144 | isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { |
@@ -152,4 +167,8 @@ export class Video implements VideoServerModel { | |||
152 | isUpdatableBy (user: AuthUser) { | 167 | isUpdatableBy (user: AuthUser) { |
153 | return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) | 168 | return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) |
154 | } | 169 | } |
170 | |||
171 | canBeDuplicatedBy (user: AuthUser) { | ||
172 | return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES) | ||
173 | } | ||
155 | } | 174 | } |
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 996202154..3aaf14990 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts | |||
@@ -27,10 +27,12 @@ import { objectToFormData } from '@app/shared/misc/utils' | |||
27 | import { Account } from '@app/shared/account/account.model' | 27 | import { Account } from '@app/shared/account/account.model' |
28 | import { AccountService } from '@app/shared/account/account.service' | 28 | import { AccountService } from '@app/shared/account/account.service' |
29 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 29 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
30 | import { ServerService } from '@app/core' | 30 | import { ServerService, AuthService } from '@app/core' |
31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' | 31 | import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service' |
32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 32 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
33 | import { I18n } from '@ngx-translate/i18n-polyfill' | 33 | import { I18n } from '@ngx-translate/i18n-polyfill' |
34 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | ||
35 | import { FfprobeData } from 'fluent-ffmpeg' | ||
34 | 36 | ||
35 | export interface VideosProvider { | 37 | export interface VideosProvider { |
36 | getVideos (parameters: { | 38 | getVideos (parameters: { |
@@ -49,6 +51,8 @@ export class VideoService implements VideosProvider { | |||
49 | 51 | ||
50 | constructor ( | 52 | constructor ( |
51 | private authHttp: HttpClient, | 53 | private authHttp: HttpClient, |
54 | private authService: AuthService, | ||
55 | private userService: UserService, | ||
52 | private restExtractor: RestExtractor, | 56 | private restExtractor: RestExtractor, |
53 | private restService: RestService, | 57 | private restService: RestService, |
54 | private serverService: ServerService, | 58 | private serverService: ServerService, |
@@ -199,9 +203,10 @@ export class VideoService implements VideosProvider { | |||
199 | filter?: VideoFilter, | 203 | filter?: VideoFilter, |
200 | categoryOneOf?: number, | 204 | categoryOneOf?: number, |
201 | languageOneOf?: string[], | 205 | languageOneOf?: string[], |
202 | skipCount?: boolean | 206 | skipCount?: boolean, |
207 | nsfw?: boolean | ||
203 | }): Observable<ResultList<Video>> { | 208 | }): Observable<ResultList<Video>> { |
204 | const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount } = parameters | 209 | const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfw } = parameters |
205 | 210 | ||
206 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | 211 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) |
207 | 212 | ||
@@ -212,6 +217,15 @@ export class VideoService implements VideosProvider { | |||
212 | if (categoryOneOf) params = params.set('categoryOneOf', categoryOneOf + '') | 217 | if (categoryOneOf) params = params.set('categoryOneOf', categoryOneOf + '') |
213 | if (skipCount) params = params.set('skipCount', skipCount + '') | 218 | if (skipCount) params = params.set('skipCount', skipCount + '') |
214 | 219 | ||
220 | if (nsfw) { | ||
221 | params = params.set('nsfw', nsfw + '') | ||
222 | } else { | ||
223 | const nsfwPolicy = this.authService.isLoggedIn() | ||
224 | ? this.authService.getUser().nsfwPolicy | ||
225 | : this.userService.getAnonymousUser().nsfwPolicy | ||
226 | if (this.nsfwPolicyToFilter(nsfwPolicy)) params.set('nsfw', 'false') | ||
227 | } | ||
228 | |||
215 | if (languageOneOf) { | 229 | if (languageOneOf) { |
216 | for (const l of languageOneOf) { | 230 | for (const l of languageOneOf) { |
217 | params = params.append('languageOneOf[]', l) | 231 | params = params.append('languageOneOf[]', l) |
@@ -278,6 +292,14 @@ export class VideoService implements VideosProvider { | |||
278 | return this.buildBaseFeedUrls(params) | 292 | return this.buildBaseFeedUrls(params) |
279 | } | 293 | } |
280 | 294 | ||
295 | getVideoFileMetadata (metadataUrl: string) { | ||
296 | return this.authHttp | ||
297 | .get<FfprobeData>(metadataUrl) | ||
298 | .pipe( | ||
299 | catchError(err => this.restExtractor.handleError(err)) | ||
300 | ) | ||
301 | } | ||
302 | |||
281 | removeVideo (id: number) { | 303 | removeVideo (id: number) { |
282 | return this.authHttp | 304 | return this.authHttp |
283 | .delete(VideoService.BASE_VIDEO_URL + id) | 305 | .delete(VideoService.BASE_VIDEO_URL + id) |
@@ -368,4 +390,8 @@ export class VideoService implements VideosProvider { | |||
368 | catchError(err => this.restExtractor.handleError(err)) | 390 | catchError(err => this.restExtractor.handleError(err)) |
369 | ) | 391 | ) |
370 | } | 392 | } |
393 | |||
394 | private nsfwPolicyToFilter (policy: NSFWPolicyType) { | ||
395 | return policy === 'do_not_list' | ||
396 | } | ||
371 | } | 397 | } |
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts index 064420056..17e5beb24 100644 --- a/client/src/app/shared/video/videos-selection.component.ts +++ b/client/src/app/shared/video/videos-selection.component.ts | |||
@@ -22,6 +22,8 @@ import { VideoSortField } from '@app/shared/video/sort-field.type' | |||
22 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | 22 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' |
23 | import { I18n } from '@ngx-translate/i18n-polyfill' | 23 | import { I18n } from '@ngx-translate/i18n-polyfill' |
24 | import { ResultList } from '@shared/models' | 24 | import { ResultList } from '@shared/models' |
25 | import { UserService } from '../users' | ||
26 | import { LocalStorageService } from '../misc/storage.service' | ||
25 | 27 | ||
26 | export type SelectionType = { [ id: number ]: boolean } | 28 | export type SelectionType = { [ id: number ]: boolean } |
27 | 29 | ||
@@ -51,7 +53,9 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni | |||
51 | protected route: ActivatedRoute, | 53 | protected route: ActivatedRoute, |
52 | protected notifier: Notifier, | 54 | protected notifier: Notifier, |
53 | protected authService: AuthService, | 55 | protected authService: AuthService, |
56 | protected userService: UserService, | ||
54 | protected screenService: ScreenService, | 57 | protected screenService: ScreenService, |
58 | protected storageService: LocalStorageService, | ||
55 | protected serverService: ServerService | 59 | protected serverService: ServerService |
56 | ) { | 60 | ) { |
57 | super() | 61 | super() |
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html index 19043eee6..6a9e31b5a 100644 --- a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html +++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.html | |||
@@ -9,7 +9,7 @@ | |||
9 | <div class="modal-body"> | 9 | <div class="modal-body"> |
10 | <label i18n for="language">Language</label> | 10 | <label i18n for="language">Language</label> |
11 | <div class="peertube-select-container"> | 11 | <div class="peertube-select-container"> |
12 | <select id="language" formControlName="language"> | 12 | <select id="language" formControlName="language" class="form-control"> |
13 | <option></option> | 13 | <option></option> |
14 | <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option> | 14 | <option *ngFor="let language of videoCaptionLanguages" [value]="language.id">{{ language.label }}</option> |
15 | </select> | 15 | </select> |
@@ -23,6 +23,7 @@ | |||
23 | <my-reactive-file | 23 | <my-reactive-file |
24 | formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file" | 24 | formControlName="captionfile" inputName="captionfile" i18n-inputLabel inputLabel="Select the caption file" |
25 | [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true" | 25 | [extensions]="videoCaptionExtensions" [maxFileSize]="videoCaptionMaxSize" [displayFilename]="true" |
26 | i18n-ngbTooltip [ngbTooltip]="'(extensions: ' + videoCaptionExtensions.join(', ') + ')'" | ||
26 | ></my-reactive-file> | 27 | ></my-reactive-file> |
27 | </div> | 28 | </div> |
28 | 29 | ||
@@ -32,9 +33,10 @@ | |||
32 | </div> | 33 | </div> |
33 | 34 | ||
34 | <div class="modal-footer inputs"> | 35 | <div class="modal-footer inputs"> |
35 | <span i18n class="action-button action-button-cancel" (click)="hide()"> | 36 | <input |
36 | Cancel | 37 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" |
37 | </span> | 38 | (click)="hide()" (key.enter)="hide()" |
39 | > | ||
38 | 40 | ||
39 | <input | 41 | <input |
40 | type="submit" i18n-value value="Add this caption" class="action-button-submit" | 42 | type="submit" i18n-value value="Add this caption" class="action-button-submit" |
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss index c6da1877e..b257a16a9 100644 --- a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss +++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.scss | |||
@@ -7,6 +7,11 @@ | |||
7 | 7 | ||
8 | .caption-file { | 8 | .caption-file { |
9 | margin-top: 20px; | 9 | margin-top: 20px; |
10 | width: max-content; | ||
11 | |||
12 | ::ng-deep .root { | ||
13 | width: max-content; | ||
14 | } | ||
10 | } | 15 | } |
11 | 16 | ||
12 | .warning-replace-caption { | 17 | .warning-replace-caption { |
diff --git a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts index 1a9bf5171..9856aac9e 100644 --- a/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts +++ b/client/src/app/videos/+video-edit/shared/video-caption-add-modal.component.ts | |||
@@ -56,7 +56,7 @@ export class VideoCaptionAddModalComponent extends FormReactive implements OnIni | |||
56 | show () { | 56 | show () { |
57 | this.closingModal = false | 57 | this.closingModal = false |
58 | 58 | ||
59 | this.openedModal = this.modalService.open(this.modal, { keyboard: false }) | 59 | this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) |
60 | } | 60 | } |
61 | 61 | ||
62 | hide () { | 62 | hide () { |
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.html b/client/src/app/videos/+video-edit/shared/video-edit.component.html index e40649d95..9a0e4f848 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 | |||
@@ -1,13 +1,15 @@ | |||
1 | <div class="video-edit" [formGroup]="form"> | 1 | <div class="video-edit" [formGroup]="form"> |
2 | <ngb-tabset class="root-tabset bootstrap"> | 2 | <div ngbNav #nav="ngbNav" class="nav-tabs"> |
3 | 3 | ||
4 | <ngb-tab i18n-title title="Basic info"> | 4 | <ng-container ngbNavItem> |
5 | <ng-template ngbTabContent> | 5 | <a ngbNavLink i18n>Basic info</a> |
6 | |||
7 | <ng-template ngbNavContent> | ||
6 | <div class="row"> | 8 | <div class="row"> |
7 | <div class="col-md-8"> | 9 | <div class="col-video-edit"> |
8 | <div class="form-group"> | 10 | <div class="form-group"> |
9 | <label i18n for="name">Title</label> | 11 | <label i18n for="name">Title</label> |
10 | <input type="text" id="name" formControlName="name" /> | 12 | <input type="text" id="name" class="form-control" formControlName="name" /> |
11 | <div *ngIf="formErrors.name" class="form-error"> | 13 | <div *ngIf="formErrors.name" class="form-error"> |
12 | {{ formErrors.name }} | 14 | {{ formErrors.name }} |
13 | </div> | 15 | </div> |
@@ -29,7 +31,7 @@ | |||
29 | <tag-input | 31 | <tag-input |
30 | [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" | 32 | [validators]="tagValidators" [errorMessages]="tagValidatorsMessages" |
31 | i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag" | 33 | i18n-placeholder placeholder="+ Tag" i18n-secondaryPlaceholder secondaryPlaceholder="Enter a new tag" |
32 | formControlName="tags" maxItems="5" modelAsStrings="true" | 34 | formControlName="tags" [maxItems]="5" [modelAsStrings]="true" |
33 | ></tag-input> | 35 | ></tag-input> |
34 | </div> | 36 | </div> |
35 | 37 | ||
@@ -44,7 +46,7 @@ | |||
44 | </ng-template> | 46 | </ng-template> |
45 | </my-help> | 47 | </my-help> |
46 | 48 | ||
47 | <my-markdown-textarea truncate="250" formControlName="description" markdownVideo="true"></my-markdown-textarea> | 49 | <my-markdown-textarea [truncate]="250" formControlName="description" [markdownVideo]="true"></my-markdown-textarea> |
48 | 50 | ||
49 | <div *ngIf="formErrors.description" class="form-error"> | 51 | <div *ngIf="formErrors.description" class="form-error"> |
50 | {{ formErrors.description }} | 52 | {{ formErrors.description }} |
@@ -52,11 +54,11 @@ | |||
52 | </div> | 54 | </div> |
53 | </div> | 55 | </div> |
54 | 56 | ||
55 | <div class="col-md-4"> | 57 | <div class="col-video-edit"> |
56 | <div class="form-group"> | 58 | <div class="form-group"> |
57 | <label i18n>Channel</label> | 59 | <label i18n>Channel</label> |
58 | <div class="peertube-select-container"> | 60 | <div class="peertube-select-container"> |
59 | <select formControlName="channelId"> | 61 | <select formControlName="channelId" class="form-control"> |
60 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | 62 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> |
61 | </select> | 63 | </select> |
62 | </div> | 64 | </div> |
@@ -65,7 +67,7 @@ | |||
65 | <div class="form-group"> | 67 | <div class="form-group"> |
66 | <label i18n for="category">Category</label> | 68 | <label i18n for="category">Category</label> |
67 | <div class="peertube-select-container"> | 69 | <div class="peertube-select-container"> |
68 | <select id="category" formControlName="category"> | 70 | <select id="category" formControlName="category" class="form-control"> |
69 | <option></option> | 71 | <option></option> |
70 | <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option> | 72 | <option *ngFor="let category of videoCategories" [value]="category.id">{{ category.label }}</option> |
71 | </select> | 73 | </select> |
@@ -79,7 +81,7 @@ | |||
79 | <div class="form-group"> | 81 | <div class="form-group"> |
80 | <label i18n for="licence">Licence</label> | 82 | <label i18n for="licence">Licence</label> |
81 | <div class="peertube-select-container"> | 83 | <div class="peertube-select-container"> |
82 | <select id="licence" formControlName="licence"> | 84 | <select id="licence" formControlName="licence" class="form-control"> |
83 | <option></option> | 85 | <option></option> |
84 | <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option> | 86 | <option *ngFor="let licence of videoLicences" [value]="licence.id">{{ licence.label }}</option> |
85 | </select> | 87 | </select> |
@@ -93,7 +95,7 @@ | |||
93 | <div class="form-group"> | 95 | <div class="form-group"> |
94 | <label i18n for="language">Language</label> | 96 | <label i18n for="language">Language</label> |
95 | <div class="peertube-select-container"> | 97 | <div class="peertube-select-container"> |
96 | <select id="language" formControlName="language"> | 98 | <select id="language" formControlName="language" class="form-control"> |
97 | <option></option> | 99 | <option></option> |
98 | <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option> | 100 | <option *ngFor="let language of videoLanguages" [value]="language.id">{{ language.label }}</option> |
99 | </select> | 101 | </select> |
@@ -107,7 +109,7 @@ | |||
107 | <div class="form-group"> | 109 | <div class="form-group"> |
108 | <label i18n for="privacy">Privacy</label> | 110 | <label i18n for="privacy">Privacy</label> |
109 | <div class="peertube-select-container"> | 111 | <div class="peertube-select-container"> |
110 | <select id="privacy" formControlName="privacy"> | 112 | <select id="privacy" formControlName="privacy" class="form-control"> |
111 | <option></option> | 113 | <option></option> |
112 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | 114 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> |
113 | <option *ngIf="schedulePublicationPossible" [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option> | 115 | <option *ngIf="schedulePublicationPossible" [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option> |
@@ -155,10 +157,12 @@ | |||
155 | </div> | 157 | </div> |
156 | </div> | 158 | </div> |
157 | </ng-template> | 159 | </ng-template> |
158 | </ngb-tab> | 160 | </ng-container> |
161 | |||
162 | <ng-container ngbNavItem> | ||
163 | <a ngbNavLink i18n>Captions</a> | ||
159 | 164 | ||
160 | <ngb-tab i18n-title title="Captions"> | 165 | <ng-template ngbNavContent> |
161 | <ng-template ngbTabContent> | ||
162 | <div class="captions"> | 166 | <div class="captions"> |
163 | 167 | ||
164 | <div class="captions-header"> | 168 | <div class="captions-header"> |
@@ -206,10 +210,12 @@ | |||
206 | 210 | ||
207 | </div> | 211 | </div> |
208 | </ng-template> | 212 | </ng-template> |
209 | </ngb-tab> | 213 | </ng-container> |
214 | |||
215 | <ng-container ngbNavItem> | ||
216 | <a ngbNavLink i18n>Advanced settings</a> | ||
210 | 217 | ||
211 | <ngb-tab i18n-title title="Advanced settings"> | 218 | <ng-template ngbNavContent> |
212 | <ng-template ngbTabContent> | ||
213 | <div class="row advanced-settings"> | 219 | <div class="row advanced-settings"> |
214 | <div class="col-md-12 col-xl-8"> | 220 | <div class="col-md-12 col-xl-8"> |
215 | 221 | ||
@@ -226,7 +232,7 @@ | |||
226 | <label i18n for="support">Support</label> | 232 | <label i18n for="support">Support</label> |
227 | <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help> | 233 | <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help> |
228 | <my-markdown-textarea | 234 | <my-markdown-textarea |
229 | id="support" formControlName="support" textareaWidth="500px" [previewColumn]="true" markdownType="enhanced" | 235 | id="support" formControlName="support" markdownType="enhanced" |
230 | [classes]="{ 'input-error': formErrors['support'] }" | 236 | [classes]="{ 'input-error': formErrors['support'] }" |
231 | ></my-markdown-textarea> | 237 | ></my-markdown-textarea> |
232 | <div *ngIf="formErrors.support" class="form-error"> | 238 | <div *ngIf="formErrors.support" class="form-error"> |
@@ -262,10 +268,11 @@ | |||
262 | </div> | 268 | </div> |
263 | </div> | 269 | </div> |
264 | </ng-template> | 270 | </ng-template> |
265 | </ngb-tab> | 271 | </ng-container> |
266 | 272 | ||
267 | </ngb-tabset> | 273 | </div> |
268 | 274 | ||
275 | <div [ngbNavOutlet]="nav"></div> | ||
269 | </div> | 276 | </div> |
270 | 277 | ||
271 | <my-video-caption-add-modal | 278 | <my-video-caption-add-modal |
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss index 144914731..2f9067132 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss | |||
@@ -1,5 +1,16 @@ | |||
1 | @import '_variables'; | 1 | // Bootstrap grid utilities require functions, variables and mixins |
2 | @import '_mixins'; | 2 | @import 'node_modules/bootstrap/scss/functions'; |
3 | @import 'node_modules/bootstrap/scss/variables'; | ||
4 | @import 'node_modules/bootstrap/scss/mixins'; | ||
5 | @import 'node_modules/bootstrap/scss/grid'; | ||
6 | |||
7 | @import 'variables'; | ||
8 | @import 'mixins'; | ||
9 | |||
10 | label { | ||
11 | font-weight: $font-regular; | ||
12 | font-size: 100%; | ||
13 | } | ||
3 | 14 | ||
4 | .peertube-select-container { | 15 | .peertube-select-container { |
5 | @include peertube-select-container(auto); | 16 | @include peertube-select-container(auto); |
@@ -19,6 +30,10 @@ my-peertube-checkbox { | |||
19 | margin-bottom: 1rem; | 30 | margin-bottom: 1rem; |
20 | } | 31 | } |
21 | 32 | ||
33 | .nav-tabs { | ||
34 | margin-bottom: 15px; | ||
35 | } | ||
36 | |||
22 | .video-edit { | 37 | .video-edit { |
23 | height: 100%; | 38 | height: 100%; |
24 | min-height: 300px; | 39 | min-height: 300px; |
@@ -45,6 +60,7 @@ my-peertube-checkbox { | |||
45 | 60 | ||
46 | .captions-header { | 61 | .captions-header { |
47 | text-align: right; | 62 | text-align: right; |
63 | margin-bottom: 1rem; | ||
48 | 64 | ||
49 | .create-caption { | 65 | .create-caption { |
50 | @include create-button; | 66 | @include create-button; |
@@ -59,6 +75,7 @@ my-peertube-checkbox { | |||
59 | a.caption-entry-label { | 75 | a.caption-entry-label { |
60 | @include disable-default-a-behaviour; | 76 | @include disable-default-a-behaviour; |
61 | 77 | ||
78 | flex-grow: 1; | ||
62 | color: #000; | 79 | color: #000; |
63 | 80 | ||
64 | &:hover { | 81 | &:hover { |
@@ -144,69 +161,37 @@ p-calendar { | |||
144 | } | 161 | } |
145 | } | 162 | } |
146 | 163 | ||
147 | ::ng-deep { | 164 | @include ng2-tags; |
148 | .root-tabset > .nav { | ||
149 | margin-bottom: 15px; | ||
150 | } | ||
151 | 165 | ||
152 | .ng2-tag-input { | 166 | // columns for the video |
153 | border: none !important; | 167 | .col-video-edit { |
154 | } | 168 | @include make-col-ready(); |
155 | 169 | ||
156 | .ng2-tags-container { | 170 | @include media-breakpoint-up(md) { |
157 | display: flex; | 171 | @include make-col(7); |
158 | align-items: center; | ||
159 | border: 1px solid #C6C6C6; | ||
160 | border-radius: 3px; | ||
161 | padding: 5px !important; | ||
162 | height: max-content; | ||
163 | } | ||
164 | 172 | ||
165 | tag-input-form { | 173 | & + .col-video-edit { |
166 | input { | 174 | @include make-col(5); |
167 | height: 30px !important; | ||
168 | font-size: 12px !important; | ||
169 | |||
170 | background-color: var(--mainBackgroundColor) !important; | ||
171 | color: var(--mainForegroundColor) !important; | ||
172 | } | 175 | } |
173 | } | 176 | } |
174 | 177 | ||
175 | tag { | 178 | @include media-breakpoint-up(xl) { |
176 | background-color: $grey-background-color !important; | 179 | @include make-col(8); |
177 | color: #000 !important; | 180 | |
178 | border-radius: 3px !important; | 181 | & + .col-video-edit { |
179 | font-size: 12px !important; | 182 | @include make-col(4); |
180 | height: 30px !important; | ||
181 | line-height: 30px !important; | ||
182 | margin: 0 5px 0 0 !important; | ||
183 | cursor: default !important; | ||
184 | padding: 0 8px 0 10px !important; | ||
185 | |||
186 | div { | ||
187 | height: 100% !important; | ||
188 | } | 183 | } |
189 | } | 184 | } |
185 | } | ||
190 | 186 | ||
191 | delete-icon { | 187 | :host-context(.expanded) { |
192 | cursor: pointer !important; | 188 | .col-video-edit { |
193 | height: auto !important; | 189 | @include media-breakpoint-up(md) { |
194 | vertical-align: middle !important; | 190 | @include make-col(8); |
195 | padding-left: 6px !important; | ||
196 | |||
197 | svg { | ||
198 | position: relative; | ||
199 | top: -1px; | ||
200 | height: auto !important; | ||
201 | vertical-align: middle !important; | ||
202 | 191 | ||
203 | path { | 192 | & + .col-video-edit { |
204 | fill: $grey-foreground-color !important; | 193 | @include make-col(4); |
205 | } | 194 | } |
206 | } | 195 | } |
207 | |||
208 | &:hover { | ||
209 | transform: none !important; | ||
210 | } | ||
211 | } | 196 | } |
212 | } | 197 | } |
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.module.ts b/client/src/app/videos/+video-edit/shared/video-edit.module.ts index 39b6daa93..1357d607c 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.module.ts +++ b/client/src/app/videos/+video-edit/shared/video-edit.module.ts | |||
@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core' | |||
2 | import { TagInputModule } from 'ngx-chips' | 2 | import { TagInputModule } from 'ngx-chips' |
3 | import { SharedModule } from '../../../shared/' | 3 | import { SharedModule } from '../../../shared/' |
4 | import { VideoEditComponent } from './video-edit.component' | 4 | import { VideoEditComponent } from './video-edit.component' |
5 | import { CalendarModule } from 'primeng/components/calendar/calendar' | 5 | import { CalendarModule } from 'primeng/calendar' |
6 | import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' | 6 | import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' |
7 | 7 | ||
8 | @NgModule({ | 8 | @NgModule({ |
diff --git a/client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts b/client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts new file mode 100644 index 000000000..7b1a38c62 --- /dev/null +++ b/client/src/app/videos/+video-edit/video-add-components/drag-drop.directive.ts | |||
@@ -0,0 +1,30 @@ | |||
1 | import { Directive, Output, EventEmitter, HostBinding, HostListener } from '@angular/core' | ||
2 | |||
3 | @Directive({ | ||
4 | selector: '[dragDrop]' | ||
5 | }) | ||
6 | export class DragDropDirective { | ||
7 | @Output() fileDropped = new EventEmitter<FileList>() | ||
8 | |||
9 | @HostBinding('class.dragover') dragover = false | ||
10 | |||
11 | @HostListener('dragover', ['$event']) onDragOver (e: Event) { | ||
12 | e.preventDefault() | ||
13 | e.stopPropagation() | ||
14 | this.dragover = true | ||
15 | } | ||
16 | |||
17 | @HostListener('dragleave', ['$event']) public onDragLeave (e: Event) { | ||
18 | e.preventDefault() | ||
19 | e.stopPropagation() | ||
20 | this.dragover = false | ||
21 | } | ||
22 | |||
23 | @HostListener('drop', ['$event']) public ondrop (e: DragEvent) { | ||
24 | e.preventDefault() | ||
25 | e.stopPropagation() | ||
26 | this.dragover = false | ||
27 | const files = e.dataTransfer.files | ||
28 | if (files.length > 0) this.fileDropped.emit(files) | ||
29 | } | ||
30 | } | ||
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 c290fd4b1..c2ee3ad57 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 | |||
@@ -1,14 +1,13 @@ | |||
1 | <div *ngIf="!hasImportedVideo" class="upload-video-container"> | 1 | <div *ngIf="!hasImportedVideo" class="upload-video-container" dragDrop (fileDropped)="setTorrentFile($event)"> |
2 | <div class="first-step-block"> | 2 | <div class="first-step-block"> |
3 | <my-global-icon class="upload-icon" iconName="upload"></my-global-icon> | 3 | <my-global-icon class="upload-icon" iconName="upload"></my-global-icon> |
4 | 4 | ||
5 | <div class="button-file"> | 5 | <div class="button-file form-control" [ngbTooltip]="'(extensions: .torrent)'"> |
6 | <span i18n>Select the torrent to import</span> | 6 | <span i18n>Select the torrent to import</span> |
7 | <input #torrentfileInput type="file" name="torrentfile" id="torrentfile" accept=".torrent" (change)="fileChange()" /> | 7 | <input #torrentfileInput type="file" name="torrentfile" id="torrentfile" accept=".torrent" (change)="fileChange()" /> |
8 | </div> | 8 | </div> |
9 | <span class="button-file-extension">(.torrent)</span> | ||
10 | 9 | ||
11 | <div class="torrent-or-magnet" i18n>Or</div> | 10 | <div class="torrent-or-magnet" i18n-data-content data-content="OR"></div> |
12 | 11 | ||
13 | <div class="form-group form-group-magnet-uri"> | 12 | <div class="form-group form-group-magnet-uri"> |
14 | <label i18n for="magnetUri">Paste magnet URI</label> | 13 | <label i18n for="magnetUri">Paste magnet URI</label> |
@@ -21,13 +20,13 @@ | |||
21 | </ng-template> | 20 | </ng-template> |
22 | </my-help> | 21 | </my-help> |
23 | 22 | ||
24 | <input type="text" id="magnetUri" [(ngModel)]="magnetUri" /> | 23 | <input type="text" id="magnetUri" [(ngModel)]="magnetUri" class="form-control" /> |
25 | </div> | 24 | </div> |
26 | 25 | ||
27 | <div class="form-group"> | 26 | <div class="form-group"> |
28 | <label i18n for="first-step-channel">Channel</label> | 27 | <label i18n for="first-step-channel">Channel</label> |
29 | <div class="peertube-select-container"> | 28 | <div class="peertube-select-container"> |
30 | <select id="first-step-channel" [(ngModel)]="firstStepChannelId"> | 29 | <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control"> |
31 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | 30 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> |
32 | </select> | 31 | </select> |
33 | </div> | 32 | </div> |
@@ -36,7 +35,7 @@ | |||
36 | <div class="form-group"> | 35 | <div class="form-group"> |
37 | <label i18n for="first-step-privacy">Privacy</label> | 36 | <label i18n for="first-step-privacy">Privacy</label> |
38 | <div class="peertube-select-container"> | 37 | <div class="peertube-select-container"> |
39 | <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId"> | 38 | <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control"> |
40 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | 39 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> |
41 | </select> | 40 | </select> |
42 | </div> | 41 | </div> |
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss index 6d59ed834..3b46475b4 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss +++ b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.scss | |||
@@ -3,7 +3,11 @@ | |||
3 | 3 | ||
4 | .first-step-block { | 4 | .first-step-block { |
5 | .torrent-or-magnet { | 5 | .torrent-or-magnet { |
6 | margin: 10px 0; | 6 | @include divider($color: var(--inputPlaceholderColor), $background: var(--submenuColor)); |
7 | |||
8 | &[data-content] { | ||
9 | margin: 1.5rem 0; | ||
10 | } | ||
7 | } | 11 | } |
8 | 12 | ||
9 | .form-group-magnet-uri { | 13 | .form-group-magnet-uri { |
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts index 74e1e755b..4d0b0b080 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts +++ b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts | |||
@@ -25,7 +25,7 @@ import { scrollToTop } from '@app/shared/misc/utils' | |||
25 | export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate { | 25 | export class VideoImportTorrentComponent extends VideoSend implements OnInit, CanComponentDeactivate { |
26 | @Output() firstStepDone = new EventEmitter<string>() | 26 | @Output() firstStepDone = new EventEmitter<string>() |
27 | @Output() firstStepError = new EventEmitter<void>() | 27 | @Output() firstStepError = new EventEmitter<void>() |
28 | @ViewChild('torrentfileInput', { static: false }) torrentfileInput: ElementRef<HTMLInputElement> | 28 | @ViewChild('torrentfileInput') torrentfileInput: ElementRef<HTMLInputElement> |
29 | 29 | ||
30 | magnetUri = '' | 30 | magnetUri = '' |
31 | 31 | ||
@@ -72,6 +72,11 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca | |||
72 | this.importVideo(torrentfile) | 72 | this.importVideo(torrentfile) |
73 | } | 73 | } |
74 | 74 | ||
75 | setTorrentFile (files: FileList) { | ||
76 | this.torrentfileInput.nativeElement.files = files | ||
77 | this.fileChange() | ||
78 | } | ||
79 | |||
75 | importVideo (torrentfile?: Blob) { | 80 | importVideo (torrentfile?: Blob) { |
76 | this.isImportingVideo = true | 81 | this.isImportingVideo = true |
77 | 82 | ||
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 09d0b8272..9a26fe308 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 | |||
@@ -15,13 +15,13 @@ | |||
15 | </ng-template> | 15 | </ng-template> |
16 | </my-help> | 16 | </my-help> |
17 | 17 | ||
18 | <input type="text" id="targetUrl" [(ngModel)]="targetUrl" /> | 18 | <input type="text" id="targetUrl" [(ngModel)]="targetUrl" class="form-control" /> |
19 | </div> | 19 | </div> |
20 | 20 | ||
21 | <div class="form-group"> | 21 | <div class="form-group"> |
22 | <label i18n for="first-step-channel">Channel</label> | 22 | <label i18n for="first-step-channel">Channel</label> |
23 | <div class="peertube-select-container"> | 23 | <div class="peertube-select-container"> |
24 | <select id="first-step-channel" [(ngModel)]="firstStepChannelId"> | 24 | <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control"> |
25 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | 25 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> |
26 | </select> | 26 | </select> |
27 | </div> | 27 | </div> |
@@ -30,7 +30,7 @@ | |||
30 | <div class="form-group"> | 30 | <div class="form-group"> |
31 | <label i18n for="first-step-privacy">Privacy</label> | 31 | <label i18n for="first-step-privacy">Privacy</label> |
32 | <div class="peertube-select-container"> | 32 | <div class="peertube-select-container"> |
33 | <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId"> | 33 | <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control"> |
34 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | 34 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> |
35 | </select> | 35 | </select> |
36 | </div> | 36 | </div> |
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 a5578bebd..213c42333 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 | |||
@@ -11,7 +11,8 @@ import { VideoEdit } from '@app/shared/video/video-edit.model' | |||
11 | import { FormValidatorService } from '@app/shared' | 11 | import { FormValidatorService } from '@app/shared' |
12 | import { VideoCaptionService } from '@app/shared/video-caption' | 12 | import { VideoCaptionService } from '@app/shared/video-caption' |
13 | import { VideoImportService } from '@app/shared/video-import' | 13 | import { VideoImportService } from '@app/shared/video-import' |
14 | import { scrollToTop } from '@app/shared/misc/utils' | 14 | import { scrollToTop, getAbsoluteAPIUrl } from '@app/shared/misc/utils' |
15 | import { switchMap, map } from 'rxjs/operators' | ||
15 | 16 | ||
16 | @Component({ | 17 | @Component({ |
17 | selector: 'my-video-import-url', | 18 | selector: 'my-video-import-url', |
@@ -76,31 +77,54 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom | |||
76 | 77 | ||
77 | this.loadingBar.start() | 78 | this.loadingBar.start() |
78 | 79 | ||
79 | this.videoImportService.importVideoUrl(this.targetUrl, videoUpdate).subscribe( | 80 | this.videoImportService |
80 | res => { | 81 | .importVideoUrl(this.targetUrl, videoUpdate) |
81 | this.loadingBar.complete() | 82 | .pipe( |
82 | this.firstStepDone.emit(res.video.name) | 83 | switchMap(res => { |
83 | this.isImportingVideo = false | 84 | return this.videoCaptionService |
84 | this.hasImportedVideo = true | 85 | .listCaptions(res.video.id) |
85 | 86 | .pipe( | |
86 | this.video = new VideoEdit(Object.assign(res.video, { | 87 | map(result => ({ video: res.video, videoCaptions: result.data })) |
87 | commentsEnabled: videoUpdate.commentsEnabled, | 88 | ) |
88 | downloadEnabled: videoUpdate.downloadEnabled, | 89 | }) |
89 | support: null, | 90 | ) |
90 | thumbnailUrl: null, | 91 | .subscribe( |
91 | previewUrl: null | 92 | ({ video, videoCaptions }) => { |
92 | })) | 93 | this.loadingBar.complete() |
93 | 94 | this.firstStepDone.emit(video.name) | |
94 | this.hydrateFormFromVideo() | 95 | this.isImportingVideo = false |
95 | }, | 96 | this.hasImportedVideo = true |
96 | 97 | ||
97 | err => { | 98 | const absoluteAPIUrl = getAbsoluteAPIUrl() |
98 | this.loadingBar.complete() | 99 | |
99 | this.isImportingVideo = false | 100 | const thumbnailUrl = video.thumbnailPath |
100 | this.firstStepError.emit() | 101 | ? absoluteAPIUrl + video.thumbnailPath |
101 | this.notifier.error(err.message) | 102 | : null |
102 | } | 103 | |
103 | ) | 104 | const previewUrl = video.previewPath |
105 | ? absoluteAPIUrl + video.previewPath | ||
106 | : null | ||
107 | |||
108 | this.video = new VideoEdit(Object.assign(video, { | ||
109 | commentsEnabled: videoUpdate.commentsEnabled, | ||
110 | downloadEnabled: videoUpdate.downloadEnabled, | ||
111 | support: null, | ||
112 | thumbnailUrl, | ||
113 | previewUrl | ||
114 | })) | ||
115 | |||
116 | this.videoCaptions = videoCaptions | ||
117 | |||
118 | this.hydrateFormFromVideo() | ||
119 | }, | ||
120 | |||
121 | err => { | ||
122 | this.loadingBar.complete() | ||
123 | this.isImportingVideo = false | ||
124 | this.firstStepError.emit() | ||
125 | this.notifier.error(err.message) | ||
126 | } | ||
127 | ) | ||
104 | } | 128 | } |
105 | 129 | ||
106 | updateSecondStep () { | 130 | updateSecondStep () { |
@@ -133,5 +157,26 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom | |||
133 | 157 | ||
134 | private hydrateFormFromVideo () { | 158 | private hydrateFormFromVideo () { |
135 | this.form.patchValue(this.video.toFormPatch()) | 159 | this.form.patchValue(this.video.toFormPatch()) |
160 | |||
161 | const objects = [ | ||
162 | { | ||
163 | url: 'thumbnailUrl', | ||
164 | name: 'thumbnailfile' | ||
165 | }, | ||
166 | { | ||
167 | url: 'previewUrl', | ||
168 | name: 'previewfile' | ||
169 | } | ||
170 | ] | ||
171 | |||
172 | for (const obj of objects) { | ||
173 | fetch(this.video[obj.url]) | ||
174 | .then(response => response.blob()) | ||
175 | .then(data => { | ||
176 | this.form.patchValue({ | ||
177 | [ obj.name ]: data | ||
178 | }) | ||
179 | }) | ||
180 | } | ||
136 | } | 181 | } |
137 | } | 182 | } |
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-send.scss b/client/src/app/videos/+video-edit/video-add-components/video-send.scss index 8769dd302..ebe14c59e 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-send.scss +++ b/client/src/app/videos/+video-edit/video-add-components/video-send.scss | |||
@@ -41,14 +41,6 @@ $width-size: 190px; | |||
41 | } | 41 | } |
42 | 42 | ||
43 | .button-file { | 43 | .button-file { |
44 | @include peertube-button-file(auto); | 44 | @include peertube-button-file(max-content); |
45 | |||
46 | min-width: 190px; | ||
47 | } | ||
48 | |||
49 | .button-file-extension { | ||
50 | display: block; | ||
51 | font-size: 12px; | ||
52 | margin-top: 5px; | ||
53 | } | 45 | } |
54 | } | 46 | } |
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 0f904affb..950e55a52 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 | |||
@@ -1,17 +1,16 @@ | |||
1 | <div *ngIf="!isUploadingVideo" class="upload-video-container"> | 1 | <div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="setVideoFile($event)"> |
2 | <div class="first-step-block"> | 2 | <div class="first-step-block"> |
3 | <my-global-icon class="upload-icon" iconName="upload"></my-global-icon> | 3 | <my-global-icon class="upload-icon" iconName="upload"></my-global-icon> |
4 | 4 | ||
5 | <div class="button-file"> | 5 | <div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'"> |
6 | <span i18n>Select the file to upload</span> | 6 | <span i18n>Select the file to upload</span> |
7 | <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" /> | 7 | <input #videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" autofocus /> |
8 | </div> | 8 | </div> |
9 | <span class="button-file-extension">({{ videoExtensions }})</span> | ||
10 | 9 | ||
11 | <div class="form-group form-group-channel"> | 10 | <div class="form-group form-group-channel"> |
12 | <label i18n for="first-step-channel">Channel</label> | 11 | <label i18n for="first-step-channel">Channel</label> |
13 | <div class="peertube-select-container"> | 12 | <div class="peertube-select-container"> |
14 | <select id="first-step-channel" [(ngModel)]="firstStepChannelId"> | 13 | <select id="first-step-channel" [(ngModel)]="firstStepChannelId" class="form-control"> |
15 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | 14 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> |
16 | </select> | 15 | </select> |
17 | </div> | 16 | </div> |
@@ -20,7 +19,7 @@ | |||
20 | <div class="form-group"> | 19 | <div class="form-group"> |
21 | <label i18n for="first-step-privacy">Privacy</label> | 20 | <label i18n for="first-step-privacy">Privacy</label> |
22 | <div class="peertube-select-container"> | 21 | <div class="peertube-select-container"> |
23 | <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId"> | 22 | <select id="first-step-privacy" [(ngModel)]="firstStepPrivacyId" class="form-control"> |
24 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | 23 | <option *ngFor="let privacy of videoPrivacies" [value]="privacy.id">{{ privacy.label }}</option> |
25 | <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option> | 24 | <option i18n [value]="SPECIAL_SCHEDULED_PRIVACY">Scheduled</option> |
26 | </select> | 25 | </select> |
@@ -51,10 +50,12 @@ | |||
51 | </div> | 50 | </div> |
52 | 51 | ||
53 | <div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel"> | 52 | <div *ngIf="isUploadingVideo && !error" class="upload-progress-cancel"> |
54 | <p-progressBar | 53 | <div class="progress" i18n-title title="Total video quota"> |
55 | [value]="videoUploadPercents" | 54 | <div class="progress-bar" role="progressbar" [style]="{ width: videoUploadPercents + '%' }" [attr.aria-valuenow]="videoUploadPercents" aria-valuemin="0" [attr.aria-valuemax]="100"> |
56 | [ngClass]="{ processing: videoUploadPercents === 100 && videoUploaded === false }" | 55 | <span *ngIf="videoUploadPercents === 100 && videoUploaded === false" i18n>Processing…</span> |
57 | ></p-progressBar> | 56 | <span *ngIf="videoUploadPercents !== 100 || videoUploaded">{{ videoUploadPercents }}%</span> |
57 | </div> | ||
58 | </div> | ||
58 | <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" /> | 59 | <input *ngIf="videoUploaded === false" type="button" value="Cancel" (click)="cancelUpload()" /> |
59 | </div> | 60 | </div> |
60 | 61 | ||
@@ -68,7 +69,7 @@ | |||
68 | </div> | 69 | </div> |
69 | 70 | ||
70 | <!-- Hidden because we want to load the component --> | 71 | <!-- Hidden because we want to load the component --> |
71 | <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form"> | 72 | <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form" class="mb-3"> |
72 | <my-video-edit | 73 | <my-video-edit |
73 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" | 74 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" |
74 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" | 75 | [validationMessages]="validationMessages" [userVideoChannels]="userVideoChannels" |
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 b5628e276..a4f87b0b8 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 | |||
@@ -2,7 +2,6 @@ | |||
2 | @import 'mixins'; | 2 | @import 'mixins'; |
3 | 3 | ||
4 | .first-step-block { | 4 | .first-step-block { |
5 | |||
6 | .form-group-channel { | 5 | .form-group-channel { |
7 | margin-bottom: 20px; | 6 | margin-bottom: 20px; |
8 | margin-top: 35px; | 7 | margin-top: 35px; |
@@ -22,37 +21,21 @@ | |||
22 | margin-top: 25px; | 21 | margin-top: 25px; |
23 | margin-bottom: 40px; | 22 | margin-bottom: 40px; |
24 | 23 | ||
25 | p-progressBar { | 24 | .progress { |
25 | @include progressbar; | ||
26 | flex-grow: 1; | 26 | flex-grow: 1; |
27 | 27 | height: 30px; | |
28 | ::ng-deep .ui-progressbar { | 28 | font-size: 15px; |
29 | font-size: 15px !important; | 29 | background-color: rgba(11, 204, 41, 0.16); |
30 | height: 30px !important; | 30 | |
31 | border-radius: 3px !important; | 31 | .progress-bar { |
32 | background-color: rgba(11, 204, 41, 0.16) !important; | 32 | background-color: $green; |
33 | 33 | line-height: 30px; | |
34 | .ui-progressbar-value { | 34 | text-align: left; |
35 | background-color: #0BCC29 !important; | 35 | font-weight: $font-bold; |
36 | } | 36 | |
37 | 37 | span { | |
38 | .ui-progressbar-label { | 38 | margin-left: 18px; |
39 | text-align: left; | ||
40 | padding-left: 18px; | ||
41 | margin-top: 0 !important; | ||
42 | color: #fff !important; | ||
43 | line-height: 30px !important; | ||
44 | } | ||
45 | } | ||
46 | |||
47 | &.processing { | ||
48 | ::ng-deep .ui-progressbar-label { | ||
49 | // Same color as background to hide "100%" | ||
50 | color: rgba(11, 204, 41, 0.16) !important; | ||
51 | |||
52 | &::before { | ||
53 | content: 'Processing...'; | ||
54 | color: #fff; | ||
55 | } | ||
56 | } | 39 | } |
57 | } | 40 | } |
58 | } | 41 | } |
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts index aa87f9581..9ce3fbc6d 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts +++ b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.ts | |||
@@ -27,7 +27,7 @@ import { scrollToTop } from '@app/shared/misc/utils' | |||
27 | export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate { | 27 | export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, CanComponentDeactivate { |
28 | @Output() firstStepDone = new EventEmitter<string>() | 28 | @Output() firstStepDone = new EventEmitter<string>() |
29 | @Output() firstStepError = new EventEmitter<void>() | 29 | @Output() firstStepError = new EventEmitter<void>() |
30 | @ViewChild('videofileInput', { static: false }) videofileInput: ElementRef<HTMLInputElement> | 30 | @ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement> |
31 | 31 | ||
32 | // So that it can be accessed in the template | 32 | // So that it can be accessed in the template |
33 | readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY | 33 | readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY |
@@ -70,7 +70,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
70 | } | 70 | } |
71 | 71 | ||
72 | get videoExtensions () { | 72 | get videoExtensions () { |
73 | return this.serverConfig.video.file.extensions.join(',') | 73 | return this.serverConfig.video.file.extensions.join(', ') |
74 | } | 74 | } |
75 | 75 | ||
76 | ngOnInit () { | 76 | ngOnInit () { |
@@ -108,6 +108,11 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
108 | return this.videofileInput.nativeElement.files[0] | 108 | return this.videofileInput.nativeElement.files[0] |
109 | } | 109 | } |
110 | 110 | ||
111 | setVideoFile (files: FileList) { | ||
112 | this.videofileInput.nativeElement.files = files | ||
113 | this.fileChange() | ||
114 | } | ||
115 | |||
111 | getAudioUploadLabel () { | 116 | getAudioUploadLabel () { |
112 | const videofile = this.getVideoFile() | 117 | const videofile = this.getVideoFile() |
113 | if (!videofile) return this.i18n('Upload') | 118 | if (!videofile) return this.i18n('Upload') |
@@ -122,9 +127,13 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
122 | cancelUpload () { | 127 | cancelUpload () { |
123 | if (this.videoUploadObservable !== null) { | 128 | if (this.videoUploadObservable !== null) { |
124 | this.videoUploadObservable.unsubscribe() | 129 | this.videoUploadObservable.unsubscribe() |
130 | |||
125 | this.isUploadingVideo = false | 131 | this.isUploadingVideo = false |
126 | this.videoUploadPercents = 0 | 132 | this.videoUploadPercents = 0 |
127 | this.videoUploadObservable = null | 133 | this.videoUploadObservable = null |
134 | |||
135 | this.firstStepError.emit() | ||
136 | |||
128 | this.notifier.info(this.i18n('Upload cancelled')) | 137 | this.notifier.info(this.i18n('Upload cancelled')) |
129 | } | 138 | } |
130 | } | 139 | } |
diff --git a/client/src/app/videos/+video-edit/video-add.component.html b/client/src/app/videos/+video-edit/video-add.component.html index a99988600..79bfc6e5c 100644 --- a/client/src/app/videos/+video-edit/video-add.component.html +++ b/client/src/app/videos/+video-edit/video-add.component.html | |||
@@ -10,27 +10,37 @@ | |||
10 | <ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container> | 10 | <ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container> |
11 | </div> | 11 | </div> |
12 | 12 | ||
13 | <ngb-tabset class="video-add-tabset root-tabset bootstrap" [ngClass]="{ 'hide-nav': secondStepType !== undefined }"> | 13 | <div ngbNav #nav="ngbNav" class="nav-tabs video-add-nav" [ngClass]="{ 'hide-nav': secondStepType !== undefined }"> |
14 | <ng-container ngbNavItem> | ||
15 | <a ngbNavLink> | ||
16 | <span i18n>Upload a file</span> | ||
17 | </a> | ||
14 | 18 | ||
15 | <ngb-tab> | 19 | <ng-template ngbNavContent> |
16 | <ng-template ngbTabTitle><span i18n>Upload a file</span></ng-template> | ||
17 | <ng-template ngbTabContent> | ||
18 | <my-video-upload #videoUpload (firstStepDone)="onFirstStepDone('upload', $event)" (firstStepError)="onError()"></my-video-upload> | 20 | <my-video-upload #videoUpload (firstStepDone)="onFirstStepDone('upload', $event)" (firstStepError)="onError()"></my-video-upload> |
19 | </ng-template> | 21 | </ng-template> |
20 | </ngb-tab> | 22 | </ng-container> |
21 | 23 | ||
22 | <ngb-tab *ngIf="isVideoImportHttpEnabled()"> | 24 | <ng-container ngbNavItem *ngIf="isVideoImportHttpEnabled()"> |
23 | <ng-template ngbTabTitle><span i18n>Import with URL</span></ng-template> | 25 | <a ngbNavLink> |
24 | <ng-template ngbTabContent> | 26 | <span i18n>Import with URL</span> |
27 | </a> | ||
28 | |||
29 | <ng-template ngbNavContent> | ||
25 | <my-video-import-url #videoImportUrl (firstStepDone)="onFirstStepDone('import-url', $event)" (firstStepError)="onError()"></my-video-import-url> | 30 | <my-video-import-url #videoImportUrl (firstStepDone)="onFirstStepDone('import-url', $event)" (firstStepError)="onError()"></my-video-import-url> |
26 | </ng-template> | 31 | </ng-template> |
27 | </ngb-tab> | 32 | </ng-container> |
33 | |||
34 | <ng-container ngbNavItem *ngIf="isVideoImportTorrentEnabled()"> | ||
35 | <a ngbNavLink> | ||
36 | <span i18n>Import with torrent</span> | ||
37 | </a> | ||
28 | 38 | ||
29 | <ngb-tab *ngIf="isVideoImportTorrentEnabled()"> | 39 | <ng-template ngbNavContent> |
30 | <ng-template ngbTabTitle><span i18n>Import with torrent</span></ng-template> | ||
31 | <ng-template ngbTabContent> | ||
32 | <my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent> | 40 | <my-video-import-torrent #videoImportTorrent (firstStepDone)="onFirstStepDone('import-torrent', $event)" (firstStepError)="onError()"></my-video-import-torrent> |
33 | </ng-template> | 41 | </ng-template> |
34 | </ngb-tab> | 42 | </ng-container> |
35 | </ngb-tabset> | 43 | </div> |
44 | |||
45 | <div [ngbNavOutlet]="nav"></div> | ||
36 | </div> | 46 | </div> |
diff --git a/client/src/app/videos/+video-edit/video-add.component.scss b/client/src/app/videos/+video-edit/video-add.component.scss index 7acab3744..1316e09e4 100644 --- a/client/src/app/videos/+video-edit/video-add.component.scss +++ b/client/src/app/videos/+video-edit/video-add.component.scss | |||
@@ -4,6 +4,7 @@ | |||
4 | $border-width: 3px; | 4 | $border-width: 3px; |
5 | $border-type: solid; | 5 | $border-type: solid; |
6 | $border-color: #EAEAEA; | 6 | $border-color: #EAEAEA; |
7 | $nav-link-height: 40px; | ||
7 | 8 | ||
8 | .margin-content { | 9 | .margin-content { |
9 | padding-top: 50px; | 10 | padding-top: 50px; |
@@ -13,52 +14,76 @@ $border-color: #EAEAEA; | |||
13 | font-size: 15px; | 14 | font-size: 15px; |
14 | } | 15 | } |
15 | 16 | ||
16 | ::ng-deep .root-tabset.video-add-tabset { | 17 | ::ng-deep .video-add-nav { |
17 | margin-top: 50px; | 18 | border-bottom: $border-width $border-type $border-color; |
19 | margin: 50px 0 0 0 !important; | ||
18 | 20 | ||
19 | &.hide-nav > .nav { | 21 | &.hide-nav { |
20 | display: none !important; | 22 | display: none !important; |
21 | } | 23 | } |
22 | 24 | ||
23 | & > .nav { | 25 | a.nav-link { |
24 | border-bottom: $border-width $border-type $border-color; | 26 | @include disable-default-a-behaviour; |
25 | margin: 0 !important; | ||
26 | 27 | ||
27 | & > li { | 28 | margin-bottom: -$border-width; |
28 | margin-bottom: -$border-width; | 29 | height: $nav-link-height !important; |
30 | padding: 0 30px !important; | ||
31 | font-size: 15px; | ||
32 | |||
33 | &.active { | ||
34 | border: $border-width $border-type $border-color; | ||
35 | border-bottom: none; | ||
36 | background-color: var(--submenuColor) !important; | ||
37 | |||
38 | span { | ||
39 | border-bottom: 2px solid var(--mainColor); | ||
40 | font-weight: $font-bold; | ||
41 | } | ||
29 | } | 42 | } |
43 | } | ||
44 | } | ||
45 | |||
46 | ::ng-deep .upload-video-container { | ||
47 | border: $border-width $border-type $border-color; | ||
48 | border-top: transparent; | ||
30 | 49 | ||
31 | a.nav-link { | 50 | background-color: var(--submenuColor); |
32 | @include disable-default-a-behaviour; | 51 | border-bottom-left-radius: 3px; |
52 | border-bottom-right-radius: 3px; | ||
53 | width: 100%; | ||
54 | min-height: 440px; | ||
55 | padding-bottom: 20px; | ||
56 | display: flex; | ||
57 | justify-content: center; | ||
58 | align-items: center; | ||
33 | 59 | ||
34 | height: 40px !important; | 60 | &.dragover { |
35 | padding: 0 30px !important; | 61 | border: 3px dashed var(--mainColor); |
36 | font-size: 15px; | 62 | } |
63 | } | ||
37 | 64 | ||
38 | &.active { | 65 | @mixin nav-scroll { |
39 | border: $border-width $border-type $border-color; | 66 | ::ng-deep .video-add-nav { |
40 | border-bottom: none; | 67 | height: #{$nav-link-height + $border-width * 2}; |
41 | background-color: var(--submenuColor) !important; | 68 | overflow-x: auto; |
69 | white-space: nowrap; | ||
70 | flex-wrap: unset; | ||
42 | 71 | ||
43 | span { | 72 | /* Hide active tab style to not have a moving tab effect */ |
44 | border-bottom: 2px solid var(--mainColor); | 73 | a.nav-link.active { |
45 | font-weight: $font-bold; | 74 | border: none; |
46 | } | 75 | background-color: var(--mainBackgroundColor) !important; |
47 | } | ||
48 | } | 76 | } |
49 | } | 77 | } |
78 | } | ||
79 | |||
80 | /* Make .video-add-nav tabs scrollable on small devices */ | ||
81 | @media screen and (max-width: $small-view) { | ||
82 | @include nav-scroll(); | ||
83 | } | ||
50 | 84 | ||
51 | .upload-video-container { | 85 | @media screen and (max-width: #{$small-view + $menu-width}) { |
52 | border: $border-width $border-type $border-color; | 86 | :host-context(.main-col:not(.expanded)) { |
53 | border-top: none; | 87 | @include nav-scroll(); |
54 | |||
55 | background-color: var(--submenuColor); | ||
56 | border-radius: 3px; | ||
57 | width: 100%; | ||
58 | min-height: 440px; | ||
59 | padding-bottom: 20px; | ||
60 | display: flex; | ||
61 | justify-content: center; | ||
62 | align-items: center; | ||
63 | } | 88 | } |
64 | } | 89 | } |
diff --git a/client/src/app/videos/+video-edit/video-add.component.ts b/client/src/app/videos/+video-edit/video-add.component.ts index 401d8a08f..30ab08ea0 100644 --- a/client/src/app/videos/+video-edit/video-add.component.ts +++ b/client/src/app/videos/+video-edit/video-add.component.ts | |||
@@ -12,9 +12,9 @@ import { ServerConfig } from '@shared/models' | |||
12 | styleUrls: [ './video-add.component.scss' ] | 12 | styleUrls: [ './video-add.component.scss' ] |
13 | }) | 13 | }) |
14 | export class VideoAddComponent implements OnInit, CanComponentDeactivate { | 14 | export class VideoAddComponent implements OnInit, CanComponentDeactivate { |
15 | @ViewChild('videoUpload', { static: false }) videoUpload: VideoUploadComponent | 15 | @ViewChild('videoUpload') videoUpload: VideoUploadComponent |
16 | @ViewChild('videoImportUrl', { static: false }) videoImportUrl: VideoImportUrlComponent | 16 | @ViewChild('videoImportUrl') videoImportUrl: VideoImportUrlComponent |
17 | @ViewChild('videoImportTorrent', { static: false }) videoImportTorrent: VideoImportTorrentComponent | 17 | @ViewChild('videoImportTorrent') videoImportTorrent: VideoImportTorrentComponent |
18 | 18 | ||
19 | secondStepType: 'upload' | 'import-url' | 'import-torrent' | 19 | secondStepType: 'upload' | 'import-url' | 'import-torrent' |
20 | videoName: string | 20 | videoName: string |
diff --git a/client/src/app/videos/+video-edit/video-add.module.ts b/client/src/app/videos/+video-edit/video-add.module.ts index 870f7cb97..b8f5a9a47 100644 --- a/client/src/app/videos/+video-edit/video-add.module.ts +++ b/client/src/app/videos/+video-edit/video-add.module.ts | |||
@@ -3,27 +3,28 @@ import { SharedModule } from '../../shared' | |||
3 | import { VideoEditModule } from './shared/video-edit.module' | 3 | import { VideoEditModule } from './shared/video-edit.module' |
4 | import { VideoAddRoutingModule } from './video-add-routing.module' | 4 | import { VideoAddRoutingModule } from './video-add-routing.module' |
5 | import { VideoAddComponent } from './video-add.component' | 5 | import { VideoAddComponent } from './video-add.component' |
6 | import { DragDropDirective } from './video-add-components/drag-drop.directive' | ||
6 | import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service' | 7 | import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service' |
7 | import { VideoUploadComponent } from '@app/videos/+video-edit/video-add-components/video-upload.component' | 8 | import { VideoUploadComponent } from '@app/videos/+video-edit/video-add-components/video-upload.component' |
8 | import { VideoImportUrlComponent } from '@app/videos/+video-edit/video-add-components/video-import-url.component' | 9 | import { VideoImportUrlComponent } from '@app/videos/+video-edit/video-add-components/video-import-url.component' |
9 | import { VideoImportTorrentComponent } from '@app/videos/+video-edit/video-add-components/video-import-torrent.component' | 10 | import { VideoImportTorrentComponent } from '@app/videos/+video-edit/video-add-components/video-import-torrent.component' |
10 | import { ProgressBarModule } from 'primeng/progressbar' | ||
11 | 11 | ||
12 | @NgModule({ | 12 | @NgModule({ |
13 | imports: [ | 13 | imports: [ |
14 | VideoAddRoutingModule, | 14 | VideoAddRoutingModule, |
15 | VideoEditModule, | 15 | VideoEditModule, |
16 | SharedModule, | 16 | SharedModule |
17 | ProgressBarModule | ||
18 | ], | 17 | ], |
19 | declarations: [ | 18 | declarations: [ |
20 | VideoAddComponent, | 19 | VideoAddComponent, |
21 | VideoUploadComponent, | 20 | VideoUploadComponent, |
22 | VideoImportUrlComponent, | 21 | VideoImportUrlComponent, |
23 | VideoImportTorrentComponent | 22 | VideoImportTorrentComponent, |
23 | DragDropDirective | ||
24 | ], | 24 | ], |
25 | exports: [ | 25 | exports: [ |
26 | VideoAddComponent | 26 | VideoAddComponent, |
27 | DragDropDirective | ||
27 | ], | 28 | ], |
28 | providers: [ | 29 | providers: [ |
29 | CanDeactivateGuard | 30 | CanDeactivateGuard |
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html index 3a9977df6..9b43d91da 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html +++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html | |||
@@ -17,7 +17,7 @@ | |||
17 | </div> | 17 | </div> |
18 | 18 | ||
19 | <div class="comment-buttons"> | 19 | <div class="comment-buttons"> |
20 | <button *ngIf="isAddButtonDisplayed()" class="cancel-button" (click)="cancelCommentReply()" i18n> | 20 | <button *ngIf="isAddButtonDisplayed()" class="cancel-button" (click)="cancelCommentReply()" type="button" i18n> |
21 | Cancel | 21 | Cancel |
22 | </button> | 22 | </button> |
23 | <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid || addingComment }" i18n> | 23 | <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid || addingComment }" i18n> |
@@ -29,15 +29,11 @@ | |||
29 | <ng-template #visitorModal let-modal> | 29 | <ng-template #visitorModal let-modal> |
30 | <div class="modal-header"> | 30 | <div class="modal-header"> |
31 | <h4 class="modal-title" id="modal-basic-title" i18n>You are one step away from commenting</h4> | 31 | <h4 class="modal-title" id="modal-basic-title" i18n>You are one step away from commenting</h4> |
32 | <button type="button" class="close" aria-label="Close" (click)="hideVisitorModal()"></button> | 32 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideVisitorModal()"></my-global-icon> |
33 | </div> | 33 | </div> |
34 | <div class="modal-body"> | 34 | <div class="modal-body"> |
35 | <span i18n> | 35 | <span i18n> |
36 | If you have an account on this instance, you can login: | 36 | You can comment using an account on any ActivityPub-compatible instance. |
37 | </span> | ||
38 | <span class="btn btn-sm mx-3" role="button" (click)="gotoLogin()" i18n>login to comment</span> | ||
39 | <span i18n> | ||
40 | Otherwise, you can comment using an account on any ActivityPub-compatible instance. | ||
41 | On most platforms, you can find the video by typing its URL in the search bar and then comment it | 37 | On most platforms, you can find the video by typing its URL in the search bar and then comment it |
42 | from within the software's interface. | 38 | from within the software's interface. |
43 | </span> | 39 | </span> |
@@ -47,8 +43,14 @@ | |||
47 | <my-remote-subscribe [interact]="true" [uri]="getUri()"></my-remote-subscribe> | 43 | <my-remote-subscribe [interact]="true" [uri]="getUri()"></my-remote-subscribe> |
48 | </div> | 44 | </div> |
49 | <div class="modal-footer inputs"> | 45 | <div class="modal-footer inputs"> |
50 | <span i18n class="action-button action-button-cancel" role="button" (click)="hideVisitorModal()"> | 46 | <input |
51 | Cancel | 47 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" |
52 | </span> | 48 | (click)="hideVisitorModal()" (key.enter)="hideVisitorModal()" |
49 | > | ||
50 | |||
51 | <input | ||
52 | type="submit" i18n-value value="Login to comment" class="action-button-submit" | ||
53 | (click)="gotoLogin()" | ||
54 | > | ||
53 | </div> | 55 | </div> |
54 | </ng-template> | 56 | </ng-template> |
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss b/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss index c04727ba0..b3725ab94 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss +++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss | |||
@@ -30,25 +30,33 @@ form { | |||
30 | } | 30 | } |
31 | } | 31 | } |
32 | 32 | ||
33 | .cancel-button { | ||
34 | font-weight: $font-semibold; | ||
35 | display: inline-block; | ||
36 | padding: 0 10px 0 10px; | ||
37 | white-space: nowrap; | ||
38 | background: transparent; | ||
39 | } | ||
40 | |||
41 | .comment-buttons { | 33 | .comment-buttons { |
42 | display: flex; | 34 | display: flex; |
43 | justify-content: flex-end; | 35 | justify-content: flex-end; |
44 | 36 | ||
45 | button { | 37 | button { |
46 | @include peertube-button; | 38 | @include peertube-button; |
39 | @include disable-outline; | ||
40 | @include disable-default-a-behaviour; | ||
41 | |||
42 | &:not(:last-child) { | ||
43 | margin-right: .5rem; | ||
44 | } | ||
47 | 45 | ||
48 | &:last-child { | 46 | &:last-child { |
49 | @include orange-button; | 47 | @include orange-button; |
50 | } | 48 | } |
51 | } | 49 | } |
50 | |||
51 | .cancel-button { | ||
52 | @include tertiary-button; | ||
53 | |||
54 | font-weight: $font-semibold; | ||
55 | display: inline-block; | ||
56 | padding: 0 10px 0 10px; | ||
57 | white-space: nowrap; | ||
58 | background: transparent; | ||
59 | } | ||
52 | } | 60 | } |
53 | 61 | ||
54 | @media screen and (max-width: 600px) { | 62 | @media screen and (max-width: 600px) { |
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts index 1be96ad9e..e1a8f6260 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts | |||
@@ -11,7 +11,6 @@ import { VideoCommentService } from './video-comment.service' | |||
11 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 11 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
12 | import { VideoCommentValidatorsService } from '@app/shared/forms/form-validators/video-comment-validators.service' | 12 | import { VideoCommentValidatorsService } from '@app/shared/forms/form-validators/video-comment-validators.service' |
13 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 13 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
14 | import { AuthService } from '@app/core/auth' | ||
15 | 14 | ||
16 | @Component({ | 15 | @Component({ |
17 | selector: 'my-video-comment-add', | 16 | selector: 'my-video-comment-add', |
@@ -25,7 +24,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit { | |||
25 | @Input() parentComments: VideoComment[] | 24 | @Input() parentComments: VideoComment[] |
26 | @Input() focusOnInit = false | 25 | @Input() focusOnInit = false |
27 | 26 | ||
28 | @Output() commentCreated = new EventEmitter<VideoCommentCreate>() | 27 | @Output() commentCreated = new EventEmitter<VideoComment>() |
29 | @Output() cancel = new EventEmitter() | 28 | @Output() cancel = new EventEmitter() |
30 | 29 | ||
31 | @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal | 30 | @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal |
@@ -38,7 +37,6 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit { | |||
38 | private videoCommentValidatorsService: VideoCommentValidatorsService, | 37 | private videoCommentValidatorsService: VideoCommentValidatorsService, |
39 | private notifier: Notifier, | 38 | private notifier: Notifier, |
40 | private videoCommentService: VideoCommentService, | 39 | private videoCommentService: VideoCommentService, |
41 | private authService: AuthService, | ||
42 | private modalService: NgbModal, | 40 | private modalService: NgbModal, |
43 | private router: Router | 41 | private router: Router |
44 | ) { | 42 | ) { |
@@ -57,7 +55,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit { | |||
57 | 55 | ||
58 | if (this.parentComment) { | 56 | if (this.parentComment) { |
59 | const mentions = this.parentComments | 57 | const mentions = this.parentComments |
60 | .filter(c => c.account.id !== this.user.account.id) // Don't add mention of ourselves | 58 | .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves |
61 | .map(c => '@' + c.by) | 59 | .map(c => '@' + c.by) |
62 | 60 | ||
63 | const mentionsSet = new Set(mentions) | 61 | const mentionsSet = new Set(mentions) |
@@ -96,7 +94,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit { | |||
96 | this.addingComment = true | 94 | this.addingComment = true |
97 | 95 | ||
98 | const commentCreate: VideoCommentCreate = this.form.value | 96 | const commentCreate: VideoCommentCreate = this.form.value |
99 | let obs: Observable<any> | 97 | let obs: Observable<VideoComment> |
100 | 98 | ||
101 | if (this.parentComment) { | 99 | if (this.parentComment) { |
102 | obs = this.addCommentReply(commentCreate) | 100 | obs = this.addCommentReply(commentCreate) |
@@ -139,6 +137,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit { | |||
139 | 137 | ||
140 | cancelCommentReply () { | 138 | cancelCommentReply () { |
141 | this.cancel.emit(null) | 139 | this.cancel.emit(null) |
140 | this.form.value['text'] = this.textareaElement.nativeElement.value = '' | ||
142 | } | 141 | } |
143 | 142 | ||
144 | private addCommentReply (commentCreate: VideoCommentCreate) { | 143 | private addCommentReply (commentCreate: VideoCommentCreate) { |
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts new file mode 100644 index 000000000..1566d7369 --- /dev/null +++ b/client/src/app/videos/+video-watch/comment/video-comment-thread-tree.model.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '../../../../../../shared/models/videos/video-comment.model' | ||
2 | import { VideoComment } from '@app/videos/+video-watch/comment/video-comment.model' | ||
3 | |||
4 | export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel { | ||
5 | comment: VideoComment | ||
6 | children: VideoCommentThreadTree[] | ||
7 | } | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.ts b/client/src/app/videos/+video-watch/comment/video-comment.component.ts index 61f9335d1..868addd58 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.ts | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' |
2 | import { User, UserRight } from '../../../../../../shared/models/users' | 2 | import { User, UserRight } from '../../../../../../shared/models/users' |
3 | import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' | ||
4 | import { AuthService } from '@app/core/auth' | 3 | import { AuthService } from '@app/core/auth' |
5 | import { AccountService } from '@app/shared/account/account.service' | 4 | import { AccountService } from '@app/shared/account/account.service' |
6 | import { Video } from '@app/shared/video/video.model' | 5 | import { Video } from '@app/shared/video/video.model' |
@@ -10,6 +9,7 @@ import { Account } from '@app/shared/account/account.model' | |||
10 | import { Notifier } from '@app/core' | 9 | import { Notifier } from '@app/core' |
11 | import { UserService } from '@app/shared' | 10 | import { UserService } from '@app/shared' |
12 | import { Actor } from '@app/shared/actor/actor.model' | 11 | import { Actor } from '@app/shared/actor/actor.model' |
12 | import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model' | ||
13 | 13 | ||
14 | @Component({ | 14 | @Component({ |
15 | selector: 'my-video-comment', | 15 | selector: 'my-video-comment', |
@@ -98,6 +98,7 @@ export class VideoCommentComponent implements OnInit, OnChanges { | |||
98 | return this.comment.account && this.isUserLoggedIn() && | 98 | return this.comment.account && this.isUserLoggedIn() && |
99 | ( | 99 | ( |
100 | this.user.account.id === this.comment.account.id || | 100 | this.user.account.id === this.comment.account.id || |
101 | this.user.account.id === this.video.account.id || | ||
101 | this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) | 102 | this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) |
102 | ) | 103 | ) |
103 | } | 104 | } |
@@ -125,7 +126,12 @@ export class VideoCommentComponent implements OnInit, OnChanges { | |||
125 | const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true) | 126 | const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true) |
126 | this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) | 127 | this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) |
127 | this.newParentComments = this.parentComments.concat([ this.comment ]) | 128 | this.newParentComments = this.parentComments.concat([ this.comment ]) |
128 | this.commentAccount = new Account(this.comment.account) | 129 | |
129 | this.getUserIfNeeded(this.commentAccount) | 130 | if (this.comment.account) { |
131 | this.commentAccount = new Account(this.comment.account) | ||
132 | this.getUserIfNeeded(this.commentAccount) | ||
133 | } else { | ||
134 | this.comment.account = null | ||
135 | } | ||
130 | } | 136 | } |
131 | } | 137 | } |
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.model.ts b/client/src/app/videos/+video-watch/comment/video-comment.model.ts index aaeb0ea9c..171fc4acc 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.model.ts +++ b/client/src/app/videos/+video-watch/comment/video-comment.model.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { Account as AccountInterface } from '../../../../../../shared/models/actors' | 1 | import { Account as AccountInterface } from '../../../../../../shared/models/actors' |
2 | import { VideoComment as VideoCommentServerModel } from '../../../../../../shared/models/videos/video-comment.model' | 2 | import { VideoComment as VideoCommentServerModel, VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model' |
3 | import { Actor } from '@app/shared/actor/actor.model' | 3 | import { Actor } from '@app/shared/actor/actor.model' |
4 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' | 4 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' |
5 | 5 | ||
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.service.ts b/client/src/app/videos/+video-watch/comment/video-comment.service.ts index a81e5236a..0b0715390 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.service.ts +++ b/client/src/app/videos/+video-watch/comment/video-comment.service.ts | |||
@@ -7,13 +7,14 @@ import { FeedFormat, ResultList } from '../../../../../../shared/models' | |||
7 | import { | 7 | import { |
8 | VideoComment as VideoCommentServerModel, | 8 | VideoComment as VideoCommentServerModel, |
9 | VideoCommentCreate, | 9 | VideoCommentCreate, |
10 | VideoCommentThreadTree | 10 | VideoCommentThreadTree as VideoCommentThreadTreeServerModel |
11 | } from '../../../../../../shared/models/videos/video-comment.model' | 11 | } from '../../../../../../shared/models/videos/video-comment.model' |
12 | import { environment } from '../../../../environments/environment' | 12 | import { environment } from '../../../../environments/environment' |
13 | import { RestExtractor, RestService } from '../../../shared/rest' | 13 | import { RestExtractor, RestService } from '../../../shared/rest' |
14 | import { ComponentPaginationLight } from '../../../shared/rest/component-pagination.model' | 14 | import { ComponentPaginationLight } from '../../../shared/rest/component-pagination.model' |
15 | import { CommentSortField } from '../../../shared/video/sort-field.type' | 15 | import { CommentSortField } from '../../../shared/video/sort-field.type' |
16 | import { VideoComment } from './video-comment.model' | 16 | import { VideoComment } from './video-comment.model' |
17 | import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model' | ||
17 | 18 | ||
18 | @Injectable() | 19 | @Injectable() |
19 | export class VideoCommentService { | 20 | export class VideoCommentService { |
@@ -76,9 +77,9 @@ export class VideoCommentService { | |||
76 | const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` | 77 | const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}` |
77 | 78 | ||
78 | return this.authHttp | 79 | return this.authHttp |
79 | .get(url) | 80 | .get<VideoCommentThreadTreeServerModel>(url) |
80 | .pipe( | 81 | .pipe( |
81 | map(tree => this.extractVideoCommentTree(tree as VideoCommentThreadTree)), | 82 | map(tree => this.extractVideoCommentTree(tree)), |
82 | catchError(err => this.restExtractor.handleError(err)) | 83 | catchError(err => this.restExtractor.handleError(err)) |
83 | ) | 84 | ) |
84 | } | 85 | } |
@@ -138,12 +139,12 @@ export class VideoCommentService { | |||
138 | return { data: comments, total: totalComments } | 139 | return { data: comments, total: totalComments } |
139 | } | 140 | } |
140 | 141 | ||
141 | private extractVideoCommentTree (tree: VideoCommentThreadTree) { | 142 | private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) { |
142 | if (!tree) return tree | 143 | if (!tree) return tree as VideoCommentThreadTree |
143 | 144 | ||
144 | tree.comment = new VideoComment(tree.comment) | 145 | tree.comment = new VideoComment(tree.comment) |
145 | tree.children.forEach(c => this.extractVideoCommentTree(c)) | 146 | tree.children.forEach(c => this.extractVideoCommentTree(c)) |
146 | 147 | ||
147 | return tree | 148 | return tree as VideoCommentThreadTree |
148 | } | 149 | } |
149 | } | 150 | } |
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.html b/client/src/app/videos/+video-watch/comment/video-comments.component.html index 2bf52ab86..a21042f09 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.html +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.html | |||
@@ -13,7 +13,7 @@ | |||
13 | 13 | ||
14 | <div ngbDropdown class="d-inline-block ml-4"> | 14 | <div ngbDropdown class="d-inline-block ml-4"> |
15 | <button class="btn btn-sm btn-outline-secondary" id="dropdownSortComments" ngbDropdownToggle i18n> | 15 | <button class="btn btn-sm btn-outline-secondary" id="dropdownSortComments" ngbDropdownToggle i18n> |
16 | Sort by | 16 | SORT BY |
17 | </button> | 17 | </button> |
18 | <div ngbDropdownMenu aria-labelledby="dropdownSortComments"> | 18 | <div ngbDropdownMenu aria-labelledby="dropdownSortComments"> |
19 | <button (click)="handleSortChange('-createdAt')" ngbDropdownItem i18n>Most recent first (default)</button> | 19 | <button (click)="handleSortChange('-createdAt')" ngbDropdownItem i18n>Most recent first (default)</button> |
@@ -38,7 +38,8 @@ | |||
38 | (nearOfBottom)="onNearOfBottom()" | 38 | (nearOfBottom)="onNearOfBottom()" |
39 | [dataObservable]="onDataSubject.asObservable()" | 39 | [dataObservable]="onDataSubject.asObservable()" |
40 | > | 40 | > |
41 | <div #commentHighlightBlock id="highlighted-comment"> | 41 | <div> |
42 | <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div> | ||
42 | <my-video-comment | 43 | <my-video-comment |
43 | *ngIf="highlightedThread" | 44 | *ngIf="highlightedThread" |
44 | [comment]="highlightedThread" | 45 | [comment]="highlightedThread" |
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.scss b/client/src/app/videos/+video-watch/comment/video-comments.component.scss index f95ff5aba..5ed1ac629 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.scss +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.scss | |||
@@ -17,8 +17,20 @@ | |||
17 | font-size: 13px; | 17 | font-size: 13px; |
18 | } | 18 | } |
19 | 19 | ||
20 | .title-block .title-page { | 20 | .title-block { |
21 | margin-right: 0; | 21 | .title-page { |
22 | margin-right: 0; | ||
23 | } | ||
24 | |||
25 | my-feed { | ||
26 | display: inline-block; | ||
27 | margin-left: 5px; | ||
28 | opacity: 0; | ||
29 | transition: ease-in .2s opacity; | ||
30 | } | ||
31 | &:hover my-feed { | ||
32 | opacity: 1; | ||
33 | } | ||
22 | } | 34 | } |
23 | 35 | ||
24 | #dropdownSortComments { | 36 | #dropdownSortComments { |
@@ -28,11 +40,6 @@ | |||
28 | transform: translateY(-7%); | 40 | transform: translateY(-7%); |
29 | } | 41 | } |
30 | 42 | ||
31 | my-feed { | ||
32 | display: inline-block; | ||
33 | margin-left: 5px; | ||
34 | } | ||
35 | |||
36 | @media screen and (max-width: 600px) { | 43 | @media screen and (max-width: 600px) { |
37 | .view-replies { | 44 | .view-replies { |
38 | margin-left: 46px; | 45 | margin-left: 46px; |
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts index 974c61d6c..c6c28e3f7 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts | |||
@@ -1,8 +1,7 @@ | |||
1 | import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, Output, EventEmitter } from '@angular/core' | 1 | import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core' |
2 | import { ActivatedRoute } from '@angular/router' | 2 | import { ActivatedRoute } from '@angular/router' |
3 | import { ConfirmService, Notifier } from '@app/core' | 3 | import { ConfirmService, Notifier } from '@app/core' |
4 | import { Subject, Subscription } from 'rxjs' | 4 | import { Subject, Subscription } from 'rxjs' |
5 | import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' | ||
6 | import { AuthService } from '../../../core/auth' | 5 | import { AuthService } from '../../../core/auth' |
7 | import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model' | 6 | import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model' |
8 | import { User } from '../../../shared/users' | 7 | import { User } from '../../../shared/users' |
@@ -13,6 +12,7 @@ import { VideoCommentService } from './video-comment.service' | |||
13 | import { I18n } from '@ngx-translate/i18n-polyfill' | 12 | import { I18n } from '@ngx-translate/i18n-polyfill' |
14 | import { Syndication } from '@app/shared/video/syndication.model' | 13 | import { Syndication } from '@app/shared/video/syndication.model' |
15 | import { HooksService } from '@app/core/plugins/hooks.service' | 14 | import { HooksService } from '@app/core/plugins/hooks.service' |
15 | import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model' | ||
16 | 16 | ||
17 | @Component({ | 17 | @Component({ |
18 | selector: 'my-video-comments', | 18 | selector: 'my-video-comments', |
@@ -20,7 +20,7 @@ import { HooksService } from '@app/core/plugins/hooks.service' | |||
20 | styleUrls: ['./video-comments.component.scss'] | 20 | styleUrls: ['./video-comments.component.scss'] |
21 | }) | 21 | }) |
22 | export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | 22 | export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { |
23 | @ViewChild('commentHighlightBlock', { static: false }) commentHighlightBlock: ElementRef | 23 | @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef |
24 | @Input() video: VideoDetails | 24 | @Input() video: VideoDetails |
25 | @Input() user: User | 25 | @Input() user: User |
26 | 26 | ||
@@ -96,6 +96,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | |||
96 | res => { | 96 | res => { |
97 | this.threadComments[commentId] = res | 97 | this.threadComments[commentId] = res |
98 | this.threadLoading[commentId] = false | 98 | this.threadLoading[commentId] = false |
99 | this.hooks.runAction('action:video-watch.video-thread-replies.loaded', 'video-watch', { data: res }) | ||
99 | 100 | ||
100 | if (highlightThread) { | 101 | if (highlightThread) { |
101 | this.highlightedThread = new VideoComment(res.comment) | 102 | this.highlightedThread = new VideoComment(res.comment) |
@@ -130,6 +131,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | |||
130 | this.componentPagination.totalItems = res.total | 131 | this.componentPagination.totalItems = res.total |
131 | 132 | ||
132 | this.onDataSubject.next(res.data) | 133 | this.onDataSubject.next(res.data) |
134 | this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination }) | ||
133 | }, | 135 | }, |
134 | 136 | ||
135 | err => this.notifier.error(err.message) | 137 | err => this.notifier.error(err.message) |
@@ -167,7 +169,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | |||
167 | let message = 'Do you really want to delete this comment?' | 169 | let message = 'Do you really want to delete this comment?' |
168 | 170 | ||
169 | if (commentToDelete.isLocal) { | 171 | if (commentToDelete.isLocal) { |
170 | message += this.i18n(' The deletion will be sent to remote instances, so they remove the comment too.') | 172 | message += this.i18n(' The deletion will be sent to remote instances so they can reflect the change.') |
171 | } else { | 173 | } else { |
172 | message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.') | 174 | message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.') |
173 | } | 175 | } |
@@ -181,7 +183,7 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | |||
181 | // Mark the comment as deleted | 183 | // Mark the comment as deleted |
182 | this.softDeleteComment(commentToDelete) | 184 | this.softDeleteComment(commentToDelete) |
183 | 185 | ||
184 | if (this.highlightedThread.id === commentToDelete.id) this.highlightedThread = undefined | 186 | if (this.highlightedThread?.id === commentToDelete.id) this.highlightedThread = undefined |
185 | }, | 187 | }, |
186 | 188 | ||
187 | err => this.notifier.error(err.message) | 189 | err => this.notifier.error(err.message) |
@@ -193,9 +195,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | |||
193 | } | 195 | } |
194 | 196 | ||
195 | onNearOfBottom () { | 197 | onNearOfBottom () { |
196 | this.componentPagination.currentPage++ | ||
197 | |||
198 | if (hasMoreItems(this.componentPagination)) { | 198 | if (hasMoreItems(this.componentPagination)) { |
199 | this.componentPagination.currentPage++ | ||
199 | this.loadMoreThreads() | 200 | this.loadMoreThreads() |
200 | } | 201 | } |
201 | } | 202 | } |
@@ -219,7 +220,6 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | |||
219 | this.componentPagination.totalItems = null | 220 | this.componentPagination.totalItems = null |
220 | 221 | ||
221 | this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid) | 222 | this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid) |
222 | |||
223 | this.loadMoreThreads() | 223 | this.loadMoreThreads() |
224 | } | 224 | } |
225 | } | 225 | } |
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html index 549a9f30e..5e6a2d518 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 | |||
@@ -27,29 +27,33 @@ | |||
27 | <div class="video"> | 27 | <div class="video"> |
28 | <div class="title-page title-page-single" *ngIf="hasPlaylist()" i18n>Share the video</div> | 28 | <div class="title-page title-page-single" *ngIf="hasPlaylist()" i18n>Share the video</div> |
29 | 29 | ||
30 | <ngb-tabset class="root-tabset bootstrap" (tabChange)="onTabChange($event)"> | 30 | <div ngbNav #nav="ngbNav" class="nav-tabs" [(activeId)]="activeId"> |
31 | 31 | ||
32 | <ngb-tab i18n-title title="URL" id="url"> | 32 | <ng-container ngbNavItem="url"> |
33 | <ng-template ngbTabContent> | 33 | <a ngbNavLink i18n>URL</a> |
34 | 34 | ||
35 | <div class="tab-content"> | 35 | <ng-template ngbNavContent> |
36 | <div class="nav-content"> | ||
36 | <my-input-readonly-copy [value]="getVideoUrl()"></my-input-readonly-copy> | 37 | <my-input-readonly-copy [value]="getVideoUrl()"></my-input-readonly-copy> |
37 | </div> | 38 | </div> |
38 | |||
39 | </ng-template> | 39 | </ng-template> |
40 | </ngb-tab> | 40 | </ng-container> |
41 | |||
42 | <ng-container ngbNavItem="qrcode"> | ||
43 | <a ngbNavLink i18n>QR-Code</a> | ||
41 | 44 | ||
42 | <ngb-tab i18n-title title="QR-Code" id="qrcode"> | 45 | <ng-template ngbNavContent> |
43 | <ng-template ngbTabContent> | 46 | <div class="nav-content"> |
44 | <div class="tab-content"> | 47 | <qrcode [qrdata]="getVideoUrl()" [size]="256" level="Q"></qrcode> |
45 | <qrcode [qrdata]="getVideoUrl()" size="256" level="Q"></qrcode> | ||
46 | </div> | 48 | </div> |
47 | </ng-template> | 49 | </ng-template> |
48 | </ngb-tab> | 50 | </ng-container> |
51 | |||
52 | <ng-container ngbNavItem="embed"> | ||
53 | <a ngbNavLink i18n>Embed</a> | ||
49 | 54 | ||
50 | <ngb-tab i18n-title title="Embed" id="embed"> | 55 | <ng-template ngbNavContent> |
51 | <ng-template ngbTabContent> | 56 | <div class="nav-content"> |
52 | <div class="tab-content"> | ||
53 | <my-input-readonly-copy [value]="getVideoIframeCode()"></my-input-readonly-copy> | 57 | <my-input-readonly-copy [value]="getVideoIframeCode()"></my-input-readonly-copy> |
54 | 58 | ||
55 | <div i18n *ngIf="notSecure()" class="alert alert-warning"> | 59 | <div i18n *ngIf="notSecure()" class="alert alert-warning"> |
@@ -57,9 +61,11 @@ | |||
57 | </div> | 61 | </div> |
58 | </div> | 62 | </div> |
59 | </ng-template> | 63 | </ng-template> |
60 | </ngb-tab> | 64 | </ng-container> |
61 | 65 | ||
62 | </ngb-tabset> | 66 | </div> |
67 | |||
68 | <div [ngbNavOutlet]="nav"></div> | ||
63 | 69 | ||
64 | <div class="filters"> | 70 | <div class="filters"> |
65 | <div> | 71 | <div> |
@@ -92,26 +98,6 @@ | |||
92 | </div> | 98 | </div> |
93 | </div> | 99 | </div> |
94 | 100 | ||
95 | <div (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed" role="button" class="advanced-filters-button" | ||
96 | [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic"> | ||
97 | |||
98 | <ng-container *ngIf="isAdvancedCustomizationCollapsed"> | ||
99 | <span class="glyphicon glyphicon-menu-down"></span> | ||
100 | |||
101 | <ng-container i18n> | ||
102 | More customization | ||
103 | </ng-container> | ||
104 | </ng-container> | ||
105 | |||
106 | <ng-container *ngIf="!isAdvancedCustomizationCollapsed"> | ||
107 | <span class="glyphicon glyphicon-menu-up"></span> | ||
108 | |||
109 | <ng-container i18n> | ||
110 | Less customization | ||
111 | </ng-container> | ||
112 | </ng-container> | ||
113 | </div> | ||
114 | |||
115 | <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed"> | 101 | <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed"> |
116 | <div> | 102 | <div> |
117 | <div class="form-group stop-at"> | 103 | <div class="form-group stop-at"> |
@@ -174,12 +160,28 @@ | |||
174 | </div> | 160 | </div> |
175 | </ng-container> | 161 | </ng-container> |
176 | </div> | 162 | </div> |
163 | |||
164 | <div (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed" role="button" class="advanced-filters-button" | ||
165 | [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic"> | ||
166 | |||
167 | <ng-container *ngIf="isAdvancedCustomizationCollapsed"> | ||
168 | <span class="glyphicon glyphicon-menu-down"></span> | ||
169 | |||
170 | <ng-container i18n> | ||
171 | More customization | ||
172 | </ng-container> | ||
173 | </ng-container> | ||
174 | |||
175 | <ng-container *ngIf="!isAdvancedCustomizationCollapsed"> | ||
176 | <span class="glyphicon glyphicon-menu-up"></span> | ||
177 | |||
178 | <ng-container i18n> | ||
179 | Less customization | ||
180 | </ng-container> | ||
181 | </ng-container> | ||
182 | </div> | ||
177 | </div> | 183 | </div> |
178 | </div> | 184 | </div> |
179 | </div> | 185 | </div> |
180 | 186 | ||
181 | <div class="modal-footer inputs"> | ||
182 | <span i18n class="action-button action-button-cancel" (click)="hide()">Close</span> | ||
183 | </div> | ||
184 | |||
185 | </ng-template> | 187 | </ng-template> |
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 8b5952da6..091d4dc3b 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 | |||
@@ -17,15 +17,11 @@ my-input-readonly-copy { | |||
17 | @include peertube-select-container(200px); | 17 | @include peertube-select-container(200px); |
18 | } | 18 | } |
19 | 19 | ||
20 | .action-button-cancel { | ||
21 | margin-right: 0 !important; | ||
22 | } | ||
23 | |||
24 | .qr-code-group { | 20 | .qr-code-group { |
25 | text-align: center; | 21 | text-align: center; |
26 | } | 22 | } |
27 | 23 | ||
28 | .tab-content { | 24 | .nav-content { |
29 | margin-top: 30px; | 25 | margin-top: 30px; |
30 | display: flex; | 26 | display: flex; |
31 | justify-content: center; | 27 | justify-content: center; |
@@ -39,14 +35,12 @@ my-input-readonly-copy { | |||
39 | 35 | ||
40 | .filters { | 36 | .filters { |
41 | margin-top: 30px; | 37 | margin-top: 30px; |
42 | padding-top: 30px; | ||
43 | border-top: 1px solid $separator-border-color; | ||
44 | 38 | ||
45 | .advanced-filters-button { | 39 | .advanced-filters-button { |
46 | display: flex; | 40 | display: flex; |
47 | justify-content: center; | 41 | justify-content: center; |
48 | align-items: center; | 42 | align-items: center; |
49 | margin-top: 30px; | 43 | margin-top: 20px; |
50 | font-size: 16px; | 44 | font-size: 16px; |
51 | font-weight: $font-semibold; | 45 | font-weight: $font-semibold; |
52 | cursor: pointer; | 46 | cursor: pointer; |
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts index a2b38b3a0..3550556a0 100644 --- a/client/src/app/videos/+video-watch/modal/video-share.component.ts +++ b/client/src/app/videos/+video-watch/modal/video-share.component.ts | |||
@@ -1,9 +1,7 @@ | |||
1 | import { Component, ElementRef, Input, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, Input, ViewChild } from '@angular/core' |
2 | import { Notifier } from '@app/core' | ||
3 | import { VideoDetails } from '../../../shared/video/video-details.model' | 2 | import { VideoDetails } from '../../../shared/video/video-details.model' |
4 | import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils' | 3 | import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils' |
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
6 | import { NgbModal, NgbTabChangeEvent } from '@ng-bootstrap/ng-bootstrap' | ||
7 | import { VideoCaption } from '@shared/models' | 5 | import { VideoCaption } from '@shared/models' |
8 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | 6 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' |
9 | 7 | ||
@@ -37,7 +35,7 @@ export class VideoShareComponent { | |||
37 | @Input() videoCaptions: VideoCaption[] = [] | 35 | @Input() videoCaptions: VideoCaption[] = [] |
38 | @Input() playlist: VideoPlaylist = null | 36 | @Input() playlist: VideoPlaylist = null |
39 | 37 | ||
40 | activeId: 'url' | 'qrcode' | 'embed' | 38 | activeId: 'url' | 'qrcode' | 'embed' = 'url' |
41 | customizations: Customizations | 39 | customizations: Customizations |
42 | isAdvancedCustomizationCollapsed = true | 40 | isAdvancedCustomizationCollapsed = true |
43 | includeVideoInPlaylist = false | 41 | includeVideoInPlaylist = false |
@@ -74,7 +72,7 @@ export class VideoShareComponent { | |||
74 | controls: true | 72 | controls: true |
75 | } | 73 | } |
76 | 74 | ||
77 | this.modalService.open(this.modal) | 75 | this.modalService.open(this.modal, { centered: true }) |
78 | } | 76 | } |
79 | 77 | ||
80 | getVideoIframeCode () { | 78 | getVideoIframeCode () { |
@@ -103,10 +101,6 @@ export class VideoShareComponent { | |||
103 | return window.location.protocol === 'http:' | 101 | return window.location.protocol === 'http:' |
104 | } | 102 | } |
105 | 103 | ||
106 | onTabChange (event: NgbTabChangeEvent) { | ||
107 | this.activeId = event.nextId as any | ||
108 | } | ||
109 | |||
110 | isInEmbedTab () { | 104 | isInEmbedTab () { |
111 | return this.activeId === 'embed' | 105 | return this.activeId === 'embed' |
112 | } | 106 | } |
diff --git a/client/src/app/videos/+video-watch/modal/video-support.component.html b/client/src/app/videos/+video-watch/modal/video-support.component.html index 608a4632b..935656d23 100644 --- a/client/src/app/videos/+video-watch/modal/video-support.component.html +++ b/client/src/app/videos/+video-watch/modal/video-support.component.html | |||
@@ -7,6 +7,9 @@ | |||
7 | <div class="modal-body" [innerHTML]="videoHTMLSupport"></div> | 7 | <div class="modal-body" [innerHTML]="videoHTMLSupport"></div> |
8 | 8 | ||
9 | <div class="modal-footer inputs"> | 9 | <div class="modal-footer inputs"> |
10 | <span i18n class="action-button action-button-cancel" (click)="hide()">Maybe later</span> | 10 | <input |
11 | type="button" role="button" i18n-value value="Maybe later" class="action-button action-button-cancel" | ||
12 | (click)="hide()" (key.enter)="hide()" | ||
13 | > | ||
11 | </div> | 14 | </div> |
12 | </ng-template> | 15 | </ng-template> |
diff --git a/client/src/app/videos/+video-watch/modal/video-support.component.ts b/client/src/app/videos/+video-watch/modal/video-support.component.ts index b56a51fbf..0058172f2 100644 --- a/client/src/app/videos/+video-watch/modal/video-support.component.ts +++ b/client/src/app/videos/+video-watch/modal/video-support.component.ts | |||
@@ -21,7 +21,7 @@ export class VideoSupportComponent { | |||
21 | ) { } | 21 | ) { } |
22 | 22 | ||
23 | show () { | 23 | show () { |
24 | this.modalService.open(this.modal) | 24 | this.modalService.open(this.modal, { centered: true }) |
25 | 25 | ||
26 | this.markdownService.enhancedMarkdownToHTML(this.video.support) | 26 | this.markdownService.enhancedMarkdownToHTML(this.video.support) |
27 | .then(r => this.videoHTMLSupport = r) | 27 | .then(r => this.videoHTMLSupport = r) |
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.ts b/client/src/app/videos/+video-watch/video-watch-playlist.component.ts index c5ed36000..827c34d41 100644 --- a/client/src/app/videos/+video-watch/video-watch-playlist.component.ts +++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.ts | |||
@@ -9,6 +9,7 @@ import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist. | |||
9 | import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model' | 9 | import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model' |
10 | import { peertubeLocalStorage, peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage' | 10 | import { peertubeLocalStorage, peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage' |
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | 11 | import { I18n } from '@ngx-translate/i18n-polyfill' |
12 | import { SessionStorageService, LocalStorageService } from '@app/shared/misc/storage.service' | ||
12 | 13 | ||
13 | @Component({ | 14 | @Component({ |
14 | selector: 'my-video-watch-playlist', | 15 | selector: 'my-video-watch-playlist', |
@@ -42,16 +43,18 @@ export class VideoWatchPlaylistComponent { | |||
42 | private notifier: Notifier, | 43 | private notifier: Notifier, |
43 | private i18n: I18n, | 44 | private i18n: I18n, |
44 | private videoPlaylist: VideoPlaylistService, | 45 | private videoPlaylist: VideoPlaylistService, |
46 | private localStorageService: LocalStorageService, | ||
47 | private sessionStorageService: SessionStorageService, | ||
45 | private router: Router | 48 | private router: Router |
46 | ) { | 49 | ) { |
47 | // defaults to true | 50 | // defaults to true |
48 | this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn() | 51 | this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn() |
49 | ? this.auth.getUser().autoPlayNextVideoPlaylist | 52 | ? this.auth.getUser().autoPlayNextVideoPlaylist |
50 | : peertubeLocalStorage.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false' | 53 | : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false' |
51 | this.setAutoPlayNextVideoPlaylistSwitchText() | 54 | this.setAutoPlayNextVideoPlaylistSwitchText() |
52 | 55 | ||
53 | // defaults to false | 56 | // defaults to false |
54 | this.loopPlaylist = peertubeSessionStorage.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' | 57 | this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' |
55 | this.setLoopPlaylistSwitchText() | 58 | this.setLoopPlaylistSwitchText() |
56 | } | 59 | } |
57 | 60 | ||
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html index bc3a3ffdd..0244860dd 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html | |||
@@ -84,12 +84,12 @@ | |||
84 | placement="bottom auto" | 84 | placement="bottom auto" |
85 | > | 85 | > |
86 | <my-global-icon iconName="support"></my-global-icon> | 86 | <my-global-icon iconName="support"></my-global-icon> |
87 | <span class="icon-text" i18n>Support</span> | 87 | <span class="icon-text" i18n>SUPPORT</span> |
88 | </div> | 88 | </div> |
89 | 89 | ||
90 | <div (click)="showShareModal()" class="action-button" role="button"> | 90 | <div (click)="showShareModal()" class="action-button" role="button"> |
91 | <my-global-icon iconName="share"></my-global-icon> | 91 | <my-global-icon iconName="share"></my-global-icon> |
92 | <span class="icon-text" i18n>Share</span> | 92 | <span class="icon-text" i18n>SHARE</span> |
93 | </div> | 93 | </div> |
94 | 94 | ||
95 | <div | 95 | <div |
@@ -100,7 +100,7 @@ | |||
100 | > | 100 | > |
101 | <div class="action-button action-button-save" ngbDropdownToggle role="button"> | 101 | <div class="action-button action-button-save" ngbDropdownToggle role="button"> |
102 | <my-global-icon iconName="playlist-add"></my-global-icon> | 102 | <my-global-icon iconName="playlist-add"></my-global-icon> |
103 | <span class="icon-text" i18n>Save</span> | 103 | <span class="icon-text" i18n>SAVE</span> |
104 | </div> | 104 | </div> |
105 | 105 | ||
106 | <div ngbDropdownMenu> | 106 | <div ngbDropdownMenu> |
@@ -188,6 +188,11 @@ | |||
188 | <span class="video-attribute-value">{{ video.privacy.label }}</span> | 188 | <span class="video-attribute-value">{{ video.privacy.label }}</span> |
189 | </div> | 189 | </div> |
190 | 190 | ||
191 | <div *ngIf="video.isLocal === false" class="video-attribute"> | ||
192 | <span i18n class="video-attribute-label">Origin instance</span> | ||
193 | <a class="video-attribute-value" target="_blank" rel="noopener noreferrer" [href]="video.originInstanceUrl">{{ video.originInstanceHost }}</a> | ||
194 | </div> | ||
195 | |||
191 | <div *ngIf="!!video.originallyPublishedAt" class="video-attribute"> | 196 | <div *ngIf="!!video.originallyPublishedAt" class="video-attribute"> |
192 | <span i18n class="video-attribute-label">Originally published</span> | 197 | <span i18n class="video-attribute-label">Originally published</span> |
193 | <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span> | 198 | <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span> |
@@ -252,14 +257,16 @@ | |||
252 | 257 | ||
253 | <div class="row privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false"> | 258 | <div class="row privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false"> |
254 | <div class="privacy-concerns-text"> | 259 | <div class="privacy-concerns-text"> |
255 | <strong i18n>Friendly Reminder: </strong> | 260 | <span class="mr-2"> |
256 | <ng-container i18n> | 261 | <strong i18n>Friendly Reminder: </strong> |
257 | the sharing system used for this video implies that some technical information about your system (such as a public IP address) can be sent to other peers. | 262 | <ng-container i18n> |
258 | </ng-container> | 263 | the sharing system used for this video implies that some technical information about your system (such as a public IP address) can be sent to other peers. |
259 | <a i18n i18n-title title="Get more information" target="_blank" rel="noopener noreferrer" href="/about/peertube">More information</a> | 264 | </ng-container> |
265 | </span> | ||
266 | <a i18n i18n-title title="Get more information" target="_blank" rel="noopener noreferrer" href="/about/peertube#privacy">More information</a> | ||
260 | </div> | 267 | </div> |
261 | 268 | ||
262 | <div i18n class="privacy-concerns-okay" (click)="acceptedPrivacyConcern()"> | 269 | <div i18n class="privacy-concerns-button privacy-concerns-okay" (click)="acceptedPrivacyConcern()"> |
263 | OK | 270 | OK |
264 | </div> | 271 | </div> |
265 | </div> | 272 | </div> |
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 c92f773e4..977312a83 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.scss +++ b/client/src/app/videos/+video-watch/video-watch.component.scss | |||
@@ -53,7 +53,6 @@ $video-info-margin-left: 44px; | |||
53 | background-color: #000; | 53 | background-color: #000; |
54 | display: flex; | 54 | display: flex; |
55 | justify-content: center; | 55 | justify-content: center; |
56 | margin: 0 -15px; | ||
57 | 56 | ||
58 | #videojs-wrapper { | 57 | #videojs-wrapper { |
59 | display: flex; | 58 | display: flex; |
@@ -443,6 +442,7 @@ my-video-comments { | |||
443 | 442 | ||
444 | // If the view is not expanded, take into account the menu | 443 | // If the view is not expanded, take into account the menu |
445 | .privacy-concerns { | 444 | .privacy-concerns { |
445 | z-index: z(dropdown) + 1; | ||
446 | width: calc(100% - #{$menu-width}); | 446 | width: calc(100% - #{$menu-width}); |
447 | } | 447 | } |
448 | 448 | ||
@@ -462,13 +462,14 @@ my-video-comments { | |||
462 | .privacy-concerns { | 462 | .privacy-concerns { |
463 | position: fixed; | 463 | position: fixed; |
464 | bottom: 0; | 464 | bottom: 0; |
465 | z-index: z(privacymsg); | ||
465 | 466 | ||
466 | padding: 5px 15px; | 467 | padding: 5px 15px; |
467 | 468 | ||
468 | display: flex; | 469 | display: flex; |
469 | flex-wrap: nowrap; | 470 | flex-wrap: nowrap; |
470 | align-items: center; | 471 | align-items: center; |
471 | justify-content: flex-start; | 472 | justify-content: space-between; |
472 | background-color: rgba(0, 0, 0, 0.9); | 473 | background-color: rgba(0, 0, 0, 0.9); |
473 | color: #fff; | 474 | color: #fff; |
474 | 475 | ||
@@ -487,11 +488,11 @@ my-video-comments { | |||
487 | } | 488 | } |
488 | } | 489 | } |
489 | 490 | ||
490 | .privacy-concerns-okay { | 491 | .privacy-concerns-button { |
491 | background-color: var(--mainColor); | ||
492 | padding: 5px 8px 5px 7px; | 492 | padding: 5px 8px 5px 7px; |
493 | margin-left: auto; | 493 | margin-left: auto; |
494 | border-radius: 3px; | 494 | border-radius: 3px; |
495 | white-space: nowrap; | ||
495 | cursor: pointer; | 496 | cursor: pointer; |
496 | transition: background-color 0.3s; | 497 | transition: background-color 0.3s; |
497 | font-weight: $font-semibold; | 498 | font-weight: $font-semibold; |
@@ -500,6 +501,11 @@ my-video-comments { | |||
500 | background-color: #000; | 501 | background-color: #000; |
501 | } | 502 | } |
502 | } | 503 | } |
504 | |||
505 | .privacy-concerns-okay { | ||
506 | background-color: var(--mainColor); | ||
507 | margin-left: 10px; | ||
508 | } | ||
503 | } | 509 | } |
504 | 510 | ||
505 | @media screen and (max-width: 1600px) { | 511 | @media screen and (max-width: 1600px) { |
@@ -545,7 +551,8 @@ my-video-comments { | |||
545 | 551 | ||
546 | @media screen and (max-width: 600px) { | 552 | @media screen and (max-width: 600px) { |
547 | .video-bottom { | 553 | .video-bottom { |
548 | margin: 20px 0 0 0 !important; | 554 | margin-top: 20px !important; |
555 | margin-bottom: 20px !important; | ||
549 | 556 | ||
550 | .video-info { | 557 | .video-info { |
551 | padding: 0; | 558 | padding: 0; |
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index e09e44809..51e484275 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts | |||
@@ -2,7 +2,7 @@ import { catchError } from 'rxjs/operators' | |||
2 | import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' | 2 | import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { RedirectService } from '@app/core/routing/redirect.service' | 4 | import { RedirectService } from '@app/core/routing/redirect.service' |
5 | import { peertubeLocalStorage, peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage' | 5 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage' |
6 | import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component' | 6 | import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component' |
7 | import { MetaService } from '@ngx-meta/core' | 7 | import { MetaService } from '@ngx-meta/core' |
8 | import { AuthUser, Notifier, ServerService } from '@app/core' | 8 | import { AuthUser, Notifier, ServerService } from '@app/core' |
@@ -10,7 +10,7 @@ import { forkJoin, Observable, Subscription } from 'rxjs' | |||
10 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | 10 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' |
11 | import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' | 11 | import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' |
12 | import { AuthService, ConfirmService } from '../../core' | 12 | import { AuthService, ConfirmService } from '../../core' |
13 | import { RestExtractor } from '../../shared' | 13 | import { RestExtractor, UserService } from '../../shared' |
14 | import { VideoDetails } from '../../shared/video/video-details.model' | 14 | import { VideoDetails } from '../../shared/video/video-details.model' |
15 | import { VideoService } from '../../shared/video/video.service' | 15 | import { VideoService } from '../../shared/video/video.service' |
16 | import { VideoShareComponent } from './modal/video-share.component' | 16 | import { VideoShareComponent } from './modal/video-share.component' |
@@ -35,7 +35,6 @@ import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watc | |||
35 | import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage' | 35 | import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage' |
36 | import { HooksService } from '@app/core/plugins/hooks.service' | 36 | import { HooksService } from '@app/core/plugins/hooks.service' |
37 | import { PlatformLocation } from '@angular/common' | 37 | import { PlatformLocation } from '@angular/common' |
38 | import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component' | ||
39 | import { scrollToTop, isXPercentInViewport } from '@app/shared/misc/utils' | 38 | import { scrollToTop, isXPercentInViewport } from '@app/shared/misc/utils' |
40 | 39 | ||
41 | @Component({ | 40 | @Component({ |
@@ -47,9 +46,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
47 | private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' | 46 | private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' |
48 | 47 | ||
49 | @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent | 48 | @ViewChild('videoWatchPlaylist', { static: true }) videoWatchPlaylist: VideoWatchPlaylistComponent |
50 | @ViewChild('videoShareModal', { static: false }) videoShareModal: VideoShareComponent | 49 | @ViewChild('videoShareModal') videoShareModal: VideoShareComponent |
51 | @ViewChild('videoSupportModal', { static: false }) videoSupportModal: VideoSupportComponent | 50 | @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent |
52 | @ViewChild('subscribeButton', { static: false }) subscribeButton: SubscribeButtonComponent | 51 | @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent |
53 | 52 | ||
54 | player: any | 53 | player: any |
55 | playerElement: HTMLVideoElement | 54 | playerElement: HTMLVideoElement |
@@ -95,6 +94,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
95 | private confirmService: ConfirmService, | 94 | private confirmService: ConfirmService, |
96 | private metaService: MetaService, | 95 | private metaService: MetaService, |
97 | private authService: AuthService, | 96 | private authService: AuthService, |
97 | private userService: UserService, | ||
98 | private serverService: ServerService, | 98 | private serverService: ServerService, |
99 | private restExtractor: RestExtractor, | 99 | private restExtractor: RestExtractor, |
100 | private notifier: Notifier, | 100 | private notifier: Notifier, |
@@ -118,6 +118,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
118 | return this.authService.getUser() | 118 | return this.authService.getUser() |
119 | } | 119 | } |
120 | 120 | ||
121 | get anonymousUser () { | ||
122 | return this.userService.getAnonymousUser() | ||
123 | } | ||
124 | |||
121 | async ngOnInit () { | 125 | async ngOnInit () { |
122 | this.serverConfig = this.serverService.getTmpConfig() | 126 | this.serverConfig = this.serverService.getTmpConfig() |
123 | 127 | ||
@@ -266,6 +270,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
266 | this.redirectService.redirectToHomepage() | 270 | this.redirectService.redirectToHomepage() |
267 | } | 271 | } |
268 | 272 | ||
273 | declinedPrivacyConcern () { | ||
274 | peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'false') | ||
275 | this.hasAlreadyAcceptedPrivacyConcern = false | ||
276 | } | ||
277 | |||
269 | acceptedPrivacyConcern () { | 278 | acceptedPrivacyConcern () { |
270 | peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true') | 279 | peertubeLocalStorage.setItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY, 'true') |
271 | this.hasAlreadyAcceptedPrivacyConcern = true | 280 | this.hasAlreadyAcceptedPrivacyConcern = true |
@@ -290,7 +299,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
290 | isAutoPlayEnabled () { | 299 | isAutoPlayEnabled () { |
291 | return ( | 300 | return ( |
292 | (this.user && this.user.autoPlayNextVideo) || | 301 | (this.user && this.user.autoPlayNextVideo) || |
293 | peertubeSessionStorage.getItem(RecommendedVideosComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' | 302 | this.anonymousUser.autoPlayNextVideo |
294 | ) | 303 | ) |
295 | } | 304 | } |
296 | 305 | ||
@@ -302,7 +311,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
302 | isPlaylistAutoPlayEnabled () { | 311 | isPlaylistAutoPlayEnabled () { |
303 | return ( | 312 | return ( |
304 | (this.user && this.user.autoPlayNextVideoPlaylist) || | 313 | (this.user && this.user.autoPlayNextVideoPlaylist) || |
305 | peertubeSessionStorage.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' | 314 | this.anonymousUser.autoPlayNextVideoPlaylist |
306 | ) | 315 | ) |
307 | } | 316 | } |
308 | 317 | ||
@@ -532,7 +541,9 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
532 | } | 541 | } |
533 | 542 | ||
534 | private autoplayNext () { | 543 | private autoplayNext () { |
535 | if (this.nextVideoUuid) { | 544 | if (this.playlist) { |
545 | this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | ||
546 | } else if (this.nextVideoUuid) { | ||
536 | this.router.navigate([ '/videos/watch', this.nextVideoUuid ]) | 547 | this.router.navigate([ '/videos/watch', this.nextVideoUuid ]) |
537 | } | 548 | } |
538 | } | 549 | } |
diff --git a/client/src/app/videos/+video-watch/video-watch.module.ts b/client/src/app/videos/+video-watch/video-watch.module.ts index 5fa50ecbb..9b445269d 100644 --- a/client/src/app/videos/+video-watch/video-watch.module.ts +++ b/client/src/app/videos/+video-watch/video-watch.module.ts | |||
@@ -12,7 +12,6 @@ import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' | |||
12 | import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module' | 12 | import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module' |
13 | import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' | 13 | import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' |
14 | import { QRCodeModule } from 'angularx-qrcode' | 14 | import { QRCodeModule } from 'angularx-qrcode' |
15 | import { InputSwitchModule } from 'primeng/inputswitch' | ||
16 | import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestamp-route-transformer.directive' | 15 | import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestamp-route-transformer.directive' |
17 | 16 | ||
18 | @NgModule({ | 17 | @NgModule({ |
@@ -21,8 +20,7 @@ import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestam | |||
21 | SharedModule, | 20 | SharedModule, |
22 | NgbTooltipModule, | 21 | NgbTooltipModule, |
23 | QRCodeModule, | 22 | QRCodeModule, |
24 | RecommendationsModule, | 23 | RecommendationsModule |
25 | InputSwitchModule | ||
26 | ], | 24 | ], |
27 | 25 | ||
28 | declarations: [ | 26 | declarations: [ |
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.html b/client/src/app/videos/recommendations/recommended-videos.component.html index 476eca071..74f9ed2a5 100644 --- a/client/src/app/videos/recommendations/recommended-videos.component.html +++ b/client/src/app/videos/recommendations/recommended-videos.component.html | |||
@@ -7,8 +7,8 @@ | |||
7 | <div *ngIf="!playlist" class="title-page-autoplay" | 7 | <div *ngIf="!playlist" class="title-page-autoplay" |
8 | [ngbTooltip]="autoPlayNextVideoTooltip" placement="bottom-right auto" | 8 | [ngbTooltip]="autoPlayNextVideoTooltip" placement="bottom-right auto" |
9 | > | 9 | > |
10 | <span i18n>Autoplay</span> | 10 | <span i18n>AUTOPLAY</span> |
11 | <p-inputSwitch [(ngModel)]="autoPlayNextVideo" (ngModelChange)="switchAutoPlayNextVideo()"></p-inputSwitch> | 11 | <p-inputSwitch class="small" [(ngModel)]="autoPlayNextVideo" (ngModelChange)="switchAutoPlayNextVideo()"></p-inputSwitch> |
12 | </div> | 12 | </div> |
13 | </div> | 13 | </div> |
14 | 14 | ||
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.scss b/client/src/app/videos/recommendations/recommended-videos.component.scss index 1ab0c47ff..cde62f87f 100644 --- a/client/src/app/videos/recommendations/recommended-videos.component.scss +++ b/client/src/app/videos/recommendations/recommended-videos.component.scss | |||
@@ -25,25 +25,3 @@ | |||
25 | font-weight: 600; | 25 | font-weight: 600; |
26 | } | 26 | } |
27 | } | 27 | } |
28 | |||
29 | /* p-inputSwitch styles to reduce the switch size */ | ||
30 | |||
31 | ::ng-deep { | ||
32 | p-inputswitch { | ||
33 | height: 20px; | ||
34 | } | ||
35 | |||
36 | .ui-inputswitch { | ||
37 | width: 2.5em !important; | ||
38 | height: 1.45em !important; | ||
39 | |||
40 | .ui-inputswitch-slider::before { | ||
41 | height: 1em !important; | ||
42 | width: 1em !important; | ||
43 | } | ||
44 | } | ||
45 | |||
46 | .ui-inputswitch-checked .ui-inputswitch-slider::before { | ||
47 | transform: translateX(1em) !important; | ||
48 | } | ||
49 | } | ||
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.ts b/client/src/app/videos/recommendations/recommended-videos.component.ts index ada6d3433..d4b4c929b 100644 --- a/client/src/app/videos/recommendations/recommended-videos.component.ts +++ b/client/src/app/videos/recommendations/recommended-videos.component.ts | |||
@@ -7,8 +7,8 @@ import { RecommendedVideosStore } from '@app/videos/recommendations/recommended- | |||
7 | import { User } from '@app/shared' | 7 | import { User } from '@app/shared' |
8 | import { AuthService, Notifier } from '@app/core' | 8 | import { AuthService, Notifier } from '@app/core' |
9 | import { UserService } from '@app/shared/users/user.service' | 9 | import { UserService } from '@app/shared/users/user.service' |
10 | import { peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage' | ||
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | 10 | import { I18n } from '@ngx-translate/i18n-polyfill' |
11 | import { SessionStorageService } from '@app/shared/misc/storage.service' | ||
12 | 12 | ||
13 | @Component({ | 13 | @Component({ |
14 | selector: 'my-recommended-videos', | 14 | selector: 'my-recommended-videos', |
@@ -16,8 +16,6 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
16 | styleUrls: [ './recommended-videos.component.scss' ] | 16 | styleUrls: [ './recommended-videos.component.scss' ] |
17 | }) | 17 | }) |
18 | export class RecommendedVideosComponent implements OnChanges { | 18 | export class RecommendedVideosComponent implements OnChanges { |
19 | static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO = 'auto_play_next_video' | ||
20 | |||
21 | @Input() inputRecommendation: RecommendationInfo | 19 | @Input() inputRecommendation: RecommendationInfo |
22 | @Input() user: User | 20 | @Input() user: User |
23 | @Input() playlist: VideoPlaylist | 21 | @Input() playlist: VideoPlaylist |
@@ -34,15 +32,21 @@ export class RecommendedVideosComponent implements OnChanges { | |||
34 | private authService: AuthService, | 32 | private authService: AuthService, |
35 | private notifier: Notifier, | 33 | private notifier: Notifier, |
36 | private i18n: I18n, | 34 | private i18n: I18n, |
37 | private store: RecommendedVideosStore | 35 | private store: RecommendedVideosStore, |
36 | private sessionStorageService: SessionStorageService | ||
38 | ) { | 37 | ) { |
39 | this.videos$ = this.store.recommendations$ | 38 | this.videos$ = this.store.recommendations$ |
40 | this.hasVideos$ = this.store.hasRecommendations$ | 39 | this.hasVideos$ = this.store.hasRecommendations$ |
41 | this.videos$.subscribe(videos => this.gotRecommendations.emit(videos)) | 40 | this.videos$.subscribe(videos => this.gotRecommendations.emit(videos)) |
42 | 41 | ||
43 | this.autoPlayNextVideo = this.authService.isLoggedIn() | 42 | if (this.authService.isLoggedIn()) { |
44 | ? this.authService.getUser().autoPlayNextVideo | 43 | this.autoPlayNextVideo = this.authService.getUser().autoPlayNextVideo |
45 | : peertubeSessionStorage.getItem(RecommendedVideosComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' || false | 44 | } else { |
45 | this.autoPlayNextVideo = this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' || false | ||
46 | this.sessionStorageService.watch([User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO]).subscribe( | ||
47 | () => this.autoPlayNextVideo = this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' | ||
48 | ) | ||
49 | } | ||
46 | 50 | ||
47 | this.autoPlayNextVideoTooltip = this.i18n('When active, the next video is automatically played after the current one.') | 51 | this.autoPlayNextVideoTooltip = this.i18n('When active, the next video is automatically played after the current one.') |
48 | } | 52 | } |
@@ -58,7 +62,7 @@ export class RecommendedVideosComponent implements OnChanges { | |||
58 | } | 62 | } |
59 | 63 | ||
60 | switchAutoPlayNextVideo () { | 64 | switchAutoPlayNextVideo () { |
61 | peertubeSessionStorage.setItem(RecommendedVideosComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, this.autoPlayNextVideo.toString()) | 65 | this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, this.autoPlayNextVideo.toString()) |
62 | 66 | ||
63 | if (this.authService.isLoggedIn()) { | 67 | if (this.authService.isLoggedIn()) { |
64 | const details = { | 68 | const details = { |
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 59f65f95c..757b0e498 100644 --- a/client/src/app/videos/video-list/video-local.component.ts +++ b/client/src/app/videos/video-list/video-local.component.ts | |||
@@ -11,6 +11,8 @@ import { ScreenService } from '@app/shared/misc/screen.service' | |||
11 | import { UserRight } from '../../../../../shared/models/users' | 11 | import { UserRight } from '../../../../../shared/models/users' |
12 | import { Notifier, ServerService } from '@app/core' | 12 | import { Notifier, ServerService } from '@app/core' |
13 | import { HooksService } from '@app/core/plugins/hooks.service' | 13 | import { HooksService } from '@app/core/plugins/hooks.service' |
14 | import { UserService } from '@app/shared' | ||
15 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
14 | 16 | ||
15 | @Component({ | 17 | @Component({ |
16 | selector: 'my-videos-local', | 18 | selector: 'my-videos-local', |
@@ -31,7 +33,9 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On | |||
31 | protected route: ActivatedRoute, | 33 | protected route: ActivatedRoute, |
32 | protected notifier: Notifier, | 34 | protected notifier: Notifier, |
33 | protected authService: AuthService, | 35 | protected authService: AuthService, |
36 | protected userService: UserService, | ||
34 | protected screenService: ScreenService, | 37 | protected screenService: ScreenService, |
38 | protected storageService: LocalStorageService, | ||
35 | private videoService: VideoService, | 39 | private videoService: VideoService, |
36 | private hooks: HooksService | 40 | private hooks: HooksService |
37 | ) { | 41 | ) { |
diff --git a/client/src/app/videos/video-list/video-most-liked.component.ts b/client/src/app/videos/video-list/video-most-liked.component.ts index 6ff7a1e0e..b69fad05f 100644 --- a/client/src/app/videos/video-list/video-most-liked.component.ts +++ b/client/src/app/videos/video-list/video-most-liked.component.ts | |||
@@ -9,6 +9,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
9 | import { ScreenService } from '@app/shared/misc/screen.service' | 9 | import { ScreenService } from '@app/shared/misc/screen.service' |
10 | import { Notifier, ServerService } from '@app/core' | 10 | import { Notifier, ServerService } from '@app/core' |
11 | import { HooksService } from '@app/core/plugins/hooks.service' | 11 | import { HooksService } from '@app/core/plugins/hooks.service' |
12 | import { UserService } from '@app/shared' | ||
13 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
12 | 14 | ||
13 | @Component({ | 15 | @Component({ |
14 | selector: 'my-videos-most-liked', | 16 | selector: 'my-videos-most-liked', |
@@ -28,7 +30,9 @@ export class VideoMostLikedComponent extends AbstractVideoList implements OnInit | |||
28 | protected route: ActivatedRoute, | 30 | protected route: ActivatedRoute, |
29 | protected notifier: Notifier, | 31 | protected notifier: Notifier, |
30 | protected authService: AuthService, | 32 | protected authService: AuthService, |
33 | protected userService: UserService, | ||
31 | protected screenService: ScreenService, | 34 | protected screenService: ScreenService, |
35 | protected storageService: LocalStorageService, | ||
32 | private videoService: VideoService, | 36 | private videoService: VideoService, |
33 | private hooks: HooksService | 37 | private hooks: HooksService |
34 | ) { | 38 | ) { |
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 5fe1f5c80..84999cfb2 100644 --- a/client/src/app/videos/video-list/video-overview.component.html +++ b/client/src/app/videos/video-list/video-overview.component.html | |||
@@ -2,35 +2,44 @@ | |||
2 | 2 | ||
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 |
6 | <div class="section-title"> | 6 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" |
7 | <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a> | 7 | > |
8 | </div> | 8 | <ng-container *ngFor="let overview of overviews"> |
9 | 9 | ||
10 | <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false"> | 10 | <div class="section" *ngFor="let object of overview.categories"> |
11 | </my-video-miniature> | 11 | <div class="section-title"> |
12 | </div> | 12 | <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a> |
13 | </div> | ||
13 | 14 | ||
14 | <div class="section" *ngFor="let object of overview.tags"> | 15 | <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false"> |
15 | <div class="section-title"> | 16 | </my-video-miniature> |
16 | <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a> | 17 | </div> |
17 | </div> | ||
18 | 18 | ||
19 | <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false"> | 19 | <div class="section" *ngFor="let object of overview.tags"> |
20 | </my-video-miniature> | 20 | <div class="section-title"> |
21 | </div> | 21 | <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a> |
22 | </div> | ||
23 | |||
24 | <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false"> | ||
25 | </my-video-miniature> | ||
26 | </div> | ||
27 | |||
28 | <div class="section channel" *ngFor="let object of overview.channels"> | ||
29 | <div class="section-title"> | ||
30 | <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]"> | ||
31 | <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" /> | ||
32 | |||
33 | <div>{{ object.channel.displayName }}</div> | ||
34 | </a> | ||
35 | </div> | ||
22 | 36 | ||
23 | <div class="section channel" *ngFor="let object of overview.channels"> | 37 | <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false"> |
24 | <div class="section-title"> | 38 | </my-video-miniature> |
25 | <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]"> | 39 | </div> |
26 | <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" /> | ||
27 | 40 | ||
28 | <div>{{ object.channel.displayName }}</div> | 41 | </ng-container> |
29 | </a> | ||
30 | </div> | ||
31 | 42 | ||
32 | <my-video-miniature *ngFor="let video of buildVideos(object.videos)" [video]="video" [user]="user" [displayVideoActions]="false"> | ||
33 | </my-video-miniature> | ||
34 | </div> | 43 | </div> |
35 | 44 | ||
36 | </div> | 45 | </div> |
diff --git a/client/src/app/videos/video-list/video-overview.component.ts b/client/src/app/videos/video-list/video-overview.component.ts index 4fee92d54..101073949 100644 --- a/client/src/app/videos/video-list/video-overview.component.ts +++ b/client/src/app/videos/video-list/video-overview.component.ts | |||
@@ -5,6 +5,7 @@ import { VideosOverview } from '@app/shared/overview/videos-overview.model' | |||
5 | import { OverviewService } from '@app/shared/overview' | 5 | import { OverviewService } from '@app/shared/overview' |
6 | import { Video } from '@app/shared/video/video.model' | 6 | import { Video } from '@app/shared/video/video.model' |
7 | import { ScreenService } from '@app/shared/misc/screen.service' | 7 | import { ScreenService } from '@app/shared/misc/screen.service' |
8 | import { Subject } from 'rxjs' | ||
8 | 9 | ||
9 | @Component({ | 10 | @Component({ |
10 | selector: 'my-video-overview', | 11 | selector: 'my-video-overview', |
@@ -12,13 +13,17 @@ import { ScreenService } from '@app/shared/misc/screen.service' | |||
12 | styleUrls: [ './video-overview.component.scss' ] | 13 | styleUrls: [ './video-overview.component.scss' ] |
13 | }) | 14 | }) |
14 | export class VideoOverviewComponent implements OnInit { | 15 | export class VideoOverviewComponent implements OnInit { |
15 | overview: VideosOverview = { | 16 | onDataSubject = new Subject<any>() |
16 | categories: [], | 17 | |
17 | channels: [], | 18 | overviews: VideosOverview[] = [] |
18 | tags: [] | ||
19 | } | ||
20 | notResults = false | 19 | notResults = false |
21 | 20 | ||
21 | private loaded = false | ||
22 | private currentPage = 1 | ||
23 | private maxPage = 20 | ||
24 | private lastWasEmpty = false | ||
25 | private isLoading = false | ||
26 | |||
22 | constructor ( | 27 | constructor ( |
23 | private i18n: I18n, | 28 | private i18n: I18n, |
24 | private notifier: Notifier, | 29 | private notifier: Notifier, |
@@ -32,20 +37,7 @@ export class VideoOverviewComponent implements OnInit { | |||
32 | } | 37 | } |
33 | 38 | ||
34 | ngOnInit () { | 39 | ngOnInit () { |
35 | this.overviewService.getVideosOverview() | 40 | this.loadMoreResults() |
36 | .subscribe( | ||
37 | overview => { | ||
38 | this.overview = overview | ||
39 | |||
40 | if ( | ||
41 | this.overview.categories.length === 0 && | ||
42 | this.overview.channels.length === 0 && | ||
43 | this.overview.tags.length === 0 | ||
44 | ) this.notResults = true | ||
45 | }, | ||
46 | |||
47 | err => this.notifier.error(err.message) | ||
48 | ) | ||
49 | } | 41 | } |
50 | 42 | ||
51 | buildVideoChannelBy (object: { videos: Video[] }) { | 43 | buildVideoChannelBy (object: { videos: Video[] }) { |
@@ -61,4 +53,41 @@ export class VideoOverviewComponent implements OnInit { | |||
61 | 53 | ||
62 | return videos.slice(0, numberOfVideos * 2) | 54 | return videos.slice(0, numberOfVideos * 2) |
63 | } | 55 | } |
56 | |||
57 | onNearOfBottom () { | ||
58 | if (this.currentPage >= this.maxPage) return | ||
59 | if (this.lastWasEmpty) return | ||
60 | if (this.isLoading) return | ||
61 | |||
62 | this.currentPage++ | ||
63 | this.loadMoreResults() | ||
64 | } | ||
65 | |||
66 | private loadMoreResults () { | ||
67 | this.isLoading = true | ||
68 | |||
69 | this.overviewService.getVideosOverview(this.currentPage) | ||
70 | .subscribe( | ||
71 | overview => { | ||
72 | this.isLoading = false | ||
73 | |||
74 | if (overview.tags.length === 0 && overview.channels.length === 0 && overview.categories.length === 0) { | ||
75 | this.lastWasEmpty = true | ||
76 | if (this.loaded === false) this.notResults = true | ||
77 | |||
78 | return | ||
79 | } | ||
80 | |||
81 | this.loaded = true | ||
82 | this.onDataSubject.next(overview) | ||
83 | |||
84 | this.overviews.push(overview) | ||
85 | }, | ||
86 | |||
87 | err => { | ||
88 | this.notifier.error(err.message) | ||
89 | this.isLoading = false | ||
90 | } | ||
91 | ) | ||
92 | } | ||
64 | } | 93 | } |
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 7568f4536..c1ddd4fd4 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 | |||
@@ -9,6 +9,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
9 | import { ScreenService } from '@app/shared/misc/screen.service' | 9 | import { ScreenService } from '@app/shared/misc/screen.service' |
10 | import { Notifier, ServerService } from '@app/core' | 10 | import { Notifier, ServerService } from '@app/core' |
11 | import { HooksService } from '@app/core/plugins/hooks.service' | 11 | import { HooksService } from '@app/core/plugins/hooks.service' |
12 | import { UserService } from '@app/shared' | ||
13 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
12 | 14 | ||
13 | @Component({ | 15 | @Component({ |
14 | selector: 'my-videos-recently-added', | 16 | selector: 'my-videos-recently-added', |
@@ -29,7 +31,9 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On | |||
29 | protected router: Router, | 31 | protected router: Router, |
30 | protected notifier: Notifier, | 32 | protected notifier: Notifier, |
31 | protected authService: AuthService, | 33 | protected authService: AuthService, |
34 | protected userService: UserService, | ||
32 | protected screenService: ScreenService, | 35 | protected screenService: ScreenService, |
36 | protected storageService: LocalStorageService, | ||
33 | private videoService: VideoService, | 37 | private videoService: VideoService, |
34 | private hooks: HooksService | 38 | private hooks: HooksService |
35 | ) { | 39 | ) { |
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 e29830b5b..fbe052277 100644 --- a/client/src/app/videos/video-list/video-trending.component.ts +++ b/client/src/app/videos/video-list/video-trending.component.ts | |||
@@ -9,6 +9,8 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
9 | import { ScreenService } from '@app/shared/misc/screen.service' | 9 | import { ScreenService } from '@app/shared/misc/screen.service' |
10 | import { Notifier, ServerService } from '@app/core' | 10 | import { Notifier, ServerService } from '@app/core' |
11 | import { HooksService } from '@app/core/plugins/hooks.service' | 11 | import { HooksService } from '@app/core/plugins/hooks.service' |
12 | import { UserService } from '@app/shared' | ||
13 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
12 | 14 | ||
13 | @Component({ | 15 | @Component({ |
14 | selector: 'my-videos-trending', | 16 | selector: 'my-videos-trending', |
@@ -28,7 +30,9 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit, | |||
28 | protected route: ActivatedRoute, | 30 | protected route: ActivatedRoute, |
29 | protected notifier: Notifier, | 31 | protected notifier: Notifier, |
30 | protected authService: AuthService, | 32 | protected authService: AuthService, |
33 | protected userService: UserService, | ||
31 | protected screenService: ScreenService, | 34 | protected screenService: ScreenService, |
35 | protected storageService: LocalStorageService, | ||
32 | private videoService: VideoService, | 36 | private videoService: VideoService, |
33 | private hooks: HooksService | 37 | private hooks: HooksService |
34 | ) { | 38 | ) { |
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 cf0b15054..036fd8dcb 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 | |||
@@ -10,6 +10,8 @@ import { ScreenService } from '@app/shared/misc/screen.service' | |||
10 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' | 10 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' |
11 | import { Notifier, ServerService } from '@app/core' | 11 | import { Notifier, ServerService } from '@app/core' |
12 | import { HooksService } from '@app/core/plugins/hooks.service' | 12 | import { HooksService } from '@app/core/plugins/hooks.service' |
13 | import { UserService } from '@app/shared' | ||
14 | import { LocalStorageService } from '@app/shared/misc/storage.service' | ||
13 | 15 | ||
14 | @Component({ | 16 | @Component({ |
15 | selector: 'my-videos-user-subscriptions', | 17 | selector: 'my-videos-user-subscriptions', |
@@ -29,7 +31,9 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement | |||
29 | protected route: ActivatedRoute, | 31 | protected route: ActivatedRoute, |
30 | protected notifier: Notifier, | 32 | protected notifier: Notifier, |
31 | protected authService: AuthService, | 33 | protected authService: AuthService, |
34 | protected userService: UserService, | ||
32 | protected screenService: ScreenService, | 35 | protected screenService: ScreenService, |
36 | protected storageService: LocalStorageService, | ||
33 | private videoService: VideoService, | 37 | private videoService: VideoService, |
34 | private hooks: HooksService | 38 | private hooks: HooksService |
35 | ) { | 39 | ) { |