diff options
Diffstat (limited to 'client/src/app')
259 files changed, 6283 insertions, 1859 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 8c700752e..7c27ec760 100644 --- a/client/src/app/+about/about-instance/about-instance.component.html +++ b/client/src/app/+about/about-instance/about-instance.component.html | |||
@@ -8,6 +8,8 @@ | |||
8 | 8 | ||
9 | <div class="short-description"> | 9 | <div class="short-description"> |
10 | <div>{{ shortDescription }}</div> | 10 | <div>{{ shortDescription }}</div> |
11 | |||
12 | <div *ngIf="isNSFW" class="dedicated-to-nsfw">This instance is dedicated to sensitive/NSFW content.</div> | ||
11 | </div> | 13 | </div> |
12 | 14 | ||
13 | <div class="description"> | 15 | <div class="description"> |
@@ -21,26 +23,6 @@ | |||
21 | 23 | ||
22 | <div [innerHTML]="termsHTML"></div> | 24 | <div [innerHTML]="termsHTML"></div> |
23 | </div> | 25 | </div> |
24 | |||
25 | <div class="signup"> | ||
26 | <div i18n class="section-title">Signup</div> | ||
27 | |||
28 | <div *ngIf="isSignupAllowed"> | ||
29 | <ng-container i18n>User registration is allowed and</ng-container> | ||
30 | |||
31 | <ng-container i18n *ngIf="userVideoQuota !== -1"> | ||
32 | this instance provides a baseline quota of {{ userVideoQuota | bytes: 0 }} space for the videos of its users. | ||
33 | </ng-container> | ||
34 | |||
35 | <ng-container i18n *ngIf="userVideoQuota === -1"> | ||
36 | this instance provides unlimited space for the videos of its users. | ||
37 | </ng-container> | ||
38 | </div> | ||
39 | |||
40 | <div i18n *ngIf="isSignupAllowed === false"> | ||
41 | User registration is currently not allowed. | ||
42 | </div> | ||
43 | </div> | ||
44 | </div> | 26 | </div> |
45 | 27 | ||
46 | <div class="col-md-12 col-xl-6"> | 28 | <div class="col-md-12 col-xl-6"> |
diff --git a/client/src/app/+about/about-instance/about-instance.component.scss b/client/src/app/+about/about-instance/about-instance.component.scss index 75cf57322..0296ae8e9 100644 --- a/client/src/app/+about/about-instance/about-instance.component.scss +++ b/client/src/app/+about/about-instance/about-instance.component.scss | |||
@@ -14,6 +14,8 @@ | |||
14 | & > .contact-admin { | 14 | & > .contact-admin { |
15 | @include peertube-button; | 15 | @include peertube-button; |
16 | @include orange-button; | 16 | @include orange-button; |
17 | |||
18 | height: fit-content; | ||
17 | } | 19 | } |
18 | } | 20 | } |
19 | 21 | ||
@@ -26,3 +28,8 @@ | |||
26 | .short-description, .description, .terms, .signup { | 28 | .short-description, .description, .terms, .signup { |
27 | margin-bottom: 30px; | 29 | margin-bottom: 30px; |
28 | } | 30 | } |
31 | |||
32 | .short-description .dedicated-to-nsfw { | ||
33 | margin-top: 20px; | ||
34 | font-weight: $font-semibold; | ||
35 | } | ||
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 a1b30fa8c..4a63f5e38 100644 --- a/client/src/app/+about/about-instance/about-instance.component.ts +++ b/client/src/app/+about/about-instance/about-instance.component.ts | |||
@@ -29,25 +29,22 @@ export class AboutInstanceComponent implements OnInit { | |||
29 | return this.serverService.getConfig().instance.name | 29 | return this.serverService.getConfig().instance.name |
30 | } | 30 | } |
31 | 31 | ||
32 | get userVideoQuota () { | ||
33 | return this.serverService.getConfig().user.videoQuota | ||
34 | } | ||
35 | |||
36 | get isSignupAllowed () { | ||
37 | return this.serverService.getConfig().signup.allowed | ||
38 | } | ||
39 | |||
40 | get isContactFormEnabled () { | 32 | get isContactFormEnabled () { |
41 | return this.serverService.getConfig().email.enabled && this.serverService.getConfig().contactForm.enabled | 33 | return this.serverService.getConfig().email.enabled && this.serverService.getConfig().contactForm.enabled |
42 | } | 34 | } |
43 | 35 | ||
36 | get isNSFW () { | ||
37 | return this.serverService.getConfig().instance.isNSFW | ||
38 | } | ||
39 | |||
44 | ngOnInit () { | 40 | ngOnInit () { |
45 | this.instanceService.getAbout() | 41 | this.instanceService.getAbout() |
46 | .subscribe( | 42 | .subscribe( |
47 | res => { | 43 | async res => { |
48 | this.shortDescription = res.instance.shortDescription | 44 | this.shortDescription = res.instance.shortDescription |
49 | this.descriptionHTML = this.markdownService.textMarkdownToHTML(res.instance.description) | 45 | |
50 | this.termsHTML = this.markdownService.textMarkdownToHTML(res.instance.terms) | 46 | this.descriptionHTML = await this.markdownService.textMarkdownToHTML(res.instance.description) |
47 | this.termsHTML = await this.markdownService.textMarkdownToHTML(res.instance.terms) | ||
51 | }, | 48 | }, |
52 | 49 | ||
53 | () => this.notifier.error(this.i18n('Cannot get about information from server')) | 50 | () => this.notifier.error(this.i18n('Cannot get about information from server')) |
diff --git a/client/src/app/+accounts/account-about/account-about.component.ts b/client/src/app/+accounts/account-about/account-about.component.ts index 13890a0ee..ce22d3c2e 100644 --- a/client/src/app/+accounts/account-about/account-about.component.ts +++ b/client/src/app/+accounts/account-about/account-about.component.ts | |||
@@ -25,9 +25,9 @@ export class AccountAboutComponent implements OnInit, OnDestroy { | |||
25 | ngOnInit () { | 25 | ngOnInit () { |
26 | // Parent get the account for us | 26 | // Parent get the account for us |
27 | this.accountSub = this.accountService.accountLoaded | 27 | this.accountSub = this.accountService.accountLoaded |
28 | .subscribe(account => { | 28 | .subscribe(async account => { |
29 | this.account = account | 29 | this.account = account |
30 | this.descriptionHTML = this.markdownService.textMarkdownToHTML(this.account.description) | 30 | this.descriptionHTML = await this.markdownService.textMarkdownToHTML(this.account.description) |
31 | }) | 31 | }) |
32 | } | 32 | } |
33 | 33 | ||
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 13b634a01..0d579fa0c 100644 --- a/client/src/app/+accounts/account-videos/account-videos.component.ts +++ b/client/src/app/+accounts/account-videos/account-videos.component.ts | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { Location } from '@angular/common' | ||
4 | import { immutableAssign } from '@app/shared/misc/utils' | 3 | import { immutableAssign } from '@app/shared/misc/utils' |
5 | import { AuthService } from '../../core/auth' | 4 | import { AuthService } from '../../core/auth' |
6 | import { ConfirmService } from '../../core/confirm' | 5 | import { ConfirmService } from '../../core/confirm' |
@@ -8,11 +7,11 @@ import { AbstractVideoList } from '../../shared/video/abstract-video-list' | |||
8 | import { VideoService } from '../../shared/video/video.service' | 7 | import { VideoService } from '../../shared/video/video.service' |
9 | import { Account } from '@app/shared/account/account.model' | 8 | import { Account } from '@app/shared/account/account.model' |
10 | import { AccountService } from '@app/shared/account/account.service' | 9 | import { AccountService } from '@app/shared/account/account.service' |
11 | import { tap } from 'rxjs/operators' | 10 | import { first, tap } from 'rxjs/operators' |
12 | import { I18n } from '@ngx-translate/i18n-polyfill' | 11 | import { I18n } from '@ngx-translate/i18n-polyfill' |
13 | import { Subscription } from 'rxjs' | 12 | import { Subscription } from 'rxjs' |
14 | import { ScreenService } from '@app/shared/misc/screen.service' | 13 | import { ScreenService } from '@app/shared/misc/screen.service' |
15 | import { Notifier } from '@app/core' | 14 | import { Notifier, ServerService } from '@app/core' |
16 | 15 | ||
17 | @Component({ | 16 | @Component({ |
18 | selector: 'my-account-videos', | 17 | selector: 'my-account-videos', |
@@ -24,8 +23,6 @@ import { Notifier } from '@app/core' | |||
24 | }) | 23 | }) |
25 | export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { | 24 | export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { |
26 | titlePage: string | 25 | titlePage: string |
27 | marginContent = false // Disable margin | ||
28 | currentRoute = '/accounts/videos' | ||
29 | loadOnInit = false | 26 | loadOnInit = false |
30 | 27 | ||
31 | private account: Account | 28 | private account: Account |
@@ -33,13 +30,13 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit, | |||
33 | 30 | ||
34 | constructor ( | 31 | constructor ( |
35 | protected router: Router, | 32 | protected router: Router, |
33 | protected serverService: ServerService, | ||
36 | protected route: ActivatedRoute, | 34 | protected route: ActivatedRoute, |
37 | protected authService: AuthService, | 35 | protected authService: AuthService, |
38 | protected notifier: Notifier, | 36 | protected notifier: Notifier, |
39 | protected confirmService: ConfirmService, | 37 | protected confirmService: ConfirmService, |
40 | protected location: Location, | ||
41 | protected screenService: ScreenService, | 38 | protected screenService: ScreenService, |
42 | protected i18n: I18n, | 39 | private i18n: I18n, |
43 | private accountService: AccountService, | 40 | private accountService: AccountService, |
44 | private videoService: VideoService | 41 | private videoService: VideoService |
45 | ) { | 42 | ) { |
@@ -53,13 +50,13 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit, | |||
53 | 50 | ||
54 | // Parent get the account for us | 51 | // Parent get the account for us |
55 | this.accountSub = this.accountService.accountLoaded | 52 | this.accountSub = this.accountService.accountLoaded |
56 | .subscribe(account => { | 53 | .pipe(first()) |
57 | this.account = account | 54 | .subscribe(account => { |
58 | this.currentRoute = '/accounts/' + this.account.nameWithHost + '/videos' | 55 | this.account = account |
59 | 56 | ||
60 | this.reloadVideos() | 57 | this.reloadVideos() |
61 | this.generateSyndicationList() | 58 | this.generateSyndicationList() |
62 | }) | 59 | }) |
63 | } | 60 | } |
64 | 61 | ||
65 | ngOnDestroy () { | 62 | ngOnDestroy () { |
diff --git a/client/src/app/+accounts/accounts-routing.module.ts b/client/src/app/+accounts/accounts-routing.module.ts index ffe606b43..531d763c4 100644 --- a/client/src/app/+accounts/accounts-routing.module.ts +++ b/client/src/app/+accounts/accounts-routing.module.ts | |||
@@ -23,6 +23,10 @@ const accountsRoutes: Routes = [ | |||
23 | data: { | 23 | data: { |
24 | meta: { | 24 | meta: { |
25 | title: 'Account videos' | 25 | title: 'Account videos' |
26 | }, | ||
27 | reuse: { | ||
28 | enabled: true, | ||
29 | key: 'account-videos-list' | ||
26 | } | 30 | } |
27 | } | 31 | } |
28 | }, | 32 | }, |
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index e8339b78b..d9786fb5c 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts | |||
@@ -7,7 +7,6 @@ import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/oper | |||
7 | import { 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' | ||
11 | 10 | ||
12 | @Component({ | 11 | @Component({ |
13 | templateUrl: './accounts.component.html', | 12 | templateUrl: './accounts.component.html', |
diff --git a/client/src/app/+admin/admin-routing.module.ts b/client/src/app/+admin/admin-routing.module.ts index ca31ba585..215da1e4f 100644 --- a/client/src/app/+admin/admin-routing.module.ts +++ b/client/src/app/+admin/admin-routing.module.ts | |||
@@ -6,9 +6,9 @@ import { MetaGuard } from '@ngx-meta/core' | |||
6 | 6 | ||
7 | import { AdminComponent } from './admin.component' | 7 | import { AdminComponent } from './admin.component' |
8 | import { FollowsRoutes } from './follows' | 8 | import { FollowsRoutes } from './follows' |
9 | import { JobsRoutes } from './jobs/job.routes' | ||
10 | import { UsersRoutes } from './users' | 9 | import { UsersRoutes } from './users' |
11 | import { ModerationRoutes } from '@app/+admin/moderation/moderation.routes' | 10 | import { ModerationRoutes } from '@app/+admin/moderation/moderation.routes' |
11 | import { SystemRoutes } from '@app/+admin/system' | ||
12 | 12 | ||
13 | const adminRoutes: Routes = [ | 13 | const adminRoutes: Routes = [ |
14 | { | 14 | { |
@@ -25,7 +25,7 @@ const adminRoutes: Routes = [ | |||
25 | ...FollowsRoutes, | 25 | ...FollowsRoutes, |
26 | ...UsersRoutes, | 26 | ...UsersRoutes, |
27 | ...ModerationRoutes, | 27 | ...ModerationRoutes, |
28 | ...JobsRoutes, | 28 | ...SystemRoutes, |
29 | ...ConfigRoutes | 29 | ...ConfigRoutes |
30 | ] | 30 | ] |
31 | } | 31 | } |
diff --git a/client/src/app/+admin/admin.component.html b/client/src/app/+admin/admin.component.html index 345fb9f5a..98f45a7d1 100644 --- a/client/src/app/+admin/admin.component.html +++ b/client/src/app/+admin/admin.component.html | |||
@@ -12,13 +12,13 @@ | |||
12 | Moderation | 12 | Moderation |
13 | </a> | 13 | </a> |
14 | 14 | ||
15 | <a i18n *ngIf="hasJobsRight()" routerLink="/admin/jobs" routerLinkActive="active" class="title-page"> | ||
16 | Jobs | ||
17 | </a> | ||
18 | |||
19 | <a i18n *ngIf="hasConfigRight()" routerLink="/admin/config" routerLinkActive="active" class="title-page"> | 15 | <a i18n *ngIf="hasConfigRight()" routerLink="/admin/config" routerLinkActive="active" class="title-page"> |
20 | Configuration | 16 | Configuration |
21 | </a> | 17 | </a> |
18 | |||
19 | <a i18n *ngIf="hasJobsRight() || hasLogsRight() || hasDebugRight()" routerLink="/admin/system" routerLinkActive="active" class="title-page"> | ||
20 | System | ||
21 | </a> | ||
22 | </div> | 22 | </div> |
23 | 23 | ||
24 | <div class="margin-content"> | 24 | <div class="margin-content"> |
diff --git a/client/src/app/+admin/admin.component.scss b/client/src/app/+admin/admin.component.scss deleted file mode 100644 index e69de29bb..000000000 --- a/client/src/app/+admin/admin.component.scss +++ /dev/null | |||
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts index 1a4dd6786..408de4837 100644 --- a/client/src/app/+admin/admin.component.ts +++ b/client/src/app/+admin/admin.component.ts | |||
@@ -3,8 +3,7 @@ import { UserRight } from '../../../../shared' | |||
3 | import { AuthService } from '../core/auth/auth.service' | 3 | import { AuthService } from '../core/auth/auth.service' |
4 | 4 | ||
5 | @Component({ | 5 | @Component({ |
6 | templateUrl: './admin.component.html', | 6 | templateUrl: './admin.component.html' |
7 | styleUrls: [ './admin.component.scss' ] | ||
8 | }) | 7 | }) |
9 | export class AdminComponent { | 8 | export class AdminComponent { |
10 | constructor (private auth: AuthService) {} | 9 | constructor (private auth: AuthService) {} |
@@ -25,11 +24,19 @@ export class AdminComponent { | |||
25 | return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) | 24 | return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) |
26 | } | 25 | } |
27 | 26 | ||
27 | hasConfigRight () { | ||
28 | return this.auth.getUser().hasRight(UserRight.MANAGE_CONFIGURATION) | ||
29 | } | ||
30 | |||
31 | hasLogsRight () { | ||
32 | return this.auth.getUser().hasRight(UserRight.MANAGE_LOGS) | ||
33 | } | ||
34 | |||
28 | hasJobsRight () { | 35 | hasJobsRight () { |
29 | return this.auth.getUser().hasRight(UserRight.MANAGE_JOBS) | 36 | return this.auth.getUser().hasRight(UserRight.MANAGE_JOBS) |
30 | } | 37 | } |
31 | 38 | ||
32 | hasConfigRight () { | 39 | hasDebugRight () { |
33 | return this.auth.getUser().hasRight(UserRight.MANAGE_CONFIGURATION) | 40 | return this.auth.getUser().hasRight(UserRight.MANAGE_DEBUG) |
34 | } | 41 | } |
35 | } | 42 | } |
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts index c06ae1d60..71a4dfc4a 100644 --- a/client/src/app/+admin/admin.module.ts +++ b/client/src/app/+admin/admin.module.ts | |||
@@ -7,15 +7,20 @@ import { AdminRoutingModule } from './admin-routing.module' | |||
7 | import { AdminComponent } from './admin.component' | 7 | import { AdminComponent } from './admin.component' |
8 | import { FollowersListComponent, FollowingAddComponent, FollowsComponent, FollowService } from './follows' | 8 | import { FollowersListComponent, FollowingAddComponent, FollowsComponent, FollowService } from './follows' |
9 | import { FollowingListComponent } from './follows/following-list/following-list.component' | 9 | import { FollowingListComponent } from './follows/following-list/following-list.component' |
10 | import { JobsComponent } from './jobs/job.component' | 10 | import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users' |
11 | import { JobsListComponent } from './jobs/jobs-list/jobs-list.component' | 11 | import { |
12 | import { JobService } from './jobs/shared/job.service' | 12 | ModerationCommentModalComponent, |
13 | import { UserCreateComponent, UserListComponent, UsersComponent, UserUpdateComponent } from './users' | 13 | VideoAbuseListComponent, |
14 | import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlacklistListComponent } from './moderation' | 14 | VideoAutoBlacklistListComponent, |
15 | VideoBlacklistListComponent | ||
16 | } from './moderation' | ||
15 | import { ModerationComponent } from '@app/+admin/moderation/moderation.component' | 17 | import { ModerationComponent } from '@app/+admin/moderation/moderation.component' |
16 | import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' | 18 | import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component' |
17 | import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' | 19 | import { RedundancyService } from '@app/+admin/follows/shared/redundancy.service' |
18 | import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' | 20 | import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' |
21 | import { JobsComponent } from '@app/+admin/system/jobs/jobs.component' | ||
22 | import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system' | ||
23 | import { DebugComponent, DebugService } from '@app/+admin/system/debug' | ||
19 | 24 | ||
20 | @NgModule({ | 25 | @NgModule({ |
21 | imports: [ | 26 | imports: [ |
@@ -36,17 +41,21 @@ import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } f | |||
36 | UsersComponent, | 41 | UsersComponent, |
37 | UserCreateComponent, | 42 | UserCreateComponent, |
38 | UserUpdateComponent, | 43 | UserUpdateComponent, |
44 | UserPasswordComponent, | ||
39 | UserListComponent, | 45 | UserListComponent, |
40 | 46 | ||
41 | ModerationComponent, | 47 | ModerationComponent, |
42 | VideoBlacklistListComponent, | 48 | VideoBlacklistListComponent, |
43 | VideoAbuseListComponent, | 49 | VideoAbuseListComponent, |
50 | VideoAutoBlacklistListComponent, | ||
44 | ModerationCommentModalComponent, | 51 | ModerationCommentModalComponent, |
45 | InstanceServerBlocklistComponent, | 52 | InstanceServerBlocklistComponent, |
46 | InstanceAccountBlocklistComponent, | 53 | InstanceAccountBlocklistComponent, |
47 | 54 | ||
55 | SystemComponent, | ||
48 | JobsComponent, | 56 | JobsComponent, |
49 | JobsListComponent, | 57 | LogsComponent, |
58 | DebugComponent, | ||
50 | 59 | ||
51 | ConfigComponent, | 60 | ConfigComponent, |
52 | EditCustomConfigComponent | 61 | EditCustomConfigComponent |
@@ -60,6 +69,8 @@ import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } f | |||
60 | FollowService, | 69 | FollowService, |
61 | RedundancyService, | 70 | RedundancyService, |
62 | JobService, | 71 | JobService, |
72 | LogsService, | ||
73 | DebugService, | ||
63 | ConfigService | 74 | ConfigService |
64 | ] | 75 | ] |
65 | }) | 76 | }) |
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 52eb00d93..637484622 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 | |||
@@ -45,6 +45,15 @@ | |||
45 | </div> | 45 | </div> |
46 | 46 | ||
47 | <div class="form-group"> | 47 | <div class="form-group"> |
48 | <my-peertube-checkbox | ||
49 | inputName="instanceIsNSFW" formControlName="isNSFW" | ||
50 | i18n-labelText labelText="Dedicated to sensitive or NSFW content" | ||
51 | i18n-helpHtml helpHtml="Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br /><br /> | ||
52 | Moreover, the NSFW checkbox on video upload will be automatically checked by default." | ||
53 | ></my-peertube-checkbox> | ||
54 | </div> | ||
55 | |||
56 | <div class="form-group"> | ||
48 | <label i18n for="instanceDefaultClientRoute">Default client route</label> | 57 | <label i18n for="instanceDefaultClientRoute">Default client route</label> |
49 | <div class="peertube-select-container"> | 58 | <div class="peertube-select-container"> |
50 | <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute"> | 59 | <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute"> |
@@ -75,6 +84,7 @@ | |||
75 | </div> | 84 | </div> |
76 | </ng-container> | 85 | </ng-container> |
77 | 86 | ||
87 | |||
78 | <div i18n class="inner-form-title">Signup</div> | 88 | <div i18n class="inner-form-title">Signup</div> |
79 | 89 | ||
80 | <ng-container formGroupName="signup"> | 90 | <ng-container formGroupName="signup"> |
@@ -102,6 +112,7 @@ | |||
102 | </div> | 112 | </div> |
103 | </ng-container> | 113 | </ng-container> |
104 | 114 | ||
115 | |||
105 | <div i18n class="inner-form-title">Users</div> | 116 | <div i18n class="inner-form-title">Users</div> |
106 | 117 | ||
107 | <ng-container formGroupName="user"> | 118 | <ng-container formGroupName="user"> |
@@ -130,6 +141,7 @@ | |||
130 | </div> | 141 | </div> |
131 | </ng-container> | 142 | </ng-container> |
132 | 143 | ||
144 | |||
133 | <div i18n class="inner-form-title">Import</div> | 145 | <div i18n class="inner-form-title">Import</div> |
134 | 146 | ||
135 | <ng-container formGroupName="import"> | 147 | <ng-container formGroupName="import"> |
@@ -152,6 +164,47 @@ | |||
152 | </ng-container> | 164 | </ng-container> |
153 | </ng-container> | 165 | </ng-container> |
154 | 166 | ||
167 | |||
168 | <div i18n class="inner-form-title">Auto-blacklist</div> | ||
169 | |||
170 | <ng-container formGroupName="autoBlacklist"> | ||
171 | <ng-container formGroupName="videos"> | ||
172 | <ng-container formGroupName="ofUsers"> | ||
173 | |||
174 | <div class="form-group"> | ||
175 | <my-peertube-checkbox | ||
176 | inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled" | ||
177 | i18n-labelText labelText="New videos of users automatically blacklisted enabled" | ||
178 | ></my-peertube-checkbox> | ||
179 | </div> | ||
180 | |||
181 | </ng-container> | ||
182 | </ng-container> | ||
183 | </ng-container> | ||
184 | |||
185 | |||
186 | <div i18n class="inner-form-title">Instance followers</div> | ||
187 | |||
188 | <ng-container formGroupName="followers"> | ||
189 | <ng-container formGroupName="instance"> | ||
190 | |||
191 | <div class="form-group"> | ||
192 | <my-peertube-checkbox | ||
193 | inputName="followersInstanceEnabled" formControlName="enabled" | ||
194 | i18n-labelText labelText="Other instances can follow your instance" | ||
195 | ></my-peertube-checkbox> | ||
196 | </div> | ||
197 | |||
198 | <div class="form-group"> | ||
199 | <my-peertube-checkbox | ||
200 | inputName="followersInstanceManualApproval" formControlName="manualApproval" | ||
201 | i18n-labelText labelText="Manually approve new instance follower" | ||
202 | ></my-peertube-checkbox> | ||
203 | </div> | ||
204 | </ng-container> | ||
205 | </ng-container> | ||
206 | |||
207 | |||
155 | <div i18n class="inner-form-title">Administrator</div> | 208 | <div i18n class="inner-form-title">Administrator</div> |
156 | 209 | ||
157 | <div class="form-group" formGroupName="admin"> | 210 | <div class="form-group" formGroupName="admin"> |
@@ -309,18 +362,18 @@ | |||
309 | helpType="custom" | 362 | helpType="custom" |
310 | i18n-customHtml | 363 | i18n-customHtml |
311 | customHtml=" | 364 | customHtml=" |
312 | Write directly CSS code. Example:<br /> | 365 | Write directly CSS code. Example:<br /><br /> |
313 | <pre> | 366 | <pre> |
314 | body {{ '{' }} | 367 | #custom-css {{ '{' }} |
315 | background-color: red; | 368 | color: red; |
316 | {{ '}' }} | 369 | {{ '}' }} |
317 | </pre> | 370 | </pre> |
318 | 371 | ||
319 | Prepend with <em>#custom-css</em> to override styles. Example: | 372 | Prepend with <em>#custom-css</em> to override styles. Example:<br /><br /> |
320 | <pre> | 373 | <pre> |
321 | #custom-css .logged-in-email {{ '{' }} | 374 | #custom-css .logged-in-email {{ '{' }} |
322 | color: red; | 375 | color: red; |
323 | {{ '}' }} | 376 | {{ '}' }} |
324 | </pre> | 377 | </pre> |
325 | " | 378 | " |
326 | ></my-help> | 379 | ></my-help> |
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 654a076b0..e64750713 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 | |||
@@ -5,7 +5,7 @@ import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } fr | |||
5 | import { Notifier } from '@app/core' | 5 | import { Notifier } from '@app/core' |
6 | import { CustomConfig } from '../../../../../../shared/models/server/custom-config.model' | 6 | import { CustomConfig } from '../../../../../../shared/models/server/custom-config.model' |
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | 7 | import { I18n } from '@ngx-translate/i18n-polyfill' |
8 | import { BuildFormDefaultValues, FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 8 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
9 | 9 | ||
10 | @Component({ | 10 | @Component({ |
11 | selector: 'my-edit-custom-config', | 11 | selector: 'my-edit-custom-config', |
@@ -66,6 +66,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
66 | description: null, | 66 | description: null, |
67 | terms: null, | 67 | terms: null, |
68 | defaultClientRoute: null, | 68 | defaultClientRoute: null, |
69 | isNSFW: false, | ||
69 | defaultNSFWPolicy: null, | 70 | defaultNSFWPolicy: null, |
70 | customizations: { | 71 | customizations: { |
71 | javascript: null, | 72 | javascript: null, |
@@ -116,6 +117,19 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
116 | threads: this.customConfigValidatorsService.TRANSCODING_THREADS, | 117 | threads: this.customConfigValidatorsService.TRANSCODING_THREADS, |
117 | allowAdditionalExtensions: null, | 118 | allowAdditionalExtensions: null, |
118 | resolutions: {} | 119 | resolutions: {} |
120 | }, | ||
121 | autoBlacklist: { | ||
122 | videos: { | ||
123 | ofUsers: { | ||
124 | enabled: null | ||
125 | } | ||
126 | } | ||
127 | }, | ||
128 | followers: { | ||
129 | instance: { | ||
130 | enabled: null, | ||
131 | manualApproval: null | ||
132 | } | ||
119 | } | 133 | } |
120 | } | 134 | } |
121 | 135 | ||
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 fc022bdb4..da0ba95e3 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 | |||
@@ -14,25 +14,33 @@ | |||
14 | <ng-template pTemplate="header"> | 14 | <ng-template pTemplate="header"> |
15 | <tr> | 15 | <tr> |
16 | <th i18n style="width: 60px">ID</th> | 16 | <th i18n style="width: 60px">ID</th> |
17 | <th i18n>Score</th> | 17 | <th i18n>Follower handle</th> |
18 | <th i18n>Name</th> | ||
19 | <th i18n>Host</th> | ||
20 | <th i18n>State</th> | 18 | <th i18n>State</th> |
19 | <th i18n>Score</th> | ||
21 | <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> | 20 | <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> |
21 | <th></th> | ||
22 | </tr> | 22 | </tr> |
23 | </ng-template> | 23 | </ng-template> |
24 | 24 | ||
25 | <ng-template pTemplate="body" let-follow> | 25 | <ng-template pTemplate="body" let-follow> |
26 | <tr> | 26 | <tr> |
27 | <td>{{ follow.id }}</td> | 27 | <td>{{ follow.id }}</td> |
28 | <td>{{ follow.score }}</td> | 28 | <td>{{ follow.follower.name + '@' + follow.follower.host }}</td> |
29 | <td>{{ follow.follower.name }}</td> | ||
30 | <td>{{ follow.follower.host }}</td> | ||
31 | 29 | ||
32 | <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td> | 30 | <td *ngIf="follow.state === 'accepted'" i18n>Accepted</td> |
33 | <td *ngIf="follow.state === 'pending'" i18n>Pending</td> | 31 | <td *ngIf="follow.state === 'pending'" i18n>Pending</td> |
34 | 32 | ||
33 | <td>{{ follow.score }}</td> | ||
35 | <td>{{ follow.createdAt }}</td> | 34 | <td>{{ follow.createdAt }}</td> |
35 | |||
36 | <td class="action-cell"> | ||
37 | <ng-container *ngIf="follow.state === 'pending'"> | ||
38 | <my-button i18n-label label="Accept" icon="tick" (click)="acceptFollower(follow)"></my-button> | ||
39 | <my-button i18n-label label="Refuse" icon="cross" (click)="rejectFollower(follow)"></my-button> | ||
40 | </ng-container> | ||
41 | |||
42 | <my-delete-button *ngIf="follow.state === 'accepted'" (click)="deleteFollower(follow)"></my-delete-button> | ||
43 | </td> | ||
36 | </tr> | 44 | </tr> |
37 | </ng-template> | 45 | </ng-template> |
38 | </p-table> | 46 | </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 a6f0656b8..964b3f99b 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 | |||
@@ -7,4 +7,10 @@ | |||
7 | input { | 7 | input { |
8 | @include peertube-input-text(250px); | 8 | @include peertube-input-text(250px); |
9 | } | 9 | } |
10 | } \ No newline at end of file | 10 | } |
11 | |||
12 | .action-cell { | ||
13 | my-button:first-child { | ||
14 | margin-right: 10px; | ||
15 | } | ||
16 | } | ||
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 9a8848bfb..b78cdf656 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 | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
2 | 2 | import { ConfirmService, Notifier } from '@app/core' | |
3 | import { Notifier } from '@app/core' | ||
4 | import { SortMeta } from 'primeng/primeng' | 3 | import { SortMeta } from 'primeng/primeng' |
5 | import { ActorFollow } from '../../../../../../shared/models/actors/follow.model' | 4 | import { ActorFollow } from '../../../../../../shared/models/actors/follow.model' |
6 | import { RestPagination, RestTable } from '../../../shared' | 5 | import { RestPagination, RestTable } from '../../../shared' |
@@ -20,9 +19,10 @@ export class FollowersListComponent extends RestTable implements OnInit { | |||
20 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 19 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
21 | 20 | ||
22 | constructor ( | 21 | constructor ( |
22 | private confirmService: ConfirmService, | ||
23 | private notifier: Notifier, | 23 | private notifier: Notifier, |
24 | private followService: FollowService, | 24 | private i18n: I18n, |
25 | private i18n: I18n | 25 | private followService: FollowService |
26 | ) { | 26 | ) { |
27 | super() | 27 | super() |
28 | } | 28 | } |
@@ -31,6 +31,62 @@ export class FollowersListComponent extends RestTable implements OnInit { | |||
31 | this.initialize() | 31 | this.initialize() |
32 | } | 32 | } |
33 | 33 | ||
34 | acceptFollower (follow: ActorFollow) { | ||
35 | follow.state = 'accepted' | ||
36 | |||
37 | this.followService.acceptFollower(follow) | ||
38 | .subscribe( | ||
39 | () => { | ||
40 | const handle = follow.follower.name + '@' + follow.follower.host | ||
41 | this.notifier.success(this.i18n('{{handle}} accepted in instance followers', { handle })) | ||
42 | }, | ||
43 | |||
44 | err => { | ||
45 | follow.state = 'pending' | ||
46 | this.notifier.error(err.message) | ||
47 | } | ||
48 | ) | ||
49 | } | ||
50 | |||
51 | async rejectFollower (follow: ActorFollow) { | ||
52 | const message = this.i18n('Do you really want to reject this follower?') | ||
53 | const res = await this.confirmService.confirm(message, this.i18n('Reject')) | ||
54 | if (res === false) return | ||
55 | |||
56 | this.followService.rejectFollower(follow) | ||
57 | .subscribe( | ||
58 | () => { | ||
59 | const handle = follow.follower.name + '@' + follow.follower.host | ||
60 | this.notifier.success(this.i18n('{{handle}} rejected from instance followers', { handle })) | ||
61 | |||
62 | this.loadData() | ||
63 | }, | ||
64 | |||
65 | err => { | ||
66 | follow.state = 'pending' | ||
67 | this.notifier.error(err.message) | ||
68 | } | ||
69 | ) | ||
70 | } | ||
71 | |||
72 | async deleteFollower (follow: ActorFollow) { | ||
73 | const message = this.i18n('Do you really want to delete this follower?') | ||
74 | const res = await this.confirmService.confirm(message, this.i18n('Delete')) | ||
75 | if (res === false) return | ||
76 | |||
77 | this.followService.removeFollower(follow) | ||
78 | .subscribe( | ||
79 | () => { | ||
80 | const handle = follow.follower.name + '@' + follow.follower.host | ||
81 | this.notifier.success(this.i18n('{{handle}} removed from instance followers', { handle })) | ||
82 | |||
83 | this.loadData() | ||
84 | }, | ||
85 | |||
86 | err => this.notifier.error(err.message) | ||
87 | ) | ||
88 | } | ||
89 | |||
34 | protected loadData () { | 90 | protected loadData () { |
35 | this.followService.getFollowers(this.pagination, this.sort, this.search) | 91 | this.followService.getFollowers(this.pagination, this.sort, this.search) |
36 | .subscribe( | 92 | .subscribe( |
diff --git a/client/src/app/+admin/follows/shared/follow.service.ts b/client/src/app/+admin/follows/shared/follow.service.ts index a2904179e..c2b8ef006 100644 --- a/client/src/app/+admin/follows/shared/follow.service.ts +++ b/client/src/app/+admin/follows/shared/follow.service.ts | |||
@@ -63,4 +63,34 @@ export class FollowService { | |||
63 | catchError(res => this.restExtractor.handleError(res)) | 63 | catchError(res => this.restExtractor.handleError(res)) |
64 | ) | 64 | ) |
65 | } | 65 | } |
66 | |||
67 | acceptFollower (follow: ActorFollow) { | ||
68 | const handle = follow.follower.name + '@' + follow.follower.host | ||
69 | |||
70 | return this.authHttp.post(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}/accept`, {}) | ||
71 | .pipe( | ||
72 | map(this.restExtractor.extractDataBool), | ||
73 | catchError(res => this.restExtractor.handleError(res)) | ||
74 | ) | ||
75 | } | ||
76 | |||
77 | rejectFollower (follow: ActorFollow) { | ||
78 | const handle = follow.follower.name + '@' + follow.follower.host | ||
79 | |||
80 | return this.authHttp.post(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}/reject`, {}) | ||
81 | .pipe( | ||
82 | map(this.restExtractor.extractDataBool), | ||
83 | catchError(res => this.restExtractor.handleError(res)) | ||
84 | ) | ||
85 | } | ||
86 | |||
87 | removeFollower (follow: ActorFollow) { | ||
88 | const handle = follow.follower.name + '@' + follow.follower.host | ||
89 | |||
90 | return this.authHttp.delete(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}`) | ||
91 | .pipe( | ||
92 | map(this.restExtractor.extractDataBool), | ||
93 | catchError(res => this.restExtractor.handleError(res)) | ||
94 | ) | ||
95 | } | ||
66 | } | 96 | } |
diff --git a/client/src/app/+admin/jobs/index.ts b/client/src/app/+admin/jobs/index.ts deleted file mode 100644 index c0e0cc95d..000000000 --- a/client/src/app/+admin/jobs/index.ts +++ /dev/null | |||
@@ -1,4 +0,0 @@ | |||
1 | export * from './shared' | ||
2 | export * from './jobs-list' | ||
3 | export * from './job.routes' | ||
4 | export * from './job.component' | ||
diff --git a/client/src/app/+admin/jobs/job.component.ts b/client/src/app/+admin/jobs/job.component.ts deleted file mode 100644 index bc80c9a6a..000000000 --- a/client/src/app/+admin/jobs/job.component.ts +++ /dev/null | |||
@@ -1,6 +0,0 @@ | |||
1 | import { Component } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | template: '<router-outlet></router-outlet>' | ||
5 | }) | ||
6 | export class JobsComponent {} | ||
diff --git a/client/src/app/+admin/jobs/job.routes.ts b/client/src/app/+admin/jobs/job.routes.ts deleted file mode 100644 index 331dc2af2..000000000 --- a/client/src/app/+admin/jobs/job.routes.ts +++ /dev/null | |||
@@ -1,32 +0,0 @@ | |||
1 | import { Routes } from '@angular/router' | ||
2 | import { UserRight } from '../../../../../shared' | ||
3 | import { UserRightGuard } from '../../core' | ||
4 | import { JobsComponent } from './job.component' | ||
5 | import { JobsListComponent } from './jobs-list/jobs-list.component' | ||
6 | |||
7 | export const JobsRoutes: Routes = [ | ||
8 | { | ||
9 | path: 'jobs', | ||
10 | component: JobsComponent, | ||
11 | canActivate: [ UserRightGuard ], | ||
12 | data: { | ||
13 | userRight: UserRight.MANAGE_JOBS | ||
14 | }, | ||
15 | children: [ | ||
16 | { | ||
17 | path: '', | ||
18 | redirectTo: 'list', | ||
19 | pathMatch: 'full' | ||
20 | }, | ||
21 | { | ||
22 | path: 'list', | ||
23 | component: JobsListComponent, | ||
24 | data: { | ||
25 | meta: { | ||
26 | title: 'Jobs list' | ||
27 | } | ||
28 | } | ||
29 | } | ||
30 | ] | ||
31 | } | ||
32 | ] | ||
diff --git a/client/src/app/+admin/jobs/jobs-list/index.ts b/client/src/app/+admin/jobs/jobs-list/index.ts deleted file mode 100644 index cf590a6f8..000000000 --- a/client/src/app/+admin/jobs/jobs-list/index.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | export * from './jobs-list.component' | ||
diff --git a/client/src/app/+admin/jobs/shared/index.ts b/client/src/app/+admin/jobs/shared/index.ts deleted file mode 100644 index 609439e5c..000000000 --- a/client/src/app/+admin/jobs/shared/index.ts +++ /dev/null | |||
@@ -1 +0,0 @@ | |||
1 | export * from './job.service' | ||
diff --git a/client/src/app/+admin/moderation/index.ts b/client/src/app/+admin/moderation/index.ts index 66e2c6a39..3c683a28c 100644 --- a/client/src/app/+admin/moderation/index.ts +++ b/client/src/app/+admin/moderation/index.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | export * from './video-abuse-list' | 1 | export * from './video-abuse-list' |
2 | export * from './video-auto-blacklist-list' | ||
2 | export * from './video-blacklist-list' | 3 | export * from './video-blacklist-list' |
3 | export * from './moderation.component' | 4 | export * from './moderation.component' |
4 | export * from './moderation.routes' | 5 | export * from './moderation.routes' |
diff --git a/client/src/app/+admin/moderation/moderation.component.html b/client/src/app/+admin/moderation/moderation.component.html index 01457936c..b70027957 100644 --- a/client/src/app/+admin/moderation/moderation.component.html +++ b/client/src/app/+admin/moderation/moderation.component.html | |||
@@ -4,7 +4,9 @@ | |||
4 | <div class="admin-sub-nav"> | 4 | <div class="admin-sub-nav"> |
5 | <a *ngIf="hasVideoAbusesRight()" i18n routerLink="video-abuses/list" routerLinkActive="active">Video abuses</a> | 5 | <a *ngIf="hasVideoAbusesRight()" i18n routerLink="video-abuses/list" routerLinkActive="active">Video abuses</a> |
6 | 6 | ||
7 | <a *ngIf="hasVideoBlacklistRight()" i18n routerLink="video-blacklist/list" routerLinkActive="active">Blacklisted videos</a> | 7 | <a *ngIf="hasVideoBlacklistRight()" i18n routerLink="video-blacklist/list" routerLinkActive="active">{{ autoBlacklistVideosEnabled ? 'Manually blacklisted videos' : 'Blacklisted videos' }}</a> |
8 | |||
9 | <a *ngIf="autoBlacklistVideosEnabled && hasVideoBlacklistRight()" i18n routerLink="video-auto-blacklist/list" routerLinkActive="active">Auto-blacklisted videos</a> | ||
8 | 10 | ||
9 | <a *ngIf="hasAccountsBlocklistRight()" i18n routerLink="blocklist/accounts" routerLinkActive="active">Muted accounts</a> | 11 | <a *ngIf="hasAccountsBlocklistRight()" i18n routerLink="blocklist/accounts" routerLinkActive="active">Muted accounts</a> |
10 | 12 | ||
diff --git a/client/src/app/+admin/moderation/moderation.component.ts b/client/src/app/+admin/moderation/moderation.component.ts index 2b2618933..47154af3f 100644 --- a/client/src/app/+admin/moderation/moderation.component.ts +++ b/client/src/app/+admin/moderation/moderation.component.ts | |||
@@ -1,13 +1,20 @@ | |||
1 | import { Component } from '@angular/core' | 1 | import { Component } from '@angular/core' |
2 | import { UserRight } from '../../../../../shared' | 2 | import { UserRight } from '../../../../../shared' |
3 | import { AuthService } from '@app/core/auth/auth.service' | 3 | import { AuthService, ServerService } from '@app/core' |
4 | 4 | ||
5 | @Component({ | 5 | @Component({ |
6 | templateUrl: './moderation.component.html', | 6 | templateUrl: './moderation.component.html', |
7 | styleUrls: [ './moderation.component.scss' ] | 7 | styleUrls: [ './moderation.component.scss' ] |
8 | }) | 8 | }) |
9 | export class ModerationComponent { | 9 | export class ModerationComponent { |
10 | constructor (private auth: AuthService) {} | 10 | autoBlacklistVideosEnabled: boolean |
11 | |||
12 | constructor ( | ||
13 | private auth: AuthService, | ||
14 | private serverService: ServerService | ||
15 | ) { | ||
16 | this.autoBlacklistVideosEnabled = this.serverService.getConfig().autoBlacklist.videos.ofUsers.enabled | ||
17 | } | ||
11 | 18 | ||
12 | hasVideoAbusesRight () { | 19 | hasVideoAbusesRight () { |
13 | return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_ABUSES) | 20 | return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_ABUSES) |
diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts index 6f6dde290..a024f2bee 100644 --- a/client/src/app/+admin/moderation/moderation.routes.ts +++ b/client/src/app/+admin/moderation/moderation.routes.ts | |||
@@ -3,6 +3,7 @@ import { UserRight } from '../../../../../shared' | |||
3 | import { UserRightGuard } from '@app/core' | 3 | import { UserRightGuard } from '@app/core' |
4 | import { VideoAbuseListComponent } from '@app/+admin/moderation/video-abuse-list' | 4 | import { VideoAbuseListComponent } from '@app/+admin/moderation/video-abuse-list' |
5 | import { VideoBlacklistListComponent } from '@app/+admin/moderation/video-blacklist-list' | 5 | import { VideoBlacklistListComponent } from '@app/+admin/moderation/video-blacklist-list' |
6 | import { VideoAutoBlacklistListComponent } from '@app/+admin/moderation/video-auto-blacklist-list' | ||
6 | import { ModerationComponent } from '@app/+admin/moderation/moderation.component' | 7 | import { ModerationComponent } from '@app/+admin/moderation/moderation.component' |
7 | import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' | 8 | import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' |
8 | 9 | ||
@@ -27,6 +28,11 @@ export const ModerationRoutes: Routes = [ | |||
27 | pathMatch: 'full' | 28 | pathMatch: 'full' |
28 | }, | 29 | }, |
29 | { | 30 | { |
31 | path: 'video-auto-blacklist', | ||
32 | redirectTo: 'video-auto-blacklist/list', | ||
33 | pathMatch: 'full' | ||
34 | }, | ||
35 | { | ||
30 | path: 'video-abuses/list', | 36 | path: 'video-abuses/list', |
31 | component: VideoAbuseListComponent, | 37 | component: VideoAbuseListComponent, |
32 | canActivate: [ UserRightGuard ], | 38 | canActivate: [ UserRightGuard ], |
@@ -38,6 +44,17 @@ export const ModerationRoutes: Routes = [ | |||
38 | } | 44 | } |
39 | }, | 45 | }, |
40 | { | 46 | { |
47 | path: 'video-auto-blacklist/list', | ||
48 | component: VideoAutoBlacklistListComponent, | ||
49 | canActivate: [ UserRightGuard ], | ||
50 | data: { | ||
51 | userRight: UserRight.MANAGE_VIDEO_BLACKLIST, | ||
52 | meta: { | ||
53 | title: 'Auto-blacklisted videos' | ||
54 | } | ||
55 | } | ||
56 | }, | ||
57 | { | ||
41 | path: 'video-blacklist/list', | 58 | path: 'video-blacklist/list', |
42 | component: VideoBlacklistListComponent, | 59 | component: VideoBlacklistListComponent, |
43 | canActivate: [ UserRightGuard ], | 60 | canActivate: [ UserRightGuard ], |
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 05b549de6..627437053 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 | |||
@@ -51,11 +51,11 @@ | |||
51 | <td class="moderation-expanded" colspan="6"> | 51 | <td class="moderation-expanded" colspan="6"> |
52 | <div> | 52 | <div> |
53 | <span i18n class="moderation-expanded-label">Reason:</span> | 53 | <span i18n class="moderation-expanded-label">Reason:</span> |
54 | <span class="moderation-expanded-text" [innerHTML]="toHtml(videoAbuse.reason)"></span> | 54 | <span class="moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span> |
55 | </div> | 55 | </div> |
56 | <div *ngIf="videoAbuse.moderationComment"> | 56 | <div *ngIf="videoAbuse.moderationComment"> |
57 | <span i18n class="moderation-expanded-label">Moderation comment:</span> | 57 | <span i18n class="moderation-expanded-label">Moderation comment:</span> |
58 | <span class="moderation-expanded-text" [innerHTML]="toHtml(videoAbuse.moderationComment)"></span> | 58 | <span class="moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span> |
59 | </div> | 59 | </div> |
60 | </td> | 60 | </td> |
61 | </tr> | 61 | </tr> |
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 00c871659..3aa875668 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 | |||
@@ -19,7 +19,7 @@ import { MarkdownService } from '@app/shared/renderer' | |||
19 | export class VideoAbuseListComponent extends RestTable implements OnInit { | 19 | export class VideoAbuseListComponent extends RestTable implements OnInit { |
20 | @ViewChild('moderationCommentModal') moderationCommentModal: ModerationCommentModalComponent | 20 | @ViewChild('moderationCommentModal') moderationCommentModal: ModerationCommentModalComponent |
21 | 21 | ||
22 | videoAbuses: VideoAbuse[] = [] | 22 | videoAbuses: (VideoAbuse & { moderationCommentHtml?: string, reasonHtml?: string })[] = [] |
23 | totalRecords = 0 | 23 | totalRecords = 0 |
24 | rowsPerPage = 10 | 24 | rowsPerPage = 10 |
25 | sort: SortMeta = { field: 'createdAt', order: 1 } | 25 | sort: SortMeta = { field: 'createdAt', order: 1 } |
@@ -110,19 +110,28 @@ export class VideoAbuseListComponent extends RestTable implements OnInit { | |||
110 | 110 | ||
111 | } | 111 | } |
112 | 112 | ||
113 | toHtml (text: string) { | ||
114 | return this.markdownRenderer.textMarkdownToHTML(text) | ||
115 | } | ||
116 | |||
117 | protected loadData () { | 113 | protected loadData () { |
118 | return this.videoAbuseService.getVideoAbuses(this.pagination, this.sort) | 114 | return this.videoAbuseService.getVideoAbuses(this.pagination, this.sort) |
119 | .subscribe( | 115 | .subscribe( |
120 | resultList => { | 116 | async resultList => { |
121 | this.videoAbuses = resultList.data | ||
122 | this.totalRecords = resultList.total | 117 | this.totalRecords = resultList.total |
118 | |||
119 | this.videoAbuses = resultList.data | ||
120 | |||
121 | for (const abuse of this.videoAbuses) { | ||
122 | Object.assign(abuse, { | ||
123 | reasonHtml: await this.toHtml(abuse.reason), | ||
124 | moderationCommentHtml: await this.toHtml(abuse.moderationComment) | ||
125 | }) | ||
126 | } | ||
127 | |||
123 | }, | 128 | }, |
124 | 129 | ||
125 | err => this.notifier.error(err.message) | 130 | err => this.notifier.error(err.message) |
126 | ) | 131 | ) |
127 | } | 132 | } |
133 | |||
134 | private toHtml (text: string) { | ||
135 | return this.markdownRenderer.textMarkdownToHTML(text) | ||
136 | } | ||
128 | } | 137 | } |
diff --git a/client/src/app/+admin/moderation/video-auto-blacklist-list/index.ts b/client/src/app/+admin/moderation/video-auto-blacklist-list/index.ts new file mode 100644 index 000000000..e3522f68c --- /dev/null +++ b/client/src/app/+admin/moderation/video-auto-blacklist-list/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './video-auto-blacklist-list.component' | |||
diff --git a/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.html b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.html new file mode 100644 index 000000000..62dde60bb --- /dev/null +++ b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.html | |||
@@ -0,0 +1,19 @@ | |||
1 | <my-videos-selection | ||
2 | [(selection)]="selection" | ||
3 | [(videosModel)]="videos" | ||
4 | [miniatureDisplayOptions]="miniatureDisplayOptions" | ||
5 | [titlePage]="titlePage" | ||
6 | [getVideosObservableFunction]="getVideosObservableFunction" | ||
7 | > | ||
8 | <ng-template ptTemplate="globalButtons"> | ||
9 | <span class="action-button action-button-unblacklist-selection" (click)="removeSelectedVideosFromBlacklist()"> | ||
10 | <my-global-icon iconName="tick"></my-global-icon> | ||
11 | <ng-container i18n>Unblacklist</ng-container> | ||
12 | </span> | ||
13 | </ng-template> | ||
14 | |||
15 | <ng-template ptTemplate="rowButtons" let-video> | ||
16 | <my-button i18n-label label="Unblacklist" icon="tick" (click)="removeVideoFromBlacklist(video)"></my-button> | ||
17 | </ng-template> | ||
18 | |||
19 | </my-videos-selection> | ||
diff --git a/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.scss b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.scss new file mode 100644 index 000000000..85ebc6041 --- /dev/null +++ b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.scss | |||
@@ -0,0 +1,14 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .action-button-unblacklist-selection { | ||
5 | display: inline-block; | ||
6 | |||
7 | @include peertube-button; | ||
8 | @include orange-button; | ||
9 | @include button-with-icon(21px); | ||
10 | |||
11 | my-global-icon { | ||
12 | @include apply-svg-color(#fff); | ||
13 | } | ||
14 | } | ||
diff --git a/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.ts b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.ts new file mode 100644 index 000000000..fb2962b47 --- /dev/null +++ b/client/src/app/+admin/moderation/video-auto-blacklist-list/video-auto-blacklist-list.component.ts | |||
@@ -0,0 +1,86 @@ | |||
1 | import { Component } from '@angular/core' | ||
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
3 | import { ActivatedRoute, Router } from '@angular/router' | ||
4 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | ||
5 | import { AuthService, Notifier, ServerService } from '@app/core' | ||
6 | import { VideoBlacklistService } from '@app/shared' | ||
7 | import { immutableAssign } from '@app/shared/misc/utils' | ||
8 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
9 | import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component' | ||
10 | import { SelectionType } from '@app/shared/video/videos-selection.component' | ||
11 | import { Video } from '@app/shared/video/video.model' | ||
12 | |||
13 | @Component({ | ||
14 | selector: 'my-video-auto-blacklist-list', | ||
15 | templateUrl: './video-auto-blacklist-list.component.html', | ||
16 | styleUrls: [ './video-auto-blacklist-list.component.scss' ] | ||
17 | }) | ||
18 | export class VideoAutoBlacklistListComponent { | ||
19 | titlePage: string | ||
20 | selection: SelectionType = {} | ||
21 | miniatureDisplayOptions: MiniatureDisplayOptions = { | ||
22 | date: true, | ||
23 | views: false, | ||
24 | by: true, | ||
25 | privacyLabel: false, | ||
26 | privacyText: true, | ||
27 | state: false, | ||
28 | blacklistInfo: false, | ||
29 | nsfw: true | ||
30 | } | ||
31 | pagination: ComponentPagination = { | ||
32 | currentPage: 1, | ||
33 | itemsPerPage: 5, | ||
34 | totalItems: null | ||
35 | } | ||
36 | videos: Video[] = [] | ||
37 | getVideosObservableFunction = this.getVideosObservable.bind(this) | ||
38 | |||
39 | constructor ( | ||
40 | protected router: Router, | ||
41 | protected route: ActivatedRoute, | ||
42 | protected notifier: Notifier, | ||
43 | protected authService: AuthService, | ||
44 | protected screenService: ScreenService, | ||
45 | protected serverService: ServerService, | ||
46 | private i18n: I18n, | ||
47 | private videoBlacklistService: VideoBlacklistService | ||
48 | ) { | ||
49 | this.titlePage = this.i18n('Auto-blacklisted videos') | ||
50 | } | ||
51 | |||
52 | getVideosObservable (page: number) { | ||
53 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
54 | |||
55 | return this.videoBlacklistService.getAutoBlacklistedAsVideoList(newPagination) | ||
56 | } | ||
57 | |||
58 | removeVideoFromBlacklist (entry: Video) { | ||
59 | this.videoBlacklistService.removeVideoFromBlacklist(entry.id).subscribe( | ||
60 | () => { | ||
61 | this.notifier.success(this.i18n('Video {{name}} removed from blacklist.', { name: entry.name })) | ||
62 | |||
63 | this.videos = this.videos.filter(v => v.id !== entry.id) | ||
64 | }, | ||
65 | |||
66 | error => this.notifier.error(error.message) | ||
67 | ) | ||
68 | } | ||
69 | |||
70 | removeSelectedVideosFromBlacklist () { | ||
71 | const toReleaseVideosIds = Object.keys(this.selection) | ||
72 | .filter(k => this.selection[ k ] === true) | ||
73 | .map(k => parseInt(k, 10)) | ||
74 | |||
75 | this.videoBlacklistService.removeVideoFromBlacklist(toReleaseVideosIds).subscribe( | ||
76 | () => { | ||
77 | this.notifier.success(this.i18n('{{num}} videos removed from blacklist.', { num: toReleaseVideosIds.length })) | ||
78 | |||
79 | this.selection = {} | ||
80 | this.videos = this.videos.filter(v => toReleaseVideosIds.includes(v.id) === false) | ||
81 | }, | ||
82 | |||
83 | error => this.notifier.error(error.message) | ||
84 | ) | ||
85 | } | ||
86 | } | ||
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 247f441c1..608dff2d8 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 | |||
@@ -41,7 +41,7 @@ | |||
41 | <tr> | 41 | <tr> |
42 | <td class="moderation-expanded" colspan="6"> | 42 | <td class="moderation-expanded" colspan="6"> |
43 | <span i18n class="moderation-expanded-label">Blacklist reason:</span> | 43 | <span i18n class="moderation-expanded-label">Blacklist reason:</span> |
44 | <span class="moderation-expanded-text" [innerHTML]="toHtml(videoBlacklist.reason)"></span> | 44 | <span class="moderation-expanded-text" [innerHTML]="videoBlacklist.reasonHtml"></span> |
45 | </td> | 45 | </td> |
46 | </tr> | 46 | </tr> |
47 | </ng-template> | 47 | </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 b27bbbfef..f4bce7c48 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,9 +1,9 @@ | |||
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/components/common/sortmeta' |
3 | import { Notifier } 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' |
6 | import { VideoBlacklist } from '../../../../../../shared' | 6 | import { VideoBlacklist, VideoBlacklistType } 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 { Video } from '../../../shared/video/video.model' | 9 | import { Video } from '../../../shared/video/video.model' |
@@ -15,16 +15,18 @@ import { MarkdownService } from '@app/shared/renderer' | |||
15 | styleUrls: [ '../moderation.component.scss' ] | 15 | styleUrls: [ '../moderation.component.scss' ] |
16 | }) | 16 | }) |
17 | export class VideoBlacklistListComponent extends RestTable implements OnInit { | 17 | export class VideoBlacklistListComponent extends RestTable implements OnInit { |
18 | blacklist: VideoBlacklist[] = [] | 18 | blacklist: (VideoBlacklist & { reasonHtml?: string })[] = [] |
19 | totalRecords = 0 | 19 | totalRecords = 0 |
20 | rowsPerPage = 10 | 20 | rowsPerPage = 10 |
21 | sort: SortMeta = { field: 'createdAt', order: 1 } | 21 | sort: SortMeta = { field: 'createdAt', order: 1 } |
22 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } | 22 | pagination: RestPagination = { count: this.rowsPerPage, start: 0 } |
23 | listBlacklistTypeFilter: VideoBlacklistType = undefined | ||
23 | 24 | ||
24 | videoBlacklistActions: DropdownAction<VideoBlacklist>[] = [] | 25 | videoBlacklistActions: DropdownAction<VideoBlacklist>[] = [] |
25 | 26 | ||
26 | constructor ( | 27 | constructor ( |
27 | private notifier: Notifier, | 28 | private notifier: Notifier, |
29 | private serverService: ServerService, | ||
28 | private confirmService: ConfirmService, | 30 | private confirmService: ConfirmService, |
29 | private videoBlacklistService: VideoBlacklistService, | 31 | private videoBlacklistService: VideoBlacklistService, |
30 | private markdownRenderer: MarkdownService, | 32 | private markdownRenderer: MarkdownService, |
@@ -32,6 +34,11 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit { | |||
32 | ) { | 34 | ) { |
33 | super() | 35 | super() |
34 | 36 | ||
37 | // don't filter if auto-blacklist not enabled as this will be only list | ||
38 | if (this.serverService.getConfig().autoBlacklist.videos.ofUsers.enabled) { | ||
39 | this.listBlacklistTypeFilter = VideoBlacklistType.MANUAL | ||
40 | } | ||
41 | |||
35 | this.videoBlacklistActions = [ | 42 | this.videoBlacklistActions = [ |
36 | { | 43 | { |
37 | label: this.i18n('Unblacklist'), | 44 | label: this.i18n('Unblacklist'), |
@@ -77,11 +84,16 @@ export class VideoBlacklistListComponent extends RestTable implements OnInit { | |||
77 | } | 84 | } |
78 | 85 | ||
79 | protected loadData () { | 86 | protected loadData () { |
80 | this.videoBlacklistService.listBlacklist(this.pagination, this.sort) | 87 | this.videoBlacklistService.listBlacklist(this.pagination, this.sort, this.listBlacklistTypeFilter) |
81 | .subscribe( | 88 | .subscribe( |
82 | resultList => { | 89 | async resultList => { |
83 | this.blacklist = resultList.data | ||
84 | this.totalRecords = resultList.total | 90 | this.totalRecords = resultList.total |
91 | |||
92 | this.blacklist = resultList.data | ||
93 | |||
94 | for (const element of this.blacklist) { | ||
95 | Object.assign(element, { reasonHtml: await this.toHtml(element.reason) }) | ||
96 | } | ||
85 | }, | 97 | }, |
86 | 98 | ||
87 | err => this.notifier.error(err.message) | 99 | err => this.notifier.error(err.message) |
diff --git a/client/src/app/+admin/system/debug/debug.component.html b/client/src/app/+admin/system/debug/debug.component.html new file mode 100644 index 000000000..f35414b37 --- /dev/null +++ b/client/src/app/+admin/system/debug/debug.component.html | |||
@@ -0,0 +1,19 @@ | |||
1 | <div class="root"> | ||
2 | <h4>IP</h4> | ||
3 | |||
4 | <p>PeerTube thinks your public IP is <strong>{{ debug?.ip }}</strong>.</p> | ||
5 | |||
6 | <p>If this is not your correct public IP, please consider fixing it because:</p> | ||
7 | <ul> | ||
8 | <li>Views may not be counted correctly (reduced compared to what they should be)</li> | ||
9 | <li>Anti brute force system could be overzealous</li> | ||
10 | <li>P2P system could not work correctly</li> | ||
11 | </ul> | ||
12 | |||
13 | <p>To fix it:<p> | ||
14 | <ul> | ||
15 | <li>Check the <code>trust_proxy</code> configuration key</li> | ||
16 | <li>If you run PeerTube using Docker, check you run the <code>reverse-proxy</code> with <code>network_mode: "host"</code> | ||
17 | (see <a href="https://github.com/Chocobozzz/PeerTube/issues/1643#issuecomment-464789666">issue 1643</a>)</li> | ||
18 | </ul> | ||
19 | </div> | ||
diff --git a/client/src/app/+admin/system/debug/debug.component.scss b/client/src/app/+admin/system/debug/debug.component.scss new file mode 100644 index 000000000..90addd284 --- /dev/null +++ b/client/src/app/+admin/system/debug/debug.component.scss | |||
@@ -0,0 +1,6 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .root { | ||
5 | font-size: 14px; | ||
6 | } | ||
diff --git a/client/src/app/+admin/system/debug/debug.component.ts b/client/src/app/+admin/system/debug/debug.component.ts new file mode 100644 index 000000000..8a77f79f7 --- /dev/null +++ b/client/src/app/+admin/system/debug/debug.component.ts | |||
@@ -0,0 +1,31 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { Notifier } from '@app/core' | ||
3 | import { Debug } from '@shared/models/server' | ||
4 | import { DebugService } from '@app/+admin/system/debug/debug.service' | ||
5 | |||
6 | @Component({ | ||
7 | templateUrl: './debug.component.html', | ||
8 | styleUrls: [ './debug.component.scss' ] | ||
9 | }) | ||
10 | export class DebugComponent implements OnInit { | ||
11 | debug: Debug | ||
12 | |||
13 | constructor ( | ||
14 | private debugService: DebugService, | ||
15 | private notifier: Notifier | ||
16 | ) { | ||
17 | } | ||
18 | |||
19 | ngOnInit (): void { | ||
20 | this.load() | ||
21 | } | ||
22 | |||
23 | load () { | ||
24 | this.debugService.getDebug() | ||
25 | .subscribe( | ||
26 | debug => this.debug = debug, | ||
27 | |||
28 | err => this.notifier.error(err.message) | ||
29 | ) | ||
30 | } | ||
31 | } | ||
diff --git a/client/src/app/+admin/system/debug/debug.service.ts b/client/src/app/+admin/system/debug/debug.service.ts new file mode 100644 index 000000000..6c722d177 --- /dev/null +++ b/client/src/app/+admin/system/debug/debug.service.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { catchError } from 'rxjs/operators' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { Observable } from 'rxjs' | ||
5 | import { environment } from '../../../../environments/environment' | ||
6 | import { RestExtractor, RestService } from '../../../shared' | ||
7 | import { Debug } from '@shared/models/server' | ||
8 | |||
9 | @Injectable() | ||
10 | export class DebugService { | ||
11 | private static BASE_DEBUG_URL = environment.apiUrl + '/api/v1/server/debug' | ||
12 | |||
13 | constructor ( | ||
14 | private authHttp: HttpClient, | ||
15 | private restService: RestService, | ||
16 | private restExtractor: RestExtractor | ||
17 | ) {} | ||
18 | |||
19 | getDebug (): Observable<Debug> { | ||
20 | return this.authHttp.get<Debug>(DebugService.BASE_DEBUG_URL) | ||
21 | .pipe( | ||
22 | catchError(err => this.restExtractor.handleError(err)) | ||
23 | ) | ||
24 | } | ||
25 | } | ||
diff --git a/client/src/app/+admin/system/debug/index.ts b/client/src/app/+admin/system/debug/index.ts new file mode 100644 index 000000000..7fc7a0721 --- /dev/null +++ b/client/src/app/+admin/system/debug/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './debug.component' | ||
2 | export * from './debug.service' | ||
diff --git a/client/src/app/+admin/system/index.ts b/client/src/app/+admin/system/index.ts new file mode 100644 index 000000000..226d999d2 --- /dev/null +++ b/client/src/app/+admin/system/index.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export * from './jobs' | ||
2 | export * from './logs' | ||
3 | export * from './system.component' | ||
4 | export * from './system.routes' | ||
diff --git a/client/src/app/+admin/system/jobs/index.ts b/client/src/app/+admin/system/jobs/index.ts new file mode 100644 index 000000000..486a745e4 --- /dev/null +++ b/client/src/app/+admin/system/jobs/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './job.service' | ||
2 | export * from './jobs.component' | ||
diff --git a/client/src/app/+admin/jobs/shared/job.service.ts b/client/src/app/+admin/system/jobs/job.service.ts index b96dc3359..b96dc3359 100644 --- a/client/src/app/+admin/jobs/shared/job.service.ts +++ b/client/src/app/+admin/system/jobs/job.service.ts | |||
diff --git a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.html b/client/src/app/+admin/system/jobs/jobs.component.html index 7ed1888e2..7ed1888e2 100644 --- a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.html +++ b/client/src/app/+admin/system/jobs/jobs.component.html | |||
diff --git a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.scss b/client/src/app/+admin/system/jobs/jobs.component.scss index ab05f1982..ab05f1982 100644 --- a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.scss +++ b/client/src/app/+admin/system/jobs/jobs.component.scss | |||
diff --git a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts index b265e1dd6..ebfb52779 100644 --- a/client/src/app/+admin/jobs/jobs-list/jobs-list.component.ts +++ b/client/src/app/+admin/system/jobs/jobs.component.ts | |||
@@ -5,15 +5,15 @@ import { SortMeta } from 'primeng/primeng' | |||
5 | import { Job } from '../../../../../../shared/index' | 5 | import { Job } from '../../../../../../shared/index' |
6 | import { JobState } from '../../../../../../shared/models' | 6 | import { JobState } from '../../../../../../shared/models' |
7 | import { RestPagination, RestTable } from '../../../shared' | 7 | import { RestPagination, RestTable } from '../../../shared' |
8 | import { JobService } from '../shared' | 8 | import { JobService } from './job.service' |
9 | import { I18n } from '@ngx-translate/i18n-polyfill' | 9 | import { I18n } from '@ngx-translate/i18n-polyfill' |
10 | 10 | ||
11 | @Component({ | 11 | @Component({ |
12 | selector: 'my-jobs-list', | 12 | selector: 'my-jobs', |
13 | templateUrl: './jobs-list.component.html', | 13 | templateUrl: './jobs.component.html', |
14 | styleUrls: [ './jobs-list.component.scss' ] | 14 | styleUrls: [ './jobs.component.scss' ] |
15 | }) | 15 | }) |
16 | export class JobsListComponent extends RestTable implements OnInit { | 16 | export class JobsComponent extends RestTable implements OnInit { |
17 | private static JOB_STATE_LOCAL_STORAGE_STATE = 'jobs-list-state' | 17 | private static JOB_STATE_LOCAL_STORAGE_STATE = 'jobs-list-state' |
18 | 18 | ||
19 | jobState: JobState = 'waiting' | 19 | jobState: JobState = 'waiting' |
@@ -58,12 +58,12 @@ export class JobsListComponent extends RestTable implements OnInit { | |||
58 | } | 58 | } |
59 | 59 | ||
60 | private loadJobState () { | 60 | private loadJobState () { |
61 | const result = peertubeLocalStorage.getItem(JobsListComponent.JOB_STATE_LOCAL_STORAGE_STATE) | 61 | const result = peertubeLocalStorage.getItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE) |
62 | 62 | ||
63 | if (result) this.jobState = result as JobState | 63 | if (result) this.jobState = result as JobState |
64 | } | 64 | } |
65 | 65 | ||
66 | private saveJobState () { | 66 | private saveJobState () { |
67 | peertubeLocalStorage.setItem(JobsListComponent.JOB_STATE_LOCAL_STORAGE_STATE, this.jobState) | 67 | peertubeLocalStorage.setItem(JobsComponent.JOB_STATE_LOCAL_STORAGE_STATE, this.jobState) |
68 | } | 68 | } |
69 | } | 69 | } |
diff --git a/client/src/app/+admin/system/logs/index.ts b/client/src/app/+admin/system/logs/index.ts new file mode 100644 index 000000000..7b56d4237 --- /dev/null +++ b/client/src/app/+admin/system/logs/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './logs.component' | ||
2 | export * from './logs.service' | ||
diff --git a/client/src/app/+admin/system/logs/log-row.model.ts b/client/src/app/+admin/system/logs/log-row.model.ts new file mode 100644 index 000000000..9bc7dafdd --- /dev/null +++ b/client/src/app/+admin/system/logs/log-row.model.ts | |||
@@ -0,0 +1,21 @@ | |||
1 | import { LogLevel } from '@shared/models/server/log-level.type' | ||
2 | import omit from 'lodash-es/omit' | ||
3 | |||
4 | export class LogRow { | ||
5 | date: Date | ||
6 | localeDate: string | ||
7 | level: LogLevel | ||
8 | message: string | ||
9 | meta: string | ||
10 | |||
11 | constructor (row: any) { | ||
12 | this.date = new Date(row.timestamp) | ||
13 | this.localeDate = this.date.toLocaleString() | ||
14 | this.level = row.level | ||
15 | this.message = row.message | ||
16 | |||
17 | const metaObj = omit(row, 'timestamp', 'level', 'message', 'label') | ||
18 | |||
19 | if (Object.keys(metaObj).length !== 0) this.meta = JSON.stringify(metaObj, undefined, 2) | ||
20 | } | ||
21 | } | ||
diff --git a/client/src/app/+admin/system/logs/logs.component.html b/client/src/app/+admin/system/logs/logs.component.html new file mode 100644 index 000000000..45723a655 --- /dev/null +++ b/client/src/app/+admin/system/logs/logs.component.html | |||
@@ -0,0 +1,31 @@ | |||
1 | <div class="header"> | ||
2 | <div class="peertube-select-container"> | ||
3 | <select [(ngModel)]="startDate" (ngModelChange)="refresh()"> | ||
4 | <option *ngFor="let timeChoice of timeChoices" [value]="timeChoice.id">{{ timeChoice.label }}</option> | ||
5 | </select> | ||
6 | </div> | ||
7 | |||
8 | <div class="peertube-select-container"> | ||
9 | <select [(ngModel)]="level" (ngModelChange)="refresh()"> | ||
10 | <option *ngFor="let levelChoice of levelChoices" [value]="levelChoice.id">{{ levelChoice.label }}</option> | ||
11 | </select> | ||
12 | </div> | ||
13 | |||
14 | <my-button i18n-label label="Refresh" icon="refresh" (click)="refresh()"></my-button> | ||
15 | </div> | ||
16 | |||
17 | <div class="logs"> | ||
18 | <div *ngIf="loading">Loading...</div> | ||
19 | |||
20 | <div #logsElement> | ||
21 | <div *ngFor="let log of logs" class="log-row" [ngClass]="{ error: log.level === 'error', warn: log.level === 'warn' }"> | ||
22 | <span class="log-level">{{ log.level }}</span> | ||
23 | |||
24 | <span class="log-date">[{{ log.localeDate }}]</span> | ||
25 | |||
26 | {{ log.message }} | ||
27 | |||
28 | {{ log.meta }} | ||
29 | </div> | ||
30 | </div> | ||
31 | </div> | ||
diff --git a/client/src/app/+admin/system/logs/logs.component.scss b/client/src/app/+admin/system/logs/logs.component.scss new file mode 100644 index 000000000..7ad2e853c --- /dev/null +++ b/client/src/app/+admin/system/logs/logs.component.scss | |||
@@ -0,0 +1,49 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .logs { | ||
5 | font-family: monospace; | ||
6 | font-size: 13px; | ||
7 | max-height: 500px; | ||
8 | overflow-y: auto; | ||
9 | background: rgba(0, 0, 0, 0.03); | ||
10 | padding: 20px; | ||
11 | |||
12 | .log-row { | ||
13 | margin-top: 1px; | ||
14 | word-break: break-word; | ||
15 | |||
16 | &:hover { | ||
17 | background: rgba(0, 0, 0, 0.07); | ||
18 | } | ||
19 | } | ||
20 | |||
21 | .log-level { | ||
22 | font-weight: $font-semibold; | ||
23 | margin-right: 5px; | ||
24 | } | ||
25 | |||
26 | .warn { | ||
27 | color: $orange-color; | ||
28 | } | ||
29 | |||
30 | .error { | ||
31 | color: $red; | ||
32 | } | ||
33 | } | ||
34 | |||
35 | .header { | ||
36 | display: flex; | ||
37 | justify-content: flex-end; | ||
38 | margin-bottom: 10px; | ||
39 | |||
40 | .peertube-select-container { | ||
41 | @include peertube-select-container(150px); | ||
42 | } | ||
43 | |||
44 | my-button, | ||
45 | .peertube-select-container { | ||
46 | margin-left: 10px; | ||
47 | } | ||
48 | } | ||
49 | |||
diff --git a/client/src/app/+admin/system/logs/logs.component.ts b/client/src/app/+admin/system/logs/logs.component.ts new file mode 100644 index 000000000..1a3508a3b --- /dev/null +++ b/client/src/app/+admin/system/logs/logs.component.ts | |||
@@ -0,0 +1,111 @@ | |||
1 | import { Component, ElementRef, OnInit, ViewChild } from '@angular/core' | ||
2 | import { LogsService } from '@app/+admin/system/logs/logs.service' | ||
3 | import { Notifier } from '@app/core' | ||
4 | import { LogRow } from '@app/+admin/system/logs/log-row.model' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
6 | import { LogLevel } from '@shared/models/server/log-level.type' | ||
7 | |||
8 | @Component({ | ||
9 | templateUrl: './logs.component.html', | ||
10 | styleUrls: [ './logs.component.scss' ] | ||
11 | }) | ||
12 | export class LogsComponent implements OnInit { | ||
13 | @ViewChild('logsElement') logsElement: ElementRef<HTMLElement> | ||
14 | |||
15 | loading = false | ||
16 | |||
17 | logs: LogRow[] = [] | ||
18 | timeChoices: { id: string, label: string }[] = [] | ||
19 | levelChoices: { id: LogLevel, label: string }[] = [] | ||
20 | |||
21 | startDate: string | ||
22 | level: LogLevel | ||
23 | |||
24 | constructor ( | ||
25 | private logsService: LogsService, | ||
26 | private notifier: Notifier, | ||
27 | private i18n: I18n | ||
28 | ) { } | ||
29 | |||
30 | ngOnInit (): void { | ||
31 | this.buildTimeChoices() | ||
32 | this.buildLevelChoices() | ||
33 | |||
34 | this.load() | ||
35 | } | ||
36 | |||
37 | refresh () { | ||
38 | this.logs = [] | ||
39 | this.load() | ||
40 | } | ||
41 | |||
42 | load () { | ||
43 | this.loading = true | ||
44 | |||
45 | this.logsService.getLogs(this.level, this.startDate) | ||
46 | .subscribe( | ||
47 | logs => { | ||
48 | this.logs = logs | ||
49 | |||
50 | setTimeout(() => { | ||
51 | this.logsElement.nativeElement.scrollIntoView({ block: 'end', inline: 'nearest' }) | ||
52 | }) | ||
53 | }, | ||
54 | |||
55 | err => this.notifier.error(err.message), | ||
56 | |||
57 | () => this.loading = false | ||
58 | ) | ||
59 | } | ||
60 | |||
61 | buildTimeChoices () { | ||
62 | const lastHour = new Date() | ||
63 | lastHour.setHours(lastHour.getHours() - 1) | ||
64 | |||
65 | const lastDay = new Date() | ||
66 | lastDay.setDate(lastDay.getDate() - 1) | ||
67 | |||
68 | const lastWeek = new Date() | ||
69 | lastWeek.setDate(lastWeek.getDate() - 7) | ||
70 | |||
71 | this.timeChoices = [ | ||
72 | { | ||
73 | id: lastWeek.toISOString(), | ||
74 | label: this.i18n('Last week') | ||
75 | }, | ||
76 | { | ||
77 | id: lastDay.toISOString(), | ||
78 | label: this.i18n('Last day') | ||
79 | }, | ||
80 | { | ||
81 | id: lastHour.toISOString(), | ||
82 | label: this.i18n('Last hour') | ||
83 | } | ||
84 | ] | ||
85 | |||
86 | this.startDate = lastHour.toISOString() | ||
87 | } | ||
88 | |||
89 | buildLevelChoices () { | ||
90 | this.levelChoices = [ | ||
91 | { | ||
92 | id: 'debug', | ||
93 | label: this.i18n('Debug') | ||
94 | }, | ||
95 | { | ||
96 | id: 'info', | ||
97 | label: this.i18n('Info') | ||
98 | }, | ||
99 | { | ||
100 | id: 'warn', | ||
101 | label: this.i18n('Warning') | ||
102 | }, | ||
103 | { | ||
104 | id: 'error', | ||
105 | label: this.i18n('Error') | ||
106 | } | ||
107 | ] | ||
108 | |||
109 | this.level = 'warn' | ||
110 | } | ||
111 | } | ||
diff --git a/client/src/app/+admin/system/logs/logs.service.ts b/client/src/app/+admin/system/logs/logs.service.ts new file mode 100644 index 000000000..24b9cb6d1 --- /dev/null +++ b/client/src/app/+admin/system/logs/logs.service.ts | |||
@@ -0,0 +1,33 @@ | |||
1 | import { catchError, map } from 'rxjs/operators' | ||
2 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { Observable } from 'rxjs' | ||
5 | import { environment } from '../../../../environments/environment' | ||
6 | import { RestExtractor, RestService } from '../../../shared' | ||
7 | import { LogRow } from '@app/+admin/system/logs/log-row.model' | ||
8 | import { LogLevel } from '@shared/models/server/log-level.type' | ||
9 | |||
10 | @Injectable() | ||
11 | export class LogsService { | ||
12 | private static BASE_LOG_URL = environment.apiUrl + '/api/v1/server/logs' | ||
13 | |||
14 | constructor ( | ||
15 | private authHttp: HttpClient, | ||
16 | private restService: RestService, | ||
17 | private restExtractor: RestExtractor | ||
18 | ) {} | ||
19 | |||
20 | getLogs (level: LogLevel, startDate: string, endDate?: string): Observable<any[]> { | ||
21 | let params = new HttpParams() | ||
22 | params = params.append('startDate', startDate) | ||
23 | params = params.append('level', level) | ||
24 | |||
25 | if (endDate) params.append('endDate', endDate) | ||
26 | |||
27 | return this.authHttp.get<any[]>(LogsService.BASE_LOG_URL, { params }) | ||
28 | .pipe( | ||
29 | map(rows => rows.map(r => new LogRow(r))), | ||
30 | catchError(err => this.restExtractor.handleError(err)) | ||
31 | ) | ||
32 | } | ||
33 | } | ||
diff --git a/client/src/app/+admin/system/system.component.html b/client/src/app/+admin/system/system.component.html new file mode 100644 index 000000000..7c4278d35 --- /dev/null +++ b/client/src/app/+admin/system/system.component.html | |||
@@ -0,0 +1,13 @@ | |||
1 | <div class="admin-sub-header"> | ||
2 | <div i18n class="form-sub-title">System</div> | ||
3 | |||
4 | <div class="admin-sub-nav"> | ||
5 | <a *ngIf="hasJobsRight()" i18n routerLink="jobs" routerLinkActive="active">Jobs</a> | ||
6 | |||
7 | <a *ngIf="hasLogsRight()" i18n routerLink="logs" routerLinkActive="active">Logs</a> | ||
8 | |||
9 | <a *ngIf="hasDebugRight()" i18n routerLink="debug" routerLinkActive="active">Debug</a> | ||
10 | </div> | ||
11 | </div> | ||
12 | |||
13 | <router-outlet></router-outlet> | ||
diff --git a/client/src/app/+admin/system/system.component.scss b/client/src/app/+admin/system/system.component.scss new file mode 100644 index 000000000..766d7853b --- /dev/null +++ b/client/src/app/+admin/system/system.component.scss | |||
@@ -0,0 +1,4 @@ | |||
1 | .form-sub-title { | ||
2 | flex-grow: 0; | ||
3 | margin-right: 30px; | ||
4 | } | ||
diff --git a/client/src/app/+admin/system/system.component.ts b/client/src/app/+admin/system/system.component.ts new file mode 100644 index 000000000..b544c2a97 --- /dev/null +++ b/client/src/app/+admin/system/system.component.ts | |||
@@ -0,0 +1,24 @@ | |||
1 | import { Component } from '@angular/core' | ||
2 | import { UserRight } from '@shared/models' | ||
3 | import { AuthService } from '@app/core' | ||
4 | |||
5 | @Component({ | ||
6 | templateUrl: './system.component.html', | ||
7 | styleUrls: [ './system.component.scss' ] | ||
8 | }) | ||
9 | export class SystemComponent { | ||
10 | |||
11 | constructor (private auth: AuthService) {} | ||
12 | |||
13 | hasLogsRight () { | ||
14 | return this.auth.getUser().hasRight(UserRight.MANAGE_LOGS) | ||
15 | } | ||
16 | |||
17 | hasJobsRight () { | ||
18 | return this.auth.getUser().hasRight(UserRight.MANAGE_JOBS) | ||
19 | } | ||
20 | |||
21 | hasDebugRight () { | ||
22 | return this.auth.getUser().hasRight(UserRight.MANAGE_DEBUG) | ||
23 | } | ||
24 | } | ||
diff --git a/client/src/app/+admin/system/system.routes.ts b/client/src/app/+admin/system/system.routes.ts new file mode 100644 index 000000000..2d851794d --- /dev/null +++ b/client/src/app/+admin/system/system.routes.ts | |||
@@ -0,0 +1,56 @@ | |||
1 | import { Routes } from '@angular/router' | ||
2 | import { UserRightGuard } from '../../core' | ||
3 | import { UserRight } from '../../../../../shared' | ||
4 | import { JobsComponent } from '@app/+admin/system/jobs/jobs.component' | ||
5 | import { LogsComponent } from '@app/+admin/system/logs' | ||
6 | import { SystemComponent } from '@app/+admin/system/system.component' | ||
7 | import { DebugComponent } from '@app/+admin/system/debug' | ||
8 | |||
9 | export const SystemRoutes: Routes = [ | ||
10 | { | ||
11 | path: 'system', | ||
12 | component: SystemComponent, | ||
13 | data: { | ||
14 | }, | ||
15 | children: [ | ||
16 | { | ||
17 | path: '', | ||
18 | redirectTo: 'jobs', | ||
19 | pathMatch: 'full' | ||
20 | }, | ||
21 | { | ||
22 | path: 'jobs', | ||
23 | canActivate: [ UserRightGuard ], | ||
24 | component: JobsComponent, | ||
25 | data: { | ||
26 | meta: { | ||
27 | userRight: UserRight.MANAGE_JOBS, | ||
28 | title: 'Jobs' | ||
29 | } | ||
30 | } | ||
31 | }, | ||
32 | { | ||
33 | path: 'logs', | ||
34 | canActivate: [ UserRightGuard ], | ||
35 | component: LogsComponent, | ||
36 | data: { | ||
37 | meta: { | ||
38 | userRight: UserRight.MANAGE_LOGS, | ||
39 | title: 'Logs' | ||
40 | } | ||
41 | } | ||
42 | }, | ||
43 | { | ||
44 | path: 'debug', | ||
45 | canActivate: [ UserRightGuard ], | ||
46 | component: DebugComponent, | ||
47 | data: { | ||
48 | meta: { | ||
49 | userRight: UserRight.MANAGE_DEBUG, | ||
50 | title: 'Debug' | ||
51 | } | ||
52 | } | ||
53 | } | ||
54 | ] | ||
55 | } | ||
56 | ] | ||
diff --git a/client/src/app/+admin/users/user-edit/index.ts b/client/src/app/+admin/users/user-edit/index.ts index fd80a02e0..ec734ef92 100644 --- a/client/src/app/+admin/users/user-edit/index.ts +++ b/client/src/app/+admin/users/user-edit/index.ts | |||
@@ -1,2 +1,3 @@ | |||
1 | export * from './user-create.component' | 1 | export * from './user-create.component' |
2 | export * from './user-update.component' | 2 | export * from './user-update.component' |
3 | export * from './user-password.component' | ||
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 137ecfcbd..9a6801806 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 | |||
@@ -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 { UserAdminFlag } from '@shared/models/users/user-flag.model' | ||
11 | 12 | ||
12 | @Component({ | 13 | @Component({ |
13 | selector: 'my-user-create', | 14 | selector: 'my-user-create', |
@@ -45,7 +46,8 @@ export class UserCreateComponent extends UserEdit implements OnInit { | |||
45 | password: this.userValidatorsService.USER_PASSWORD, | 46 | password: this.userValidatorsService.USER_PASSWORD, |
46 | role: this.userValidatorsService.USER_ROLE, | 47 | role: this.userValidatorsService.USER_ROLE, |
47 | videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, | 48 | videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, |
48 | videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY | 49 | videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY, |
50 | byPassAutoBlacklist: null | ||
49 | }, defaultValues) | 51 | }, defaultValues) |
50 | } | 52 | } |
51 | 53 | ||
@@ -54,8 +56,11 @@ export class UserCreateComponent extends UserEdit implements OnInit { | |||
54 | 56 | ||
55 | const userCreate: UserCreate = this.form.value | 57 | const userCreate: UserCreate = this.form.value |
56 | 58 | ||
59 | userCreate.adminFlags = this.buildAdminFlags(this.form.value) | ||
60 | |||
57 | // A select in HTML is always mapped as a string, we convert it to number | 61 | // A select in HTML is always mapped as a string, we convert it to number |
58 | userCreate.videoQuota = parseInt(this.form.value['videoQuota'], 10) | 62 | userCreate.videoQuota = parseInt(this.form.value['videoQuota'], 10) |
63 | userCreate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10) | ||
59 | 64 | ||
60 | this.userService.addUser(userCreate).subscribe( | 65 | this.userService.addUser(userCreate).subscribe( |
61 | () => { | 66 | () => { |
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 56cf7d17d..400bac5d4 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 | |||
@@ -79,5 +79,26 @@ | |||
79 | </div> | 79 | </div> |
80 | </div> | 80 | </div> |
81 | 81 | ||
82 | <div class="form-group"> | ||
83 | <my-peertube-checkbox | ||
84 | inputName="byPassAutoBlacklist" formControlName="byPassAutoBlacklist" | ||
85 | i18n-labelText labelText="Bypass video auto blacklist" | ||
86 | ></my-peertube-checkbox> | ||
87 | </div> | ||
88 | |||
82 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> | 89 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> |
83 | </form> | 90 | </form> |
91 | |||
92 | <div *ngIf="!isCreation()" class="danger-zone"> | ||
93 | <div class="account-title" i18n>Danger Zone</div> | ||
94 | |||
95 | <div class="form-group reset-password-email"> | ||
96 | <label i18n>Send a link to reset the password by email to the user</label> | ||
97 | <button (click)="resetPassword()" i18n>Ask for new password</button> | ||
98 | </div> | ||
99 | |||
100 | <div class="form-group"> | ||
101 | <label i18n>Manually set the user password</label> | ||
102 | <my-user-password [userId]="userId"></my-user-password> | ||
103 | </div> | ||
104 | </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 6675f65cc..c1cc4ca45 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 | |||
@@ -14,7 +14,7 @@ input:not([type=submit]) { | |||
14 | @include peertube-select-container(340px); | 14 | @include peertube-select-container(340px); |
15 | } | 15 | } |
16 | 16 | ||
17 | input[type=submit] { | 17 | input[type=submit], button { |
18 | @include peertube-button; | 18 | @include peertube-button; |
19 | @include orange-button; | 19 | @include orange-button; |
20 | 20 | ||
@@ -25,3 +25,23 @@ input[type=submit] { | |||
25 | margin-top: 5px; | 25 | margin-top: 5px; |
26 | font-size: 11px; | 26 | font-size: 11px; |
27 | } | 27 | } |
28 | |||
29 | .account-title { | ||
30 | @include in-content-small-title; | ||
31 | |||
32 | margin-top: 55px; | ||
33 | margin-bottom: 30px; | ||
34 | } | ||
35 | |||
36 | .danger-zone { | ||
37 | .reset-password-email { | ||
38 | margin-bottom: 30px; | ||
39 | padding-bottom: 30px; | ||
40 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); | ||
41 | |||
42 | button { | ||
43 | display: block; | ||
44 | margin-top: 0; | ||
45 | } | ||
46 | } | ||
47 | } | ||
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 0b3511e8e..adce1b2d4 100644 --- a/client/src/app/+admin/users/user-edit/user-edit.ts +++ b/client/src/app/+admin/users/user-edit/user-edit.ts | |||
@@ -2,12 +2,14 @@ import { ServerService } from '../../../core' | |||
2 | import { FormReactive } from '../../../shared' | 2 | import { FormReactive } from '../../../shared' |
3 | import { USER_ROLE_LABELS, VideoResolution } from '../../../../../../shared' | 3 | import { USER_ROLE_LABELS, VideoResolution } from '../../../../../../shared' |
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 | 6 | ||
6 | export abstract class UserEdit extends FormReactive { | 7 | export abstract class UserEdit extends FormReactive { |
7 | videoQuotaOptions: { value: string, label: string }[] = [] | 8 | videoQuotaOptions: { value: string, label: string }[] = [] |
8 | videoQuotaDailyOptions: { value: string, label: string }[] = [] | 9 | videoQuotaDailyOptions: { value: string, label: string }[] = [] |
9 | roles = Object.keys(USER_ROLE_LABELS).map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) | 10 | roles = Object.keys(USER_ROLE_LABELS).map(key => ({ value: key.toString(), label: USER_ROLE_LABELS[key] })) |
10 | username: string | 11 | username: string |
12 | userId: number | ||
11 | 13 | ||
12 | protected abstract serverService: ServerService | 14 | protected abstract serverService: ServerService |
13 | protected abstract configService: ConfigService | 15 | protected abstract configService: ConfigService |
@@ -22,7 +24,9 @@ export abstract class UserEdit extends FormReactive { | |||
22 | } | 24 | } |
23 | 25 | ||
24 | computeQuotaWithTranscoding () { | 26 | computeQuotaWithTranscoding () { |
25 | const resolutions = this.serverService.getConfig().transcoding.enabledResolutions | 27 | const transcodingConfig = this.serverService.getConfig().transcoding |
28 | |||
29 | const resolutions = transcodingConfig.enabledResolutions | ||
26 | const higherResolution = VideoResolution.H_1080P | 30 | const higherResolution = VideoResolution.H_1080P |
27 | let multiplier = 0 | 31 | let multiplier = 0 |
28 | 32 | ||
@@ -30,9 +34,19 @@ export abstract class UserEdit extends FormReactive { | |||
30 | multiplier += resolution / higherResolution | 34 | multiplier += resolution / higherResolution |
31 | } | 35 | } |
32 | 36 | ||
37 | if (transcodingConfig.hls.enabled) multiplier *= 2 | ||
38 | |||
33 | return multiplier * parseInt(this.form.value['videoQuota'], 10) | 39 | return multiplier * parseInt(this.form.value['videoQuota'], 10) |
34 | } | 40 | } |
35 | 41 | ||
42 | resetPassword () { | ||
43 | return | ||
44 | } | ||
45 | |||
46 | protected buildAdminFlags (formValue: any) { | ||
47 | return formValue.byPassAutoBlacklist ? UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST : UserAdminFlag.NONE | ||
48 | } | ||
49 | |||
36 | protected buildQuotaOptions () { | 50 | protected buildQuotaOptions () { |
37 | // These are used by a HTML select, so convert key into strings | 51 | // These are used by a HTML select, so convert key into strings |
38 | this.videoQuotaOptions = this.configService | 52 | this.videoQuotaOptions = this.configService |
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 new file mode 100644 index 000000000..a1e1f6216 --- /dev/null +++ b/client/src/app/+admin/users/user-edit/user-password.component.html | |||
@@ -0,0 +1,21 @@ | |||
1 | <form role="form" (ngSubmit)="formValidated()" [formGroup]="form"> | ||
2 | <div class="form-group"> | ||
3 | |||
4 | <div class="input-group"> | ||
5 | <input id="password" [attr.type]="showPassword ? 'text' : 'password'" | ||
6 | formControlName="password" [ngClass]="{ 'input-error': formErrors['password'] }" | ||
7 | > | ||
8 | <div class="input-group-append"> | ||
9 | <button class="btn btn-sm btn-outline-secondary" (click)="togglePasswordVisibility()" type="button"> | ||
10 | <ng-container *ngIf="!showPassword" i18n>Show</ng-container> | ||
11 | <ng-container *ngIf="!!showPassword" i18n>Hide</ng-container> | ||
12 | </button> | ||
13 | </div> | ||
14 | </div> | ||
15 | <div *ngIf="formErrors.password" class="form-error"> | ||
16 | {{ formErrors.password }} | ||
17 | </div> | ||
18 | </div> | ||
19 | |||
20 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> | ||
21 | </form> | ||
diff --git a/client/src/app/+admin/users/user-edit/user-password.component.scss b/client/src/app/+admin/users/user-edit/user-password.component.scss new file mode 100644 index 000000000..217d585af --- /dev/null +++ b/client/src/app/+admin/users/user-edit/user-password.component.scss | |||
@@ -0,0 +1,22 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | input:not([type=submit]):not([type=checkbox]) { | ||
5 | @include peertube-input-text(340px); | ||
6 | |||
7 | display: block; | ||
8 | border-top-right-radius: 0; | ||
9 | border-bottom-right-radius: 0; | ||
10 | border-right: none; | ||
11 | } | ||
12 | |||
13 | input[type=submit] { | ||
14 | @include peertube-button; | ||
15 | @include orange-button; | ||
16 | |||
17 | margin-top: 10px; | ||
18 | } | ||
19 | |||
20 | .input-group-append { | ||
21 | height: 30px; | ||
22 | } | ||
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 new file mode 100644 index 000000000..5b3040440 --- /dev/null +++ b/client/src/app/+admin/users/user-edit/user-password.component.ts | |||
@@ -0,0 +1,64 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { UserService } from '@app/shared/users/user.service' | ||
4 | import { Notifier } from '../../../core' | ||
5 | import { User, UserUpdate } from '../../../../../../shared' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | ||
8 | import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service' | ||
9 | import { FormReactive } from '../../../shared' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-user-password', | ||
13 | templateUrl: './user-password.component.html', | ||
14 | styleUrls: [ './user-password.component.scss' ] | ||
15 | }) | ||
16 | export class UserPasswordComponent extends FormReactive implements OnInit { | ||
17 | error: string | ||
18 | username: string | ||
19 | showPassword = false | ||
20 | |||
21 | @Input() userId: number | ||
22 | |||
23 | constructor ( | ||
24 | protected formValidatorService: FormValidatorService, | ||
25 | private userValidatorsService: UserValidatorsService, | ||
26 | private route: ActivatedRoute, | ||
27 | private router: Router, | ||
28 | private notifier: Notifier, | ||
29 | private userService: UserService, | ||
30 | private i18n: I18n | ||
31 | ) { | ||
32 | super() | ||
33 | } | ||
34 | |||
35 | ngOnInit () { | ||
36 | this.buildForm({ | ||
37 | password: this.userValidatorsService.USER_PASSWORD | ||
38 | }) | ||
39 | } | ||
40 | |||
41 | formValidated () { | ||
42 | this.error = undefined | ||
43 | |||
44 | const userUpdate: UserUpdate = this.form.value | ||
45 | |||
46 | this.userService.updateUser(this.userId, userUpdate).subscribe( | ||
47 | () => { | ||
48 | this.notifier.success( | ||
49 | this.i18n('Password changed for user {{username}}.', { username: this.username }) | ||
50 | ) | ||
51 | }, | ||
52 | |||
53 | err => this.error = err.message | ||
54 | ) | ||
55 | } | ||
56 | |||
57 | togglePasswordVisibility () { | ||
58 | this.showPassword = !this.showPassword | ||
59 | } | ||
60 | |||
61 | getFormButtonTitle () { | ||
62 | return this.i18n('Update user password') | ||
63 | } | ||
64 | } | ||
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 61e641823..04b2935f4 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 | |||
@@ -10,6 +10,7 @@ import { FormValidatorService } from '@app/shared/forms/form-validators/form-val | |||
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 | 14 | ||
14 | @Component({ | 15 | @Component({ |
15 | selector: 'my-user-update', | 16 | selector: 'my-user-update', |
@@ -19,6 +20,7 @@ import { UserService } from '@app/shared' | |||
19 | export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | 20 | export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { |
20 | error: string | 21 | error: string |
21 | userId: number | 22 | userId: number |
23 | userEmail: string | ||
22 | username: string | 24 | username: string |
23 | 25 | ||
24 | private paramsSub: Subscription | 26 | private paramsSub: Subscription |
@@ -45,7 +47,8 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | |||
45 | email: this.userValidatorsService.USER_EMAIL, | 47 | email: this.userValidatorsService.USER_EMAIL, |
46 | role: this.userValidatorsService.USER_ROLE, | 48 | role: this.userValidatorsService.USER_ROLE, |
47 | videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, | 49 | videoQuota: this.userValidatorsService.USER_VIDEO_QUOTA, |
48 | videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY | 50 | videoQuotaDaily: this.userValidatorsService.USER_VIDEO_QUOTA_DAILY, |
51 | byPassAutoBlacklist: null | ||
49 | }, defaultValues) | 52 | }, defaultValues) |
50 | 53 | ||
51 | this.paramsSub = this.route.params.subscribe(routeParams => { | 54 | this.paramsSub = this.route.params.subscribe(routeParams => { |
@@ -66,6 +69,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | |||
66 | this.error = undefined | 69 | this.error = undefined |
67 | 70 | ||
68 | const userUpdate: UserUpdate = this.form.value | 71 | const userUpdate: UserUpdate = this.form.value |
72 | userUpdate.adminFlags = this.buildAdminFlags(this.form.value) | ||
69 | 73 | ||
70 | // A select in HTML is always mapped as a string, we convert it to number | 74 | // A select in HTML is always mapped as a string, we convert it to number |
71 | userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10) | 75 | userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10) |
@@ -89,15 +93,29 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { | |||
89 | return this.i18n('Update user') | 93 | return this.i18n('Update user') |
90 | } | 94 | } |
91 | 95 | ||
96 | resetPassword () { | ||
97 | this.userService.askResetPassword(this.userEmail).subscribe( | ||
98 | () => { | ||
99 | this.notifier.success( | ||
100 | this.i18n('An email asking for password reset has been sent to {{username}}.', { username: this.username }) | ||
101 | ) | ||
102 | }, | ||
103 | |||
104 | err => this.error = err.message | ||
105 | ) | ||
106 | } | ||
107 | |||
92 | private onUserFetched (userJson: User) { | 108 | private onUserFetched (userJson: User) { |
93 | this.userId = userJson.id | 109 | this.userId = userJson.id |
94 | this.username = userJson.username | 110 | this.username = userJson.username |
111 | this.userEmail = userJson.email | ||
95 | 112 | ||
96 | this.form.patchValue({ | 113 | this.form.patchValue({ |
97 | email: userJson.email, | 114 | email: userJson.email, |
98 | role: userJson.role, | 115 | role: userJson.role, |
99 | videoQuota: userJson.videoQuota, | 116 | videoQuota: userJson.videoQuota, |
100 | videoQuotaDaily: userJson.videoQuotaDaily | 117 | videoQuotaDaily: userJson.videoQuotaDaily, |
118 | byPassAutoBlacklist: userJson.adminFlags & UserAdminFlag.BY_PASS_VIDEO_AUTO_BLACKLIST | ||
101 | }) | 119 | }) |
102 | } | 120 | } |
103 | } | 121 | } |
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 d42af37d4..6e274f689 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 | |||
@@ -4,24 +4,19 @@ | |||
4 | <label i18n>History enabled</label> | 4 | <label i18n>History enabled</label> |
5 | </div> | 5 | </div> |
6 | 6 | ||
7 | <div class="delete-history"> | 7 | <button class="delete-history" (click)="deleteHistory()" i18n> |
8 | <button (click)="deleteHistory()" i18n>Delete history</button> | 8 | <my-global-icon iconName="delete"></my-global-icon> |
9 | </div> | 9 | Delete history |
10 | </button> | ||
10 | </div> | 11 | </div> |
11 | 12 | ||
12 | 13 | ||
13 | <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 videos history yet.</div> |
14 | 15 | ||
15 | <div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" class="videos" #videosElement> | 16 | <div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" class="videos"> |
16 | <div *ngFor="let videos of videoPages;" class="videos-page"> | 17 | <div class="video" *ngFor="let video of videos"> |
17 | <div class="video" *ngFor="let video of videos"> | 18 | <my-video-miniature |
18 | <my-video-thumbnail [video]="video"></my-video-thumbnail> | 19 | [video]="video" [displayAsRow]="true" |
19 | 20 | (videoRemoved)="removeVideoFromArray(video)" (videoBlacklisted)="removeVideoFromArray(video)"></my-video-miniature> | |
20 | <div class="video-info"> | ||
21 | <a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a> | ||
22 | <span i18n class="video-info-date-views">{{ video.views | myNumberFormatter }} views</span> | ||
23 | <a tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a> | ||
24 | </div> | ||
25 | </div> | ||
26 | </div> | 21 | </div> |
27 | </div> | 22 | </div> |
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 e7c6863f1..af6395fb1 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 | |||
@@ -23,77 +23,18 @@ | |||
23 | } | 23 | } |
24 | 24 | ||
25 | .delete-history { | 25 | .delete-history { |
26 | font-size: 15px; | 26 | @include peertube-button; |
27 | @include grey-button; | ||
28 | @include button-with-icon; | ||
27 | 29 | ||
28 | button { | 30 | font-size: 15px; |
29 | @include peertube-button; | ||
30 | @include grey-button; | ||
31 | } | ||
32 | } | 31 | } |
33 | } | 32 | } |
34 | 33 | ||
35 | .video { | 34 | .video { |
36 | @include row-blocks; | 35 | @include row-blocks; |
37 | 36 | ||
38 | my-video-thumbnail { | 37 | .my-video-miniature { |
39 | margin-right: 10px; | ||
40 | } | ||
41 | |||
42 | .video-info { | ||
43 | flex-grow: 1; | 38 | flex-grow: 1; |
44 | |||
45 | .video-info-name { | ||
46 | @include disable-default-a-behaviour; | ||
47 | |||
48 | color: var(--mainForegroundColor); | ||
49 | display: block; | ||
50 | width: fit-content; | ||
51 | font-size: 18px; | ||
52 | font-weight: $font-semibold; | ||
53 | } | ||
54 | |||
55 | .video-info-date-views { | ||
56 | font-size: 14px; | ||
57 | } | ||
58 | |||
59 | .video-info-account { | ||
60 | @include disable-default-a-behaviour; | ||
61 | |||
62 | display: block; | ||
63 | width: fit-content; | ||
64 | overflow: hidden; | ||
65 | text-overflow: ellipsis; | ||
66 | white-space: nowrap; | ||
67 | font-size: 14px; | ||
68 | color: $grey-foreground-color; | ||
69 | |||
70 | &:hover { | ||
71 | color: $grey-foreground-hover-color; | ||
72 | } | ||
73 | } | ||
74 | } | ||
75 | } | ||
76 | |||
77 | @media screen and (max-width: $small-view) { | ||
78 | .video { | ||
79 | flex-direction: column; | ||
80 | height: auto; | ||
81 | text-align: center; | ||
82 | |||
83 | .video-info-name { | ||
84 | margin: auto; | ||
85 | } | ||
86 | |||
87 | input[type=checkbox] { | ||
88 | display: none; | ||
89 | } | ||
90 | |||
91 | my-video-thumbnail { | ||
92 | margin-right: 0; | ||
93 | } | ||
94 | |||
95 | .video-buttons { | ||
96 | margin-top: 10px; | ||
97 | } | ||
98 | } | 39 | } |
99 | } | 40 | } |
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 394091bad..73340d21a 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 | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { Location } from '@angular/common' | ||
4 | import { immutableAssign } from '@app/shared/misc/utils' | 3 | import { immutableAssign } from '@app/shared/misc/utils' |
5 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | 4 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' |
6 | import { AuthService } from '../../core/auth' | 5 | import { AuthService } from '../../core/auth' |
@@ -11,7 +10,7 @@ import { I18n } from '@ngx-translate/i18n-polyfill' | |||
11 | import { ScreenService } from '@app/shared/misc/screen.service' | 10 | import { ScreenService } from '@app/shared/misc/screen.service' |
12 | import { UserHistoryService } from '@app/shared/users/user-history.service' | 11 | import { UserHistoryService } from '@app/shared/users/user-history.service' |
13 | import { UserService } from '@app/shared' | 12 | import { UserService } from '@app/shared' |
14 | import { Notifier } from '@app/core' | 13 | import { Notifier, ServerService } from '@app/core' |
15 | 14 | ||
16 | @Component({ | 15 | @Component({ |
17 | selector: 'my-account-history', | 16 | selector: 'my-account-history', |
@@ -20,7 +19,6 @@ import { Notifier } from '@app/core' | |||
20 | }) | 19 | }) |
21 | export class MyAccountHistoryComponent extends AbstractVideoList implements OnInit, OnDestroy { | 20 | export class MyAccountHistoryComponent extends AbstractVideoList implements OnInit, OnDestroy { |
22 | titlePage: string | 21 | titlePage: string |
23 | currentRoute = '/my-account/history/videos' | ||
24 | pagination: ComponentPagination = { | 22 | pagination: ComponentPagination = { |
25 | currentPage: 1, | 23 | currentPage: 1, |
26 | itemsPerPage: 5, | 24 | itemsPerPage: 5, |
@@ -28,16 +26,13 @@ export class MyAccountHistoryComponent extends AbstractVideoList implements OnIn | |||
28 | } | 26 | } |
29 | videosHistoryEnabled: boolean | 27 | videosHistoryEnabled: boolean |
30 | 28 | ||
31 | protected baseVideoWidth = -1 | ||
32 | protected baseVideoHeight = 155 | ||
33 | |||
34 | constructor ( | 29 | constructor ( |
35 | protected router: Router, | 30 | protected router: Router, |
31 | protected serverService: ServerService, | ||
36 | protected route: ActivatedRoute, | 32 | protected route: ActivatedRoute, |
37 | protected authService: AuthService, | 33 | protected authService: AuthService, |
38 | protected userService: UserService, | 34 | protected userService: UserService, |
39 | protected notifier: Notifier, | 35 | protected notifier: Notifier, |
40 | protected location: Location, | ||
41 | protected screenService: ScreenService, | 36 | protected screenService: ScreenService, |
42 | protected i18n: I18n, | 37 | protected i18n: I18n, |
43 | private confirmService: ConfirmService, | 38 | private confirmService: ConfirmService, |
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 5709e9f54..c5fd3ccb9 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 | |||
@@ -30,8 +30,7 @@ | |||
30 | </a> | 30 | </a> |
31 | </td> | 31 | </td> |
32 | <td> | 32 | <td> |
33 | <a [href]="videoChangeOwnership.video.url" i18n-title title="Go to the video" target="_blank" | 33 | <a [href]="videoChangeOwnership.video.url" i18n-title title="Go to the video" target="_blank" rel="noopener noreferrer"> |
34 | rel="noopener noreferrer"> | ||
35 | {{ videoChangeOwnership.video.name }} | 34 | {{ videoChangeOwnership.video.name }} |
36 | </a> | 35 | </a> |
37 | </td> | 36 | </td> |
@@ -39,16 +38,12 @@ | |||
39 | <td i18n>{{ videoChangeOwnership.status }}</td> | 38 | <td i18n>{{ videoChangeOwnership.status }}</td> |
40 | <td class="action-cell"> | 39 | <td class="action-cell"> |
41 | <ng-container *ngIf="videoChangeOwnership.status === 'WAITING'"> | 40 | <ng-container *ngIf="videoChangeOwnership.status === 'WAITING'"> |
42 | <my-button i18n label="Accept" | 41 | <my-button i18n-label label="Accept" icon="tick" (click)="openAcceptModal(videoChangeOwnership)"></my-button> |
43 | icon="tick" | 42 | <my-button i18n-label label="Refuse" icon="cross" (click)="refuse(videoChangeOwnership)"></my-button> |
44 | (click)="openAcceptModal(videoChangeOwnership)"></my-button> | ||
45 | <my-button i18n label="Refuse" | ||
46 | icon="cross" | ||
47 | (click)="refuse(videoChangeOwnership)">Refuse</my-button> | ||
48 | </ng-container> | 43 | </ng-container> |
49 | </td> | 44 | </td> |
50 | </tr> | 45 | </tr> |
51 | </ng-template> | 46 | </ng-template> |
52 | </p-table> | 47 | </p-table> |
53 | 48 | ||
54 | <my-account-accept-ownership #myAccountAcceptOwnershipComponent (accepted)="accepted()"></my-account-accept-ownership> \ No newline at end of file | 49 | <my-account-accept-ownership #myAccountAcceptOwnershipComponent (accepted)="accepted()"></my-account-accept-ownership> |
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 9996218ca..018d6f996 100644 --- a/client/src/app/+my-account/my-account-routing.module.ts +++ b/client/src/app/+my-account/my-account-routing.module.ts | |||
@@ -15,6 +15,16 @@ import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blockli | |||
15 | import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component' | 15 | import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component' |
16 | import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' | 16 | import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' |
17 | import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' | 17 | import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' |
18 | import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' | ||
19 | import { | ||
20 | MyAccountVideoPlaylistCreateComponent | ||
21 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component' | ||
22 | import { | ||
23 | MyAccountVideoPlaylistUpdateComponent | ||
24 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' | ||
25 | import { | ||
26 | MyAccountVideoPlaylistElementsComponent | ||
27 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' | ||
18 | 28 | ||
19 | const myAccountRoutes: Routes = [ | 29 | const myAccountRoutes: Routes = [ |
20 | { | 30 | { |
@@ -36,6 +46,7 @@ const myAccountRoutes: Routes = [ | |||
36 | } | 46 | } |
37 | } | 47 | } |
38 | }, | 48 | }, |
49 | |||
39 | { | 50 | { |
40 | path: 'video-channels', | 51 | path: 'video-channels', |
41 | component: MyAccountVideoChannelsComponent, | 52 | component: MyAccountVideoChannelsComponent, |
@@ -63,12 +74,54 @@ const myAccountRoutes: Routes = [ | |||
63 | } | 74 | } |
64 | } | 75 | } |
65 | }, | 76 | }, |
77 | |||
78 | { | ||
79 | path: 'video-playlists', | ||
80 | component: MyAccountVideoPlaylistsComponent, | ||
81 | data: { | ||
82 | meta: { | ||
83 | title: 'Account playlists' | ||
84 | } | ||
85 | } | ||
86 | }, | ||
87 | { | ||
88 | path: 'video-playlists/create', | ||
89 | component: MyAccountVideoPlaylistCreateComponent, | ||
90 | data: { | ||
91 | meta: { | ||
92 | title: 'Create new playlist' | ||
93 | } | ||
94 | } | ||
95 | }, | ||
96 | { | ||
97 | path: 'video-playlists/:videoPlaylistId', | ||
98 | component: MyAccountVideoPlaylistElementsComponent, | ||
99 | data: { | ||
100 | meta: { | ||
101 | title: 'Playlist elements' | ||
102 | } | ||
103 | } | ||
104 | }, | ||
105 | { | ||
106 | path: 'video-playlists/update/:videoPlaylistId', | ||
107 | component: MyAccountVideoPlaylistUpdateComponent, | ||
108 | data: { | ||
109 | meta: { | ||
110 | title: 'Update playlist' | ||
111 | } | ||
112 | } | ||
113 | }, | ||
114 | |||
66 | { | 115 | { |
67 | path: 'videos', | 116 | path: 'videos', |
68 | component: MyAccountVideosComponent, | 117 | component: MyAccountVideosComponent, |
69 | data: { | 118 | data: { |
70 | meta: { | 119 | meta: { |
71 | title: 'Account videos' | 120 | title: 'Account videos' |
121 | }, | ||
122 | reuse: { | ||
123 | enabled: true, | ||
124 | key: 'my-account-videos-list' | ||
72 | } | 125 | } |
73 | } | 126 | } |
74 | }, | 127 | }, |
@@ -123,6 +176,10 @@ const myAccountRoutes: Routes = [ | |||
123 | data: { | 176 | data: { |
124 | meta: { | 177 | meta: { |
125 | title: 'Videos history' | 178 | title: 'Videos history' |
179 | }, | ||
180 | reuse: { | ||
181 | enabled: true, | ||
182 | key: 'my-videos-history-list' | ||
126 | } | 183 | } |
127 | } | 184 | } |
128 | }, | 185 | }, |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html index 59422d682..93e294a96 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.html | |||
@@ -4,8 +4,8 @@ | |||
4 | <div i18n *ngIf="emailEnabled">Email</div> | 4 | <div i18n *ngIf="emailEnabled">Email</div> |
5 | </div> | 5 | </div> |
6 | 6 | ||
7 | <div class="custom-row" *ngFor="let notificationType of notificationSettingKeys"> | 7 | <ng-container *ngFor="let notificationType of notificationSettingKeys"> |
8 | <ng-container *ngIf="hasUserRight(notificationType)"> | 8 | <div class="custom-row" *ngIf="hasUserRight(notificationType)"> |
9 | <div>{{ labelNotifications[notificationType] }}</div> | 9 | <div>{{ labelNotifications[notificationType] }}</div> |
10 | 10 | ||
11 | <div> | 11 | <div> |
@@ -15,5 +15,5 @@ | |||
15 | <div *ngIf="emailEnabled"> | 15 | <div *ngIf="emailEnabled"> |
16 | <p-inputSwitch [(ngModel)]="emailNotifications[notificationType]" (onChange)="updateEmailSetting(notificationType, $event.checked)"></p-inputSwitch> | 16 | <p-inputSwitch [(ngModel)]="emailNotifications[notificationType]" (onChange)="updateEmailSetting(notificationType, $event.checked)"></p-inputSwitch> |
17 | </div> | 17 | </div> |
18 | </ng-container> | 18 | </div> |
19 | </div> | 19 | </ng-container> |
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 6feb16ab1..7cd5c3b46 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 | |||
@@ -4,7 +4,7 @@ | |||
4 | .custom-row { | 4 | .custom-row { |
5 | display: flex; | 5 | display: flex; |
6 | align-items: center; | 6 | align-items: center; |
7 | border-bottom: 1px solid rgba(0, 0, 0, 0.10); | 7 | border-bottom: 1px solid $separator-border-color; |
8 | 8 | ||
9 | &:first-child { | 9 | &:first-child { |
10 | font-size: 16px; | 10 | font-size: 16px; |
@@ -16,6 +16,14 @@ | |||
16 | 16 | ||
17 | & > div { | 17 | & > div { |
18 | width: 350px; | 18 | width: 350px; |
19 | |||
20 | @media screen and (max-width: $small-view) { | ||
21 | width: auto; | ||
22 | |||
23 | &:first-child { | ||
24 | flex-grow: 1; | ||
25 | } | ||
26 | } | ||
19 | } | 27 | } |
20 | 28 | ||
21 | & > div { | 29 | & > div { |
diff --git a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts index 519bdfab4..34febc457 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-notification-preferences/my-account-notification-preferences.component.ts | |||
@@ -31,22 +31,27 @@ export class MyAccountNotificationPreferencesComponent implements OnInit { | |||
31 | private serverService: ServerService, | 31 | private serverService: ServerService, |
32 | private notifier: Notifier | 32 | private notifier: Notifier |
33 | ) { | 33 | ) { |
34 | |||
34 | this.labelNotifications = { | 35 | this.labelNotifications = { |
35 | newVideoFromSubscription: this.i18n('New video from your subscriptions'), | 36 | newVideoFromSubscription: this.i18n('New video from your subscriptions'), |
36 | newCommentOnMyVideo: this.i18n('New comment on your video'), | 37 | newCommentOnMyVideo: this.i18n('New comment on your video'), |
37 | videoAbuseAsModerator: this.i18n('New video abuse on local video'), | 38 | videoAbuseAsModerator: this.i18n('New video abuse'), |
39 | videoAutoBlacklistAsModerator: this.i18n('Video auto-blacklisted waiting review'), | ||
38 | blacklistOnMyVideo: this.i18n('One of your video is blacklisted/unblacklisted'), | 40 | blacklistOnMyVideo: this.i18n('One of your video is blacklisted/unblacklisted'), |
39 | myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'), | 41 | myVideoPublished: this.i18n('Video published (after transcoding/scheduled update)'), |
40 | myVideoImportFinished: this.i18n('Video import finished'), | 42 | myVideoImportFinished: this.i18n('Video import finished'), |
41 | newUserRegistration: this.i18n('A new user registered on your instance'), | 43 | newUserRegistration: this.i18n('A new user registered on your instance'), |
42 | newFollow: this.i18n('You or your channel(s) has a new follower'), | 44 | newFollow: this.i18n('You or your channel(s) has a new follower'), |
43 | commentMention: this.i18n('Someone mentioned you in video comments') | 45 | commentMention: this.i18n('Someone mentioned you in video comments'), |
46 | newInstanceFollower: this.i18n('Your instance has a new follower') | ||
44 | } | 47 | } |
45 | this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[] | 48 | this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[] |
46 | 49 | ||
47 | this.rightNotifications = { | 50 | this.rightNotifications = { |
48 | videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES, | 51 | videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES, |
49 | newUserRegistration: UserRight.MANAGE_USERS | 52 | videoAutoBlacklistAsModerator: UserRight.MANAGE_VIDEO_BLACKLIST, |
53 | newUserRegistration: UserRight.MANAGE_USERS, | ||
54 | newInstanceFollower: UserRight.MANAGE_SERVER_FOLLOW | ||
50 | } | 55 | } |
51 | 56 | ||
52 | this.emailEnabled = this.serverService.getConfig().email.enabled | 57 | this.emailEnabled = this.serverService.getConfig().email.enabled |
diff --git a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts index 9d2dccdf0..6ce22989b 100644 --- a/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts +++ b/client/src/app/+my-account/my-account-subscriptions/my-account-subscriptions.component.ts | |||
@@ -1,7 +1,6 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
2 | import { Notifier } from '@app/core' | 2 | import { Notifier } from '@app/core' |
3 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 3 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
5 | import { UserSubscriptionService } from '@app/shared/user-subscription' | 4 | import { UserSubscriptionService } from '@app/shared/user-subscription' |
6 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | 5 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' |
7 | 6 | ||
@@ -21,8 +20,7 @@ export class MyAccountSubscriptionsComponent implements OnInit { | |||
21 | 20 | ||
22 | constructor ( | 21 | constructor ( |
23 | private userSubscriptionService: UserSubscriptionService, | 22 | private userSubscriptionService: UserSubscriptionService, |
24 | private notifier: Notifier, | 23 | private notifier: Notifier |
25 | private i18n: I18n | ||
26 | ) {} | 24 | ) {} |
27 | 25 | ||
28 | ngOnInit () { | 26 | ngOnInit () { |
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 51db2e75d..11e87ba79 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,7 +1,7 @@ | |||
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 another video channel</ng-container> | 4 | <ng-container i18n>Create a new video channel</ng-container> |
5 | </a> | 5 | </a> |
6 | </div> | 6 | </div> |
7 | 7 | ||
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 da2c5bcd3..3b01b6c9f 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 | |||
@@ -35,8 +35,8 @@ export class MyAccountVideoChannelsComponent implements OnInit { | |||
35 | async deleteVideoChannel (videoChannel: VideoChannel) { | 35 | async deleteVideoChannel (videoChannel: VideoChannel) { |
36 | const res = await this.confirmService.confirmWithInput( | 36 | const res = await this.confirmService.confirmWithInput( |
37 | this.i18n( | 37 | this.i18n( |
38 | 'Do you really want to delete {{channelDisplayName}}? It will delete all videos uploaded in this channel, ' + | 38 | // tslint:disable |
39 | 'and you will not be able to create another channel with the same name ({{channelName}})!', | 39 | 'Do you really want to delete {{channelDisplayName}}? It will delete all videos uploaded in this channel, and you will not be able to create another channel with the same name ({{channelName}})!', |
40 | { channelDisplayName: videoChannel.displayName, channelName: videoChannel.name } | 40 | { channelDisplayName: videoChannel.displayName, channelName: videoChannel.name } |
41 | ), | 41 | ), |
42 | this.i18n( | 42 | this.i18n( |
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts new file mode 100644 index 000000000..87a10961f --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component.ts | |||
@@ -0,0 +1,93 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { AuthService, Notifier, ServerService } from '@app/core' | ||
4 | import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | ||
7 | import { VideoPlaylistValidatorsService } from '@app/shared' | ||
8 | import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model' | ||
9 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
10 | import { VideoConstant } from '@shared/models' | ||
11 | import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model' | ||
12 | import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils' | ||
13 | |||
14 | @Component({ | ||
15 | selector: 'my-account-video-playlist-create', | ||
16 | templateUrl: './my-account-video-playlist-edit.component.html', | ||
17 | styleUrls: [ './my-account-video-playlist-edit.component.scss' ] | ||
18 | }) | ||
19 | export class MyAccountVideoPlaylistCreateComponent extends MyAccountVideoPlaylistEdit implements OnInit { | ||
20 | error: string | ||
21 | videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = [] | ||
22 | |||
23 | constructor ( | ||
24 | protected formValidatorService: FormValidatorService, | ||
25 | private authService: AuthService, | ||
26 | private videoPlaylistValidatorsService: VideoPlaylistValidatorsService, | ||
27 | private notifier: Notifier, | ||
28 | private router: Router, | ||
29 | private videoPlaylistService: VideoPlaylistService, | ||
30 | private serverService: ServerService, | ||
31 | private i18n: I18n | ||
32 | ) { | ||
33 | super() | ||
34 | } | ||
35 | |||
36 | ngOnInit () { | ||
37 | this.buildForm({ | ||
38 | displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME, | ||
39 | privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY, | ||
40 | description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION, | ||
41 | videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID, | ||
42 | thumbnailfile: null | ||
43 | }) | ||
44 | |||
45 | this.form.get('privacy').valueChanges.subscribe(privacy => { | ||
46 | this.videoPlaylistValidatorsService.setChannelValidator(this.form.get('videoChannelId'), privacy) | ||
47 | }) | ||
48 | |||
49 | populateAsyncUserVideoChannels(this.authService, this.userVideoChannels) | ||
50 | |||
51 | this.serverService.videoPlaylistPrivaciesLoaded.subscribe( | ||
52 | () => { | ||
53 | this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies() | ||
54 | |||
55 | this.form.patchValue({ | ||
56 | privacy: VideoPlaylistPrivacy.PRIVATE | ||
57 | }) | ||
58 | } | ||
59 | ) | ||
60 | } | ||
61 | |||
62 | formValidated () { | ||
63 | this.error = undefined | ||
64 | |||
65 | const body = this.form.value | ||
66 | const videoPlaylistCreate: VideoPlaylistCreate = { | ||
67 | displayName: body.displayName, | ||
68 | privacy: body.privacy, | ||
69 | description: body.description || null, | ||
70 | videoChannelId: body.videoChannelId || null, | ||
71 | thumbnailfile: body.thumbnailfile || null | ||
72 | } | ||
73 | |||
74 | this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe( | ||
75 | () => { | ||
76 | this.notifier.success( | ||
77 | this.i18n('Playlist {{playlistName}} created.', { playlistName: videoPlaylistCreate.displayName }) | ||
78 | ) | ||
79 | this.router.navigate([ '/my-account', 'video-playlists' ]) | ||
80 | }, | ||
81 | |||
82 | err => this.error = err.message | ||
83 | ) | ||
84 | } | ||
85 | |||
86 | isCreation () { | ||
87 | return true | ||
88 | } | ||
89 | |||
90 | getFormButtonTitle () { | ||
91 | return this.i18n('Create') | ||
92 | } | ||
93 | } | ||
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 new file mode 100644 index 000000000..303fc46f7 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.html | |||
@@ -0,0 +1,69 @@ | |||
1 | <div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a new playlist</div> | ||
2 | |||
3 | <div *ngIf="error" class="alert alert-danger">{{ error }}</div> | ||
4 | |||
5 | <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 | |||
19 | <div class="form-group"> | ||
20 | <label i18n for="description">Description</label> | ||
21 | <textarea | ||
22 | id="description" formControlName="description" | ||
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> | ||
30 | |||
31 | <div class="col-md-12 col-xl-6"> | ||
32 | <div class="form-group"> | ||
33 | <label i18n for="privacy">Privacy</label> | ||
34 | <div class="peertube-select-container"> | ||
35 | <select id="privacy" formControlName="privacy"> | ||
36 | <option *ngFor="let privacy of videoPlaylistPrivacies" [value]="privacy.id">{{ privacy.label }}</option> | ||
37 | </select> | ||
38 | </div> | ||
39 | |||
40 | <div *ngIf="formErrors.privacy" class="form-error"> | ||
41 | {{ formErrors.privacy }} | ||
42 | </div> | ||
43 | </div> | ||
44 | |||
45 | <div class="form-group"> | ||
46 | <label i18n>Channel</label> | ||
47 | <div class="peertube-select-container"> | ||
48 | <select formControlName="videoChannelId"> | ||
49 | <option></option> | ||
50 | <option *ngFor="let channel of userVideoChannels" [value]="channel.id">{{ channel.label }}</option> | ||
51 | </select> | ||
52 | </div> | ||
53 | |||
54 | <div *ngIf="formErrors['videoChannelId']" class="form-error"> | ||
55 | {{ formErrors['videoChannelId'] }} | ||
56 | </div> | ||
57 | </div> | ||
58 | |||
59 | <div class="form-group"> | ||
60 | <my-image-upload | ||
61 | i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile" | ||
62 | previewWidth="200px" previewHeight="110px" | ||
63 | ></my-image-upload> | ||
64 | </div> | ||
65 | </div> | ||
66 | </div> | ||
67 | |||
68 | <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid"> | ||
69 | </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 new file mode 100644 index 000000000..5af846d8e --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.component.scss | |||
@@ -0,0 +1,27 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .form-sub-title { | ||
5 | margin-bottom: 20px; | ||
6 | } | ||
7 | |||
8 | input[type=text] { | ||
9 | @include peertube-input-text(340px); | ||
10 | |||
11 | display: block; | ||
12 | } | ||
13 | |||
14 | textarea { | ||
15 | @include peertube-textarea(500px, 150px); | ||
16 | |||
17 | display: block; | ||
18 | } | ||
19 | |||
20 | .peertube-select-container { | ||
21 | @include peertube-select-container(340px); | ||
22 | } | ||
23 | |||
24 | input[type=submit] { | ||
25 | @include peertube-button; | ||
26 | @include orange-button; | ||
27 | } | ||
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts new file mode 100644 index 000000000..fbfb4c8f7 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-edit.ts | |||
@@ -0,0 +1,13 @@ | |||
1 | import { FormReactive } from '@app/shared' | ||
2 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | ||
3 | import { ServerService } from '@app/core' | ||
4 | import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model' | ||
5 | |||
6 | export abstract class MyAccountVideoPlaylistEdit extends FormReactive { | ||
7 | // Declare it here to avoid errors in create template | ||
8 | videoPlaylistToUpdate: VideoPlaylist | ||
9 | userVideoChannels: { id: number, label: string }[] = [] | ||
10 | |||
11 | abstract isCreation (): boolean | ||
12 | abstract getFormButtonTitle (): string | ||
13 | } | ||
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html new file mode 100644 index 000000000..284694b7f --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.html | |||
@@ -0,0 +1,26 @@ | |||
1 | <div class="row"> | ||
2 | |||
3 | <div class="playlist-info col-xs-12 col-md-5 col-xl-3"> | ||
4 | <my-video-playlist-miniature | ||
5 | *ngIf="playlist" [playlist]="playlist" [toManage]="false" [displayChannel]="true" | ||
6 | [displayDescription]="true" [displayPrivacy]="true" | ||
7 | ></my-video-playlist-miniature> | ||
8 | </div> | ||
9 | |||
10 | <div class="playlist-elements col-xs-12 col-md-7 col-xl-9"> | ||
11 | <div i18n class="no-results" *ngIf="pagination.totalItems === 0">No videos in this playlist.</div> | ||
12 | |||
13 | <div | ||
14 | class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" | ||
15 | cdkDropList (cdkDropListDropped)="drop($event)" | ||
16 | > | ||
17 | <div class="video" *ngFor="let video of videos; trackBy: trackByFn" cdkDrag (cdkDragMoved)="onDragMove($event)"> | ||
18 | <my-video-playlist-element-miniature | ||
19 | [video]="video" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)" | ||
20 | [position]="video.playlistElement.position" | ||
21 | > | ||
22 | </my-video-playlist-element-miniature> | ||
23 | </div> | ||
24 | </div> | ||
25 | </div> | ||
26 | </div> | ||
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 new file mode 100644 index 000000000..900669827 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.scss | |||
@@ -0,0 +1,39 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | @import '_miniature'; | ||
4 | |||
5 | .playlist-info { | ||
6 | background-color: var(--submenuColor); | ||
7 | margin-left: -15px; | ||
8 | margin-top: -$sub-menu-margin-bottom; | ||
9 | |||
10 | padding: $sub-menu-margin-bottom 0; | ||
11 | |||
12 | display: flex; | ||
13 | justify-content: center; | ||
14 | } | ||
15 | |||
16 | // Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples | ||
17 | .cdk-drag-preview { | ||
18 | box-sizing: border-box; | ||
19 | border-radius: 4px; | ||
20 | box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), | ||
21 | 0 8px 10px 1px rgba(0, 0, 0, 0.14), | ||
22 | 0 3px 14px 2px rgba(0, 0, 0, 0.12); | ||
23 | } | ||
24 | |||
25 | .cdk-drag-placeholder { | ||
26 | opacity: 0; | ||
27 | } | ||
28 | |||
29 | .cdk-drag-animating { | ||
30 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); | ||
31 | } | ||
32 | |||
33 | .video:last-child { | ||
34 | border: none; | ||
35 | } | ||
36 | |||
37 | .videos.cdk-drop-list-dragging .video:not(.cdk-drag-placeholder) { | ||
38 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); | ||
39 | } | ||
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts new file mode 100644 index 000000000..25d51d2cb --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component.ts | |||
@@ -0,0 +1,153 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { Notifier, ServerService } from '@app/core' | ||
3 | import { AuthService } from '../../core/auth' | ||
4 | import { ConfirmService } from '../../core/confirm' | ||
5 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | ||
6 | import { Video } from '@app/shared/video/video.model' | ||
7 | import { Subject, Subscription } from 'rxjs' | ||
8 | import { ActivatedRoute } from '@angular/router' | ||
9 | import { VideoService } from '@app/shared/video/video.service' | ||
10 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
11 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
12 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
13 | import { CdkDragDrop, CdkDragMove } from '@angular/cdk/drag-drop' | ||
14 | import { throttleTime } from 'rxjs/operators' | ||
15 | |||
16 | @Component({ | ||
17 | selector: 'my-account-video-playlist-elements', | ||
18 | templateUrl: './my-account-video-playlist-elements.component.html', | ||
19 | styleUrls: [ './my-account-video-playlist-elements.component.scss' ] | ||
20 | }) | ||
21 | export class MyAccountVideoPlaylistElementsComponent implements OnInit, OnDestroy { | ||
22 | videos: Video[] = [] | ||
23 | playlist: VideoPlaylist | ||
24 | |||
25 | pagination: ComponentPagination = { | ||
26 | currentPage: 1, | ||
27 | itemsPerPage: 30, | ||
28 | totalItems: null | ||
29 | } | ||
30 | |||
31 | private videoPlaylistId: string | number | ||
32 | private paramsSub: Subscription | ||
33 | private dragMoveSubject = new Subject<number>() | ||
34 | |||
35 | constructor ( | ||
36 | private authService: AuthService, | ||
37 | private serverService: ServerService, | ||
38 | private notifier: Notifier, | ||
39 | private confirmService: ConfirmService, | ||
40 | private route: ActivatedRoute, | ||
41 | private i18n: I18n, | ||
42 | private videoService: VideoService, | ||
43 | private videoPlaylistService: VideoPlaylistService | ||
44 | ) {} | ||
45 | |||
46 | ngOnInit () { | ||
47 | this.paramsSub = this.route.params.subscribe(routeParams => { | ||
48 | this.videoPlaylistId = routeParams[ 'videoPlaylistId' ] | ||
49 | this.loadElements() | ||
50 | |||
51 | this.loadPlaylistInfo() | ||
52 | }) | ||
53 | |||
54 | this.dragMoveSubject.asObservable() | ||
55 | .pipe(throttleTime(200)) | ||
56 | .subscribe(y => this.checkScroll(y)) | ||
57 | } | ||
58 | |||
59 | ngOnDestroy () { | ||
60 | if (this.paramsSub) this.paramsSub.unsubscribe() | ||
61 | } | ||
62 | |||
63 | drop (event: CdkDragDrop<any>) { | ||
64 | const previousIndex = event.previousIndex | ||
65 | const newIndex = event.currentIndex | ||
66 | |||
67 | if (previousIndex === newIndex) return | ||
68 | |||
69 | const oldPosition = this.videos[previousIndex].playlistElement.position | ||
70 | const insertAfter = newIndex === 0 ? 0 : this.videos[newIndex].playlistElement.position | ||
71 | |||
72 | this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter) | ||
73 | .subscribe( | ||
74 | () => { /* nothing to do */ }, | ||
75 | |||
76 | err => this.notifier.error(err.message) | ||
77 | ) | ||
78 | |||
79 | const video = this.videos[previousIndex] | ||
80 | |||
81 | this.videos.splice(previousIndex, 1) | ||
82 | this.videos.splice(newIndex, 0, video) | ||
83 | |||
84 | this.reorderClientPositions() | ||
85 | } | ||
86 | |||
87 | onDragMove (event: CdkDragMove<any>) { | ||
88 | this.dragMoveSubject.next(event.pointerPosition.y) | ||
89 | } | ||
90 | |||
91 | checkScroll (pointerY: number) { | ||
92 | // FIXME: Uncomment when https://github.com/angular/material2/issues/14098 is fixed | ||
93 | // FIXME: Remove when https://github.com/angular/material2/issues/13588 is implemented | ||
94 | // if (pointerY < 150) { | ||
95 | // window.scrollBy({ | ||
96 | // left: 0, | ||
97 | // top: -20, | ||
98 | // behavior: 'smooth' | ||
99 | // }) | ||
100 | // | ||
101 | // return | ||
102 | // } | ||
103 | // | ||
104 | // if (window.innerHeight - pointerY <= 50) { | ||
105 | // window.scrollBy({ | ||
106 | // left: 0, | ||
107 | // top: 20, | ||
108 | // behavior: 'smooth' | ||
109 | // }) | ||
110 | // } | ||
111 | } | ||
112 | |||
113 | onElementRemoved (video: Video) { | ||
114 | this.videos = this.videos.filter(v => v.id !== video.id) | ||
115 | this.reorderClientPositions() | ||
116 | } | ||
117 | |||
118 | onNearOfBottom () { | ||
119 | // Last page | ||
120 | if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return | ||
121 | |||
122 | this.pagination.currentPage += 1 | ||
123 | this.loadElements() | ||
124 | } | ||
125 | |||
126 | trackByFn (index: number, elem: Video) { | ||
127 | return elem.id | ||
128 | } | ||
129 | |||
130 | private loadElements () { | ||
131 | this.videoService.getPlaylistVideos(this.videoPlaylistId, this.pagination) | ||
132 | .subscribe(({ totalVideos, videos }) => { | ||
133 | this.videos = this.videos.concat(videos) | ||
134 | this.pagination.totalItems = totalVideos | ||
135 | }) | ||
136 | } | ||
137 | |||
138 | private loadPlaylistInfo () { | ||
139 | this.videoPlaylistService.getVideoPlaylist(this.videoPlaylistId) | ||
140 | .subscribe(playlist => { | ||
141 | this.playlist = playlist | ||
142 | }) | ||
143 | } | ||
144 | |||
145 | private reorderClientPositions () { | ||
146 | let i = 1 | ||
147 | |||
148 | for (const video of this.videos) { | ||
149 | video.playlistElement.position = i | ||
150 | i++ | ||
151 | } | ||
152 | } | ||
153 | } | ||
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts new file mode 100644 index 000000000..4887fdfb4 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component.ts | |||
@@ -0,0 +1,136 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { AuthService, Notifier, ServerService } from '@app/core' | ||
4 | import { Subscription } from 'rxjs' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | ||
7 | import { MyAccountVideoPlaylistEdit } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-edit' | ||
8 | import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils' | ||
9 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
10 | import { VideoPlaylistValidatorsService } from '@app/shared' | ||
11 | import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model' | ||
12 | import { VideoConstant } from '@shared/models' | ||
13 | import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model' | ||
14 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
15 | |||
16 | @Component({ | ||
17 | selector: 'my-account-video-playlist-update', | ||
18 | templateUrl: './my-account-video-playlist-edit.component.html', | ||
19 | styleUrls: [ './my-account-video-playlist-edit.component.scss' ] | ||
20 | }) | ||
21 | export class MyAccountVideoPlaylistUpdateComponent extends MyAccountVideoPlaylistEdit implements OnInit, OnDestroy { | ||
22 | error: string | ||
23 | videoPlaylistToUpdate: VideoPlaylist | ||
24 | videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = [] | ||
25 | |||
26 | private paramsSub: Subscription | ||
27 | |||
28 | constructor ( | ||
29 | protected formValidatorService: FormValidatorService, | ||
30 | private authService: AuthService, | ||
31 | private videoPlaylistValidatorsService: VideoPlaylistValidatorsService, | ||
32 | private notifier: Notifier, | ||
33 | private router: Router, | ||
34 | private route: ActivatedRoute, | ||
35 | private videoPlaylistService: VideoPlaylistService, | ||
36 | private i18n: I18n, | ||
37 | private serverService: ServerService | ||
38 | ) { | ||
39 | super() | ||
40 | } | ||
41 | |||
42 | ngOnInit () { | ||
43 | this.buildForm({ | ||
44 | displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME, | ||
45 | privacy: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_PRIVACY, | ||
46 | description: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DESCRIPTION, | ||
47 | videoChannelId: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_CHANNEL_ID, | ||
48 | thumbnailfile: null | ||
49 | }) | ||
50 | |||
51 | this.form.get('privacy').valueChanges.subscribe(privacy => { | ||
52 | this.videoPlaylistValidatorsService.setChannelValidator(this.form.get('videoChannelId'), privacy) | ||
53 | }) | ||
54 | |||
55 | populateAsyncUserVideoChannels(this.authService, this.userVideoChannels) | ||
56 | |||
57 | this.paramsSub = this.route.params.subscribe(routeParams => { | ||
58 | const videoPlaylistId = routeParams['videoPlaylistId'] | ||
59 | |||
60 | this.videoPlaylistService.getVideoPlaylist(videoPlaylistId).subscribe( | ||
61 | videoPlaylistToUpdate => { | ||
62 | this.videoPlaylistToUpdate = videoPlaylistToUpdate | ||
63 | |||
64 | this.hydrateFormFromPlaylist() | ||
65 | |||
66 | this.serverService.videoPlaylistPrivaciesLoaded.subscribe( | ||
67 | () => { | ||
68 | this.videoPlaylistPrivacies = this.serverService.getVideoPlaylistPrivacies() | ||
69 | .filter(p => { | ||
70 | // If the playlist is not private, we cannot put it in private anymore | ||
71 | return this.videoPlaylistToUpdate.privacy.id === VideoPlaylistPrivacy.PRIVATE || | ||
72 | p.id !== VideoPlaylistPrivacy.PRIVATE | ||
73 | }) | ||
74 | } | ||
75 | ) | ||
76 | }, | ||
77 | |||
78 | err => this.error = err.message | ||
79 | ) | ||
80 | }) | ||
81 | } | ||
82 | |||
83 | ngOnDestroy () { | ||
84 | if (this.paramsSub) this.paramsSub.unsubscribe() | ||
85 | } | ||
86 | |||
87 | formValidated () { | ||
88 | this.error = undefined | ||
89 | |||
90 | const body = this.form.value | ||
91 | const videoPlaylistUpdate: VideoPlaylistUpdate = { | ||
92 | displayName: body.displayName, | ||
93 | privacy: body.privacy, | ||
94 | description: body.description || null, | ||
95 | videoChannelId: body.videoChannelId || null, | ||
96 | thumbnailfile: body.thumbnailfile || undefined | ||
97 | } | ||
98 | |||
99 | this.videoPlaylistService.updateVideoPlaylist(this.videoPlaylistToUpdate, videoPlaylistUpdate).subscribe( | ||
100 | () => { | ||
101 | this.notifier.success( | ||
102 | this.i18n('Playlist {{videoPlaylistName}} updated.', { videoPlaylistName: videoPlaylistUpdate.displayName }) | ||
103 | ) | ||
104 | |||
105 | this.router.navigate([ '/my-account', 'video-playlists' ]) | ||
106 | }, | ||
107 | |||
108 | err => this.error = err.message | ||
109 | ) | ||
110 | } | ||
111 | |||
112 | isCreation () { | ||
113 | return false | ||
114 | } | ||
115 | |||
116 | getFormButtonTitle () { | ||
117 | return this.i18n('Update') | ||
118 | } | ||
119 | |||
120 | private hydrateFormFromPlaylist () { | ||
121 | this.form.patchValue({ | ||
122 | displayName: this.videoPlaylistToUpdate.displayName, | ||
123 | privacy: this.videoPlaylistToUpdate.privacy.id, | ||
124 | description: this.videoPlaylistToUpdate.description, | ||
125 | videoChannelId: this.videoPlaylistToUpdate.videoChannel ? this.videoPlaylistToUpdate.videoChannel.id : null | ||
126 | }) | ||
127 | |||
128 | fetch(this.videoPlaylistToUpdate.thumbnailUrl) | ||
129 | .then(response => response.blob()) | ||
130 | .then(data => { | ||
131 | this.form.patchValue({ | ||
132 | thumbnailfile: data | ||
133 | }) | ||
134 | }) | ||
135 | } | ||
136 | } | ||
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 new file mode 100644 index 000000000..322560673 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.html | |||
@@ -0,0 +1,21 @@ | |||
1 | <div class="video-playlists-header"> | ||
2 | <a class="create-button" routerLink="create"> | ||
3 | <my-global-icon iconName="add"></my-global-icon> | ||
4 | <ng-container i18n>Create a new playlist</ng-container> | ||
5 | </a> | ||
6 | </div> | ||
7 | |||
8 | <div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()"> | ||
9 | <div *ngFor="let playlist of videoPlaylists" class="video-playlist"> | ||
10 | <div class="miniature-wrapper"> | ||
11 | <my-video-playlist-miniature [playlist]="playlist" [toManage]="true" [displayChannel]="true" [displayDescription]="true" [displayPrivacy]="true" | ||
12 | ></my-video-playlist-miniature> | ||
13 | </div> | ||
14 | |||
15 | <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons"> | ||
16 | <my-delete-button (click)="deleteVideoPlaylist(playlist)"></my-delete-button> | ||
17 | |||
18 | <my-edit-button [routerLink]="[ 'update', playlist.uuid ]"></my-edit-button> | ||
19 | </div> | ||
20 | </div> | ||
21 | </div> | ||
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 new file mode 100644 index 000000000..f648c33e4 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.scss | |||
@@ -0,0 +1,51 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .create-button { | ||
5 | @include create-button; | ||
6 | } | ||
7 | |||
8 | /deep/ .action-button { | ||
9 | &.action-button-delete { | ||
10 | margin-right: 10px; | ||
11 | } | ||
12 | } | ||
13 | |||
14 | .video-playlist { | ||
15 | @include row-blocks; | ||
16 | |||
17 | .miniature-wrapper { | ||
18 | flex-grow: 1; | ||
19 | |||
20 | /deep/ .miniature { | ||
21 | display: flex; | ||
22 | |||
23 | .miniature-info { | ||
24 | margin-left: 10px; | ||
25 | width: auto; | ||
26 | } | ||
27 | } | ||
28 | } | ||
29 | |||
30 | .video-playlist-buttons { | ||
31 | min-width: 190px; | ||
32 | } | ||
33 | } | ||
34 | |||
35 | .video-playlists-header { | ||
36 | text-align: right; | ||
37 | margin: 20px 0 50px; | ||
38 | } | ||
39 | |||
40 | @media screen and (max-width: 800px) { | ||
41 | .video-playlists-header { | ||
42 | text-align: center; | ||
43 | } | ||
44 | |||
45 | .video-playlist { | ||
46 | |||
47 | .video-playlist-buttons { | ||
48 | margin-top: 10px; | ||
49 | } | ||
50 | } | ||
51 | } | ||
diff --git a/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts new file mode 100644 index 000000000..e30656b92 --- /dev/null +++ b/client/src/app/+my-account/my-account-video-playlists/my-account-video-playlists.component.ts | |||
@@ -0,0 +1,88 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { Notifier } from '@app/core' | ||
3 | import { AuthService } from '../../core/auth' | ||
4 | import { ConfirmService } from '../../core/confirm' | ||
5 | import { User } from '@app/shared' | ||
6 | import { flatMap } from 'rxjs/operators' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
9 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | ||
10 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
11 | import { VideoPlaylistType } from '@shared/models' | ||
12 | |||
13 | @Component({ | ||
14 | selector: 'my-account-video-playlists', | ||
15 | templateUrl: './my-account-video-playlists.component.html', | ||
16 | styleUrls: [ './my-account-video-playlists.component.scss' ] | ||
17 | }) | ||
18 | export class MyAccountVideoPlaylistsComponent implements OnInit { | ||
19 | videoPlaylists: VideoPlaylist[] = [] | ||
20 | |||
21 | pagination: ComponentPagination = { | ||
22 | currentPage: 1, | ||
23 | itemsPerPage: 10, | ||
24 | totalItems: null | ||
25 | } | ||
26 | |||
27 | private user: User | ||
28 | |||
29 | constructor ( | ||
30 | private authService: AuthService, | ||
31 | private notifier: Notifier, | ||
32 | private confirmService: ConfirmService, | ||
33 | private videoPlaylistService: VideoPlaylistService, | ||
34 | private i18n: I18n | ||
35 | ) {} | ||
36 | |||
37 | ngOnInit () { | ||
38 | this.user = this.authService.getUser() | ||
39 | |||
40 | this.loadVideoPlaylists() | ||
41 | } | ||
42 | |||
43 | async deleteVideoPlaylist (videoPlaylist: VideoPlaylist) { | ||
44 | const res = await this.confirmService.confirm( | ||
45 | this.i18n( | ||
46 | 'Do you really want to delete {{playlistDisplayName}}?', | ||
47 | { playlistDisplayName: videoPlaylist.displayName } | ||
48 | ), | ||
49 | this.i18n('Delete') | ||
50 | ) | ||
51 | if (res === false) return | ||
52 | |||
53 | this.videoPlaylistService.removeVideoPlaylist(videoPlaylist) | ||
54 | .subscribe( | ||
55 | () => { | ||
56 | this.videoPlaylists = this.videoPlaylists | ||
57 | .filter(p => p.id !== videoPlaylist.id) | ||
58 | |||
59 | this.notifier.success( | ||
60 | this.i18n('Playlist {{playlistDisplayName}} deleted.', { playlistDisplayName: videoPlaylist.displayName }) | ||
61 | ) | ||
62 | }, | ||
63 | |||
64 | error => this.notifier.error(error.message) | ||
65 | ) | ||
66 | } | ||
67 | |||
68 | isRegularPlaylist (playlist: VideoPlaylist) { | ||
69 | return playlist.type.id === VideoPlaylistType.REGULAR | ||
70 | } | ||
71 | |||
72 | onNearOfBottom () { | ||
73 | // Last page | ||
74 | if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return | ||
75 | |||
76 | this.pagination.currentPage += 1 | ||
77 | this.loadVideoPlaylists() | ||
78 | } | ||
79 | |||
80 | private loadVideoPlaylists () { | ||
81 | this.authService.userInformationLoaded | ||
82 | .pipe(flatMap(() => this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt'))) | ||
83 | .subscribe(res => { | ||
84 | this.videoPlaylists = this.videoPlaylists.concat(res.data) | ||
85 | this.pagination.totalItems = res.total | ||
86 | }) | ||
87 | } | ||
88 | } | ||
diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html index 69748ef37..d7993fdc2 100644 --- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.html +++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.html | |||
@@ -1,56 +1,30 @@ | |||
1 | <div i18n *ngIf="pagination.totalItems === 0">No results.</div> | 1 | <my-videos-selection |
2 | 2 | [(selection)]="selection" | |
3 | <div | 3 | [(videosModel)]="videos" |
4 | myInfiniteScroller | 4 | [miniatureDisplayOptions]="miniatureDisplayOptions" |
5 | [pageHeight]="pageHeight" | 5 | [titlePage]="titlePage" |
6 | (nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)" | 6 | [getVideosObservableFunction]="getVideosObservableFunction" |
7 | class="videos" #videosElement | 7 | #videosSelection |
8 | > | 8 | > |
9 | <div *ngFor="let videos of videoPages; let i = index" class="videos-page"> | 9 | <ng-template ptTemplate="globalButtons"> |
10 | <div class="video" *ngFor="let video of videos; let j = index"> | 10 | <span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()"> |
11 | <div class="checkbox-container"> | 11 | <my-global-icon iconName="delete"></my-global-icon> |
12 | <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="checkedVideos[video.id]"></my-peertube-checkbox> | 12 | <ng-container i18n>Delete</ng-container> |
13 | </div> | 13 | </span> |
14 | 14 | </ng-template> | |
15 | <my-video-thumbnail [video]="video"></my-video-thumbnail> | 15 | |
16 | 16 | <ng-template ptTemplate="rowButtons" let-video> | |
17 | <div class="video-info"> | 17 | <my-delete-button (click)="deleteVideo(video)"></my-delete-button> |
18 | <a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a> | 18 | |
19 | <span i18n class="video-info-date-views">{{ video.createdAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span> | 19 | <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button> |
20 | <div class="video-info-private">{{ video.privacy.label }}{{ getStateLabel(video) }}</div> | 20 | |
21 | <div *ngIf="video.blacklisted" class="video-info-blacklisted"> | 21 | <my-button i18n-label label="Change ownership" |
22 | <span class="blacklisted-label" i18n>Blacklisted</span> | 22 | className="action-button-change-ownership" |
23 | <span class="blacklisted-reason" *ngIf="video.blacklistedReason">{{ video.blacklistedReason }}</span> | 23 | icon="im-with-her" |
24 | </div> | 24 | (click)="changeOwnership($event, video)" |
25 | </div> | 25 | ></my-button> |
26 | 26 | </ng-template> | |
27 | <!-- Display only once --> | 27 | </my-videos-selection> |
28 | <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0 && j === 0"> | ||
29 | <div class="action-selection-mode-child"> | ||
30 | <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()"> | ||
31 | Cancel | ||
32 | </span> | ||
33 | |||
34 | <span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()"> | ||
35 | <my-global-icon iconName="delete"></my-global-icon> | ||
36 | <ng-container i18n>Delete</ng-container> | ||
37 | </span> | ||
38 | </div> | ||
39 | </div> | ||
40 | |||
41 | <div class="video-buttons" *ngIf="isInSelectionMode() === false"> | ||
42 | <my-delete-button (click)="deleteVideo(video)"></my-delete-button> | ||
43 | |||
44 | <my-edit-button [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button> | ||
45 | 28 | ||
46 | <my-button i18n-label label="Change ownership" | ||
47 | className="action-button-change-ownership" | ||
48 | icon="im-with-her" | ||
49 | (click)="changeOwnership($event, video)" | ||
50 | ></my-button> | ||
51 | </div> | ||
52 | </div> | ||
53 | </div> | ||
54 | </div> | ||
55 | 29 | ||
56 | <my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership> | 30 | <my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership> |
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 39d0cf2f7..87398e7c8 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 | |||
@@ -1,119 +1,19 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | 3 | ||
4 | .action-selection-mode { | 4 | .action-button-delete-selection { |
5 | width: 174px; | 5 | display: inline-block; |
6 | display: flex; | ||
7 | justify-content: flex-end; | ||
8 | 6 | ||
9 | .action-selection-mode-child { | 7 | @include peertube-button; |
10 | position: fixed; | 8 | @include orange-button; |
9 | @include button-with-icon(21px); | ||
11 | 10 | ||
12 | .action-button { | 11 | my-global-icon { |
13 | display: inline-block; | 12 | @include apply-svg-color(#fff); |
14 | } | ||
15 | |||
16 | .action-button-cancel-selection { | ||
17 | @include peertube-button; | ||
18 | @include grey-button; | ||
19 | |||
20 | margin-right: 10px; | ||
21 | } | ||
22 | |||
23 | .action-button-delete-selection { | ||
24 | @include peertube-button; | ||
25 | @include orange-button; | ||
26 | @include button-with-icon(21px); | ||
27 | |||
28 | my-global-icon { | ||
29 | @include apply-svg-color(#fff); | ||
30 | } | ||
31 | } | ||
32 | } | ||
33 | } | ||
34 | |||
35 | .video { | ||
36 | @include row-blocks; | ||
37 | |||
38 | &:first-child { | ||
39 | margin-top: 47px; | ||
40 | } | ||
41 | |||
42 | .checkbox-container { | ||
43 | display: flex; | ||
44 | align-items: center; | ||
45 | margin-right: 20px; | ||
46 | margin-left: 12px; | ||
47 | } | ||
48 | |||
49 | my-video-thumbnail { | ||
50 | margin-right: 10px; | ||
51 | } | ||
52 | |||
53 | .video-info { | ||
54 | flex-grow: 1; | ||
55 | |||
56 | .video-info-name { | ||
57 | @include disable-default-a-behaviour; | ||
58 | |||
59 | color: var(--mainForegroundColor); | ||
60 | display: block; | ||
61 | width: fit-content; | ||
62 | font-size: 16px; | ||
63 | font-weight: $font-semibold; | ||
64 | } | ||
65 | |||
66 | .video-info-date-views, | ||
67 | .video-info-private, | ||
68 | .video-info-blacklisted { | ||
69 | font-size: 13px; | ||
70 | |||
71 | &.video-info-private, | ||
72 | &.video-info-blacklisted .blacklisted-label { | ||
73 | font-weight: $font-semibold; | ||
74 | } | ||
75 | |||
76 | &.video-info-blacklisted { | ||
77 | color: red; | ||
78 | |||
79 | .blacklisted-reason { | ||
80 | &::before { | ||
81 | content: ' - '; | ||
82 | } | ||
83 | } | ||
84 | } | ||
85 | } | ||
86 | } | ||
87 | |||
88 | .video-buttons { | ||
89 | min-width: 190px; | ||
90 | |||
91 | *:not(:last-child) { | ||
92 | margin-right: 10px; | ||
93 | } | ||
94 | } | 13 | } |
95 | } | 14 | } |
96 | 15 | ||
97 | @media screen and (max-width: $small-view) { | 16 | my-delete-button, |
98 | .video { | 17 | my-edit-button { |
99 | flex-direction: column; | 18 | margin-right: 10px; |
100 | height: auto; | ||
101 | text-align: center; | ||
102 | |||
103 | .video-info-name { | ||
104 | margin: auto; | ||
105 | } | ||
106 | |||
107 | input[type=checkbox] { | ||
108 | display: none; | ||
109 | } | ||
110 | |||
111 | my-video-thumbnail { | ||
112 | margin-right: 0; | ||
113 | } | ||
114 | |||
115 | .video-buttons { | ||
116 | margin-top: 10px; | ||
117 | } | ||
118 | } | ||
119 | } | 19 | } |
diff --git a/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts b/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts index 41608f796..5f29364a8 100644 --- a/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts +++ b/client/src/app/+my-account/my-account-videos/my-account-videos.component.ts | |||
@@ -1,87 +1,81 @@ | |||
1 | import { from as observableFrom, Observable } from 'rxjs' | 1 | import { concat, Observable } from 'rxjs' |
2 | import { concatAll, tap } from 'rxjs/operators' | 2 | import { tap, toArray } from 'rxjs/operators' |
3 | import { Component, OnDestroy, OnInit, Inject, LOCALE_ID, ViewChild } from '@angular/core' | 3 | import { Component, ViewChild } from '@angular/core' |
4 | import { ActivatedRoute, Router } from '@angular/router' | 4 | import { ActivatedRoute, Router } from '@angular/router' |
5 | import { Location } from '@angular/common' | ||
6 | import { immutableAssign } from '@app/shared/misc/utils' | 5 | import { immutableAssign } from '@app/shared/misc/utils' |
7 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | 6 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' |
8 | import { Notifier } from '@app/core' | 7 | import { Notifier, ServerService } from '@app/core' |
9 | import { AuthService } from '../../core/auth' | 8 | import { AuthService } from '../../core/auth' |
10 | import { ConfirmService } from '../../core/confirm' | 9 | import { ConfirmService } from '../../core/confirm' |
11 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' | ||
12 | import { Video } from '../../shared/video/video.model' | 10 | import { Video } from '../../shared/video/video.model' |
13 | import { VideoService } from '../../shared/video/video.service' | 11 | import { VideoService } from '../../shared/video/video.service' |
14 | import { I18n } from '@ngx-translate/i18n-polyfill' | 12 | import { I18n } from '@ngx-translate/i18n-polyfill' |
15 | import { VideoPrivacy, VideoState } from '../../../../../shared/models/videos' | ||
16 | import { ScreenService } from '@app/shared/misc/screen.service' | 13 | import { ScreenService } from '@app/shared/misc/screen.service' |
17 | import { VideoChangeOwnershipComponent } from './video-change-ownership/video-change-ownership.component' | 14 | import { VideoChangeOwnershipComponent } from './video-change-ownership/video-change-ownership.component' |
15 | import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component' | ||
16 | import { SelectionType, VideosSelectionComponent } from '@app/shared/video/videos-selection.component' | ||
17 | import { VideoSortField } from '@app/shared/video/sort-field.type' | ||
18 | import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | ||
18 | 19 | ||
19 | @Component({ | 20 | @Component({ |
20 | selector: 'my-account-videos', | 21 | selector: 'my-account-videos', |
21 | templateUrl: './my-account-videos.component.html', | 22 | templateUrl: './my-account-videos.component.html', |
22 | styleUrls: [ './my-account-videos.component.scss' ] | 23 | styleUrls: [ './my-account-videos.component.scss' ] |
23 | }) | 24 | }) |
24 | export class MyAccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { | 25 | export class MyAccountVideosComponent implements DisableForReuseHook { |
26 | @ViewChild('videosSelection') videosSelection: VideosSelectionComponent | ||
27 | @ViewChild('videoChangeOwnershipModal') videoChangeOwnershipModal: VideoChangeOwnershipComponent | ||
28 | |||
25 | titlePage: string | 29 | titlePage: string |
26 | currentRoute = '/my-account/videos' | 30 | selection: SelectionType = {} |
27 | checkedVideos: { [ id: number ]: boolean } = {} | ||
28 | pagination: ComponentPagination = { | 31 | pagination: ComponentPagination = { |
29 | currentPage: 1, | 32 | currentPage: 1, |
30 | itemsPerPage: 5, | 33 | itemsPerPage: 5, |
31 | totalItems: null | 34 | totalItems: null |
32 | } | 35 | } |
33 | 36 | miniatureDisplayOptions: MiniatureDisplayOptions = { | |
34 | protected baseVideoWidth = -1 | 37 | date: true, |
35 | protected baseVideoHeight = 155 | 38 | views: true, |
36 | 39 | by: false, | |
37 | @ViewChild('videoChangeOwnershipModal') videoChangeOwnershipModal: VideoChangeOwnershipComponent | 40 | privacyLabel: false, |
41 | privacyText: true, | ||
42 | state: true, | ||
43 | blacklistInfo: true | ||
44 | } | ||
45 | videos: Video[] = [] | ||
46 | getVideosObservableFunction = this.getVideosObservable.bind(this) | ||
38 | 47 | ||
39 | constructor ( | 48 | constructor ( |
40 | protected router: Router, | 49 | protected router: Router, |
50 | protected serverService: ServerService, | ||
41 | protected route: ActivatedRoute, | 51 | protected route: ActivatedRoute, |
42 | protected authService: AuthService, | 52 | protected authService: AuthService, |
43 | protected notifier: Notifier, | 53 | protected notifier: Notifier, |
44 | protected location: Location, | ||
45 | protected screenService: ScreenService, | 54 | protected screenService: ScreenService, |
46 | protected i18n: I18n, | 55 | private i18n: I18n, |
47 | private confirmService: ConfirmService, | 56 | private confirmService: ConfirmService, |
48 | private videoService: VideoService, | 57 | private videoService: VideoService |
49 | @Inject(LOCALE_ID) private localeId: string | ||
50 | ) { | 58 | ) { |
51 | super() | ||
52 | |||
53 | this.titlePage = this.i18n('My videos') | 59 | this.titlePage = this.i18n('My videos') |
54 | } | 60 | } |
55 | 61 | ||
56 | ngOnInit () { | 62 | disableForReuse () { |
57 | super.ngOnInit() | 63 | this.videosSelection.disableForReuse() |
58 | } | ||
59 | |||
60 | ngOnDestroy () { | ||
61 | super.ngOnDestroy() | ||
62 | } | ||
63 | |||
64 | abortSelectionMode () { | ||
65 | this.checkedVideos = {} | ||
66 | } | 64 | } |
67 | 65 | ||
68 | isInSelectionMode () { | 66 | enabledForReuse () { |
69 | return Object.keys(this.checkedVideos).some(k => this.checkedVideos[ k ] === true) | 67 | this.videosSelection.enabledForReuse() |
70 | } | 68 | } |
71 | 69 | ||
72 | getVideosObservable (page: number) { | 70 | getVideosObservable (page: number, sort: VideoSortField) { |
73 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | 71 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) |
74 | 72 | ||
75 | return this.videoService.getMyVideos(newPagination, this.sort) | 73 | return this.videoService.getMyVideos(newPagination, sort) |
76 | } | ||
77 | |||
78 | generateSyndicationList () { | ||
79 | throw new Error('Method not implemented.') | ||
80 | } | 74 | } |
81 | 75 | ||
82 | async deleteSelectedVideos () { | 76 | async deleteSelectedVideos () { |
83 | const toDeleteVideosIds = Object.keys(this.checkedVideos) | 77 | const toDeleteVideosIds = Object.keys(this.selection) |
84 | .filter(k => this.checkedVideos[ k ] === true) | 78 | .filter(k => this.selection[ k ] === true) |
85 | .map(k => parseInt(k, 10)) | 79 | .map(k => parseInt(k, 10)) |
86 | 80 | ||
87 | const res = await this.confirmService.confirm( | 81 | const res = await this.confirmService.confirm( |
@@ -93,19 +87,18 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni | |||
93 | const observables: Observable<any>[] = [] | 87 | const observables: Observable<any>[] = [] |
94 | for (const videoId of toDeleteVideosIds) { | 88 | for (const videoId of toDeleteVideosIds) { |
95 | const o = this.videoService.removeVideo(videoId) | 89 | const o = this.videoService.removeVideo(videoId) |
96 | .pipe(tap(() => this.spliceVideosById(videoId))) | 90 | .pipe(tap(() => this.removeVideoFromArray(videoId))) |
97 | 91 | ||
98 | observables.push(o) | 92 | observables.push(o) |
99 | } | 93 | } |
100 | 94 | ||
101 | observableFrom(observables) | 95 | concat(...observables) |
102 | .pipe(concatAll()) | 96 | .pipe(toArray()) |
103 | .subscribe( | 97 | .subscribe( |
104 | res => { | 98 | () => { |
105 | this.notifier.success(this.i18n('{{deleteLength}} videos deleted.', { deleteLength: toDeleteVideosIds.length })) | 99 | this.notifier.success(this.i18n('{{deleteLength}} videos deleted.', { deleteLength: toDeleteVideosIds.length })) |
106 | 100 | ||
107 | this.abortSelectionMode() | 101 | this.selection = {} |
108 | this.reloadVideos() | ||
109 | }, | 102 | }, |
110 | 103 | ||
111 | err => this.notifier.error(err.message) | 104 | err => this.notifier.error(err.message) |
@@ -123,7 +116,7 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni | |||
123 | .subscribe( | 116 | .subscribe( |
124 | () => { | 117 | () => { |
125 | this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: video.name })) | 118 | this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: video.name })) |
126 | this.reloadVideos() | 119 | this.removeVideoFromArray(video.id) |
127 | }, | 120 | }, |
128 | 121 | ||
129 | error => this.notifier.error(error.message) | 122 | error => this.notifier.error(error.message) |
@@ -135,41 +128,7 @@ export class MyAccountVideosComponent extends AbstractVideoList implements OnIni | |||
135 | this.videoChangeOwnershipModal.show(video) | 128 | this.videoChangeOwnershipModal.show(video) |
136 | } | 129 | } |
137 | 130 | ||
138 | getStateLabel (video: Video) { | 131 | private removeVideoFromArray (id: number) { |
139 | let suffix: string | 132 | this.videos = this.videos.filter(v => v.id !== id) |
140 | |||
141 | if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) { | ||
142 | suffix = this.i18n('Published') | ||
143 | } else if (video.scheduledUpdate) { | ||
144 | const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId) | ||
145 | suffix = this.i18n('Publication scheduled on ') + updateAt | ||
146 | } else if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) { | ||
147 | suffix = this.i18n('Waiting transcoding') | ||
148 | } else if (video.state.id === VideoState.TO_TRANSCODE) { | ||
149 | suffix = this.i18n('To transcode') | ||
150 | } else if (video.state.id === VideoState.TO_IMPORT) { | ||
151 | suffix = this.i18n('To import') | ||
152 | } else { | ||
153 | return '' | ||
154 | } | ||
155 | |||
156 | return ' - ' + suffix | ||
157 | } | ||
158 | |||
159 | protected buildVideoHeight () { | ||
160 | // In account videos, the video height is fixed | ||
161 | return this.baseVideoHeight | ||
162 | } | ||
163 | |||
164 | private spliceVideosById (id: number) { | ||
165 | for (const key of Object.keys(this.loadedPages)) { | ||
166 | const videos: Video[] = this.loadedPages[ key ] | ||
167 | const index = videos.findIndex(v => v.id === id) | ||
168 | |||
169 | if (index !== -1) { | ||
170 | videos.splice(index, 1) | ||
171 | return | ||
172 | } | ||
173 | } | ||
174 | } | 133 | } |
175 | } | 134 | } |
diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts index 8a4102d80..d98d06f8e 100644 --- a/client/src/app/+my-account/my-account.component.ts +++ b/client/src/app/+my-account/my-account.component.ts | |||
@@ -21,19 +21,28 @@ export class MyAccountComponent { | |||
21 | children: [ | 21 | children: [ |
22 | { | 22 | { |
23 | label: this.i18n('My channels'), | 23 | label: this.i18n('My channels'), |
24 | routerLink: '/my-account/video-channels' | 24 | routerLink: '/my-account/video-channels', |
25 | iconName: 'folder' | ||
25 | }, | 26 | }, |
26 | { | 27 | { |
27 | label: this.i18n('My videos'), | 28 | label: this.i18n('My videos'), |
28 | routerLink: '/my-account/videos' | 29 | routerLink: '/my-account/videos', |
30 | iconName: 'videos' | ||
31 | }, | ||
32 | { | ||
33 | label: this.i18n('My playlists'), | ||
34 | routerLink: '/my-account/video-playlists', | ||
35 | iconName: 'playlists' | ||
29 | }, | 36 | }, |
30 | { | 37 | { |
31 | label: this.i18n('My subscriptions'), | 38 | label: this.i18n('My subscriptions'), |
32 | routerLink: '/my-account/subscriptions' | 39 | routerLink: '/my-account/subscriptions', |
40 | iconName: 'subscriptions' | ||
33 | }, | 41 | }, |
34 | { | 42 | { |
35 | label: this.i18n('My history'), | 43 | label: this.i18n('My history'), |
36 | routerLink: '/my-account/history/videos' | 44 | routerLink: '/my-account/history/videos', |
45 | iconName: 'history' | ||
37 | } | 46 | } |
38 | ] | 47 | ] |
39 | } | 48 | } |
@@ -41,7 +50,8 @@ export class MyAccountComponent { | |||
41 | if (this.isVideoImportEnabled()) { | 50 | if (this.isVideoImportEnabled()) { |
42 | libraryEntries.children.push({ | 51 | libraryEntries.children.push({ |
43 | label: 'My imports', | 52 | label: 'My imports', |
44 | routerLink: '/my-account/video-imports' | 53 | routerLink: '/my-account/video-imports', |
54 | iconName: 'cloud-download' | ||
45 | }) | 55 | }) |
46 | } | 56 | } |
47 | 57 | ||
@@ -50,15 +60,18 @@ export class MyAccountComponent { | |||
50 | children: [ | 60 | children: [ |
51 | { | 61 | { |
52 | label: this.i18n('Muted accounts'), | 62 | label: this.i18n('Muted accounts'), |
53 | routerLink: '/my-account/blocklist/accounts' | 63 | routerLink: '/my-account/blocklist/accounts', |
64 | iconName: 'user' | ||
54 | }, | 65 | }, |
55 | { | 66 | { |
56 | label: this.i18n('Muted instances'), | 67 | label: this.i18n('Muted instances'), |
57 | routerLink: '/my-account/blocklist/servers' | 68 | routerLink: '/my-account/blocklist/servers', |
69 | iconName: 'server' | ||
58 | }, | 70 | }, |
59 | { | 71 | { |
60 | label: this.i18n('Ownership changes'), | 72 | label: this.i18n('Ownership changes'), |
61 | routerLink: '/my-account/ownership' | 73 | routerLink: '/my-account/ownership', |
74 | iconName: 'im-with-her' | ||
62 | } | 75 | } |
63 | ] | 76 | ] |
64 | } | 77 | } |
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index 18f51f171..4a18a9968 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts | |||
@@ -25,6 +25,17 @@ import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-b | |||
25 | import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' | 25 | import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component' |
26 | import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' | 26 | import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component' |
27 | import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences' | 27 | import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences' |
28 | import { | ||
29 | MyAccountVideoPlaylistCreateComponent | ||
30 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component' | ||
31 | import { | ||
32 | MyAccountVideoPlaylistUpdateComponent | ||
33 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component' | ||
34 | import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component' | ||
35 | import { | ||
36 | MyAccountVideoPlaylistElementsComponent | ||
37 | } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component' | ||
38 | import { DragDropModule } from '@angular/cdk/drag-drop' | ||
28 | 39 | ||
29 | @NgModule({ | 40 | @NgModule({ |
30 | imports: [ | 41 | imports: [ |
@@ -33,7 +44,8 @@ import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-a | |||
33 | AutoCompleteModule, | 44 | AutoCompleteModule, |
34 | SharedModule, | 45 | SharedModule, |
35 | TableModule, | 46 | TableModule, |
36 | InputSwitchModule | 47 | InputSwitchModule, |
48 | DragDropModule | ||
37 | ], | 49 | ], |
38 | 50 | ||
39 | declarations: [ | 51 | declarations: [ |
@@ -57,7 +69,12 @@ import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-a | |||
57 | MyAccountServerBlocklistComponent, | 69 | MyAccountServerBlocklistComponent, |
58 | MyAccountHistoryComponent, | 70 | MyAccountHistoryComponent, |
59 | MyAccountNotificationsComponent, | 71 | MyAccountNotificationsComponent, |
60 | MyAccountNotificationPreferencesComponent | 72 | MyAccountNotificationPreferencesComponent, |
73 | |||
74 | MyAccountVideoPlaylistCreateComponent, | ||
75 | MyAccountVideoPlaylistUpdateComponent, | ||
76 | MyAccountVideoPlaylistsComponent, | ||
77 | MyAccountVideoPlaylistElementsComponent | ||
61 | ], | 78 | ], |
62 | 79 | ||
63 | exports: [ | 80 | exports: [ |
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 0b0c83de5..86f8108b9 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 | |||
@@ -18,6 +18,10 @@ | |||
18 | .actor-info-display-name { | 18 | .actor-info-display-name { |
19 | font-size: 20px; | 19 | font-size: 20px; |
20 | font-weight: $font-bold; | 20 | font-weight: $font-bold; |
21 | |||
22 | @media screen and (max-width: $small-view) { | ||
23 | font-size: 16px; | ||
24 | } | ||
21 | } | 25 | } |
22 | 26 | ||
23 | .actor-info-username { | 27 | .actor-info-username { |
@@ -48,4 +52,4 @@ | |||
48 | 52 | ||
49 | position: relative; | 53 | position: relative; |
50 | top: -10px; | 54 | top: -10px; |
51 | } \ No newline at end of file | 55 | } |
diff --git a/client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts b/client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts index 895b19064..11f9391e1 100644 --- a/client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts +++ b/client/src/app/+video-channels/video-channel-about/video-channel-about.component.ts | |||
@@ -26,11 +26,11 @@ export class VideoChannelAboutComponent implements OnInit, OnDestroy { | |||
26 | ngOnInit () { | 26 | ngOnInit () { |
27 | // Parent get the video channel for us | 27 | // Parent get the video channel for us |
28 | this.videoChannelSub = this.videoChannelService.videoChannelLoaded | 28 | this.videoChannelSub = this.videoChannelService.videoChannelLoaded |
29 | .subscribe(videoChannel => { | 29 | .subscribe(async videoChannel => { |
30 | this.videoChannel = videoChannel | 30 | this.videoChannel = videoChannel |
31 | 31 | ||
32 | this.descriptionHTML = this.markdownService.textMarkdownToHTML(this.videoChannel.description) | 32 | this.descriptionHTML = await this.markdownService.textMarkdownToHTML(this.videoChannel.description) |
33 | this.supportHTML = this.markdownService.enhancedMarkdownToHTML(this.videoChannel.support) | 33 | this.supportHTML = await this.markdownService.enhancedMarkdownToHTML(this.videoChannel.support) |
34 | }) | 34 | }) |
35 | } | 35 | } |
36 | 36 | ||
diff --git a/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html new file mode 100644 index 000000000..befc7143c --- /dev/null +++ b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html | |||
@@ -0,0 +1,11 @@ | |||
1 | <div i18n class="title-page title-page-single"> | ||
2 | Created {{ pagination.totalItems }} playlists | ||
3 | </div> | ||
4 | |||
5 | <div i18n class="no-results" *ngIf="pagination.totalItems === 0">This channel does not have playlists.</div> | ||
6 | |||
7 | <div class="video-playlist" myInfiniteScroller (nearOfBottom)="onNearOfBottom()"> | ||
8 | <div *ngFor="let playlist of videoPlaylists"> | ||
9 | <my-video-playlist-miniature [playlist]="playlist" [toManage]="false"></my-video-playlist-miniature> | ||
10 | </div> | ||
11 | </div> | ||
diff --git a/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.scss b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.scss new file mode 100644 index 000000000..fe9104794 --- /dev/null +++ b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.scss | |||
@@ -0,0 +1,9 @@ | |||
1 | .video-playlist { | ||
2 | display: flex; | ||
3 | justify-content: center; | ||
4 | |||
5 | my-video-playlist-miniature { | ||
6 | margin-right: 15px; | ||
7 | margin-bottom: 30px; | ||
8 | } | ||
9 | } | ||
diff --git a/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts new file mode 100644 index 000000000..907aefae1 --- /dev/null +++ b/client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.ts | |||
@@ -0,0 +1,63 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { ConfirmService } from '../../core/confirm' | ||
3 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | ||
4 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | ||
5 | import { Subscription } from 'rxjs' | ||
6 | import { Notifier } from '@app/core' | ||
7 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
8 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | ||
9 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-video-channel-playlists', | ||
13 | templateUrl: './video-channel-playlists.component.html', | ||
14 | styleUrls: [ './video-channel-playlists.component.scss' ] | ||
15 | }) | ||
16 | export class VideoChannelPlaylistsComponent implements OnInit, OnDestroy { | ||
17 | videoPlaylists: VideoPlaylist[] = [] | ||
18 | |||
19 | pagination: ComponentPagination = { | ||
20 | currentPage: 1, | ||
21 | itemsPerPage: 20, | ||
22 | totalItems: null | ||
23 | } | ||
24 | |||
25 | private videoChannelSub: Subscription | ||
26 | private videoChannel: VideoChannel | ||
27 | |||
28 | constructor ( | ||
29 | private notifier: Notifier, | ||
30 | private confirmService: ConfirmService, | ||
31 | private videoPlaylistService: VideoPlaylistService, | ||
32 | private videoChannelService: VideoChannelService | ||
33 | ) {} | ||
34 | |||
35 | ngOnInit () { | ||
36 | // Parent get the video channel for us | ||
37 | this.videoChannelSub = this.videoChannelService.videoChannelLoaded | ||
38 | .subscribe(videoChannel => { | ||
39 | this.videoChannel = videoChannel | ||
40 | this.loadVideoPlaylists() | ||
41 | }) | ||
42 | } | ||
43 | |||
44 | ngOnDestroy () { | ||
45 | if (this.videoChannelSub) this.videoChannelSub.unsubscribe() | ||
46 | } | ||
47 | |||
48 | onNearOfBottom () { | ||
49 | // Last page | ||
50 | if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return | ||
51 | |||
52 | this.pagination.currentPage += 1 | ||
53 | this.loadVideoPlaylists() | ||
54 | } | ||
55 | |||
56 | private loadVideoPlaylists () { | ||
57 | this.videoPlaylistService.listChannelPlaylists(this.videoChannel) | ||
58 | .subscribe(res => { | ||
59 | this.videoPlaylists = this.videoPlaylists.concat(res.data) | ||
60 | this.pagination.totalItems = res.total | ||
61 | }) | ||
62 | } | ||
63 | } | ||
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 dea378a6e..5e60b34b4 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 | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { Location } from '@angular/common' | ||
4 | import { immutableAssign } from '@app/shared/misc/utils' | 3 | import { immutableAssign } from '@app/shared/misc/utils' |
5 | import { AuthService } from '../../core/auth' | 4 | import { AuthService } from '../../core/auth' |
6 | import { ConfirmService } from '../../core/confirm' | 5 | import { ConfirmService } from '../../core/confirm' |
@@ -8,11 +7,11 @@ import { AbstractVideoList } from '../../shared/video/abstract-video-list' | |||
8 | import { VideoService } from '../../shared/video/video.service' | 7 | import { VideoService } from '../../shared/video/video.service' |
9 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 8 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
10 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 9 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
11 | import { tap } from 'rxjs/operators' | 10 | import { first, tap } from 'rxjs/operators' |
12 | import { I18n } from '@ngx-translate/i18n-polyfill' | 11 | import { I18n } from '@ngx-translate/i18n-polyfill' |
13 | import { Subscription } from 'rxjs' | 12 | import { Subscription } from 'rxjs' |
14 | import { ScreenService } from '@app/shared/misc/screen.service' | 13 | import { ScreenService } from '@app/shared/misc/screen.service' |
15 | import { Notifier } from '@app/core' | 14 | import { Notifier, ServerService } from '@app/core' |
16 | 15 | ||
17 | @Component({ | 16 | @Component({ |
18 | selector: 'my-video-channel-videos', | 17 | selector: 'my-video-channel-videos', |
@@ -24,8 +23,6 @@ import { Notifier } from '@app/core' | |||
24 | }) | 23 | }) |
25 | export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { | 24 | export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { |
26 | titlePage: string | 25 | titlePage: string |
27 | marginContent = false // Disable margin | ||
28 | currentRoute = '/video-channels/videos' | ||
29 | loadOnInit = false | 26 | loadOnInit = false |
30 | 27 | ||
31 | private videoChannel: VideoChannel | 28 | private videoChannel: VideoChannel |
@@ -33,13 +30,13 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On | |||
33 | 30 | ||
34 | constructor ( | 31 | constructor ( |
35 | protected router: Router, | 32 | protected router: Router, |
33 | protected serverService: ServerService, | ||
36 | protected route: ActivatedRoute, | 34 | protected route: ActivatedRoute, |
37 | protected authService: AuthService, | 35 | protected authService: AuthService, |
38 | protected notifier: Notifier, | 36 | protected notifier: Notifier, |
39 | protected confirmService: ConfirmService, | 37 | protected confirmService: ConfirmService, |
40 | protected location: Location, | ||
41 | protected screenService: ScreenService, | 38 | protected screenService: ScreenService, |
42 | protected i18n: I18n, | 39 | private i18n: I18n, |
43 | private videoChannelService: VideoChannelService, | 40 | private videoChannelService: VideoChannelService, |
44 | private videoService: VideoService | 41 | private videoService: VideoService |
45 | ) { | 42 | ) { |
@@ -53,13 +50,13 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On | |||
53 | 50 | ||
54 | // Parent get the video channel for us | 51 | // Parent get the video channel for us |
55 | this.videoChannelSub = this.videoChannelService.videoChannelLoaded | 52 | this.videoChannelSub = this.videoChannelService.videoChannelLoaded |
56 | .subscribe(videoChannel => { | 53 | .pipe(first()) |
57 | this.videoChannel = videoChannel | 54 | .subscribe(videoChannel => { |
58 | this.currentRoute = '/video-channels/' + this.videoChannel.nameWithHost + '/videos' | 55 | this.videoChannel = videoChannel |
59 | 56 | ||
60 | this.reloadVideos() | 57 | this.reloadVideos() |
61 | this.generateSyndicationList() | 58 | this.generateSyndicationList() |
62 | }) | 59 | }) |
63 | } | 60 | } |
64 | 61 | ||
65 | ngOnDestroy () { | 62 | ngOnDestroy () { |
diff --git a/client/src/app/+video-channels/video-channels-routing.module.ts b/client/src/app/+video-channels/video-channels-routing.module.ts index 3ac3533d9..d4872a0a5 100644 --- a/client/src/app/+video-channels/video-channels-routing.module.ts +++ b/client/src/app/+video-channels/video-channels-routing.module.ts | |||
@@ -4,6 +4,7 @@ import { MetaGuard } from '@ngx-meta/core' | |||
4 | import { VideoChannelsComponent } from './video-channels.component' | 4 | import { VideoChannelsComponent } from './video-channels.component' |
5 | import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component' | 5 | import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component' |
6 | import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component' | 6 | import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component' |
7 | import { VideoChannelPlaylistsComponent } from '@app/+video-channels/video-channel-playlists/video-channel-playlists.component' | ||
7 | 8 | ||
8 | const videoChannelsRoutes: Routes = [ | 9 | const videoChannelsRoutes: Routes = [ |
9 | { | 10 | { |
@@ -22,6 +23,19 @@ const videoChannelsRoutes: Routes = [ | |||
22 | data: { | 23 | data: { |
23 | meta: { | 24 | meta: { |
24 | title: 'Video channel videos' | 25 | title: 'Video channel videos' |
26 | }, | ||
27 | reuse: { | ||
28 | enabled: true, | ||
29 | key: 'video-channel-videos-list' | ||
30 | } | ||
31 | } | ||
32 | }, | ||
33 | { | ||
34 | path: 'video-playlists', | ||
35 | component: VideoChannelPlaylistsComponent, | ||
36 | data: { | ||
37 | meta: { | ||
38 | title: 'Video channel playlists' | ||
25 | } | 39 | } |
26 | } | 40 | } |
27 | }, | 41 | }, |
diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html index c65b5713d..600b7a365 100644 --- a/client/src/app/+video-channels/video-channels.component.html +++ b/client/src/app/+video-channels/video-channels.component.html | |||
@@ -22,6 +22,7 @@ | |||
22 | 22 | ||
23 | <div class="links"> | 23 | <div class="links"> |
24 | <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a> | 24 | <a i18n routerLink="videos" routerLinkActive="active" class="title-page">Videos</a> |
25 | <a i18n routerLink="video-playlists" routerLinkActive="active" class="title-page">Video playlists</a> | ||
25 | <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> | 26 | <a i18n routerLink="about" routerLinkActive="active" class="title-page">About</a> |
26 | </div> | 27 | </div> |
27 | </div> | 28 | </div> |
diff --git a/client/src/app/+video-channels/video-channels.module.ts b/client/src/app/+video-channels/video-channels.module.ts index a09ea6f11..6975d05b2 100644 --- a/client/src/app/+video-channels/video-channels.module.ts +++ b/client/src/app/+video-channels/video-channels.module.ts | |||
@@ -4,6 +4,7 @@ import { VideoChannelsRoutingModule } from './video-channels-routing.module' | |||
4 | import { VideoChannelsComponent } from './video-channels.component' | 4 | import { VideoChannelsComponent } from './video-channels.component' |
5 | import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component' | 5 | import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component' |
6 | import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component' | 6 | import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component' |
7 | import { VideoChannelPlaylistsComponent } from '@app/+video-channels/video-channel-playlists/video-channel-playlists.component' | ||
7 | 8 | ||
8 | @NgModule({ | 9 | @NgModule({ |
9 | imports: [ | 10 | imports: [ |
@@ -14,7 +15,8 @@ import { VideoChannelAboutComponent } from './video-channel-about/video-channel- | |||
14 | declarations: [ | 15 | declarations: [ |
15 | VideoChannelsComponent, | 16 | VideoChannelsComponent, |
16 | VideoChannelVideosComponent, | 17 | VideoChannelVideosComponent, |
17 | VideoChannelAboutComponent | 18 | VideoChannelAboutComponent, |
19 | VideoChannelPlaylistsComponent | ||
18 | ], | 20 | ], |
19 | 21 | ||
20 | exports: [ | 22 | exports: [ |
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts index cff37a7d6..db8888dba 100644 --- a/client/src/app/app-routing.module.ts +++ b/client/src/app/app-routing.module.ts | |||
@@ -1,8 +1,9 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { RouterModule, Routes } from '@angular/router' | 2 | import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router' |
3 | 3 | ||
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 | 7 | ||
7 | const routes: Routes = [ | 8 | const routes: Routes = [ |
8 | { | 9 | { |
@@ -43,12 +44,14 @@ const routes: Routes = [ | |||
43 | imports: [ | 44 | imports: [ |
44 | RouterModule.forRoot(routes, { | 45 | RouterModule.forRoot(routes, { |
45 | useHash: Boolean(history.pushState) === false, | 46 | useHash: Boolean(history.pushState) === false, |
47 | scrollPositionRestoration: 'disabled', | ||
46 | preloadingStrategy: PreloadSelectedModulesList, | 48 | preloadingStrategy: PreloadSelectedModulesList, |
47 | anchorScrolling: 'enabled' | 49 | anchorScrolling: 'disabled' |
48 | }) | 50 | }) |
49 | ], | 51 | ], |
50 | providers: [ | 52 | providers: [ |
51 | PreloadSelectedModulesList | 53 | PreloadSelectedModulesList, |
54 | { provide: RouteReuseStrategy, useClass: CustomReuseStrategy } | ||
52 | ], | 55 | ], |
53 | exports: [ RouterModule ] | 56 | exports: [ RouterModule ] |
54 | }) | 57 | }) |
diff --git a/client/src/app/app.component.scss b/client/src/app/app.component.scss index 881f3ff31..3f8b9777a 100644 --- a/client/src/app/app.component.scss +++ b/client/src/app/app.component.scss | |||
@@ -48,9 +48,8 @@ | |||
48 | overflow: hidden; | 48 | overflow: hidden; |
49 | 49 | ||
50 | .instance-name { | 50 | .instance-name { |
51 | overflow: hidden; | 51 | @include ellipsis; |
52 | text-overflow: ellipsis; | 52 | |
53 | white-space: nowrap; | ||
54 | width: 100%; | 53 | width: 100%; |
55 | } | 54 | } |
56 | 55 | ||
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 7583fdee8..915466af7 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts | |||
@@ -1,13 +1,14 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | 1 | import { Component, OnInit } from '@angular/core' |
2 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser' | 2 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser' |
3 | import { GuardsCheckStart, NavigationEnd, Router } 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 { skip, debounceTime } from 'rxjs/operators' | 7 | import { debounceTime, filter, map, pairwise, skip } from 'rxjs/operators' |
8 | import { HotkeysService, Hotkey } 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' | 10 | import { fromEvent } from 'rxjs' |
11 | import { ViewportScroller } from '@angular/common' | ||
11 | 12 | ||
12 | @Component({ | 13 | @Component({ |
13 | selector: 'my-app', | 14 | selector: 'my-app', |
@@ -22,6 +23,7 @@ export class AppComponent implements OnInit { | |||
22 | 23 | ||
23 | constructor ( | 24 | constructor ( |
24 | private i18n: I18n, | 25 | private i18n: I18n, |
26 | private viewportScroller: ViewportScroller, | ||
25 | private router: Router, | 27 | private router: Router, |
26 | private authService: AuthService, | 28 | private authService: AuthService, |
27 | private serverService: ServerService, | 29 | private serverService: ServerService, |
@@ -52,15 +54,6 @@ export class AppComponent implements OnInit { | |||
52 | ngOnInit () { | 54 | ngOnInit () { |
53 | document.getElementById('incompatible-browser').className += ' browser-ok' | 55 | document.getElementById('incompatible-browser').className += ' browser-ok' |
54 | 56 | ||
55 | this.router.events.subscribe(e => { | ||
56 | if (e instanceof NavigationEnd) { | ||
57 | const pathname = window.location.pathname | ||
58 | if (!pathname || pathname === '/' || is18nPath(pathname)) { | ||
59 | this.redirectService.redirectToHomepage(true) | ||
60 | } | ||
61 | } | ||
62 | }) | ||
63 | |||
64 | this.authService.loadClientCredentials() | 57 | this.authService.loadClientCredentials() |
65 | 58 | ||
66 | if (this.isUserLoggedIn()) { | 59 | if (this.isUserLoggedIn()) { |
@@ -74,21 +67,101 @@ export class AppComponent implements OnInit { | |||
74 | this.serverService.loadVideoLanguages() | 67 | this.serverService.loadVideoLanguages() |
75 | this.serverService.loadVideoLicences() | 68 | this.serverService.loadVideoLicences() |
76 | this.serverService.loadVideoPrivacies() | 69 | this.serverService.loadVideoPrivacies() |
70 | this.serverService.loadVideoPlaylistPrivacies() | ||
77 | 71 | ||
78 | // Do not display menu on small screens | 72 | // Do not display menu on small screens |
79 | if (this.screenService.isInSmallView()) { | 73 | if (this.screenService.isInSmallView()) { |
80 | this.isMenuDisplayed = false | 74 | this.isMenuDisplayed = false |
81 | } | 75 | } |
82 | 76 | ||
83 | this.router.events.subscribe( | 77 | this.initRouteEvents() |
84 | e => { | 78 | this.injectJS() |
85 | // User clicked on a link in the menu, change the page | 79 | this.injectCSS() |
86 | if (e instanceof GuardsCheckStart && this.screenService.isInSmallView()) { | 80 | |
87 | this.isMenuDisplayed = false | 81 | this.initHotkeys() |
88 | } | 82 | |
83 | fromEvent(window, 'resize') | ||
84 | .pipe(debounceTime(200)) | ||
85 | .subscribe(() => this.onResize()) | ||
86 | } | ||
87 | |||
88 | isUserLoggedIn () { | ||
89 | return this.authService.isLoggedIn() | ||
90 | } | ||
91 | |||
92 | toggleMenu () { | ||
93 | this.isMenuDisplayed = !this.isMenuDisplayed | ||
94 | this.isMenuChangedByUser = true | ||
95 | } | ||
96 | |||
97 | onResize () { | ||
98 | this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser | ||
99 | } | ||
100 | |||
101 | private initRouteEvents () { | ||
102 | let resetScroll = true | ||
103 | const eventsObs = this.router.events | ||
104 | |||
105 | const scrollEvent = eventsObs.pipe(filter((e: Event): e is Scroll => e instanceof Scroll)) | ||
106 | const navigationEndEvent = eventsObs.pipe(filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd)) | ||
107 | |||
108 | scrollEvent.subscribe(e => { | ||
109 | if (e.position) { | ||
110 | return this.viewportScroller.scrollToPosition(e.position) | ||
89 | } | 111 | } |
90 | ) | ||
91 | 112 | ||
113 | if (e.anchor) { | ||
114 | return this.viewportScroller.scrollToAnchor(e.anchor) | ||
115 | } | ||
116 | |||
117 | if (resetScroll) { | ||
118 | return this.viewportScroller.scrollToPosition([ 0, 0 ]) | ||
119 | } | ||
120 | }) | ||
121 | |||
122 | // When we add the a-state parameter, we don't want to alter the scroll | ||
123 | navigationEndEvent.pipe(pairwise()) | ||
124 | .subscribe(([ e1, e2 ]) => { | ||
125 | try { | ||
126 | resetScroll = false | ||
127 | |||
128 | const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects) | ||
129 | const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects) | ||
130 | |||
131 | if (previousUrl.pathname !== nextUrl.pathname) { | ||
132 | resetScroll = true | ||
133 | return | ||
134 | } | ||
135 | |||
136 | const nextSearchParams = nextUrl.searchParams | ||
137 | nextSearchParams.delete('a-state') | ||
138 | |||
139 | const previousSearchParams = previousUrl.searchParams | ||
140 | |||
141 | nextSearchParams.sort() | ||
142 | previousSearchParams.sort() | ||
143 | |||
144 | if (nextSearchParams.toString() !== previousSearchParams.toString()) { | ||
145 | resetScroll = true | ||
146 | } | ||
147 | } catch (e) { | ||
148 | console.error('Cannot parse URL to check next scroll.', e) | ||
149 | resetScroll = true | ||
150 | } | ||
151 | }) | ||
152 | |||
153 | navigationEndEvent.pipe( | ||
154 | map(() => window.location.pathname), | ||
155 | filter(pathname => !pathname || pathname === '/' || is18nPath(pathname)) | ||
156 | ).subscribe(() => this.redirectService.redirectToHomepage(true)) | ||
157 | |||
158 | eventsObs.pipe( | ||
159 | filter((e: Event): e is GuardsCheckStart => e instanceof GuardsCheckStart), | ||
160 | filter(() => this.screenService.isInSmallView()) | ||
161 | ).subscribe(() => this.isMenuDisplayed = false) // User clicked on a link in the menu, change the page | ||
162 | } | ||
163 | |||
164 | private injectJS () { | ||
92 | // Inject JS | 165 | // Inject JS |
93 | this.serverService.configLoaded | 166 | this.serverService.configLoaded |
94 | .subscribe(() => { | 167 | .subscribe(() => { |
@@ -103,7 +176,9 @@ export class AppComponent implements OnInit { | |||
103 | } | 176 | } |
104 | } | 177 | } |
105 | }) | 178 | }) |
179 | } | ||
106 | 180 | ||
181 | private injectCSS () { | ||
107 | // Inject CSS if modified (admin config settings) | 182 | // Inject CSS if modified (admin config settings) |
108 | this.serverService.configLoaded | 183 | this.serverService.configLoaded |
109 | .pipe(skip(1)) // We only want to subscribe to reloads, because the CSS is already injected by the server | 184 | .pipe(skip(1)) // We only want to subscribe to reloads, because the CSS is already injected by the server |
@@ -119,7 +194,9 @@ export class AppComponent implements OnInit { | |||
119 | this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag) | 194 | this.customCSS = this.domSanitizer.bypassSecurityTrustHtml(styleTag) |
120 | } | 195 | } |
121 | }) | 196 | }) |
197 | } | ||
122 | 198 | ||
199 | private initHotkeys () { | ||
123 | this.hotkeysService.add([ | 200 | this.hotkeysService.add([ |
124 | new Hotkey(['/', 's'], (event: KeyboardEvent): boolean => { | 201 | new Hotkey(['/', 's'], (event: KeyboardEvent): boolean => { |
125 | document.getElementById('search-video').focus() | 202 | document.getElementById('search-video').focus() |
@@ -154,22 +231,5 @@ export class AppComponent implements OnInit { | |||
154 | return false | 231 | return false |
155 | }, undefined, this.i18n('Toggle Dark theme')) | 232 | }, undefined, this.i18n('Toggle Dark theme')) |
156 | ]) | 233 | ]) |
157 | |||
158 | fromEvent(window, 'resize') | ||
159 | .pipe(debounceTime(200)) | ||
160 | .subscribe(() => this.onResize()) | ||
161 | } | ||
162 | |||
163 | isUserLoggedIn () { | ||
164 | return this.authService.isLoggedIn() | ||
165 | } | ||
166 | |||
167 | toggleMenu () { | ||
168 | this.isMenuDisplayed = !this.isMenuDisplayed | ||
169 | this.isMenuChangedByUser = true | ||
170 | } | ||
171 | |||
172 | onResize () { | ||
173 | this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser | ||
174 | } | 234 | } |
175 | } | 235 | } |
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index eaa822e0f..4fc04a05c 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts | |||
@@ -153,7 +153,7 @@ export class AuthService { | |||
153 | response_type: 'code', | 153 | response_type: 'code', |
154 | grant_type: 'password', | 154 | grant_type: 'password', |
155 | scope: 'upload', | 155 | scope: 'upload', |
156 | username, | 156 | username: username.toLowerCase(), |
157 | password | 157 | password |
158 | } | 158 | } |
159 | 159 | ||
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts index 4ef3b1e73..d3e72afb4 100644 --- a/client/src/app/core/core.module.ts +++ b/client/src/app/core/core.module.ts | |||
@@ -19,6 +19,7 @@ import { ToastModule } from 'primeng/toast' | |||
19 | import { Notifier } from './notification' | 19 | import { Notifier } from './notification' |
20 | import { MessageService } from 'primeng/api' | 20 | import { MessageService } from 'primeng/api' |
21 | import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' | 21 | import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' |
22 | import { ServerConfigResolver } from './routing/server-config-resolver.service' | ||
22 | 23 | ||
23 | @NgModule({ | 24 | @NgModule({ |
24 | imports: [ | 25 | imports: [ |
@@ -60,7 +61,8 @@ import { UserNotificationSocket } from '@app/core/notification/user-notification | |||
60 | RedirectService, | 61 | RedirectService, |
61 | Notifier, | 62 | Notifier, |
62 | MessageService, | 63 | MessageService, |
63 | UserNotificationSocket | 64 | UserNotificationSocket, |
65 | ServerConfigResolver | ||
64 | ] | 66 | ] |
65 | }) | 67 | }) |
66 | export class CoreModule { | 68 | export class CoreModule { |
diff --git a/client/src/app/core/hotkeys/hotkeys.component.scss b/client/src/app/core/hotkeys/hotkeys.component.scss index 9af10b7c4..3aa0b6252 100644 --- a/client/src/app/core/hotkeys/hotkeys.component.scss +++ b/client/src/app/core/hotkeys/hotkeys.component.scss | |||
@@ -1,5 +1,6 @@ | |||
1 | .cfp-hotkeys-container { | 1 | .cfp-hotkeys-container { |
2 | display: table !important; | 2 | display: flex !important; |
3 | align-items: center; | ||
3 | position: fixed; | 4 | position: fixed; |
4 | overflow: auto; | 5 | overflow: auto; |
5 | width: 100%; | 6 | width: 100%; |
@@ -35,9 +36,7 @@ | |||
35 | 36 | ||
36 | .cfp-hotkeys { | 37 | .cfp-hotkeys { |
37 | width: 100%; | 38 | width: 100%; |
38 | height: 100%; | 39 | max-height: 100%; |
39 | display: table-cell; | ||
40 | vertical-align: middle; | ||
41 | } | 40 | } |
42 | 41 | ||
43 | .cfp-hotkeys table { | 42 | .cfp-hotkeys table { |
@@ -102,4 +101,4 @@ | |||
102 | .cfp-hotkeys { | 101 | .cfp-hotkeys { |
103 | font-size: 1.2em; | 102 | font-size: 1.2em; |
104 | } | 103 | } |
105 | } \ No newline at end of file | 104 | } |
diff --git a/client/src/app/core/notification/user-notification-socket.service.ts b/client/src/app/core/notification/user-notification-socket.service.ts index f367d9ae4..3f22da476 100644 --- a/client/src/app/core/notification/user-notification-socket.service.ts +++ b/client/src/app/core/notification/user-notification-socket.service.ts | |||
@@ -1,8 +1,7 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable, NgZone } from '@angular/core' |
2 | import { environment } from '../../../environments/environment' | 2 | import { environment } from '../../../environments/environment' |
3 | import { UserNotification as UserNotificationServer } from '../../../../../shared' | 3 | import { UserNotification as UserNotificationServer } from '../../../../../shared' |
4 | import { Subject } from 'rxjs' | 4 | import { Subject } from 'rxjs' |
5 | import * as io from 'socket.io-client' | ||
6 | import { AuthService } from '../auth' | 5 | import { AuthService } from '../auth' |
7 | 6 | ||
8 | export type NotificationEvent = 'new' | 'read' | 'read-all' | 7 | export type NotificationEvent = 'new' | 'read' | 'read-all' |
@@ -14,28 +13,32 @@ export class UserNotificationSocket { | |||
14 | private socket: SocketIOClient.Socket | 13 | private socket: SocketIOClient.Socket |
15 | 14 | ||
16 | constructor ( | 15 | constructor ( |
17 | private auth: AuthService | 16 | private auth: AuthService, |
17 | private ngZone: NgZone | ||
18 | ) {} | 18 | ) {} |
19 | 19 | ||
20 | dispatch (type: NotificationEvent, notification?: UserNotificationServer) { | 20 | dispatch (type: NotificationEvent, notification?: UserNotificationServer) { |
21 | this.notificationSubject.next({ type, notification }) | 21 | this.notificationSubject.next({ type, notification }) |
22 | } | 22 | } |
23 | 23 | ||
24 | getMyNotificationsSocket () { | 24 | async getMyNotificationsSocket () { |
25 | const socket = this.getSocket() | 25 | await this.initSocket() |
26 | |||
27 | socket.on('new-notification', (n: UserNotificationServer) => this.dispatch('new', n)) | ||
28 | 26 | ||
29 | return this.notificationSubject.asObservable() | 27 | return this.notificationSubject.asObservable() |
30 | } | 28 | } |
31 | 29 | ||
32 | private getSocket () { | 30 | private async initSocket () { |
33 | if (this.socket) return this.socket | 31 | if (this.socket) return |
34 | 32 | ||
35 | this.socket = io(environment.apiUrl + '/user-notifications', { | 33 | // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function |
36 | query: { accessToken: this.auth.getAccessToken() } | 34 | const io: typeof import ('socket.io-client') = (await import('socket.io-client') as any).default |
37 | }) | ||
38 | 35 | ||
39 | return this.socket | 36 | this.ngZone.runOutsideAngular(() => { |
37 | this.socket = io(environment.apiUrl + '/user-notifications', { | ||
38 | query: { accessToken: this.auth.getAccessToken() } | ||
39 | }) | ||
40 | |||
41 | this.socket.on('new-notification', (n: UserNotificationServer) => this.dispatch('new', n)) | ||
42 | }) | ||
40 | } | 43 | } |
41 | } | 44 | } |
diff --git a/client/src/app/core/routing/custom-reuse-strategy.ts b/client/src/app/core/routing/custom-reuse-strategy.ts new file mode 100644 index 000000000..a9f61acec --- /dev/null +++ b/client/src/app/core/routing/custom-reuse-strategy.ts | |||
@@ -0,0 +1,81 @@ | |||
1 | import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router' | ||
2 | |||
3 | export class CustomReuseStrategy implements RouteReuseStrategy { | ||
4 | storedRouteHandles = new Map<string, DetachedRouteHandle>() | ||
5 | recentlyUsed: string | ||
6 | |||
7 | private readonly MAX_SIZE = 2 | ||
8 | |||
9 | // Decides if the route should be stored | ||
10 | shouldDetach (route: ActivatedRouteSnapshot): boolean { | ||
11 | return this.isReuseEnabled(route) | ||
12 | } | ||
13 | |||
14 | // Store the information for the route we're destructing | ||
15 | store (route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void { | ||
16 | if (!handle) return | ||
17 | |||
18 | const key = this.generateKey(route) | ||
19 | this.recentlyUsed = key | ||
20 | |||
21 | console.log('Storing component %s to reuse later.', key); | ||
22 | |||
23 | (handle as any).componentRef.instance.disableForReuse() | ||
24 | |||
25 | this.storedRouteHandles.set(key, handle) | ||
26 | |||
27 | this.gb() | ||
28 | } | ||
29 | |||
30 | // Return true if we have a stored route object for the next route | ||
31 | shouldAttach (route: ActivatedRouteSnapshot): boolean { | ||
32 | const key = this.generateKey(route) | ||
33 | return this.isReuseEnabled(route) && this.storedRouteHandles.has(key) | ||
34 | } | ||
35 | |||
36 | // If we returned true in shouldAttach(), now return the actual route data for restoration | ||
37 | retrieve (route: ActivatedRouteSnapshot): DetachedRouteHandle { | ||
38 | if (!this.isReuseEnabled(route)) return undefined | ||
39 | |||
40 | const key = this.generateKey(route) | ||
41 | this.recentlyUsed = key | ||
42 | |||
43 | console.log('Reusing component %s.', key) | ||
44 | |||
45 | const handle = this.storedRouteHandles.get(key) | ||
46 | if (!handle) return handle; | ||
47 | |||
48 | (handle as any).componentRef.instance.enabledForReuse() | ||
49 | |||
50 | return handle | ||
51 | } | ||
52 | |||
53 | // Reuse the route if we're going to and from the same route | ||
54 | shouldReuseRoute (future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { | ||
55 | return future.routeConfig === curr.routeConfig | ||
56 | } | ||
57 | |||
58 | private gb () { | ||
59 | if (this.storedRouteHandles.size >= this.MAX_SIZE) { | ||
60 | this.storedRouteHandles.forEach((r, key) => { | ||
61 | if (key === this.recentlyUsed) return | ||
62 | |||
63 | console.log('Removing stored component %s.', key); | ||
64 | |||
65 | (r as any).componentRef.destroy() | ||
66 | this.storedRouteHandles.delete(key) | ||
67 | }) | ||
68 | } | ||
69 | } | ||
70 | |||
71 | private generateKey (route: ActivatedRouteSnapshot) { | ||
72 | const reuse = route.data.reuse | ||
73 | if (!reuse) return undefined | ||
74 | |||
75 | return reuse.key + JSON.stringify(route.queryParams) | ||
76 | } | ||
77 | |||
78 | private isReuseEnabled (route: ActivatedRouteSnapshot) { | ||
79 | return route.data.reuse && route.data.reuse.enabled && route.queryParams['a-state'] | ||
80 | } | ||
81 | } | ||
diff --git a/client/src/app/core/routing/disable-for-reuse-hook.ts b/client/src/app/core/routing/disable-for-reuse-hook.ts new file mode 100644 index 000000000..c5eb5c578 --- /dev/null +++ b/client/src/app/core/routing/disable-for-reuse-hook.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | export interface DisableForReuseHook { | ||
2 | |||
3 | disableForReuse (): void | ||
4 | |||
5 | enabledForReuse (): void | ||
6 | |||
7 | } | ||
diff --git a/client/src/app/core/routing/server-config-resolver.service.ts b/client/src/app/core/routing/server-config-resolver.service.ts new file mode 100644 index 000000000..ec7d6428f --- /dev/null +++ b/client/src/app/core/routing/server-config-resolver.service.ts | |||
@@ -0,0 +1,17 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { Resolve } from '@angular/router' | ||
3 | import { ServerService } from '@app/core/server' | ||
4 | |||
5 | @Injectable() | ||
6 | export class ServerConfigResolver implements Resolve<boolean> { | ||
7 | constructor ( | ||
8 | private server: ServerService | ||
9 | ) {} | ||
10 | |||
11 | resolve () { | ||
12 | // FIXME: directly returning this.server.configLoaded does not seem to work | ||
13 | return new Promise<boolean>(res => { | ||
14 | return this.server.configLoaded.subscribe(() => res(true)) | ||
15 | }) | ||
16 | } | ||
17 | } | ||
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index 4ae72427b..3a8a535fd 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts | |||
@@ -4,23 +4,25 @@ import { Inject, Injectable, LOCALE_ID } from '@angular/core' | |||
4 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' | 4 | import { peertubeLocalStorage } from '@app/shared/misc/peertube-local-storage' |
5 | import { Observable, of, ReplaySubject } from 'rxjs' | 5 | import { Observable, of, ReplaySubject } from 'rxjs' |
6 | import { getCompleteLocale, ServerConfig } from '../../../../../shared' | 6 | import { getCompleteLocale, ServerConfig } from '../../../../../shared' |
7 | import { About } from '../../../../../shared/models/server/about.model' | ||
8 | import { environment } from '../../../environments/environment' | 7 | import { environment } from '../../../environments/environment' |
9 | import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos' | 8 | import { VideoConstant, VideoPrivacy } from '../../../../../shared/models/videos' |
10 | import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' | 9 | import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n' |
11 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' | 10 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' |
12 | import { sortBy } from '@app/shared/misc/utils' | 11 | import { sortBy } from '@app/shared/misc/utils' |
12 | import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model' | ||
13 | 13 | ||
14 | @Injectable() | 14 | @Injectable() |
15 | export class ServerService { | 15 | export class ServerService { |
16 | private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server/' | 16 | private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server/' |
17 | private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config/' | 17 | private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config/' |
18 | private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' | 18 | private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' |
19 | private static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' | ||
19 | private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' | 20 | private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/' |
20 | private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' | 21 | private static CONFIG_LOCAL_STORAGE_KEY = 'server-config' |
21 | 22 | ||
22 | configLoaded = new ReplaySubject<boolean>(1) | 23 | configLoaded = new ReplaySubject<boolean>(1) |
23 | videoPrivaciesLoaded = new ReplaySubject<boolean>(1) | 24 | videoPrivaciesLoaded = new ReplaySubject<boolean>(1) |
25 | videoPlaylistPrivaciesLoaded = new ReplaySubject<boolean>(1) | ||
24 | videoCategoriesLoaded = new ReplaySubject<boolean>(1) | 26 | videoCategoriesLoaded = new ReplaySubject<boolean>(1) |
25 | videoLicencesLoaded = new ReplaySubject<boolean>(1) | 27 | videoLicencesLoaded = new ReplaySubject<boolean>(1) |
26 | videoLanguagesLoaded = new ReplaySubject<boolean>(1) | 28 | videoLanguagesLoaded = new ReplaySubject<boolean>(1) |
@@ -32,6 +34,7 @@ export class ServerService { | |||
32 | shortDescription: 'PeerTube, a federated (ActivityPub) video streaming platform ' + | 34 | shortDescription: 'PeerTube, a federated (ActivityPub) video streaming platform ' + |
33 | 'using P2P (BitTorrent) directly in the web browser with WebTorrent and Angular.', | 35 | 'using P2P (BitTorrent) directly in the web browser with WebTorrent and Angular.', |
34 | defaultClientRoute: '', | 36 | defaultClientRoute: '', |
37 | isNSFW: false, | ||
35 | defaultNSFWPolicy: 'do_not_list' as 'do_not_list', | 38 | defaultNSFWPolicy: 'do_not_list' as 'do_not_list', |
36 | customizations: { | 39 | customizations: { |
37 | javascript: '', | 40 | javascript: '', |
@@ -51,7 +54,10 @@ export class ServerService { | |||
51 | requiresEmailVerification: false | 54 | requiresEmailVerification: false |
52 | }, | 55 | }, |
53 | transcoding: { | 56 | transcoding: { |
54 | enabledResolutions: [] | 57 | enabledResolutions: [], |
58 | hls: { | ||
59 | enabled: false | ||
60 | } | ||
55 | }, | 61 | }, |
56 | avatar: { | 62 | avatar: { |
57 | file: { | 63 | file: { |
@@ -92,12 +98,23 @@ export class ServerService { | |||
92 | videos: { | 98 | videos: { |
93 | intervalDays: 0 | 99 | intervalDays: 0 |
94 | } | 100 | } |
101 | }, | ||
102 | autoBlacklist: { | ||
103 | videos: { | ||
104 | ofUsers: { | ||
105 | enabled: false | ||
106 | } | ||
107 | } | ||
108 | }, | ||
109 | tracker: { | ||
110 | enabled: true | ||
95 | } | 111 | } |
96 | } | 112 | } |
97 | private videoCategories: Array<VideoConstant<number>> = [] | 113 | private videoCategories: Array<VideoConstant<number>> = [] |
98 | private videoLicences: Array<VideoConstant<number>> = [] | 114 | private videoLicences: Array<VideoConstant<number>> = [] |
99 | private videoLanguages: Array<VideoConstant<string>> = [] | 115 | private videoLanguages: Array<VideoConstant<string>> = [] |
100 | private videoPrivacies: Array<VideoConstant<VideoPrivacy>> = [] | 116 | private videoPrivacies: Array<VideoConstant<VideoPrivacy>> = [] |
117 | private videoPlaylistPrivacies: Array<VideoConstant<VideoPlaylistPrivacy>> = [] | ||
101 | 118 | ||
102 | constructor ( | 119 | constructor ( |
103 | private http: HttpClient, | 120 | private http: HttpClient, |
@@ -118,19 +135,28 @@ export class ServerService { | |||
118 | } | 135 | } |
119 | 136 | ||
120 | loadVideoCategories () { | 137 | loadVideoCategories () { |
121 | return this.loadVideoAttributeEnum('categories', this.videoCategories, this.videoCategoriesLoaded, true) | 138 | return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'categories', this.videoCategories, this.videoCategoriesLoaded, true) |
122 | } | 139 | } |
123 | 140 | ||
124 | loadVideoLicences () { | 141 | loadVideoLicences () { |
125 | return this.loadVideoAttributeEnum('licences', this.videoLicences, this.videoLicencesLoaded) | 142 | return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'licences', this.videoLicences, this.videoLicencesLoaded) |
126 | } | 143 | } |
127 | 144 | ||
128 | loadVideoLanguages () { | 145 | loadVideoLanguages () { |
129 | return this.loadVideoAttributeEnum('languages', this.videoLanguages, this.videoLanguagesLoaded, true) | 146 | return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'languages', this.videoLanguages, this.videoLanguagesLoaded, true) |
130 | } | 147 | } |
131 | 148 | ||
132 | loadVideoPrivacies () { | 149 | loadVideoPrivacies () { |
133 | return this.loadVideoAttributeEnum('privacies', this.videoPrivacies, this.videoPrivaciesLoaded) | 150 | return this.loadAttributeEnum(ServerService.BASE_VIDEO_URL, 'privacies', this.videoPrivacies, this.videoPrivaciesLoaded) |
151 | } | ||
152 | |||
153 | loadVideoPlaylistPrivacies () { | ||
154 | return this.loadAttributeEnum( | ||
155 | ServerService.BASE_VIDEO_PLAYLIST_URL, | ||
156 | 'privacies', | ||
157 | this.videoPlaylistPrivacies, | ||
158 | this.videoPlaylistPrivaciesLoaded | ||
159 | ) | ||
134 | } | 160 | } |
135 | 161 | ||
136 | getConfig () { | 162 | getConfig () { |
@@ -153,7 +179,12 @@ export class ServerService { | |||
153 | return this.videoPrivacies | 179 | return this.videoPrivacies |
154 | } | 180 | } |
155 | 181 | ||
156 | private loadVideoAttributeEnum ( | 182 | getVideoPlaylistPrivacies () { |
183 | return this.videoPlaylistPrivacies | ||
184 | } | ||
185 | |||
186 | private loadAttributeEnum ( | ||
187 | baseUrl: string, | ||
157 | attributeName: 'categories' | 'licences' | 'languages' | 'privacies', | 188 | attributeName: 'categories' | 'licences' | 'languages' | 'privacies', |
158 | hashToPopulate: VideoConstant<string | number>[], | 189 | hashToPopulate: VideoConstant<string | number>[], |
159 | notifier: ReplaySubject<boolean>, | 190 | notifier: ReplaySubject<boolean>, |
@@ -162,7 +193,7 @@ export class ServerService { | |||
162 | this.localeObservable | 193 | this.localeObservable |
163 | .pipe( | 194 | .pipe( |
164 | switchMap(translations => { | 195 | switchMap(translations => { |
165 | return this.http.get<{ [id: string]: string }>(ServerService.BASE_VIDEO_URL + attributeName) | 196 | return this.http.get<{ [id: string]: string }>(baseUrl + attributeName) |
166 | .pipe(map(data => ({ data, translations }))) | 197 | .pipe(map(data => ({ data, translations }))) |
167 | }) | 198 | }) |
168 | ) | 199 | ) |
diff --git a/client/src/app/login/login-routing.module.ts b/client/src/app/login/login-routing.module.ts index 4d8913041..5a41f4e7e 100644 --- a/client/src/app/login/login-routing.module.ts +++ b/client/src/app/login/login-routing.module.ts | |||
@@ -1,9 +1,8 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { RouterModule, Routes } from '@angular/router' | 2 | import { RouterModule, Routes } from '@angular/router' |
3 | |||
4 | import { MetaGuard } from '@ngx-meta/core' | 3 | import { MetaGuard } from '@ngx-meta/core' |
5 | |||
6 | import { LoginComponent } from './login.component' | 4 | import { LoginComponent } from './login.component' |
5 | import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service' | ||
7 | 6 | ||
8 | const loginRoutes: Routes = [ | 7 | const loginRoutes: Routes = [ |
9 | { | 8 | { |
@@ -14,6 +13,9 @@ const loginRoutes: Routes = [ | |||
14 | meta: { | 13 | meta: { |
15 | title: 'Login' | 14 | title: 'Login' |
16 | } | 15 | } |
16 | }, | ||
17 | resolve: { | ||
18 | serverConfigLoaded: ServerConfigResolver | ||
17 | } | 19 | } |
18 | } | 20 | } |
19 | ] | 21 | ] |
diff --git a/client/src/app/menu/avatar-notification.component.html b/client/src/app/menu/avatar-notification.component.html index 4ef3f0e89..a5ef43d42 100644 --- a/client/src/app/menu/avatar-notification.component.html +++ b/client/src/app/menu/avatar-notification.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <div | 1 | <div |
2 | [ngbPopover]="popContent" autoClose="outside" placement="bottom-left" container="body" popoverClass="popover-notifications" | 2 | [ngbPopover]="popContent" autoClose="outside" placement="bottom-left" container="body" popoverClass="popover-notifications" |
3 | i18n-title title="View your notifications" class="notification-avatar" #popover="ngbPopover" | 3 | i18n-title title="View your notifications" class="notification-avatar" #popover="ngbPopover" (hidden)="onPopoverHidden()" |
4 | > | 4 | > |
5 | <div *ngIf="unreadNotifications > 0" class="unread-notifications">{{ unreadNotifications }}</div> | 5 | <div *ngIf="unreadNotifications > 0" class="unread-notifications">{{ unreadNotifications }}</div> |
6 | 6 | ||
@@ -8,16 +8,25 @@ | |||
8 | </div> | 8 | </div> |
9 | 9 | ||
10 | <ng-template #popContent> | 10 | <ng-template #popContent> |
11 | <div class="notifications-header"> | 11 | <div class="content" [ngClass]="{ loaded: loaded }"> |
12 | <div i18n>Notifications</div> | 12 | <div class="notifications-header"> |
13 | <div i18n>Notifications</div> | ||
13 | 14 | ||
14 | <a | 15 | <a |
15 | i18n-title title="Update your notification preferences" class="glyphicon glyphicon-cog" | 16 | i18n-title title="Update your notification preferences" class="glyphicon glyphicon-cog" |
16 | routerLink="/my-account/settings" fragment="notifications" | 17 | routerLink="/my-account/settings" fragment="notifications" |
17 | ></a> | 18 | ></a> |
18 | </div> | 19 | </div> |
20 | |||
21 | <div *ngIf="!loaded" class="loader"> | ||
22 | <my-loader [loading]="!loaded"></my-loader> | ||
23 | </div> | ||
19 | 24 | ||
20 | <my-user-notifications [ignoreLoadingBar]="true" [infiniteScroll]="false" itemsPerPage="10"></my-user-notifications> | 25 | <my-user-notifications |
26 | [ignoreLoadingBar]="true" [infiniteScroll]="false" itemsPerPage="10" | ||
27 | (notificationsLoaded)="onNotificationLoaded()" | ||
28 | ></my-user-notifications> | ||
21 | 29 | ||
22 | <a class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a> | 30 | <a *ngIf="loaded" class="all-notifications" routerLink="/my-account/notifications" i18n>See all your notifications</a> |
31 | </div> | ||
23 | </ng-template> | 32 | </ng-template> |
diff --git a/client/src/app/menu/avatar-notification.component.scss b/client/src/app/menu/avatar-notification.component.scss index e785db788..201668b6e 100644 --- a/client/src/app/menu/avatar-notification.component.scss +++ b/client/src/app/menu/avatar-notification.component.scss | |||
@@ -9,11 +9,27 @@ | |||
9 | padding: 0; | 9 | padding: 0; |
10 | font-size: 14px; | 10 | font-size: 14px; |
11 | font-family: $main-fonts; | 11 | font-family: $main-fonts; |
12 | overflow-y: auto; | 12 | overflow-y: scroll; |
13 | max-height: 500px; | ||
14 | width: 400px; | 13 | width: 400px; |
15 | box-shadow: 0 6px 14px rgba(0, 0, 0, 0.30); | 14 | box-shadow: 0 6px 14px rgba(0, 0, 0, 0.30); |
16 | 15 | ||
16 | .loader { | ||
17 | display: flex; | ||
18 | align-items: center; | ||
19 | justify-content: center; | ||
20 | |||
21 | padding: 5px 0; | ||
22 | } | ||
23 | |||
24 | .content { | ||
25 | max-height: 150px; | ||
26 | transition: max-height 0.15s ease-out; | ||
27 | |||
28 | &.loaded { | ||
29 | max-height: 500px; | ||
30 | } | ||
31 | } | ||
32 | |||
17 | .notifications-header { | 33 | .notifications-header { |
18 | display: flex; | 34 | display: flex; |
19 | justify-content: space-between; | 35 | justify-content: space-between; |
diff --git a/client/src/app/menu/avatar-notification.component.ts b/client/src/app/menu/avatar-notification.component.ts index f1af08096..a77a001ca 100644 --- a/client/src/app/menu/avatar-notification.component.ts +++ b/client/src/app/menu/avatar-notification.component.ts | |||
@@ -17,6 +17,7 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy { | |||
17 | @Input() user: User | 17 | @Input() user: User |
18 | 18 | ||
19 | unreadNotifications = 0 | 19 | unreadNotifications = 0 |
20 | loaded = false | ||
20 | 21 | ||
21 | private notificationSub: Subscription | 22 | private notificationSub: Subscription |
22 | private routeSub: Subscription | 23 | private routeSub: Subscription |
@@ -26,18 +27,19 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy { | |||
26 | private userNotificationSocket: UserNotificationSocket, | 27 | private userNotificationSocket: UserNotificationSocket, |
27 | private notifier: Notifier, | 28 | private notifier: Notifier, |
28 | private router: Router | 29 | private router: Router |
29 | ) {} | 30 | ) { |
31 | } | ||
30 | 32 | ||
31 | ngOnInit () { | 33 | ngOnInit () { |
32 | this.userNotificationService.countUnreadNotifications() | 34 | this.userNotificationService.countUnreadNotifications() |
33 | .subscribe( | 35 | .subscribe( |
34 | result => { | 36 | result => { |
35 | this.unreadNotifications = Math.min(result, 99) // Limit number to 99 | 37 | this.unreadNotifications = Math.min(result, 99) // Limit number to 99 |
36 | this.subscribeToNotifications() | 38 | this.subscribeToNotifications() |
37 | }, | 39 | }, |
38 | 40 | ||
39 | err => this.notifier.error(err.message) | 41 | err => this.notifier.error(err.message) |
40 | ) | 42 | ) |
41 | 43 | ||
42 | this.routeSub = this.router.events | 44 | this.routeSub = this.router.events |
43 | .pipe(filter(event => event instanceof NavigationEnd)) | 45 | .pipe(filter(event => event instanceof NavigationEnd)) |
@@ -53,13 +55,22 @@ export class AvatarNotificationComponent implements OnInit, OnDestroy { | |||
53 | this.popover.close() | 55 | this.popover.close() |
54 | } | 56 | } |
55 | 57 | ||
56 | private subscribeToNotifications () { | 58 | onPopoverHidden () { |
57 | this.notificationSub = this.userNotificationSocket.getMyNotificationsSocket() | 59 | this.loaded = false |
58 | .subscribe(data => { | 60 | } |
59 | if (data.type === 'new') return this.unreadNotifications++ | 61 | |
60 | if (data.type === 'read') return this.unreadNotifications-- | 62 | onNotificationLoaded () { |
61 | if (data.type === 'read-all') return this.unreadNotifications = 0 | 63 | this.loaded = true |
62 | }) | 64 | } |
65 | |||
66 | private async subscribeToNotifications () { | ||
67 | const obs = await this.userNotificationSocket.getMyNotificationsSocket() | ||
68 | |||
69 | this.notificationSub = obs.subscribe(data => { | ||
70 | if (data.type === 'new') return this.unreadNotifications++ | ||
71 | if (data.type === 'read') return this.unreadNotifications-- | ||
72 | if (data.type === 'read-all') return this.unreadNotifications = 0 | ||
73 | }) | ||
63 | } | 74 | } |
64 | 75 | ||
65 | } | 76 | } |
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index 1e532ec13..e80e6b803 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html | |||
@@ -1,5 +1,5 @@ | |||
1 | <div class="menu-wrapper"> | 1 | <div class="menu-wrapper"> |
2 | <menu> | 2 | <menu [ngClass]="{ 'logged-in': isLoggedIn }"> |
3 | <div class="top-menu"> | 3 | <div class="top-menu"> |
4 | <div *ngIf="isLoggedIn" class="logged-in-block"> | 4 | <div *ngIf="isLoggedIn" class="logged-in-block"> |
5 | <my-avatar-notification [user]="user"></my-avatar-notification> | 5 | <my-avatar-notification [user]="user"></my-avatar-notification> |
@@ -10,23 +10,19 @@ | |||
10 | </div> | 10 | </div> |
11 | 11 | ||
12 | <div class="logged-in-more" ngbDropdown placement="bottom-right"> | 12 | <div class="logged-in-more" ngbDropdown placement="bottom-right"> |
13 | <span class="glyphicon glyphicon-option-vertical" ngbDropdownToggle role="button"></span> | 13 | <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button"></my-global-icon> |
14 | 14 | ||
15 | <div ngbDropdownMenu> | 15 | <div ngbDropdownMenu> |
16 | <a *ngIf="user.account" i18n [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="dropdown-item"> | 16 | <a *ngIf="user.account" [routerLink]="[ '/accounts', user.account.nameWithHost ]" class="dropdown-item"> |
17 | My public profile | 17 | <my-global-icon iconName="go"></my-global-icon> <ng-container i18n>My public profile</ng-container> |
18 | </a> | 18 | </a> |
19 | 19 | ||
20 | <a i18n routerLink="/my-account" class="dropdown-item"> | 20 | <a routerLink="/my-account" class="dropdown-item"> |
21 | My account | 21 | <my-global-icon iconName="user"></my-global-icon> <ng-container i18n>My account</ng-container> |
22 | </a> | 22 | </a> |
23 | 23 | ||
24 | <a i18n routerLink="/my-account/videos" class="dropdown-item"> | 24 | <a (click)="logout($event)" class="dropdown-item" href="#"> |
25 | My videos | 25 | <my-global-icon iconName="sign-out"></my-global-icon> <ng-container i18n>Log out</ng-container> |
26 | </a> | ||
27 | |||
28 | <a i18n (click)="logout($event)" class="dropdown-item" href="#"> | ||
29 | Log out | ||
30 | </a> | 26 | </a> |
31 | </div> | 27 | </div> |
32 | </div> | 28 | </div> |
@@ -37,31 +33,51 @@ | |||
37 | <a i18n *ngIf="isRegistrationAllowed()" routerLink="/signup" class="create-account-button">Create an account</a> | 33 | <a i18n *ngIf="isRegistrationAllowed()" routerLink="/signup" class="create-account-button">Create an account</a> |
38 | </div> | 34 | </div> |
39 | 35 | ||
40 | <div class="panel-block"> | 36 | <div *ngIf="isLoggedIn" class="panel-block"> |
41 | <div i18n class="block-title">Videos</div> | 37 | <div i18n class="block-title">My library</div> |
38 | |||
39 | <a routerLink="/my-account/videos" routerLinkActive="active"> | ||
40 | <my-global-icon iconName="videos"></my-global-icon> | ||
41 | <ng-container i18n>Videos</ng-container> | ||
42 | </a> | ||
43 | |||
44 | <a routerLink="/my-account/video-playlists" routerLinkActive="active"> | ||
45 | <my-global-icon iconName="playlists"></my-global-icon> | ||
46 | <ng-container i18n>Playlists</ng-container> | ||
47 | </a> | ||
42 | 48 | ||
43 | <a *ngIf="isLoggedIn" routerLink="/videos/subscriptions" routerLinkActive="active"> | 49 | <a routerLink="/videos/subscriptions" routerLinkActive="active"> |
44 | <span class="icon icon-videos-subscriptions"></span> | 50 | <my-global-icon iconName="subscriptions"></my-global-icon> |
45 | <ng-container i18n>Subscriptions</ng-container> | 51 | <ng-container i18n>Subscriptions</ng-container> |
46 | </a> | 52 | </a> |
47 | 53 | ||
54 | <a routerLink="/my-account/history/videos" routerLinkActive="active"> | ||
55 | <my-global-icon iconName="history"></my-global-icon> | ||
56 | <ng-container i18n>History</ng-container> | ||
57 | </a> | ||
58 | |||
59 | </div> | ||
60 | |||
61 | <div class="panel-block"> | ||
62 | <div i18n class="block-title">Videos</div> | ||
63 | |||
48 | <a routerLink="/videos/overview" routerLinkActive="active"> | 64 | <a routerLink="/videos/overview" routerLinkActive="active"> |
49 | <span class="icon icon-videos-overview"></span> | 65 | <my-global-icon iconName="globe"></my-global-icon> |
50 | <ng-container i18n>Overview</ng-container> | 66 | <ng-container i18n>Overview</ng-container> |
51 | </a> | 67 | </a> |
52 | 68 | ||
53 | <a routerLink="/videos/trending" routerLinkActive="active"> | 69 | <a routerLink="/videos/trending" routerLinkActive="active"> |
54 | <span class="icon icon-videos-trending"></span> | 70 | <my-global-icon iconName="trending"></my-global-icon> |
55 | <ng-container i18n>Trending</ng-container> | 71 | <ng-container i18n>Trending</ng-container> |
56 | </a> | 72 | </a> |
57 | 73 | ||
58 | <a routerLink="/videos/recently-added" routerLinkActive="active"> | 74 | <a routerLink="/videos/recently-added" routerLinkActive="active"> |
59 | <span class="icon icon-videos-recently-added"></span> | 75 | <my-global-icon iconName="recently-added"></my-global-icon> |
60 | <ng-container i18n>Recently added</ng-container> | 76 | <ng-container i18n>Recently added</ng-container> |
61 | </a> | 77 | </a> |
62 | 78 | ||
63 | <a routerLink="/videos/local" routerLinkActive="active"> | 79 | <a routerLink="/videos/local" routerLinkActive="active"> |
64 | <span class="icon icon-videos-local"></span> | 80 | <my-global-icon iconName="home"></my-global-icon> |
65 | <ng-container i18n>Local</ng-container> | 81 | <ng-container i18n>Local</ng-container> |
66 | </a> | 82 | </a> |
67 | </div> | 83 | </div> |
@@ -70,12 +86,12 @@ | |||
70 | <div class="block-title" i18n>More</div> | 86 | <div class="block-title" i18n>More</div> |
71 | 87 | ||
72 | <a *ngIf="userHasAdminAccess" [routerLink]="getFirstAdminRouteAvailable()" routerLinkActive="active"> | 88 | <a *ngIf="userHasAdminAccess" [routerLink]="getFirstAdminRouteAvailable()" routerLinkActive="active"> |
73 | <span class="icon icon-administration"></span> | 89 | <my-global-icon iconName="administration"></my-global-icon> |
74 | <ng-container i18n>Administration</ng-container> | 90 | <ng-container i18n>Administration</ng-container> |
75 | </a> | 91 | </a> |
76 | 92 | ||
77 | <a routerLink="/about" routerLinkActive="active"> | 93 | <a routerLink="/about" routerLinkActive="active"> |
78 | <span class="icon icon-about"></span> | 94 | <my-global-icon iconName="about"></my-global-icon> |
79 | <ng-container i18n>About</ng-container> | 95 | <ng-container i18n>About</ng-container> |
80 | </a> | 96 | </a> |
81 | </div> | 97 | </div> |
diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss index 69704674a..2f23fa5f8 100644 --- a/client/src/app/menu/menu.component.scss +++ b/client/src/app/menu/menu.component.scss | |||
@@ -10,12 +10,12 @@ | |||
10 | } | 10 | } |
11 | 11 | ||
12 | menu { | 12 | menu { |
13 | @include ellipsis; | ||
14 | |||
13 | background-color: var(--menuBackgroundColor); | 15 | background-color: var(--menuBackgroundColor); |
14 | margin: 0; | 16 | margin: 0; |
15 | padding: 0; | 17 | padding: 0; |
16 | height: 100%; | 18 | height: 100%; |
17 | white-space: nowrap; | ||
18 | text-overflow: ellipsis; | ||
19 | overflow: auto; | 19 | overflow: auto; |
20 | color: var(--menuForegroundColor); | 20 | color: var(--menuForegroundColor); |
21 | display: flex; | 21 | display: flex; |
@@ -26,6 +26,16 @@ menu { | |||
26 | overflow-y: auto; | 26 | overflow-y: auto; |
27 | } | 27 | } |
28 | 28 | ||
29 | &.logged-in { | ||
30 | .panel-block { | ||
31 | margin-bottom: 25px; | ||
32 | } | ||
33 | |||
34 | .block-title { | ||
35 | margin-bottom: 15px; | ||
36 | } | ||
37 | } | ||
38 | |||
29 | .top-menu { | 39 | .top-menu { |
30 | flex-grow: 1; | 40 | flex-grow: 1; |
31 | width: $menu-width; | 41 | width: $menu-width; |
@@ -37,9 +47,11 @@ menu { | |||
37 | display: flex; | 47 | display: flex; |
38 | align-items: center; | 48 | align-items: center; |
39 | justify-content: center; | 49 | justify-content: center; |
40 | margin-bottom: 35px; | 50 | margin-bottom: 20px; |
41 | 51 | ||
42 | .logged-in-info { | 52 | .logged-in-info { |
53 | @include ellipsis; | ||
54 | |||
43 | flex-grow: 1; | 55 | flex-grow: 1; |
44 | white-space: nowrap; | 56 | white-space: nowrap; |
45 | overflow: hidden; | 57 | overflow: hidden; |
@@ -55,11 +67,10 @@ menu { | |||
55 | } | 67 | } |
56 | 68 | ||
57 | .logged-in-username { | 69 | .logged-in-username { |
70 | @include ellipsis; | ||
71 | |||
58 | font-size: 13px; | 72 | font-size: 13px; |
59 | color: #C6C6C6; | 73 | color: #C6C6C6; |
60 | white-space: nowrap; | ||
61 | overflow: hidden; | ||
62 | text-overflow: ellipsis; | ||
63 | max-width: 140px; | 74 | max-width: 140px; |
64 | } | 75 | } |
65 | } | 76 | } |
@@ -67,14 +78,33 @@ menu { | |||
67 | .logged-in-more { | 78 | .logged-in-more { |
68 | margin-right: 20px; | 79 | margin-right: 20px; |
69 | 80 | ||
70 | .glyphicon { | 81 | my-global-icon { |
82 | @include apply-svg-color(var(--mainBackgroundColor)); | ||
83 | |||
71 | cursor: pointer; | 84 | cursor: pointer; |
72 | font-size: 18px; | ||
73 | 85 | ||
74 | &::after { | 86 | &::after { |
75 | border: none; | 87 | border: none; |
76 | } | 88 | } |
77 | } | 89 | } |
90 | |||
91 | .dropdown-item { | ||
92 | @include dropdown-with-icon-item; | ||
93 | |||
94 | my-global-icon { | ||
95 | @include apply-svg-color(var(--mainForegroundColor)); | ||
96 | |||
97 | width: 22px; | ||
98 | height: 22px; | ||
99 | |||
100 | &[iconName="sign-out"] { | ||
101 | position: relative; | ||
102 | right: -1px; | ||
103 | height: 21px; | ||
104 | width: 21px; | ||
105 | } | ||
106 | } | ||
107 | } | ||
78 | } | 108 | } |
79 | } | 109 | } |
80 | 110 | ||
@@ -135,57 +165,31 @@ menu { | |||
135 | background-color: rgba(255, 255, 255, 0.10); | 165 | background-color: rgba(255, 255, 255, 0.10); |
136 | } | 166 | } |
137 | 167 | ||
138 | .icon { | 168 | my-global-icon { |
139 | @include icon(22px); | 169 | @include apply-svg-color(#808080); |
140 | 170 | ||
171 | display: flex; | ||
172 | width: 22px; | ||
173 | height: 22px; | ||
141 | margin-right: 18px; | 174 | margin-right: 18px; |
142 | 175 | ||
143 | &.icon-videos-subscriptions { | 176 | &[iconName="playlists"] { |
144 | position: relative; | 177 | height: 24px; |
145 | top: -1px; | 178 | width: 24px; |
146 | background-image: url('../../assets/images/menu/subscriptions.svg'); | ||
147 | } | ||
148 | |||
149 | &.icon-videos-overview { | ||
150 | position: relative; | ||
151 | background-image: url('../../assets/images/menu/globe.svg'); | ||
152 | } | ||
153 | |||
154 | &.icon-videos-trending { | ||
155 | position: relative; | ||
156 | top: -1px; | ||
157 | background-image: url('../../assets/images/menu/trending.svg'); | ||
158 | } | ||
159 | 179 | ||
160 | &.icon-videos-recently-added { | 180 | margin-right: 16px; |
161 | width: 23px; | ||
162 | height: 23px; | ||
163 | background-image: url('../../assets/images/menu/recently-added.svg'); | ||
164 | } | 181 | } |
165 | 182 | ||
166 | &.icon-videos-local { | 183 | &[iconName="videos"] { |
167 | width: 23px; | ||
168 | height: 23px; | ||
169 | |||
170 | position: relative; | 184 | position: relative; |
171 | top: -1px; | 185 | right: -1px; |
172 | |||
173 | background-image: url('../../assets/images/menu/home.svg'); | ||
174 | } | ||
175 | |||
176 | &.icon-administration { | ||
177 | width: 23px; | ||
178 | height: 23px; | ||
179 | |||
180 | background-image: url('../../assets/images/menu/administration.svg'); | ||
181 | } | 186 | } |
187 | } | ||
182 | 188 | ||
183 | &.icon-about { | 189 | .icon { |
184 | width: 23px; | 190 | @include icon(22px); |
185 | height: 23px; | ||
186 | 191 | ||
187 | background-image: url('../../assets/images/menu/about.svg'); | 192 | margin-right: 18px; |
188 | } | ||
189 | } | 193 | } |
190 | } | 194 | } |
191 | } | 195 | } |
@@ -224,7 +228,6 @@ menu { | |||
224 | height: 24px; | 228 | height: 24px; |
225 | 229 | ||
226 | background-image: url('../../assets/images/menu/keyboard.png'); | 230 | background-image: url('../../assets/images/menu/keyboard.png'); |
227 | background-color: #fff; | ||
228 | filter: invert(100%); | 231 | filter: invert(100%); |
229 | } | 232 | } |
230 | 233 | ||
diff --git a/client/src/app/search/advanced-search.model.ts b/client/src/app/search/advanced-search.model.ts index 033fa9bba..5b713e145 100644 --- a/client/src/app/search/advanced-search.model.ts +++ b/client/src/app/search/advanced-search.model.ts | |||
@@ -4,6 +4,9 @@ export class AdvancedSearch { | |||
4 | startDate: string // ISO 8601 | 4 | startDate: string // ISO 8601 |
5 | endDate: string // ISO 8601 | 5 | endDate: string // ISO 8601 |
6 | 6 | ||
7 | originallyPublishedStartDate: string // ISO 8601 | ||
8 | originallyPublishedEndDate: string // ISO 8601 | ||
9 | |||
7 | nsfw: NSFWQuery | 10 | nsfw: NSFWQuery |
8 | 11 | ||
9 | categoryOneOf: string | 12 | categoryOneOf: string |
@@ -23,6 +26,8 @@ export class AdvancedSearch { | |||
23 | constructor (options?: { | 26 | constructor (options?: { |
24 | startDate?: string | 27 | startDate?: string |
25 | endDate?: string | 28 | endDate?: string |
29 | originallyPublishedStartDate?: string | ||
30 | originallyPublishedEndDate?: string | ||
26 | nsfw?: NSFWQuery | 31 | nsfw?: NSFWQuery |
27 | categoryOneOf?: string | 32 | categoryOneOf?: string |
28 | licenceOneOf?: string | 33 | licenceOneOf?: string |
@@ -37,6 +42,9 @@ export class AdvancedSearch { | |||
37 | 42 | ||
38 | this.startDate = options.startDate || undefined | 43 | this.startDate = options.startDate || undefined |
39 | this.endDate = options.endDate || undefined | 44 | this.endDate = options.endDate || undefined |
45 | this.originallyPublishedStartDate = options.originallyPublishedStartDate || undefined | ||
46 | this.originallyPublishedEndDate = options.originallyPublishedEndDate || undefined | ||
47 | |||
40 | this.nsfw = options.nsfw || undefined | 48 | this.nsfw = options.nsfw || undefined |
41 | this.categoryOneOf = options.categoryOneOf || undefined | 49 | this.categoryOneOf = options.categoryOneOf || undefined |
42 | this.licenceOneOf = options.licenceOneOf || undefined | 50 | this.licenceOneOf = options.licenceOneOf || undefined |
@@ -66,6 +74,8 @@ export class AdvancedSearch { | |||
66 | reset () { | 74 | reset () { |
67 | this.startDate = undefined | 75 | this.startDate = undefined |
68 | this.endDate = undefined | 76 | this.endDate = undefined |
77 | this.originallyPublishedStartDate = undefined | ||
78 | this.originallyPublishedEndDate = undefined | ||
69 | this.nsfw = undefined | 79 | this.nsfw = undefined |
70 | this.categoryOneOf = undefined | 80 | this.categoryOneOf = undefined |
71 | this.licenceOneOf = undefined | 81 | this.licenceOneOf = undefined |
@@ -82,6 +92,8 @@ export class AdvancedSearch { | |||
82 | return { | 92 | return { |
83 | startDate: this.startDate, | 93 | startDate: this.startDate, |
84 | endDate: this.endDate, | 94 | endDate: this.endDate, |
95 | originallyPublishedStartDate: this.originallyPublishedStartDate, | ||
96 | originallyPublishedEndDate: this.originallyPublishedEndDate, | ||
85 | nsfw: this.nsfw, | 97 | nsfw: this.nsfw, |
86 | categoryOneOf: this.categoryOneOf, | 98 | categoryOneOf: this.categoryOneOf, |
87 | licenceOneOf: this.licenceOneOf, | 99 | licenceOneOf: this.licenceOneOf, |
@@ -98,6 +110,8 @@ export class AdvancedSearch { | |||
98 | return { | 110 | return { |
99 | startDate: this.startDate, | 111 | startDate: this.startDate, |
100 | endDate: this.endDate, | 112 | endDate: this.endDate, |
113 | originallyPublishedStartDate: this.originallyPublishedStartDate, | ||
114 | originallyPublishedEndDate: this.originallyPublishedEndDate, | ||
101 | nsfw: this.nsfw, | 115 | nsfw: this.nsfw, |
102 | categoryOneOf: this.intoArray(this.categoryOneOf), | 116 | categoryOneOf: this.intoArray(this.categoryOneOf), |
103 | licenceOneOf: this.intoArray(this.licenceOneOf), | 117 | licenceOneOf: this.intoArray(this.licenceOneOf), |
diff --git a/client/src/app/search/search-filters.component.html b/client/src/app/search/search-filters.component.html index 74bb781f4..8220a990b 100644 --- a/client/src/app/search/search-filters.component.html +++ b/client/src/app/search/search-filters.component.html | |||
@@ -21,6 +21,27 @@ | |||
21 | </div> | 21 | </div> |
22 | 22 | ||
23 | <div class="form-group"> | 23 | <div class="form-group"> |
24 | <label i18n for="original-publication-after">Original publication year</label> | ||
25 | |||
26 | <div class="row"> | ||
27 | <div class="col-sm-6"> | ||
28 | <input | ||
29 | type="text" id="original-publication-after" name="original-publication-after" | ||
30 | i18n-placeholder placeholder="After..." | ||
31 | [(ngModel)]="originallyPublishedStartYear" | ||
32 | > | ||
33 | </div> | ||
34 | <div class="col-sm-6"> | ||
35 | <input | ||
36 | type="text" id="original-publication-before" name="original-publication-before" | ||
37 | i18n-placeholder placeholder="Before..." | ||
38 | [(ngModel)]="originallyPublishedEndYear" | ||
39 | > | ||
40 | </div> | ||
41 | </div> | ||
42 | </div> | ||
43 | |||
44 | <div class="form-group"> | ||
24 | <div i18n class="radio-label">Duration</div> | 45 | <div i18n class="radio-label">Duration</div> |
25 | 46 | ||
26 | <div class="peertube-radio-container" *ngFor="let duration of durationRanges"> | 47 | <div class="peertube-radio-container" *ngFor="let duration of durationRanges"> |
@@ -93,4 +114,4 @@ | |||
93 | <div class="submit-button"> | 114 | <div class="submit-button"> |
94 | <input type="submit" i18n-value value="Filter"> | 115 | <input type="submit" i18n-value value="Filter"> |
95 | </div> | 116 | </div> |
96 | </form> \ No newline at end of file | 117 | </form> |
diff --git a/client/src/app/search/search-filters.component.ts b/client/src/app/search/search-filters.component.ts index 3fdc6df35..762a6b7f2 100644 --- a/client/src/app/search/search-filters.component.ts +++ b/client/src/app/search/search-filters.component.ts | |||
@@ -25,6 +25,9 @@ export class SearchFiltersComponent implements OnInit { | |||
25 | publishedDateRange: string | 25 | publishedDateRange: string |
26 | durationRange: string | 26 | durationRange: string |
27 | 27 | ||
28 | originallyPublishedStartYear: string | ||
29 | originallyPublishedEndYear: string | ||
30 | |||
28 | constructor ( | 31 | constructor ( |
29 | private i18n: I18n, | 32 | private i18n: I18n, |
30 | private serverService: ServerService | 33 | private serverService: ServerService |
@@ -86,15 +89,27 @@ export class SearchFiltersComponent implements OnInit { | |||
86 | 89 | ||
87 | this.loadFromDurationRange() | 90 | this.loadFromDurationRange() |
88 | this.loadFromPublishedRange() | 91 | this.loadFromPublishedRange() |
92 | this.loadOriginallyPublishedAtYears() | ||
89 | } | 93 | } |
90 | 94 | ||
91 | formUpdated () { | 95 | formUpdated () { |
92 | this.updateModelFromDurationRange() | 96 | this.updateModelFromDurationRange() |
93 | this.updateModelFromPublishedRange() | 97 | this.updateModelFromPublishedRange() |
98 | this.updateModelFromOriginallyPublishedAtYears() | ||
94 | 99 | ||
95 | this.filtered.emit(this.advancedSearch) | 100 | this.filtered.emit(this.advancedSearch) |
96 | } | 101 | } |
97 | 102 | ||
103 | private loadOriginallyPublishedAtYears () { | ||
104 | this.originallyPublishedStartYear = this.advancedSearch.originallyPublishedStartDate | ||
105 | ? new Date(this.advancedSearch.originallyPublishedStartDate).getFullYear().toString() | ||
106 | : null | ||
107 | |||
108 | this.originallyPublishedEndYear = this.advancedSearch.originallyPublishedEndDate | ||
109 | ? new Date(this.advancedSearch.originallyPublishedEndDate).getFullYear().toString() | ||
110 | : null | ||
111 | } | ||
112 | |||
98 | private loadFromDurationRange () { | 113 | private loadFromDurationRange () { |
99 | if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) { | 114 | if (this.advancedSearch.durationMin || this.advancedSearch.durationMax) { |
100 | const fourMinutes = 60 * 4 | 115 | const fourMinutes = 60 * 4 |
@@ -127,6 +142,32 @@ export class SearchFiltersComponent implements OnInit { | |||
127 | } | 142 | } |
128 | } | 143 | } |
129 | 144 | ||
145 | private updateModelFromOriginallyPublishedAtYears () { | ||
146 | const baseDate = new Date() | ||
147 | baseDate.setHours(0, 0, 0, 0) | ||
148 | baseDate.setMonth(0, 1) | ||
149 | |||
150 | if (this.originallyPublishedStartYear) { | ||
151 | const year = parseInt(this.originallyPublishedStartYear, 10) | ||
152 | const start = new Date(baseDate) | ||
153 | start.setFullYear(year) | ||
154 | |||
155 | this.advancedSearch.originallyPublishedStartDate = start.toISOString() | ||
156 | } else { | ||
157 | this.advancedSearch.originallyPublishedStartDate = null | ||
158 | } | ||
159 | |||
160 | if (this.originallyPublishedEndYear) { | ||
161 | const year = parseInt(this.originallyPublishedEndYear, 10) | ||
162 | const end = new Date(baseDate) | ||
163 | end.setFullYear(year) | ||
164 | |||
165 | this.advancedSearch.originallyPublishedEndDate = end.toISOString() | ||
166 | } else { | ||
167 | this.advancedSearch.originallyPublishedEndDate = null | ||
168 | } | ||
169 | } | ||
170 | |||
130 | private updateModelFromDurationRange () { | 171 | private updateModelFromDurationRange () { |
131 | if (!this.durationRange) return | 172 | if (!this.durationRange) return |
132 | 173 | ||
@@ -174,4 +215,5 @@ export class SearchFiltersComponent implements OnInit { | |||
174 | 215 | ||
175 | this.advancedSearch.startDate = date.toISOString() | 216 | this.advancedSearch.startDate = date.toISOString() |
176 | } | 217 | } |
218 | |||
177 | } | 219 | } |
diff --git a/client/src/app/search/search.component.html b/client/src/app/search/search.component.html index 82a5f0f26..0a9f78cb2 100644 --- a/client/src/app/search/search.component.html +++ b/client/src/app/search/search.component.html | |||
@@ -48,13 +48,10 @@ | |||
48 | </div> | 48 | </div> |
49 | 49 | ||
50 | <div *ngIf="isVideo(result)" class="entry video"> | 50 | <div *ngIf="isVideo(result)" class="entry video"> |
51 | <my-video-thumbnail [video]="result" [nsfw]="isVideoBlur(result)"></my-video-thumbnail> | 51 | <my-video-miniature |
52 | 52 | [video]="result" [user]="user" [displayAsRow]="true" | |
53 | <div class="video-info"> | 53 | (videoBlacklisted)="removeVideoFromArray(result)" (videoRemoved)="removeVideoFromArray(result)" |
54 | <a tabindex="-1" class="video-info-name" [routerLink]="['/videos/watch', result.uuid]" [attr.title]="result.name">{{ result.name }}</a> | 54 | ></my-video-miniature> |
55 | <span i18n class="video-info-date-views">{{ result.publishedAt | myFromNow }} - {{ result.views | myNumberFormatter }} views</span> | ||
56 | <a tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', result.byAccount ]">{{ result.byAccount }}</a> | ||
57 | </div> | ||
58 | </div> | 55 | </div> |
59 | </ng-container> | 56 | </ng-container> |
60 | 57 | ||
diff --git a/client/src/app/search/search.component.scss b/client/src/app/search/search.component.scss index 6de13d276..4e3ce1c96 100644 --- a/client/src/app/search/search.component.scss +++ b/client/src/app/search/search.component.scss | |||
@@ -55,53 +55,14 @@ | |||
55 | padding-bottom: 20px; | 55 | padding-bottom: 20px; |
56 | margin-bottom: 20px; | 56 | margin-bottom: 20px; |
57 | 57 | ||
58 | &.video { | ||
59 | |||
60 | my-video-thumbnail { | ||
61 | margin-right: 10px; | ||
62 | } | ||
63 | |||
64 | .video-info { | ||
65 | flex-grow: 1; | ||
66 | |||
67 | .video-info-name { | ||
68 | @include disable-default-a-behaviour; | ||
69 | |||
70 | color: var(--mainForegroundColor); | ||
71 | display: block; | ||
72 | width: fit-content; | ||
73 | font-size: 18px; | ||
74 | font-weight: $font-semibold; | ||
75 | } | ||
76 | |||
77 | .video-info-date-views { | ||
78 | font-size: 14px; | ||
79 | } | ||
80 | |||
81 | .video-info-account { | ||
82 | @include disable-default-a-behaviour; | ||
83 | |||
84 | display: block; | ||
85 | width: fit-content; | ||
86 | overflow: hidden; | ||
87 | text-overflow: ellipsis; | ||
88 | white-space: nowrap; | ||
89 | font-size: 14px; | ||
90 | color: $grey-foreground-color; | ||
91 | |||
92 | &:hover { | ||
93 | color: $grey-foreground-hover-color; | ||
94 | } | ||
95 | } | ||
96 | } | ||
97 | } | ||
98 | |||
99 | &.video-channel { | 58 | &.video-channel { |
100 | |||
101 | img { | 59 | img { |
102 | @include avatar(120px); | 60 | $image-size: 130px; |
61 | $margin-size: ($video-thumbnail-width - $image-size) / 2; // So we have the same width than the video miniature | ||
62 | |||
63 | @include avatar($image-size); | ||
103 | 64 | ||
104 | margin: 0 50px 0 40px; | 65 | margin: 0 ($margin-size + 10) 0 $margin-size; |
105 | } | 66 | } |
106 | 67 | ||
107 | .video-channel-info { | 68 | .video-channel-info { |
diff --git a/client/src/app/search/search.component.ts b/client/src/app/search/search.component.ts index c4a4b1fde..a7ddbe1f8 100644 --- a/client/src/app/search/search.component.ts +++ b/client/src/app/search/search.component.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { AuthService, Notifier, ServerService } from '@app/core' | 3 | import { AuthService, Notifier } from '@app/core' |
4 | import { forkJoin, Subscription } from 'rxjs' | 4 | import { forkJoin, Subscription } from 'rxjs' |
5 | import { SearchService } from '@app/search/search.service' | 5 | import { SearchService } from '@app/search/search.service' |
6 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | 6 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' |
@@ -41,10 +41,13 @@ export class SearchComponent implements OnInit, OnDestroy { | |||
41 | private metaService: MetaService, | 41 | private metaService: MetaService, |
42 | private notifier: Notifier, | 42 | private notifier: Notifier, |
43 | private searchService: SearchService, | 43 | private searchService: SearchService, |
44 | private authService: AuthService, | 44 | private authService: AuthService |
45 | private serverService: ServerService | ||
46 | ) { } | 45 | ) { } |
47 | 46 | ||
47 | get user () { | ||
48 | return this.authService.getUser() | ||
49 | } | ||
50 | |||
48 | ngOnInit () { | 51 | ngOnInit () { |
49 | this.subActivatedRoute = this.route.queryParams.subscribe( | 52 | this.subActivatedRoute = this.route.queryParams.subscribe( |
50 | queryParams => { | 53 | queryParams => { |
@@ -76,10 +79,6 @@ export class SearchComponent implements OnInit, OnDestroy { | |||
76 | if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe() | 79 | if (this.subActivatedRoute) this.subActivatedRoute.unsubscribe() |
77 | } | 80 | } |
78 | 81 | ||
79 | isVideoBlur (video: Video) { | ||
80 | return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig()) | ||
81 | } | ||
82 | |||
83 | isVideoChannel (d: VideoChannel | Video): d is VideoChannel { | 82 | isVideoChannel (d: VideoChannel | Video): d is VideoChannel { |
84 | return d instanceof VideoChannel | 83 | return d instanceof VideoChannel |
85 | } | 84 | } |
@@ -139,6 +138,10 @@ export class SearchComponent implements OnInit, OnDestroy { | |||
139 | return this.advancedSearch.size() | 138 | return this.advancedSearch.size() |
140 | } | 139 | } |
141 | 140 | ||
141 | removeVideoFromArray (video: Video) { | ||
142 | this.results = this.results.filter(r => !this.isVideo(r) || r.id !== video.id) | ||
143 | } | ||
144 | |||
142 | private resetPagination () { | 145 | private resetPagination () { |
143 | this.pagination.currentPage = 1 | 146 | this.pagination.currentPage = 1 |
144 | this.pagination.totalItems = null | 147 | this.pagination.totalItems = null |
diff --git a/client/src/app/shared/misc/from-now.pipe.ts b/client/src/app/shared/angular/from-now.pipe.ts index 00b5be6c9..3a9a76411 100644 --- a/client/src/app/shared/misc/from-now.pipe.ts +++ b/client/src/app/shared/angular/from-now.pipe.ts | |||
@@ -35,6 +35,6 @@ export class FromNowPipe implements PipeTransform { | |||
35 | interval = Math.floor(seconds / 60) | 35 | interval = Math.floor(seconds / 60) |
36 | if (interval >= 1) return this.i18n('{{interval}} min ago', { interval }) | 36 | if (interval >= 1) return this.i18n('{{interval}} min ago', { interval }) |
37 | 37 | ||
38 | return this.i18n('{{interval}} sec ago', { interval: Math.floor(seconds) }) | 38 | return this.i18n('{{interval}} sec ago', { interval: Math.max(0, seconds) }) |
39 | } | 39 | } |
40 | } | 40 | } |
diff --git a/client/src/app/shared/misc/number-formatter.pipe.ts b/client/src/app/shared/angular/number-formatter.pipe.ts index 8a0756a36..8a0756a36 100644 --- a/client/src/app/shared/misc/number-formatter.pipe.ts +++ b/client/src/app/shared/angular/number-formatter.pipe.ts | |||
diff --git a/client/src/app/shared/misc/object-length.pipe.ts b/client/src/app/shared/angular/object-length.pipe.ts index 84d182052..84d182052 100644 --- a/client/src/app/shared/misc/object-length.pipe.ts +++ b/client/src/app/shared/angular/object-length.pipe.ts | |||
diff --git a/client/src/app/shared/angular/peertube-template.directive.ts b/client/src/app/shared/angular/peertube-template.directive.ts new file mode 100644 index 000000000..a514b6057 --- /dev/null +++ b/client/src/app/shared/angular/peertube-template.directive.ts | |||
@@ -0,0 +1,12 @@ | |||
1 | import { Directive, Input, TemplateRef } from '@angular/core' | ||
2 | |||
3 | @Directive({ | ||
4 | selector: '[ptTemplate]' | ||
5 | }) | ||
6 | export class PeerTubeTemplateDirective { | ||
7 | @Input('ptTemplate') name: string | ||
8 | |||
9 | constructor (public template: TemplateRef<any>) { | ||
10 | // empty | ||
11 | } | ||
12 | } | ||
diff --git a/client/src/app/shared/buttons/action-dropdown.component.html b/client/src/app/shared/buttons/action-dropdown.component.html index 114b1d71f..cc244dc76 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.html +++ b/client/src/app/shared/buttons/action-dropdown.component.html | |||
@@ -1,9 +1,11 @@ | |||
1 | <div class="dropdown-root" ngbDropdown [placement]="placement"> | 1 | <div class="dropdown-root" ngbDropdown [placement]="placement"> |
2 | <div | 2 | <div |
3 | class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange' }" | 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" |
5 | > | 5 | > |
6 | <my-global-icon *ngIf="!label" class="more-icon" iconName="more"></my-global-icon> | 6 | <my-global-icon *ngIf="!label && buttonDirection === 'horizontal'" class="more-icon" iconName="more-horizontal"></my-global-icon> |
7 | <my-global-icon *ngIf="!label && buttonDirection === 'vertical'" class="more-icon" iconName="more-vertical"></my-global-icon> | ||
8 | |||
7 | <span *ngIf="label" class="dropdown-toggle">{{ label }}</span> | 9 | <span *ngIf="label" class="dropdown-toggle">{{ label }}</span> |
8 | </div> | 10 | </div> |
9 | 11 | ||
@@ -12,15 +14,24 @@ | |||
12 | 14 | ||
13 | <ng-container *ngFor="let action of actions"> | 15 | <ng-container *ngFor="let action of actions"> |
14 | <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true"> | 16 | <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true"> |
15 | <a *ngIf="action.linkBuilder" class="dropdown-item" [routerLink]="action.linkBuilder(entry)">{{ action.label }}</a> | ||
16 | 17 | ||
17 | <span *ngIf="!action.linkBuilder" class="custom-action dropdown-item" (click)="action.handler(entry)" role="button"> | 18 | <a *ngIf="action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" class="dropdown-item" [routerLink]="action.linkBuilder(entry)"> |
19 | <my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName"></my-global-icon> | ||
20 | {{ action.label }} | ||
21 | </a> | ||
22 | |||
23 | <span | ||
24 | *ngIf="!action.linkBuilder" [ngClass]="{ 'with-icon': !!action.iconName }" (click)="action.handler(entry)" | ||
25 | class="custom-action dropdown-item" role="button" | ||
26 | > | ||
27 | <my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName"></my-global-icon> | ||
18 | {{ action.label }} | 28 | {{ action.label }} |
19 | </span> | 29 | </span> |
30 | |||
20 | </ng-container> | 31 | </ng-container> |
21 | </ng-container> | 32 | </ng-container> |
22 | 33 | ||
23 | <div class="dropdown-divider"></div> | 34 | <div *ngIf="areActionsDisplayed(actions, entry)" class="dropdown-divider"></div> |
24 | 35 | ||
25 | </ng-container> | 36 | </ng-container> |
26 | </div> | 37 | </div> |
diff --git a/client/src/app/shared/buttons/action-dropdown.component.scss b/client/src/app/shared/buttons/action-dropdown.component.scss index 985b2ca88..5073190b0 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.scss +++ b/client/src/app/shared/buttons/action-dropdown.component.scss | |||
@@ -8,12 +8,19 @@ | |||
8 | .action-button { | 8 | .action-button { |
9 | @include peertube-button; | 9 | @include peertube-button; |
10 | 10 | ||
11 | &.grey { | 11 | &.button-styled { |
12 | @include grey-button; | 12 | |
13 | } | 13 | &.grey { |
14 | @include grey-button; | ||
15 | } | ||
16 | |||
17 | &.orange { | ||
18 | @include orange-button; | ||
19 | } | ||
14 | 20 | ||
15 | &.orange { | 21 | &:hover, &:active, &:focus { |
16 | @include orange-button; | 22 | background-color: $grey-background-color; |
23 | } | ||
17 | } | 24 | } |
18 | 25 | ||
19 | display: inline-block; | 26 | display: inline-block; |
@@ -23,10 +30,6 @@ | |||
23 | display: none; | 30 | display: none; |
24 | } | 31 | } |
25 | 32 | ||
26 | &:hover, &:active, &:focus { | ||
27 | background-color: $grey-background-color; | ||
28 | } | ||
29 | |||
30 | .more-icon { | 33 | .more-icon { |
31 | width: 21px; | 34 | width: 21px; |
32 | } | 35 | } |
@@ -48,6 +51,10 @@ | |||
48 | cursor: pointer; | 51 | cursor: pointer; |
49 | color: #000 !important; | 52 | color: #000 !important; |
50 | 53 | ||
54 | &.with-icon { | ||
55 | @include dropdown-with-icon-item; | ||
56 | } | ||
57 | |||
51 | a, span { | 58 | a, span { |
52 | display: block; | 59 | display: block; |
53 | width: 100%; | 60 | width: 100%; |
diff --git a/client/src/app/shared/buttons/action-dropdown.component.ts b/client/src/app/shared/buttons/action-dropdown.component.ts index 275e2b51e..f5345831b 100644 --- a/client/src/app/shared/buttons/action-dropdown.component.ts +++ b/client/src/app/shared/buttons/action-dropdown.component.ts | |||
@@ -1,12 +1,18 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { Component, Input } from '@angular/core' |
2 | import { GlobalIconName } from '@app/shared/images/global-icon.component' | ||
2 | 3 | ||
3 | export type DropdownAction<T> = { | 4 | export type DropdownAction<T> = { |
4 | label?: string | 5 | label?: string |
6 | iconName?: GlobalIconName | ||
5 | handler?: (a: T) => any | 7 | handler?: (a: T) => any |
6 | linkBuilder?: (a: T) => (string | number)[] | 8 | linkBuilder?: (a: T) => (string | number)[] |
7 | isDisplayed?: (a: T) => boolean | 9 | isDisplayed?: (a: T) => boolean |
8 | } | 10 | } |
9 | 11 | ||
12 | export type DropdownButtonSize = 'normal' | 'small' | ||
13 | export type DropdownTheme = 'orange' | 'grey' | ||
14 | export type DropdownDirection = 'horizontal' | 'vertical' | ||
15 | |||
10 | @Component({ | 16 | @Component({ |
11 | selector: 'my-action-dropdown', | 17 | selector: 'my-action-dropdown', |
12 | styleUrls: [ './action-dropdown.component.scss' ], | 18 | styleUrls: [ './action-dropdown.component.scss' ], |
@@ -16,14 +22,29 @@ export type DropdownAction<T> = { | |||
16 | export class ActionDropdownComponent<T> { | 22 | export class ActionDropdownComponent<T> { |
17 | @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = [] | 23 | @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = [] |
18 | @Input() entry: T | 24 | @Input() entry: T |
25 | |||
19 | @Input() placement = 'bottom-left' | 26 | @Input() placement = 'bottom-left' |
20 | @Input() buttonSize: 'normal' | 'small' = 'normal' | 27 | |
28 | @Input() buttonSize: DropdownButtonSize = 'normal' | ||
29 | @Input() buttonDirection: DropdownDirection = 'horizontal' | ||
30 | @Input() buttonStyled = true | ||
31 | |||
21 | @Input() label: string | 32 | @Input() label: string |
22 | @Input() theme: 'orange' | 'grey' = 'grey' | 33 | @Input() theme: DropdownTheme = 'grey' |
23 | 34 | ||
24 | getActions () { | 35 | getActions () { |
25 | if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions | 36 | if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions |
26 | 37 | ||
27 | return [ this.actions ] | 38 | return [ this.actions ] |
28 | } | 39 | } |
40 | |||
41 | areActionsDisplayed (actions: DropdownAction<T>[], entry: T) { | ||
42 | return actions.some(a => a.isDisplayed === undefined || a.isDisplayed(entry)) | ||
43 | } | ||
44 | |||
45 | handleClick (event: Event, action: DropdownAction<T>) { | ||
46 | event.preventDefault() | ||
47 | |||
48 | // action.handler(entry) | ||
49 | } | ||
29 | } | 50 | } |
diff --git a/client/src/app/shared/buttons/button.component.ts b/client/src/app/shared/buttons/button.component.ts index a91e9c7eb..c2b69d31a 100644 --- a/client/src/app/shared/buttons/button.component.ts +++ b/client/src/app/shared/buttons/button.component.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { Component, Input } from '@angular/core' |
2 | import { GlobalIconName } from '@app/shared/icons/global-icon.component' | 2 | import { GlobalIconName } from '@app/shared/images/global-icon.component' |
3 | 3 | ||
4 | @Component({ | 4 | @Component({ |
5 | selector: 'my-button', | 5 | selector: 'my-button', |
diff --git a/client/src/app/shared/forms/form-reactive.ts b/client/src/app/shared/forms/form-reactive.ts index b9873af2c..0d40b6f4a 100644 --- a/client/src/app/shared/forms/form-reactive.ts +++ b/client/src/app/shared/forms/form-reactive.ts | |||
@@ -59,7 +59,7 @@ export abstract class FormReactive { | |||
59 | const isDirty = control.dirty || forceCheck === true | 59 | const isDirty = control.dirty || forceCheck === true |
60 | if (control && isDirty && !control.valid) { | 60 | if (control && isDirty && !control.valid) { |
61 | const messages = validationMessages[ field ] | 61 | const messages = validationMessages[ field ] |
62 | for (const key in control.errors) { | 62 | for (const key of Object.keys(control.errors)) { |
63 | formErrors[ field ] += messages[ key ] + ' ' | 63 | formErrors[ field ] += messages[ key ] + ' ' |
64 | } | 64 | } |
65 | } | 65 | } |
diff --git a/client/src/app/shared/forms/form-validators/index.ts b/client/src/app/shared/forms/form-validators/index.ts index fdcbedb71..e3de3ae13 100644 --- a/client/src/app/shared/forms/form-validators/index.ts +++ b/client/src/app/shared/forms/form-validators/index.ts | |||
@@ -10,6 +10,7 @@ export * from './video-blacklist-validators.service' | |||
10 | export * from './video-channel-validators.service' | 10 | export * from './video-channel-validators.service' |
11 | export * from './video-comment-validators.service' | 11 | export * from './video-comment-validators.service' |
12 | export * from './video-validators.service' | 12 | export * from './video-validators.service' |
13 | export * from './video-playlist-validators.service' | ||
13 | export * from './video-captions-validators.service' | 14 | export * from './video-captions-validators.service' |
14 | export * from './video-change-ownership-validators.service' | 15 | export * from './video-change-ownership-validators.service' |
15 | export * from './video-accept-ownership-validators.service' | 16 | export * from './video-accept-ownership-validators.service' |
diff --git a/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts new file mode 100644 index 000000000..a2c9a5368 --- /dev/null +++ b/client/src/app/shared/forms/form-validators/video-playlist-validators.service.ts | |||
@@ -0,0 +1,66 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { AbstractControl, FormControl, Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from '@app/shared' | ||
5 | import { VideoPlaylistPrivacy } from '@shared/models' | ||
6 | |||
7 | @Injectable() | ||
8 | export class VideoPlaylistValidatorsService { | ||
9 | readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator | ||
10 | readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator | ||
11 | readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator | ||
12 | readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator | ||
13 | |||
14 | constructor (private i18n: I18n) { | ||
15 | this.VIDEO_PLAYLIST_DISPLAY_NAME = { | ||
16 | VALIDATORS: [ | ||
17 | Validators.required, | ||
18 | Validators.minLength(1), | ||
19 | Validators.maxLength(120) | ||
20 | ], | ||
21 | MESSAGES: { | ||
22 | 'required': this.i18n('Display name is required.'), | ||
23 | 'minlength': this.i18n('Display name must be at least 1 character long.'), | ||
24 | 'maxlength': this.i18n('Display name cannot be more than 120 characters long.') | ||
25 | } | ||
26 | } | ||
27 | |||
28 | this.VIDEO_PLAYLIST_PRIVACY = { | ||
29 | VALIDATORS: [ | ||
30 | Validators.required | ||
31 | ], | ||
32 | MESSAGES: { | ||
33 | 'required': this.i18n('Privacy is required.') | ||
34 | } | ||
35 | } | ||
36 | |||
37 | this.VIDEO_PLAYLIST_DESCRIPTION = { | ||
38 | VALIDATORS: [ | ||
39 | Validators.minLength(3), | ||
40 | Validators.maxLength(1000) | ||
41 | ], | ||
42 | MESSAGES: { | ||
43 | 'minlength': i18n('Description must be at least 3 characters long.'), | ||
44 | 'maxlength': i18n('Description cannot be more than 1000 characters long.') | ||
45 | } | ||
46 | } | ||
47 | |||
48 | this.VIDEO_PLAYLIST_CHANNEL_ID = { | ||
49 | VALIDATORS: [ ], | ||
50 | MESSAGES: { | ||
51 | 'required': this.i18n('The channel is required when the playlist is public.') | ||
52 | } | ||
53 | } | ||
54 | } | ||
55 | |||
56 | setChannelValidator (channelControl: AbstractControl, privacy: VideoPlaylistPrivacy) { | ||
57 | if (privacy.toString() === VideoPlaylistPrivacy.PUBLIC.toString()) { | ||
58 | channelControl.setValidators([ Validators.required ]) | ||
59 | } else { | ||
60 | channelControl.setValidators(null) | ||
61 | } | ||
62 | |||
63 | channelControl.markAsDirty() | ||
64 | channelControl.updateValueAndValidity() | ||
65 | } | ||
66 | } | ||
diff --git a/client/src/app/shared/forms/form-validators/video-validators.service.ts b/client/src/app/shared/forms/form-validators/video-validators.service.ts index 81ed0666f..e3f7a0969 100644 --- a/client/src/app/shared/forms/form-validators/video-validators.service.ts +++ b/client/src/app/shared/forms/form-validators/video-validators.service.ts | |||
@@ -16,6 +16,7 @@ export class VideoValidatorsService { | |||
16 | readonly VIDEO_TAGS: BuildFormValidator | 16 | readonly VIDEO_TAGS: BuildFormValidator |
17 | readonly VIDEO_SUPPORT: BuildFormValidator | 17 | readonly VIDEO_SUPPORT: BuildFormValidator |
18 | readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator | 18 | readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator |
19 | readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator | ||
19 | 20 | ||
20 | constructor (private i18n: I18n) { | 21 | constructor (private i18n: I18n) { |
21 | 22 | ||
@@ -92,5 +93,10 @@ export class VideoValidatorsService { | |||
92 | 'required': this.i18n('A date is required to schedule video update.') | 93 | 'required': this.i18n('A date is required to schedule video update.') |
93 | } | 94 | } |
94 | } | 95 | } |
96 | |||
97 | this.VIDEO_ORIGINALLY_PUBLISHED_AT = { | ||
98 | VALIDATORS: [ ], | ||
99 | MESSAGES: {} | ||
100 | } | ||
95 | } | 101 | } |
96 | } | 102 | } |
diff --git a/client/src/app/shared/forms/markdown-textarea.component.ts b/client/src/app/shared/forms/markdown-textarea.component.ts index e87aca0d4..49a57f29d 100644 --- a/client/src/app/shared/forms/markdown-textarea.component.ts +++ b/client/src/app/shared/forms/markdown-textarea.component.ts | |||
@@ -82,11 +82,11 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { | |||
82 | return this.screenService.isInSmallView() === false | 82 | return this.screenService.isInSmallView() === false |
83 | } | 83 | } |
84 | 84 | ||
85 | private updatePreviews () { | 85 | private async updatePreviews () { |
86 | if (this.content === null || this.content === undefined) return | 86 | if (this.content === null || this.content === undefined) return |
87 | 87 | ||
88 | this.truncatedPreviewHTML = this.markdownRender(truncate(this.content, { length: this.truncate })) | 88 | this.truncatedPreviewHTML = await this.markdownRender(truncate(this.content, { length: this.truncate })) |
89 | this.previewHTML = this.markdownRender(this.content) | 89 | this.previewHTML = await this.markdownRender(this.content) |
90 | } | 90 | } |
91 | 91 | ||
92 | private markdownRender (text: string) { | 92 | private markdownRender (text: string) { |
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.scss b/client/src/app/shared/forms/peertube-checkbox.component.scss index 6e4e20775..ea321ee65 100644 --- a/client/src/app/shared/forms/peertube-checkbox.component.scss +++ b/client/src/app/shared/forms/peertube-checkbox.component.scss | |||
@@ -28,4 +28,4 @@ | |||
28 | position: relative; | 28 | position: relative; |
29 | top: -2px; | 29 | top: -2px; |
30 | } | 30 | } |
31 | } \ No newline at end of file | 31 | } |
diff --git a/client/src/app/shared/forms/peertube-checkbox.component.ts b/client/src/app/shared/forms/peertube-checkbox.component.ts index c1a6915e8..9578f5618 100644 --- a/client/src/app/shared/forms/peertube-checkbox.component.ts +++ b/client/src/app/shared/forms/peertube-checkbox.component.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { Component, forwardRef, Input } from '@angular/core' | 1 | import { ChangeDetectorRef, Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core' |
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
3 | 3 | ||
4 | @Component({ | 4 | @Component({ |
@@ -21,10 +21,19 @@ export class PeertubeCheckboxComponent implements ControlValueAccessor { | |||
21 | @Input() helpHtml: string | 21 | @Input() helpHtml: string |
22 | @Input() disabled = false | 22 | @Input() disabled = false |
23 | 23 | ||
24 | // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836 | ||
25 | @Input() onPushWorkaround = false | ||
26 | |||
27 | constructor (private cdr: ChangeDetectorRef) { } | ||
28 | |||
24 | propagateChange = (_: any) => { /* empty */ } | 29 | propagateChange = (_: any) => { /* empty */ } |
25 | 30 | ||
26 | writeValue (checked: boolean) { | 31 | writeValue (checked: boolean) { |
27 | this.checked = checked | 32 | this.checked = checked |
33 | |||
34 | if (this.onPushWorkaround) { | ||
35 | this.cdr.markForCheck() | ||
36 | } | ||
28 | } | 37 | } |
29 | 38 | ||
30 | registerOnChange (fn: (_: any) => void) { | 39 | registerOnChange (fn: (_: any) => void) { |
diff --git a/client/src/app/shared/forms/timestamp-input.component.html b/client/src/app/shared/forms/timestamp-input.component.html new file mode 100644 index 000000000..c57a4b32c --- /dev/null +++ b/client/src/app/shared/forms/timestamp-input.component.html | |||
@@ -0,0 +1,4 @@ | |||
1 | <p-inputMask | ||
2 | [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()" | ||
3 | mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()" | ||
4 | ></p-inputMask> | ||
diff --git a/client/src/app/shared/forms/timestamp-input.component.scss b/client/src/app/shared/forms/timestamp-input.component.scss new file mode 100644 index 000000000..7115777fd --- /dev/null +++ b/client/src/app/shared/forms/timestamp-input.component.scss | |||
@@ -0,0 +1,8 @@ | |||
1 | p-inputmask { | ||
2 | /deep/ input { | ||
3 | width: 80px; | ||
4 | font-size: 15px; | ||
5 | |||
6 | border: none; | ||
7 | } | ||
8 | } | ||
diff --git a/client/src/app/shared/forms/timestamp-input.component.ts b/client/src/app/shared/forms/timestamp-input.component.ts new file mode 100644 index 000000000..8d67a96ac --- /dev/null +++ b/client/src/app/shared/forms/timestamp-input.component.ts | |||
@@ -0,0 +1,61 @@ | |||
1 | import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { secondsToTime, timeToInt } from '../../../assets/player/utils' | ||
4 | |||
5 | @Component({ | ||
6 | selector: 'my-timestamp-input', | ||
7 | styleUrls: [ './timestamp-input.component.scss' ], | ||
8 | templateUrl: './timestamp-input.component.html', | ||
9 | providers: [ | ||
10 | { | ||
11 | provide: NG_VALUE_ACCESSOR, | ||
12 | useExisting: forwardRef(() => TimestampInputComponent), | ||
13 | multi: true | ||
14 | } | ||
15 | ] | ||
16 | }) | ||
17 | export class TimestampInputComponent implements ControlValueAccessor, OnInit { | ||
18 | @Input() maxTimestamp: number | ||
19 | @Input() timestamp: number | ||
20 | @Input() disabled = false | ||
21 | |||
22 | timestampString: string | ||
23 | |||
24 | constructor (private changeDetector: ChangeDetectorRef) {} | ||
25 | |||
26 | ngOnInit () { | ||
27 | this.writeValue(this.timestamp || 0) | ||
28 | } | ||
29 | |||
30 | propagateChange = (_: any) => { /* empty */ } | ||
31 | |||
32 | writeValue (timestamp: number) { | ||
33 | this.timestamp = timestamp | ||
34 | |||
35 | this.timestampString = secondsToTime(this.timestamp, true, ':') | ||
36 | } | ||
37 | |||
38 | registerOnChange (fn: (_: any) => void) { | ||
39 | this.propagateChange = fn | ||
40 | } | ||
41 | |||
42 | registerOnTouched () { | ||
43 | // Unused | ||
44 | } | ||
45 | |||
46 | onModelChange () { | ||
47 | this.timestamp = timeToInt(this.timestampString) | ||
48 | |||
49 | this.propagateChange(this.timestamp) | ||
50 | } | ||
51 | |||
52 | onBlur () { | ||
53 | if (this.maxTimestamp && this.timestamp > this.maxTimestamp) { | ||
54 | this.writeValue(this.maxTimestamp) | ||
55 | |||
56 | this.changeDetector.detectChanges() | ||
57 | |||
58 | this.propagateChange(this.timestamp) | ||
59 | } | ||
60 | } | ||
61 | } | ||
diff --git a/client/src/app/shared/icons/global-icon.component.html b/client/src/app/shared/icons/global-icon.component.html deleted file mode 100644 index e69de29bb..000000000 --- a/client/src/app/shared/icons/global-icon.component.html +++ /dev/null | |||
diff --git a/client/src/app/shared/icons/global-icon.component.scss b/client/src/app/shared/images/global-icon.component.scss index 6805fb6f7..6805fb6f7 100644 --- a/client/src/app/shared/icons/global-icon.component.scss +++ b/client/src/app/shared/images/global-icon.component.scss | |||
diff --git a/client/src/app/shared/icons/global-icon.component.ts b/client/src/app/shared/images/global-icon.component.ts index e8ada0324..5a3db4531 100644 --- a/client/src/app/shared/icons/global-icon.component.ts +++ b/client/src/app/shared/images/global-icon.component.ts | |||
@@ -1,7 +1,9 @@ | |||
1 | import { Component, ElementRef, Input, OnInit } from '@angular/core' | 1 | import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core' |
2 | 2 | ||
3 | const icons = { | 3 | const icons = { |
4 | 'add': require('../../../assets/images/global/add.html'), | 4 | 'add': require('../../../assets/images/global/add.html'), |
5 | 'user': require('../../../assets/images/global/user.html'), | ||
6 | 'sign-out': require('../../../assets/images/global/sign-out.html'), | ||
5 | 'syndication': require('../../../assets/images/global/syndication.html'), | 7 | 'syndication': require('../../../assets/images/global/syndication.html'), |
6 | 'help': require('../../../assets/images/global/help.html'), | 8 | 'help': require('../../../assets/images/global/help.html'), |
7 | 'sparkle': require('../../../assets/images/global/sparkle.html'), | 9 | 'sparkle': require('../../../assets/images/global/sparkle.html'), |
@@ -11,21 +13,39 @@ const icons = { | |||
11 | 'no': require('../../../assets/images/global/no.html'), | 13 | 'no': require('../../../assets/images/global/no.html'), |
12 | 'cloud-download': require('../../../assets/images/global/cloud-download.html'), | 14 | 'cloud-download': require('../../../assets/images/global/cloud-download.html'), |
13 | 'undo': require('../../../assets/images/global/undo.html'), | 15 | 'undo': require('../../../assets/images/global/undo.html'), |
16 | 'history': require('../../../assets/images/global/history.html'), | ||
14 | 'circle-tick': require('../../../assets/images/global/circle-tick.html'), | 17 | 'circle-tick': require('../../../assets/images/global/circle-tick.html'), |
15 | 'cog': require('../../../assets/images/global/cog.html'), | 18 | 'cog': require('../../../assets/images/global/cog.html'), |
16 | 'download': require('../../../assets/images/global/download.html'), | 19 | 'download': require('../../../assets/images/global/download.html'), |
20 | 'go': require('../../../assets/images/menu/go.html'), | ||
17 | 'edit': require('../../../assets/images/global/edit.html'), | 21 | 'edit': require('../../../assets/images/global/edit.html'), |
18 | 'im-with-her': require('../../../assets/images/global/im-with-her.html'), | 22 | 'im-with-her': require('../../../assets/images/global/im-with-her.html'), |
19 | 'delete': require('../../../assets/images/global/delete.html'), | 23 | 'delete': require('../../../assets/images/global/delete.html'), |
24 | 'server': require('../../../assets/images/global/server.html'), | ||
20 | 'cross': require('../../../assets/images/global/cross.html'), | 25 | 'cross': require('../../../assets/images/global/cross.html'), |
21 | 'validate': require('../../../assets/images/global/validate.html'), | 26 | 'validate': require('../../../assets/images/global/validate.html'), |
22 | 'tick': require('../../../assets/images/global/tick.html'), | 27 | 'tick': require('../../../assets/images/global/tick.html'), |
23 | 'dislike': require('../../../assets/images/video/dislike.html'), | 28 | 'dislike': require('../../../assets/images/video/dislike.html'), |
24 | 'heart': require('../../../assets/images/video/heart.html'), | 29 | 'heart': require('../../../assets/images/video/heart.html'), |
25 | 'like': require('../../../assets/images/video/like.html'), | 30 | 'like': require('../../../assets/images/video/like.html'), |
26 | 'more': require('../../../assets/images/video/more.html'), | 31 | 'more-horizontal': require('../../../assets/images/global/more-horizontal.html'), |
32 | 'more-vertical': require('../../../assets/images/global/more-vertical.html'), | ||
27 | 'share': require('../../../assets/images/video/share.html'), | 33 | 'share': require('../../../assets/images/video/share.html'), |
28 | 'upload': require('../../../assets/images/video/upload.html') | 34 | 'upload': require('../../../assets/images/video/upload.html'), |
35 | 'playlist-add': require('../../../assets/images/video/playlist-add.html'), | ||
36 | 'play': require('../../../assets/images/global/play.html'), | ||
37 | 'playlists': require('../../../assets/images/global/playlists.html'), | ||
38 | 'about': require('../../../assets/images/menu/about.html'), | ||
39 | 'globe': require('../../../assets/images/menu/globe.html'), | ||
40 | 'home': require('../../../assets/images/menu/home.html'), | ||
41 | 'recently-added': require('../../../assets/images/menu/recently-added.html'), | ||
42 | 'trending': require('../../../assets/images/menu/trending.html'), | ||
43 | 'videos': require('../../../assets/images/global/videos.html'), | ||
44 | 'folder': require('../../../assets/images/global/folder.html'), | ||
45 | 'administration': require('../../../assets/images/menu/administration.html'), | ||
46 | 'subscriptions': require('../../../assets/images/menu/subscriptions.html'), | ||
47 | 'users': require('../../../assets/images/global/users.html'), | ||
48 | 'refresh': require('../../../assets/images/global/refresh.html') | ||
29 | } | 49 | } |
30 | 50 | ||
31 | export type GlobalIconName = keyof typeof icons | 51 | export type GlobalIconName = keyof typeof icons |
@@ -33,7 +53,8 @@ export type GlobalIconName = keyof typeof icons | |||
33 | @Component({ | 53 | @Component({ |
34 | selector: 'my-global-icon', | 54 | selector: 'my-global-icon', |
35 | template: '', | 55 | template: '', |
36 | styleUrls: [ './global-icon.component.scss' ] | 56 | styleUrls: [ './global-icon.component.scss' ], |
57 | changeDetection: ChangeDetectionStrategy.OnPush | ||
37 | }) | 58 | }) |
38 | export class GlobalIconComponent implements OnInit { | 59 | export class GlobalIconComponent implements OnInit { |
39 | @Input() iconName: GlobalIconName | 60 | @Input() iconName: GlobalIconName |
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.html b/client/src/app/shared/images/image-upload.component.html index c09c862c4..c09c862c4 100644 --- a/client/src/app/videos/+video-edit/shared/video-image.component.html +++ b/client/src/app/shared/images/image-upload.component.html | |||
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.scss b/client/src/app/shared/images/image-upload.component.scss index b63963bca..b63963bca 100644 --- a/client/src/app/videos/+video-edit/shared/video-image.component.scss +++ b/client/src/app/shared/images/image-upload.component.scss | |||
diff --git a/client/src/app/videos/+video-edit/shared/video-image.component.ts b/client/src/app/shared/images/image-upload.component.ts index a604cde90..2da1592ff 100644 --- a/client/src/app/videos/+video-edit/shared/video-image.component.ts +++ b/client/src/app/shared/images/image-upload.component.ts | |||
@@ -4,18 +4,18 @@ import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' | |||
4 | import { ServerService } from '@app/core' | 4 | import { ServerService } from '@app/core' |
5 | 5 | ||
6 | @Component({ | 6 | @Component({ |
7 | selector: 'my-video-image', | 7 | selector: 'my-image-upload', |
8 | styleUrls: [ './video-image.component.scss' ], | 8 | styleUrls: [ './image-upload.component.scss' ], |
9 | templateUrl: './video-image.component.html', | 9 | templateUrl: './image-upload.component.html', |
10 | providers: [ | 10 | providers: [ |
11 | { | 11 | { |
12 | provide: NG_VALUE_ACCESSOR, | 12 | provide: NG_VALUE_ACCESSOR, |
13 | useExisting: forwardRef(() => VideoImageComponent), | 13 | useExisting: forwardRef(() => ImageUploadComponent), |
14 | multi: true | 14 | multi: true |
15 | } | 15 | } |
16 | ] | 16 | ] |
17 | }) | 17 | }) |
18 | export class VideoImageComponent implements ControlValueAccessor { | 18 | export class ImageUploadComponent implements ControlValueAccessor { |
19 | @Input() inputLabel: string | 19 | @Input() inputLabel: string |
20 | @Input() inputName: string | 20 | @Input() inputName: string |
21 | @Input() previewWidth: string | 21 | @Input() previewWidth: string |
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 dc8db8cc1..2885f97e3 100644 --- a/client/src/app/shared/instance/instance-features-table.component.html +++ b/client/src/app/shared/instance/instance-features-table.component.html | |||
@@ -2,6 +2,20 @@ | |||
2 | 2 | ||
3 | <table class="table"> | 3 | <table class="table"> |
4 | <tr> | 4 | <tr> |
5 | <td i18n class="label">Default NSFW/sensitive videos policy (can be redefined by the users)</td> | ||
6 | |||
7 | <td class="value">{{ buildNSFWLabel() }}</td> | ||
8 | </tr> | ||
9 | |||
10 | <tr *ngFor="let feature of features"> | ||
11 | <td class="label">{{ feature.label }}</td> | ||
12 | <td> | ||
13 | <span *ngIf="feature.value === true" class="glyphicon glyphicon-ok"></span> | ||
14 | <span *ngIf="feature.value === false" class="glyphicon glyphicon-remove"></span> | ||
15 | </td> | ||
16 | </tr> | ||
17 | |||
18 | <tr> | ||
5 | <td i18n class="label">Video quota</td> | 19 | <td i18n class="label">Video quota</td> |
6 | 20 | ||
7 | <td class="value"> | 21 | <td class="value"> |
@@ -16,13 +30,5 @@ | |||
16 | </ng-container> | 30 | </ng-container> |
17 | </td> | 31 | </td> |
18 | </tr> | 32 | </tr> |
19 | |||
20 | <tr *ngFor="let feature of features"> | ||
21 | <td class="label">{{ feature.label }}</td> | ||
22 | <td> | ||
23 | <span *ngIf="feature.value === true" class="glyphicon glyphicon-ok"></span> | ||
24 | <span *ngIf="feature.value === false" class="glyphicon glyphicon-remove"></span> | ||
25 | </td> | ||
26 | </tr> | ||
27 | </table> | 33 | </table> |
28 | </div> \ No newline at end of file | 34 | </div> |
diff --git a/client/src/app/shared/instance/instance-features-table.component.ts b/client/src/app/shared/instance/instance-features-table.component.ts index da8da0702..72e7c2730 100644 --- a/client/src/app/shared/instance/instance-features-table.component.ts +++ b/client/src/app/shared/instance/instance-features-table.component.ts | |||
@@ -33,11 +33,27 @@ export class InstanceFeaturesTableComponent implements OnInit { | |||
33 | }) | 33 | }) |
34 | } | 34 | } |
35 | 35 | ||
36 | buildNSFWLabel () { | ||
37 | const policy = this.serverService.getConfig().instance.defaultNSFWPolicy | ||
38 | |||
39 | if (policy === 'do_not_list') return this.i18n('Hidden') | ||
40 | if (policy === 'blur') return this.i18n('Blurred with confirmation request') | ||
41 | if (policy === 'display') return this.i18n('Displayed') | ||
42 | } | ||
43 | |||
36 | private buildFeatures () { | 44 | private buildFeatures () { |
37 | const config = this.serverService.getConfig() | 45 | const config = this.serverService.getConfig() |
38 | 46 | ||
39 | this.features = [ | 47 | this.features = [ |
40 | { | 48 | { |
49 | label: this.i18n('User registration allowed'), | ||
50 | value: config.signup.allowed | ||
51 | }, | ||
52 | { | ||
53 | label: this.i18n('Video uploads require manual validation by moderators'), | ||
54 | value: config.autoBlacklist.videos.ofUsers.enabled | ||
55 | }, | ||
56 | { | ||
41 | label: this.i18n('Transcode your videos in multiple resolutions'), | 57 | label: this.i18n('Transcode your videos in multiple resolutions'), |
42 | value: config.transcoding.enabledResolutions.length !== 0 | 58 | value: config.transcoding.enabledResolutions.length !== 0 |
43 | }, | 59 | }, |
@@ -48,9 +64,12 @@ export class InstanceFeaturesTableComponent implements OnInit { | |||
48 | { | 64 | { |
49 | label: this.i18n('Torrent import'), | 65 | label: this.i18n('Torrent import'), |
50 | value: config.import.videos.torrent.enabled | 66 | value: config.import.videos.torrent.enabled |
67 | }, | ||
68 | { | ||
69 | label: this.i18n('P2P enabled'), | ||
70 | value: config.tracker.enabled | ||
51 | } | 71 | } |
52 | ] | 72 | ] |
53 | |||
54 | } | 73 | } |
55 | 74 | ||
56 | private getApproximateTime (seconds: number) { | 75 | private getApproximateTime (seconds: number) { |
@@ -84,5 +103,4 @@ export class InstanceFeaturesTableComponent implements OnInit { | |||
84 | 103 | ||
85 | this.quotaHelpIndication = lines.join('<br />') | 104 | this.quotaHelpIndication = lines.join('<br />') |
86 | } | 105 | } |
87 | |||
88 | } | 106 | } |
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 d3c896019..35511ee62 100644 --- a/client/src/app/shared/menu/top-menu-dropdown.component.html +++ b/client/src/app/shared/menu/top-menu-dropdown.component.html | |||
@@ -3,7 +3,7 @@ | |||
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">{{ menuEntry.label }}</a> |
5 | 5 | ||
6 | <div *ngIf="!menuEntry.routerLink" ngbDropdown class="parent-entry" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)"> | 6 | <div *ngIf="!menuEntry.routerLink" ngbDropdown [container]="container" class="parent-entry" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)"> |
7 | <span | 7 | <span |
8 | (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor | 8 | (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor |
9 | (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page" | 9 | (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page" |
@@ -13,7 +13,11 @@ | |||
13 | </span> | 13 | </span> |
14 | 14 | ||
15 | <div ngbDropdownMenu> | 15 | <div ngbDropdownMenu> |
16 | <a *ngFor="let menuChild of menuEntry.children" class="dropdown-item" [routerLink]="menuChild.routerLink">{{ menuChild.label }}</a> | 16 | <a *ngFor="let menuChild of menuEntry.children" class="dropdown-item" [ngClass]="{ icon: hasIcons }" [routerLink]="menuChild.routerLink"> |
17 | <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName"></my-global-icon> | ||
18 | |||
19 | {{ menuChild.label }} | ||
20 | </a> | ||
17 | </div> | 21 | </div> |
18 | </div> | 22 | </div> |
19 | 23 | ||
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 77159532f..d7c7de957 100644 --- a/client/src/app/shared/menu/top-menu-dropdown.component.scss +++ b/client/src/app/shared/menu/top-menu-dropdown.component.scss | |||
@@ -1,3 +1,6 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
1 | .parent-entry { | 4 | .parent-entry { |
2 | span[role=button] { | 5 | span[role=button] { |
3 | cursor: pointer; | 6 | cursor: pointer; |
@@ -16,3 +19,9 @@ | |||
16 | /deep/ .dropdown-menu { | 19 | /deep/ .dropdown-menu { |
17 | margin-top: 0 !important; | 20 | margin-top: 0 !important; |
18 | } | 21 | } |
22 | |||
23 | .icon { | ||
24 | @include dropdown-with-icon-item; | ||
25 | |||
26 | top: -1px; | ||
27 | } | ||
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 e859c30dd..5ccdafb54 100644 --- a/client/src/app/shared/menu/top-menu-dropdown.component.ts +++ b/client/src/app/shared/menu/top-menu-dropdown.component.ts | |||
@@ -3,6 +3,8 @@ import { filter, take } from 'rxjs/operators' | |||
3 | import { NavigationEnd, Router } from '@angular/router' | 3 | import { NavigationEnd, Router } from '@angular/router' |
4 | import { Subscription } from 'rxjs' | 4 | import { Subscription } from 'rxjs' |
5 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | 5 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' |
6 | import { GlobalIconName } from '@app/shared/images/global-icon.component' | ||
7 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
6 | 8 | ||
7 | export type TopMenuDropdownParam = { | 9 | export type TopMenuDropdownParam = { |
8 | label: string | 10 | label: string |
@@ -11,6 +13,8 @@ export type TopMenuDropdownParam = { | |||
11 | children?: { | 13 | children?: { |
12 | label: string | 14 | label: string |
13 | routerLink: string | 15 | routerLink: string |
16 | |||
17 | iconName?: GlobalIconName | ||
14 | }[] | 18 | }[] |
15 | } | 19 | } |
16 | 20 | ||
@@ -23,11 +27,16 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy { | |||
23 | @Input() menuEntries: TopMenuDropdownParam[] = [] | 27 | @Input() menuEntries: TopMenuDropdownParam[] = [] |
24 | 28 | ||
25 | suffixLabels: { [ parentLabel: string ]: string } | 29 | suffixLabels: { [ parentLabel: string ]: string } |
30 | hasIcons = false | ||
31 | container: undefined | 'body' = undefined | ||
26 | 32 | ||
27 | private openedOnHover = false | 33 | private openedOnHover = false |
28 | private routeSub: Subscription | 34 | private routeSub: Subscription |
29 | 35 | ||
30 | constructor (private router: Router) {} | 36 | constructor ( |
37 | private router: Router, | ||
38 | private screen: ScreenService | ||
39 | ) {} | ||
31 | 40 | ||
32 | ngOnInit () { | 41 | ngOnInit () { |
33 | this.updateChildLabels(window.location.pathname) | 42 | this.updateChildLabels(window.location.pathname) |
@@ -35,6 +44,16 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy { | |||
35 | this.routeSub = this.router.events | 44 | this.routeSub = this.router.events |
36 | .pipe(filter(event => event instanceof NavigationEnd)) | 45 | .pipe(filter(event => event instanceof NavigationEnd)) |
37 | .subscribe(() => this.updateChildLabels(window.location.pathname)) | 46 | .subscribe(() => this.updateChildLabels(window.location.pathname)) |
47 | |||
48 | this.hasIcons = this.menuEntries.some( | ||
49 | e => e.children && e.children.some(c => !!c.iconName) | ||
50 | ) | ||
51 | |||
52 | // FIXME: We have to set body for the container to avoid because of scroll overflow on mobile view | ||
53 | // But this break our hovering system | ||
54 | if (this.screen.isInMobileView()) { | ||
55 | this.container = 'body' | ||
56 | } | ||
38 | } | 57 | } |
39 | 58 | ||
40 | ngOnDestroy () { | 59 | ngOnDestroy () { |
@@ -48,7 +67,7 @@ export class TopMenuDropdownComponent implements OnInit, OnDestroy { | |||
48 | // Menu was closed | 67 | // Menu was closed |
49 | dropdown.openChange | 68 | dropdown.openChange |
50 | .pipe(take(1)) | 69 | .pipe(take(1)) |
51 | .subscribe(e => this.openedOnHover = false) | 70 | .subscribe(() => this.openedOnHover = false) |
52 | } | 71 | } |
53 | 72 | ||
54 | dropdownAnchorClicked (dropdown: NgbDropdown) { | 73 | dropdownAnchorClicked (dropdown: NgbDropdown) { |
diff --git a/client/src/app/shared/misc/help.component.html b/client/src/app/shared/misc/help.component.html index 444425c9f..e31eef06a 100644 --- a/client/src/app/shared/misc/help.component.html +++ b/client/src/app/shared/misc/help.component.html | |||
@@ -22,7 +22,7 @@ | |||
22 | [attr.aria-pressed]="isPopoverOpened" | 22 | [attr.aria-pressed]="isPopoverOpened" |
23 | [ngbPopover]="tooltipTemplate" | 23 | [ngbPopover]="tooltipTemplate" |
24 | [placement]="tooltipPlacement" | 24 | [placement]="tooltipPlacement" |
25 | [autoClose]="true" | 25 | autoClose="outside" |
26 | (onHidden)="onPopoverHidden()" | 26 | (onHidden)="onPopoverHidden()" |
27 | (onShown)="onPopoverShown()" | 27 | (onShown)="onPopoverShown()" |
28 | > | 28 | > |
diff --git a/client/src/app/shared/misc/loader.component.html b/client/src/app/shared/misc/loader.component.html index 38d06950e..b8b7ad343 100644 --- a/client/src/app/shared/misc/loader.component.html +++ b/client/src/app/shared/misc/loader.component.html | |||
@@ -1,3 +1,8 @@ | |||
1 | <div id="video-loading" *ngIf="loading"> | 1 | <div *ngIf="loading"> |
2 | <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div> | 2 | <div class="lds-ring"> |
3 | <div></div> | ||
4 | <div></div> | ||
5 | <div></div> | ||
6 | <div></div> | ||
7 | </div> | ||
3 | </div> | 8 | </div> |
diff --git a/client/src/app/shared/misc/loader.component.scss b/client/src/app/shared/misc/loader.component.scss new file mode 100644 index 000000000..ddb64f07a --- /dev/null +++ b/client/src/app/shared/misc/loader.component.scss | |||
@@ -0,0 +1,45 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | // Thanks to https://loading.io/css/ (CC0 License) | ||
5 | |||
6 | .lds-ring { | ||
7 | display: inline-block; | ||
8 | position: relative; | ||
9 | width: 50px; | ||
10 | height: 50px; | ||
11 | } | ||
12 | |||
13 | .lds-ring div { | ||
14 | box-sizing: border-box; | ||
15 | display: block; | ||
16 | position: absolute; | ||
17 | width: 44px; | ||
18 | height: 44px; | ||
19 | margin: 6px; | ||
20 | border: 4px solid; | ||
21 | border-radius: 50%; | ||
22 | animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; | ||
23 | border-color: #999999 transparent transparent transparent; | ||
24 | } | ||
25 | |||
26 | .lds-ring div:nth-child(1) { | ||
27 | animation-delay: -0.45s; | ||
28 | } | ||
29 | |||
30 | .lds-ring div:nth-child(2) { | ||
31 | animation-delay: -0.3s; | ||
32 | } | ||
33 | |||
34 | .lds-ring div:nth-child(3) { | ||
35 | animation-delay: -0.15s; | ||
36 | } | ||
37 | |||
38 | @keyframes lds-ring { | ||
39 | 0% { | ||
40 | transform: rotate(0deg); | ||
41 | } | ||
42 | 100% { | ||
43 | transform: rotate(360deg); | ||
44 | } | ||
45 | } | ||
diff --git a/client/src/app/shared/misc/loader.component.ts b/client/src/app/shared/misc/loader.component.ts index f37d70c85..e3b1eea3a 100644 --- a/client/src/app/shared/misc/loader.component.ts +++ b/client/src/app/shared/misc/loader.component.ts | |||
@@ -2,10 +2,9 @@ import { Component, Input } from '@angular/core' | |||
2 | 2 | ||
3 | @Component({ | 3 | @Component({ |
4 | selector: 'my-loader', | 4 | selector: 'my-loader', |
5 | styleUrls: [ ], | 5 | styleUrls: [ './loader.component.scss' ], |
6 | templateUrl: './loader.component.html' | 6 | templateUrl: './loader.component.html' |
7 | }) | 7 | }) |
8 | |||
9 | export class LoaderComponent { | 8 | export class LoaderComponent { |
10 | @Input() loading: boolean | 9 | @Input() loading: boolean |
11 | } | 10 | } |
diff --git a/client/src/app/shared/misc/screen.service.ts b/client/src/app/shared/misc/screen.service.ts index 1cbc96b14..af75569d9 100644 --- a/client/src/app/shared/misc/screen.service.ts +++ b/client/src/app/shared/misc/screen.service.ts | |||
@@ -18,6 +18,10 @@ export class ScreenService { | |||
18 | return this.getWindowInnerWidth() < 500 | 18 | return this.getWindowInnerWidth() < 500 |
19 | } | 19 | } |
20 | 20 | ||
21 | isInTouchScreen () { | ||
22 | return 'ontouchstart' in window || navigator.msMaxTouchPoints | ||
23 | } | ||
24 | |||
21 | // Cache window inner width, because it's an expensive call | 25 | // Cache window inner width, because it's an expensive call |
22 | private getWindowInnerWidth () { | 26 | private getWindowInnerWidth () { |
23 | if (this.cacheWindowInnerWidthExpired()) this.refreshWindowInnerWidth() | 27 | if (this.cacheWindowInnerWidthExpired()) this.refreshWindowInnerWidth() |
@@ -32,6 +36,8 @@ export class ScreenService { | |||
32 | } | 36 | } |
33 | 37 | ||
34 | private cacheWindowInnerWidthExpired () { | 38 | private cacheWindowInnerWidthExpired () { |
39 | if (!this.lastFunctionCallTime) return true | ||
40 | |||
35 | return new Date().getTime() > (this.lastFunctionCallTime + this.cacheForMs) | 41 | return new Date().getTime() > (this.lastFunctionCallTime + this.cacheForMs) |
36 | } | 42 | } |
37 | } | 43 | } |
diff --git a/client/src/app/shared/misc/small-loader.component.html b/client/src/app/shared/misc/small-loader.component.html new file mode 100644 index 000000000..5a7cea738 --- /dev/null +++ b/client/src/app/shared/misc/small-loader.component.html | |||
@@ -0,0 +1,3 @@ | |||
1 | <div *ngIf="loading"> | ||
2 | <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div> | ||
3 | </div> | ||
diff --git a/client/src/app/shared/misc/small-loader.component.ts b/client/src/app/shared/misc/small-loader.component.ts new file mode 100644 index 000000000..191877f14 --- /dev/null +++ b/client/src/app/shared/misc/small-loader.component.ts | |||
@@ -0,0 +1,11 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-small-loader', | ||
5 | styleUrls: [ ], | ||
6 | templateUrl: './small-loader.component.html' | ||
7 | }) | ||
8 | |||
9 | export class SmallLoaderComponent { | ||
10 | @Input() loading: boolean | ||
11 | } | ||
diff --git a/client/src/app/shared/misc/utils.ts b/client/src/app/shared/misc/utils.ts index 7cc6055c2..85fc1c3a0 100644 --- a/client/src/app/shared/misc/utils.ts +++ b/client/src/app/shared/misc/utils.ts | |||
@@ -17,7 +17,7 @@ function getParameterByName (name: string, url: string) { | |||
17 | return decodeURIComponent(results[2].replace(/\+/g, ' ')) | 17 | return decodeURIComponent(results[2].replace(/\+/g, ' ')) |
18 | } | 18 | } |
19 | 19 | ||
20 | function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support: string }[]) { | 20 | function populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support?: string }[]) { |
21 | return new Promise(res => { | 21 | return new Promise(res => { |
22 | authService.userInformationLoaded | 22 | authService.userInformationLoaded |
23 | .subscribe( | 23 | .subscribe( |
@@ -78,10 +78,10 @@ function objectToUrlEncoded (obj: any) { | |||
78 | 78 | ||
79 | // Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34 | 79 | // Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34 |
80 | function objectToFormData (obj: any, form?: FormData, namespace?: string) { | 80 | function objectToFormData (obj: any, form?: FormData, namespace?: string) { |
81 | let fd = form || new FormData() | 81 | const fd = form || new FormData() |
82 | let formKey | 82 | let formKey |
83 | 83 | ||
84 | for (let key of Object.keys(obj)) { | 84 | for (const key of Object.keys(obj)) { |
85 | if (namespace) formKey = `${namespace}[${key}]` | 85 | if (namespace) formKey = `${namespace}[${key}]` |
86 | else formKey = key | 86 | else formKey = key |
87 | 87 | ||
diff --git a/client/src/app/shared/moderation/user-moderation-dropdown.component.scss b/client/src/app/shared/moderation/user-moderation-dropdown.component.scss deleted file mode 100644 index e69de29bb..000000000 --- a/client/src/app/shared/moderation/user-moderation-dropdown.component.scss +++ /dev/null | |||
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 9a2461ebf..9dd16812b 100644 --- a/client/src/app/shared/moderation/user-moderation-dropdown.component.ts +++ b/client/src/app/shared/moderation/user-moderation-dropdown.component.ts | |||
@@ -10,8 +10,7 @@ import { BlocklistService } from '@app/shared/blocklist' | |||
10 | 10 | ||
11 | @Component({ | 11 | @Component({ |
12 | selector: 'my-user-moderation-dropdown', | 12 | selector: 'my-user-moderation-dropdown', |
13 | templateUrl: './user-moderation-dropdown.component.html', | 13 | templateUrl: './user-moderation-dropdown.component.html' |
14 | styleUrls: [ './user-moderation-dropdown.component.scss' ] | ||
15 | }) | 14 | }) |
16 | export class UserModerationDropdownComponent implements OnChanges { | 15 | export class UserModerationDropdownComponent implements OnChanges { |
17 | @ViewChild('userBanModal') userBanModal: UserBanModalComponent | 16 | @ViewChild('userBanModal') userBanModal: UserBanModalComponent |
diff --git a/client/src/app/shared/overview/videos-overview.model.ts b/client/src/app/shared/overview/videos-overview.model.ts index c8eafc8e8..21abe1697 100644 --- a/client/src/app/shared/overview/videos-overview.model.ts +++ b/client/src/app/shared/overview/videos-overview.model.ts | |||
@@ -1,9 +1,9 @@ | |||
1 | import { VideoChannelAttribute, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models' | 1 | import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models' |
2 | import { Video } from '@app/shared/video/video.model' | 2 | import { Video } from '@app/shared/video/video.model' |
3 | 3 | ||
4 | export class VideosOverview implements VideosOverviewServer { | 4 | export class VideosOverview implements VideosOverviewServer { |
5 | channels: { | 5 | channels: { |
6 | channel: VideoChannelAttribute | 6 | channel: VideoChannelSummary |
7 | videos: Video[] | 7 | videos: Video[] |
8 | }[] | 8 | }[] |
9 | 9 | ||
diff --git a/client/src/app/shared/renderer/html-renderer.service.ts b/client/src/app/shared/renderer/html-renderer.service.ts index d49df9b6d..28ef51e72 100644 --- a/client/src/app/shared/renderer/html-renderer.service.ts +++ b/client/src/app/shared/renderer/html-renderer.service.ts | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { LinkifierService } from '@app/shared/renderer/linkifier.service' | 2 | import { LinkifierService } from '@app/shared/renderer/linkifier.service' |
3 | import * as sanitizeHtml from 'sanitize-html' | ||
4 | 3 | ||
5 | @Injectable() | 4 | @Injectable() |
6 | export class HtmlRendererService { | 5 | export class HtmlRendererService { |
@@ -9,7 +8,10 @@ export class HtmlRendererService { | |||
9 | 8 | ||
10 | } | 9 | } |
11 | 10 | ||
12 | toSafeHtml (text: string) { | 11 | async toSafeHtml (text: string) { |
12 | // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function | ||
13 | const sanitizeHtml: typeof import ('sanitize-html') = (await import('sanitize-html') as any).default | ||
14 | |||
13 | // Convert possible markdown to html | 15 | // Convert possible markdown to html |
14 | const html = this.linkifier.linkify(text) | 16 | const html = this.linkifier.linkify(text) |
15 | 17 | ||
diff --git a/client/src/app/shared/renderer/linkifier.service.ts b/client/src/app/shared/renderer/linkifier.service.ts index 2529c9eaf..95d5f17cc 100644 --- a/client/src/app/shared/renderer/linkifier.service.ts +++ b/client/src/app/shared/renderer/linkifier.service.ts | |||
@@ -1,8 +1,7 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' | 2 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' |
3 | // FIXME: use @types/linkify when https://github.com/DefinitelyTyped/DefinitelyTyped/pull/29682/files is merged? | 3 | import * as linkify from 'linkifyjs' |
4 | const linkify = require('linkifyjs') | 4 | import linkifyHtml from 'linkifyjs/html' |
5 | const linkifyHtml = require('linkifyjs/html') | ||
6 | 5 | ||
7 | @Injectable() | 6 | @Injectable() |
8 | export class LinkifierService { | 7 | export class LinkifierService { |
diff --git a/client/src/app/shared/renderer/markdown.service.ts b/client/src/app/shared/renderer/markdown.service.ts index 07017eca5..9a9066351 100644 --- a/client/src/app/shared/renderer/markdown.service.ts +++ b/client/src/app/shared/renderer/markdown.service.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | 2 | ||
3 | import * as MarkdownIt from 'markdown-it' | 3 | import { MarkdownIt } from 'markdown-it' |
4 | 4 | ||
5 | @Injectable() | 5 | @Injectable() |
6 | export class MarkdownService { | 6 | export class MarkdownService { |
@@ -14,32 +14,38 @@ export class MarkdownService { | |||
14 | ] | 14 | ] |
15 | static ENHANCED_RULES = MarkdownService.TEXT_RULES.concat([ 'image' ]) | 15 | static ENHANCED_RULES = MarkdownService.TEXT_RULES.concat([ 'image' ]) |
16 | 16 | ||
17 | private textMarkdownIt: MarkdownIt.MarkdownIt | 17 | private textMarkdownIt: MarkdownIt |
18 | private enhancedMarkdownIt: MarkdownIt.MarkdownIt | 18 | private enhancedMarkdownIt: MarkdownIt |
19 | 19 | ||
20 | constructor () { | 20 | async textMarkdownToHTML (markdown: string) { |
21 | this.textMarkdownIt = this.createMarkdownIt(MarkdownService.TEXT_RULES) | ||
22 | this.enhancedMarkdownIt = this.createMarkdownIt(MarkdownService.ENHANCED_RULES) | ||
23 | } | ||
24 | |||
25 | textMarkdownToHTML (markdown: string) { | ||
26 | if (!markdown) return '' | 21 | if (!markdown) return '' |
27 | 22 | ||
23 | if (!this.textMarkdownIt) { | ||
24 | this.textMarkdownIt = await this.createMarkdownIt(MarkdownService.TEXT_RULES) | ||
25 | } | ||
26 | |||
28 | const html = this.textMarkdownIt.render(markdown) | 27 | const html = this.textMarkdownIt.render(markdown) |
29 | return this.avoidTruncatedTags(html) | 28 | return this.avoidTruncatedTags(html) |
30 | } | 29 | } |
31 | 30 | ||
32 | enhancedMarkdownToHTML (markdown: string) { | 31 | async enhancedMarkdownToHTML (markdown: string) { |
33 | if (!markdown) return '' | 32 | if (!markdown) return '' |
34 | 33 | ||
34 | if (!this.enhancedMarkdownIt) { | ||
35 | this.enhancedMarkdownIt = await this.createMarkdownIt(MarkdownService.ENHANCED_RULES) | ||
36 | } | ||
37 | |||
35 | const html = this.enhancedMarkdownIt.render(markdown) | 38 | const html = this.enhancedMarkdownIt.render(markdown) |
36 | return this.avoidTruncatedTags(html) | 39 | return this.avoidTruncatedTags(html) |
37 | } | 40 | } |
38 | 41 | ||
39 | private createMarkdownIt (rules: string[]) { | 42 | private async createMarkdownIt (rules: string[]) { |
40 | const markdownIt = new MarkdownIt('zero', { linkify: true, breaks: true }) | 43 | // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function |
44 | const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default | ||
45 | |||
46 | const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true }) | ||
41 | 47 | ||
42 | for (let rule of rules) { | 48 | for (const rule of rules) { |
43 | markdownIt.enable(rule) | 49 | markdownIt.enable(rule) |
44 | } | 50 | } |
45 | 51 | ||
@@ -48,7 +54,7 @@ export class MarkdownService { | |||
48 | return markdownIt | 54 | return markdownIt |
49 | } | 55 | } |
50 | 56 | ||
51 | private setTargetToLinks (markdownIt: MarkdownIt.MarkdownIt) { | 57 | private setTargetToLinks (markdownIt: MarkdownIt) { |
52 | // Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer | 58 | // Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer |
53 | const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) { | 59 | const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) { |
54 | return self.renderToken(tokens, idx, options) | 60 | return self.renderToken(tokens, idx, options) |
diff --git a/client/src/app/shared/shared.module.ts b/client/src/app/shared/shared.module.ts index 6f8625c7e..ded65653f 100644 --- a/client/src/app/shared/shared.module.ts +++ b/client/src/app/shared/shared.module.ts | |||
@@ -14,10 +14,7 @@ import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | |||
14 | import { ButtonComponent } from './buttons/button.component' | 14 | import { ButtonComponent } from './buttons/button.component' |
15 | import { DeleteButtonComponent } from './buttons/delete-button.component' | 15 | import { DeleteButtonComponent } from './buttons/delete-button.component' |
16 | import { EditButtonComponent } from './buttons/edit-button.component' | 16 | import { EditButtonComponent } from './buttons/edit-button.component' |
17 | import { FromNowPipe } from './misc/from-now.pipe' | ||
18 | import { LoaderComponent } from './misc/loader.component' | 17 | import { LoaderComponent } from './misc/loader.component' |
19 | import { NumberFormatterPipe } from './misc/number-formatter.pipe' | ||
20 | import { ObjectLengthPipe } from './misc/object-length.pipe' | ||
21 | import { RestExtractor, RestService } from './rest' | 18 | import { RestExtractor, RestService } from './rest' |
22 | import { UserService } from './users' | 19 | import { UserService } from './users' |
23 | import { VideoAbuseService } from './video-abuse' | 20 | import { VideoAbuseService } from './video-abuse' |
@@ -45,9 +42,11 @@ import { | |||
45 | VideoChangeOwnershipValidatorsService, | 42 | VideoChangeOwnershipValidatorsService, |
46 | VideoChannelValidatorsService, | 43 | VideoChannelValidatorsService, |
47 | VideoCommentValidatorsService, | 44 | VideoCommentValidatorsService, |
45 | VideoPlaylistValidatorsService, | ||
48 | VideoValidatorsService | 46 | VideoValidatorsService |
49 | } from '@app/shared/forms' | 47 | } from '@app/shared/forms' |
50 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' | 48 | import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar' |
49 | import { InputMaskModule } from 'primeng/inputmask' | ||
51 | import { ScreenService } from '@app/shared/misc/screen.service' | 50 | import { ScreenService } from '@app/shared/misc/screen.service' |
52 | 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' |
53 | import { VideoCaptionService } from '@app/shared/video-caption' | 52 | import { VideoCaptionService } from '@app/shared/video-caption' |
@@ -68,7 +67,24 @@ import { UserNotificationsComponent } from '@app/shared/users/user-notifications | |||
68 | import { InstanceService } from '@app/shared/instance/instance.service' | 67 | import { InstanceService } from '@app/shared/instance/instance.service' |
69 | import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer' | 68 | import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer' |
70 | import { ConfirmComponent } from '@app/shared/confirm/confirm.component' | 69 | import { ConfirmComponent } from '@app/shared/confirm/confirm.component' |
71 | import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' | 70 | import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component' |
71 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
72 | import { ImageUploadComponent } from '@app/shared/images/image-upload.component' | ||
73 | import { GlobalIconComponent } from '@app/shared/images/global-icon.component' | ||
74 | import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component' | ||
75 | import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' | ||
76 | import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component' | ||
77 | import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component' | ||
78 | import { VideosSelectionComponent } from '@app/shared/video/videos-selection.component' | ||
79 | import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe' | ||
80 | import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe' | ||
81 | import { FromNowPipe } from '@app/shared/angular/from-now.pipe' | ||
82 | import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' | ||
83 | import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component' | ||
84 | import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' | ||
85 | import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' | ||
86 | import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' | ||
87 | import { ClipboardModule } from 'ngx-clipboard' | ||
72 | 88 | ||
73 | @NgModule({ | 89 | @NgModule({ |
74 | imports: [ | 90 | imports: [ |
@@ -84,28 +100,50 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' | |||
84 | NgbTabsetModule, | 100 | NgbTabsetModule, |
85 | NgbTooltipModule, | 101 | NgbTooltipModule, |
86 | 102 | ||
103 | ClipboardModule, | ||
104 | |||
87 | PrimeSharedModule, | 105 | PrimeSharedModule, |
106 | InputMaskModule, | ||
88 | NgPipesModule | 107 | NgPipesModule |
89 | ], | 108 | ], |
90 | 109 | ||
91 | declarations: [ | 110 | declarations: [ |
92 | LoaderComponent, | 111 | LoaderComponent, |
112 | SmallLoaderComponent, | ||
113 | |||
93 | VideoThumbnailComponent, | 114 | VideoThumbnailComponent, |
94 | VideoMiniatureComponent, | 115 | VideoMiniatureComponent, |
116 | VideoPlaylistMiniatureComponent, | ||
117 | VideoAddToPlaylistComponent, | ||
118 | VideoPlaylistElementMiniatureComponent, | ||
119 | VideosSelectionComponent, | ||
120 | VideoActionsDropdownComponent, | ||
121 | |||
122 | VideoDownloadComponent, | ||
123 | VideoReportComponent, | ||
124 | VideoBlacklistComponent, | ||
125 | |||
95 | FeedComponent, | 126 | FeedComponent, |
127 | |||
96 | ButtonComponent, | 128 | ButtonComponent, |
97 | DeleteButtonComponent, | 129 | DeleteButtonComponent, |
98 | EditButtonComponent, | 130 | EditButtonComponent, |
99 | ActionDropdownComponent, | 131 | |
100 | NumberFormatterPipe, | 132 | NumberFormatterPipe, |
101 | ObjectLengthPipe, | 133 | ObjectLengthPipe, |
102 | FromNowPipe, | 134 | FromNowPipe, |
135 | PeerTubeTemplateDirective, | ||
136 | |||
137 | ActionDropdownComponent, | ||
103 | MarkdownTextareaComponent, | 138 | MarkdownTextareaComponent, |
104 | InfiniteScrollerDirective, | 139 | InfiniteScrollerDirective, |
105 | TextareaAutoResizeDirective, | 140 | TextareaAutoResizeDirective, |
106 | HelpComponent, | 141 | HelpComponent, |
142 | |||
107 | ReactiveFileComponent, | 143 | ReactiveFileComponent, |
108 | PeertubeCheckboxComponent, | 144 | PeertubeCheckboxComponent, |
145 | TimestampInputComponent, | ||
146 | |||
109 | SubscribeButtonComponent, | 147 | SubscribeButtonComponent, |
110 | RemoteSubscribeComponent, | 148 | RemoteSubscribeComponent, |
111 | InstanceFeaturesTableComponent, | 149 | InstanceFeaturesTableComponent, |
@@ -114,7 +152,9 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' | |||
114 | TopMenuDropdownComponent, | 152 | TopMenuDropdownComponent, |
115 | UserNotificationsComponent, | 153 | UserNotificationsComponent, |
116 | ConfirmComponent, | 154 | ConfirmComponent, |
117 | GlobalIconComponent | 155 | |
156 | GlobalIconComponent, | ||
157 | ImageUploadComponent | ||
118 | ], | 158 | ], |
119 | 159 | ||
120 | exports: [ | 160 | exports: [ |
@@ -130,24 +170,44 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' | |||
130 | NgbTabsetModule, | 170 | NgbTabsetModule, |
131 | NgbTooltipModule, | 171 | NgbTooltipModule, |
132 | 172 | ||
173 | ClipboardModule, | ||
174 | |||
133 | PrimeSharedModule, | 175 | PrimeSharedModule, |
176 | InputMaskModule, | ||
134 | BytesPipe, | 177 | BytesPipe, |
135 | KeysPipe, | 178 | KeysPipe, |
136 | 179 | ||
137 | LoaderComponent, | 180 | LoaderComponent, |
181 | SmallLoaderComponent, | ||
182 | |||
138 | VideoThumbnailComponent, | 183 | VideoThumbnailComponent, |
139 | VideoMiniatureComponent, | 184 | VideoMiniatureComponent, |
185 | VideoPlaylistMiniatureComponent, | ||
186 | VideoAddToPlaylistComponent, | ||
187 | VideoPlaylistElementMiniatureComponent, | ||
188 | VideosSelectionComponent, | ||
189 | VideoActionsDropdownComponent, | ||
190 | |||
191 | VideoDownloadComponent, | ||
192 | VideoReportComponent, | ||
193 | VideoBlacklistComponent, | ||
194 | |||
140 | FeedComponent, | 195 | FeedComponent, |
196 | |||
141 | ButtonComponent, | 197 | ButtonComponent, |
142 | DeleteButtonComponent, | 198 | DeleteButtonComponent, |
143 | EditButtonComponent, | 199 | EditButtonComponent, |
200 | |||
144 | ActionDropdownComponent, | 201 | ActionDropdownComponent, |
145 | MarkdownTextareaComponent, | 202 | MarkdownTextareaComponent, |
146 | InfiniteScrollerDirective, | 203 | InfiniteScrollerDirective, |
147 | TextareaAutoResizeDirective, | 204 | TextareaAutoResizeDirective, |
148 | HelpComponent, | 205 | HelpComponent, |
206 | |||
149 | ReactiveFileComponent, | 207 | ReactiveFileComponent, |
150 | PeertubeCheckboxComponent, | 208 | PeertubeCheckboxComponent, |
209 | TimestampInputComponent, | ||
210 | |||
151 | SubscribeButtonComponent, | 211 | SubscribeButtonComponent, |
152 | RemoteSubscribeComponent, | 212 | RemoteSubscribeComponent, |
153 | InstanceFeaturesTableComponent, | 213 | InstanceFeaturesTableComponent, |
@@ -156,11 +216,14 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' | |||
156 | TopMenuDropdownComponent, | 216 | TopMenuDropdownComponent, |
157 | UserNotificationsComponent, | 217 | UserNotificationsComponent, |
158 | ConfirmComponent, | 218 | ConfirmComponent, |
219 | |||
159 | GlobalIconComponent, | 220 | GlobalIconComponent, |
221 | ImageUploadComponent, | ||
160 | 222 | ||
161 | NumberFormatterPipe, | 223 | NumberFormatterPipe, |
162 | ObjectLengthPipe, | 224 | ObjectLengthPipe, |
163 | FromNowPipe | 225 | FromNowPipe, |
226 | PeerTubeTemplateDirective | ||
164 | ], | 227 | ], |
165 | 228 | ||
166 | providers: [ | 229 | providers: [ |
@@ -174,6 +237,7 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' | |||
174 | VideoService, | 237 | VideoService, |
175 | AccountService, | 238 | AccountService, |
176 | VideoChannelService, | 239 | VideoChannelService, |
240 | VideoPlaylistService, | ||
177 | VideoCaptionService, | 241 | VideoCaptionService, |
178 | VideoImportService, | 242 | VideoImportService, |
179 | UserSubscriptionService, | 243 | UserSubscriptionService, |
@@ -183,6 +247,7 @@ import { GlobalIconComponent } from '@app/shared/icons/global-icon.component' | |||
183 | LoginValidatorsService, | 247 | LoginValidatorsService, |
184 | ResetPasswordValidatorsService, | 248 | ResetPasswordValidatorsService, |
185 | UserValidatorsService, | 249 | UserValidatorsService, |
250 | VideoPlaylistValidatorsService, | ||
186 | VideoAbuseValidatorsService, | 251 | VideoAbuseValidatorsService, |
187 | VideoChannelValidatorsService, | 252 | VideoChannelValidatorsService, |
188 | VideoCommentValidatorsService, | 253 | VideoCommentValidatorsService, |
diff --git a/client/src/app/shared/user-subscription/subscribe-button.component.ts b/client/src/app/shared/user-subscription/subscribe-button.component.ts index 8f1754c7f..ef470ee44 100644 --- a/client/src/app/shared/user-subscription/subscribe-button.component.ts +++ b/client/src/app/shared/user-subscription/subscribe-button.component.ts | |||
@@ -38,7 +38,7 @@ export class SubscribeButtonComponent implements OnInit { | |||
38 | 38 | ||
39 | ngOnInit () { | 39 | ngOnInit () { |
40 | if (this.isUserLoggedIn()) { | 40 | if (this.isUserLoggedIn()) { |
41 | this.userSubscriptionService.isSubscriptionExists(this.uri) | 41 | this.userSubscriptionService.doesSubscriptionExist(this.uri) |
42 | .subscribe( | 42 | .subscribe( |
43 | res => this.subscribed = res[this.uri], | 43 | res => this.subscribed = res[this.uri], |
44 | 44 | ||
diff --git a/client/src/app/shared/user-subscription/user-subscription.service.ts b/client/src/app/shared/user-subscription/user-subscription.service.ts index 3d05f071e..cfd5b100f 100644 --- a/client/src/app/shared/user-subscription/user-subscription.service.ts +++ b/client/src/app/shared/user-subscription/user-subscription.service.ts | |||
@@ -28,7 +28,7 @@ export class UserSubscriptionService { | |||
28 | this.existsObservable = this.existsSubject.pipe( | 28 | this.existsObservable = this.existsSubject.pipe( |
29 | bufferTime(500), | 29 | bufferTime(500), |
30 | filter(uris => uris.length !== 0), | 30 | filter(uris => uris.length !== 0), |
31 | switchMap(uris => this.areSubscriptionExist(uris)), | 31 | switchMap(uris => this.doSubscriptionsExist(uris)), |
32 | share() | 32 | share() |
33 | ) | 33 | ) |
34 | } | 34 | } |
@@ -69,13 +69,13 @@ export class UserSubscriptionService { | |||
69 | ) | 69 | ) |
70 | } | 70 | } |
71 | 71 | ||
72 | isSubscriptionExists (nameWithHost: string) { | 72 | doesSubscriptionExist (nameWithHost: string) { |
73 | this.existsSubject.next(nameWithHost) | 73 | this.existsSubject.next(nameWithHost) |
74 | 74 | ||
75 | return this.existsObservable.pipe(first()) | 75 | return this.existsObservable.pipe(first()) |
76 | } | 76 | } |
77 | 77 | ||
78 | private areSubscriptionExist (uris: string[]): Observable<SubscriptionExistResult> { | 78 | private doSubscriptionsExist (uris: string[]): Observable<SubscriptionExistResult> { |
79 | const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist' | 79 | const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist' |
80 | let params = new HttpParams() | 80 | let params = new HttpParams() |
81 | 81 | ||
diff --git a/client/src/app/shared/users/user-notification.model.ts b/client/src/app/shared/users/user-notification.model.ts index 5d0dc19ae..72fc3e7b4 100644 --- a/client/src/app/shared/users/user-notification.model.ts +++ b/client/src/app/shared/users/user-notification.model.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, ActorInfo } from '../../../../../shared' | 1 | import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '../../../../../shared' |
2 | import { Actor } from '@app/shared/actor/actor.model' | 2 | import { Actor } from '@app/shared/actor/actor.model' |
3 | 3 | ||
4 | export class UserNotification implements UserNotificationServer { | 4 | export class UserNotification implements UserNotificationServer { |
@@ -39,6 +39,7 @@ export class UserNotification implements UserNotificationServer { | |||
39 | 39 | ||
40 | actorFollow?: { | 40 | actorFollow?: { |
41 | id: number | 41 | id: number |
42 | state: FollowState | ||
42 | follower: ActorInfo & { avatarUrl?: string } | 43 | follower: ActorInfo & { avatarUrl?: string } |
43 | following: { | 44 | following: { |
44 | type: 'account' | 'channel' | 45 | type: 'account' | 'channel' |
@@ -54,9 +55,11 @@ export class UserNotification implements UserNotificationServer { | |||
54 | videoUrl?: string | 55 | videoUrl?: string |
55 | commentUrl?: any[] | 56 | commentUrl?: any[] |
56 | videoAbuseUrl?: string | 57 | videoAbuseUrl?: string |
58 | videoAutoBlacklistUrl?: string | ||
57 | accountUrl?: string | 59 | accountUrl?: string |
58 | videoImportIdentifier?: string | 60 | videoImportIdentifier?: string |
59 | videoImportUrl?: string | 61 | videoImportUrl?: string |
62 | instanceFollowUrl?: string | ||
60 | 63 | ||
61 | constructor (hash: UserNotificationServer) { | 64 | constructor (hash: UserNotificationServer) { |
62 | this.id = hash.id | 65 | this.id = hash.id |
@@ -107,6 +110,11 @@ export class UserNotification implements UserNotificationServer { | |||
107 | this.videoUrl = this.buildVideoUrl(this.videoAbuse.video) | 110 | this.videoUrl = this.buildVideoUrl(this.videoAbuse.video) |
108 | break | 111 | break |
109 | 112 | ||
113 | case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: | ||
114 | this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list' | ||
115 | this.videoUrl = this.buildVideoUrl(this.video) | ||
116 | break | ||
117 | |||
110 | case UserNotificationType.BLACKLIST_ON_MY_VIDEO: | 118 | case UserNotificationType.BLACKLIST_ON_MY_VIDEO: |
111 | this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video) | 119 | this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video) |
112 | break | 120 | break |
@@ -118,7 +126,8 @@ export class UserNotification implements UserNotificationServer { | |||
118 | case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS: | 126 | case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS: |
119 | this.videoImportUrl = this.buildVideoImportUrl() | 127 | this.videoImportUrl = this.buildVideoImportUrl() |
120 | this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport) | 128 | this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport) |
121 | this.videoUrl = this.buildVideoUrl(this.videoImport.video) | 129 | |
130 | if (this.videoImport.video) this.videoUrl = this.buildVideoUrl(this.videoImport.video) | ||
122 | break | 131 | break |
123 | 132 | ||
124 | case UserNotificationType.MY_VIDEO_IMPORT_ERROR: | 133 | case UserNotificationType.MY_VIDEO_IMPORT_ERROR: |
@@ -133,6 +142,10 @@ export class UserNotification implements UserNotificationServer { | |||
133 | case UserNotificationType.NEW_FOLLOW: | 142 | case UserNotificationType.NEW_FOLLOW: |
134 | this.accountUrl = this.buildAccountUrl(this.actorFollow.follower) | 143 | this.accountUrl = this.buildAccountUrl(this.actorFollow.follower) |
135 | break | 144 | break |
145 | |||
146 | case UserNotificationType.NEW_INSTANCE_FOLLOWER: | ||
147 | this.instanceFollowUrl = '/admin/follows/followers-list' | ||
148 | break | ||
136 | } | 149 | } |
137 | } catch (err) { | 150 | } catch (err) { |
138 | console.error(err) | 151 | console.error(err) |
diff --git a/client/src/app/shared/users/user-notification.service.ts b/client/src/app/shared/users/user-notification.service.ts index f8a30955d..ae0bc9cb1 100644 --- a/client/src/app/shared/users/user-notification.service.ts +++ b/client/src/app/shared/users/user-notification.service.ts | |||
@@ -7,7 +7,7 @@ import { ResultList, UserNotification as UserNotificationServer, UserNotificatio | |||
7 | import { UserNotification } from './user-notification.model' | 7 | import { UserNotification } from './user-notification.model' |
8 | import { AuthService } from '../../core' | 8 | import { AuthService } from '../../core' |
9 | import { ComponentPagination } from '../rest/component-pagination.model' | 9 | import { ComponentPagination } from '../rest/component-pagination.model' |
10 | import { User } from '..' | 10 | import { User } from '../users/user.model' |
11 | import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' | 11 | import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service' |
12 | 12 | ||
13 | @Injectable() | 13 | @Injectable() |
diff --git a/client/src/app/shared/users/user-notifications.component.html b/client/src/app/shared/users/user-notifications.component.html index 0d69e0feb..d0d9d9f35 100644 --- a/client/src/app/shared/users/user-notifications.component.html +++ b/client/src/app/shared/users/user-notifications.component.html | |||
@@ -36,6 +36,14 @@ | |||
36 | </div> | 36 | </div> |
37 | </ng-container> | 37 | </ng-container> |
38 | 38 | ||
39 | <ng-container i18n *ngSwitchCase="UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS"> | ||
40 | <my-global-icon iconName="no"></my-global-icon> | ||
41 | |||
42 | <div class="message"> | ||
43 | The recently added video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been <a (click)="markAsRead(notification)" [routerLink]="notification.videoAutoBlacklistUrl">auto-blacklisted</a> | ||
44 | </div> | ||
45 | </ng-container> | ||
46 | |||
39 | <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO"> | 47 | <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO"> |
40 | <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" /> | 48 | <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" /> |
41 | 49 | ||
@@ -56,7 +64,7 @@ | |||
56 | <my-global-icon iconName="cloud-download"></my-global-icon> | 64 | <my-global-icon iconName="cloud-download"></my-global-icon> |
57 | 65 | ||
58 | <div class="message"> | 66 | <div class="message"> |
59 | <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded | 67 | <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl || notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded |
60 | </div> | 68 | </div> |
61 | </ng-container> | 69 | </ng-container> |
62 | 70 | ||
@@ -94,6 +102,15 @@ | |||
94 | <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a> | 102 | <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a> |
95 | </div> | 103 | </div> |
96 | </ng-container> | 104 | </ng-container> |
105 | |||
106 | <ng-container i18n *ngSwitchCase="UserNotificationType.NEW_INSTANCE_FOLLOWER"> | ||
107 | <my-global-icon iconName="users"></my-global-icon> | ||
108 | |||
109 | <div class="message"> | ||
110 | Your instance has <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">a new follower</a> ({{ notification.actorFollow.follower.host }}) | ||
111 | <ng-container *ngIf="notification.actorFollow.state === 'pending'"> awaiting your approval</ng-container> | ||
112 | </div> | ||
113 | </ng-container> | ||
97 | </ng-container> | 114 | </ng-container> |
98 | 115 | ||
99 | <div class="from-date">{{ notification.createdAt | myFromNow }}</div> | 116 | <div class="from-date">{{ notification.createdAt | myFromNow }}</div> |
diff --git a/client/src/app/shared/users/user-notifications.component.scss b/client/src/app/shared/users/user-notifications.component.scss index 315d504c9..88f38d9cf 100644 --- a/client/src/app/shared/users/user-notifications.component.scss +++ b/client/src/app/shared/users/user-notifications.component.scss | |||
@@ -13,7 +13,7 @@ | |||
13 | align-items: center; | 13 | align-items: center; |
14 | font-size: inherit; | 14 | font-size: inherit; |
15 | padding: 15px 5px 15px 10px; | 15 | padding: 15px 5px 15px 10px; |
16 | border-bottom: 1px solid rgba(0, 0, 0, 0.10); | 16 | border-bottom: 1px solid $separator-border-color; |
17 | 17 | ||
18 | &.unread { | 18 | &.unread { |
19 | background-color: rgba(0, 0, 0, 0.05); | 19 | background-color: rgba(0, 0, 0, 0.05); |
diff --git a/client/src/app/shared/users/user-notifications.component.ts b/client/src/app/shared/users/user-notifications.component.ts index b5f9fd399..ce43b604a 100644 --- a/client/src/app/shared/users/user-notifications.component.ts +++ b/client/src/app/shared/users/user-notifications.component.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' |
2 | import { UserNotificationService } from '@app/shared/users/user-notification.service' | 2 | import { UserNotificationService } from '@app/shared/users/user-notification.service' |
3 | import { UserNotificationType } from '../../../../../shared' | 3 | import { UserNotificationType } from '../../../../../shared' |
4 | import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model' | 4 | import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model' |
@@ -15,6 +15,8 @@ export class UserNotificationsComponent implements OnInit { | |||
15 | @Input() infiniteScroll = true | 15 | @Input() infiniteScroll = true |
16 | @Input() itemsPerPage = 20 | 16 | @Input() itemsPerPage = 20 |
17 | 17 | ||
18 | @Output() notificationsLoaded = new EventEmitter() | ||
19 | |||
18 | notifications: UserNotification[] = [] | 20 | notifications: UserNotification[] = [] |
19 | 21 | ||
20 | // So we can access it in the template | 22 | // So we can access it in the template |
@@ -43,6 +45,8 @@ export class UserNotificationsComponent implements OnInit { | |||
43 | result => { | 45 | result => { |
44 | this.notifications = this.notifications.concat(result.data) | 46 | this.notifications = this.notifications.concat(result.data) |
45 | this.componentPagination.totalItems = result.total | 47 | this.componentPagination.totalItems = result.total |
48 | |||
49 | this.notificationsLoaded.emit() | ||
46 | }, | 50 | }, |
47 | 51 | ||
48 | err => this.notifier.error(err.message) | 52 | err => this.notifier.error(err.message) |
diff --git a/client/src/app/shared/users/user.model.ts b/client/src/app/shared/users/user.model.ts index c15f1de8c..e3ed2dfbd 100644 --- a/client/src/app/shared/users/user.model.ts +++ b/client/src/app/shared/users/user.model.ts | |||
@@ -2,15 +2,18 @@ import { hasUserRight, User as UserServerModel, UserNotificationSetting, UserRig | |||
2 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' | 2 | import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type' |
3 | import { Account } from '@app/shared/account/account.model' | 3 | import { Account } from '@app/shared/account/account.model' |
4 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 4 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
5 | import { UserAdminFlag } from '@shared/models/users/user-flag.model' | ||
5 | 6 | ||
6 | export class User implements UserServerModel { | 7 | export class User implements UserServerModel { |
7 | id: number | 8 | id: number |
8 | username: string | 9 | username: string |
9 | email: string | 10 | email: string |
10 | emailVerified: boolean | 11 | emailVerified: boolean |
11 | role: UserRole | ||
12 | nsfwPolicy: NSFWPolicyType | 12 | nsfwPolicy: NSFWPolicyType |
13 | 13 | ||
14 | role: UserRole | ||
15 | roleLabel: string | ||
16 | |||
14 | webTorrentEnabled: boolean | 17 | webTorrentEnabled: boolean |
15 | autoPlayVideo: boolean | 18 | autoPlayVideo: boolean |
16 | videosHistoryEnabled: boolean | 19 | videosHistoryEnabled: boolean |
@@ -21,6 +24,8 @@ export class User implements UserServerModel { | |||
21 | videoChannels: VideoChannel[] | 24 | videoChannels: VideoChannel[] |
22 | createdAt: Date | 25 | createdAt: Date |
23 | 26 | ||
27 | adminFlags?: UserAdminFlag | ||
28 | |||
24 | blocked: boolean | 29 | blocked: boolean |
25 | blockedReason?: string | 30 | blockedReason?: string |
26 | 31 | ||
@@ -30,6 +35,7 @@ export class User implements UserServerModel { | |||
30 | this.id = hash.id | 35 | this.id = hash.id |
31 | this.username = hash.username | 36 | this.username = hash.username |
32 | this.email = hash.email | 37 | this.email = hash.email |
38 | |||
33 | this.role = hash.role | 39 | this.role = hash.role |
34 | 40 | ||
35 | this.videoChannels = hash.videoChannels | 41 | this.videoChannels = hash.videoChannels |
@@ -40,6 +46,9 @@ export class User implements UserServerModel { | |||
40 | this.videosHistoryEnabled = hash.videosHistoryEnabled | 46 | this.videosHistoryEnabled = hash.videosHistoryEnabled |
41 | this.autoPlayVideo = hash.autoPlayVideo | 47 | this.autoPlayVideo = hash.autoPlayVideo |
42 | this.createdAt = hash.createdAt | 48 | this.createdAt = hash.createdAt |
49 | |||
50 | this.adminFlags = hash.adminFlags | ||
51 | |||
43 | this.blocked = hash.blocked | 52 | this.blocked = hash.blocked |
44 | this.blockedReason = hash.blockedReason | 53 | this.blockedReason = hash.blockedReason |
45 | 54 | ||
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 94e46d7c2..a9eab9b6f 100644 --- a/client/src/app/shared/video-blacklist/video-blacklist.service.ts +++ b/client/src/app/shared/video-blacklist/video-blacklist.service.ts | |||
@@ -1,11 +1,13 @@ | |||
1 | import { catchError, map } 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/components/common/sortmeta' |
5 | import { Observable } from 'rxjs' | 5 | import { from as observableFrom, Observable } from 'rxjs' |
6 | import { VideoBlacklist, ResultList } from '../../../../../shared' | 6 | import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared' |
7 | import { Video } from '../video/video.model' | ||
7 | import { environment } from '../../../environments/environment' | 8 | import { environment } from '../../../environments/environment' |
8 | import { RestExtractor, RestPagination, RestService } from '../rest' | 9 | import { RestExtractor, RestPagination, RestService } from '../rest' |
10 | import { ComponentPagination } from '../rest/component-pagination.model' | ||
9 | 11 | ||
10 | @Injectable() | 12 | @Injectable() |
11 | export class VideoBlacklistService { | 13 | export class VideoBlacklistService { |
@@ -17,10 +19,14 @@ export class VideoBlacklistService { | |||
17 | private restExtractor: RestExtractor | 19 | private restExtractor: RestExtractor |
18 | ) {} | 20 | ) {} |
19 | 21 | ||
20 | listBlacklist (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoBlacklist>> { | 22 | listBlacklist (pagination: RestPagination, sort: SortMeta, type?: VideoBlacklistType): Observable<ResultList<VideoBlacklist>> { |
21 | let params = new HttpParams() | 23 | let params = new HttpParams() |
22 | params = this.restService.addRestGetParams(params, pagination, sort) | 24 | params = this.restService.addRestGetParams(params, pagination, sort) |
23 | 25 | ||
26 | if (type) { | ||
27 | params = params.set('type', type.toString()) | ||
28 | } | ||
29 | |||
24 | return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params }) | 30 | return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params }) |
25 | .pipe( | 31 | .pipe( |
26 | map(res => this.restExtractor.convertResultListDateToHuman(res)), | 32 | map(res => this.restExtractor.convertResultListDateToHuman(res)), |
@@ -28,12 +34,37 @@ export class VideoBlacklistService { | |||
28 | ) | 34 | ) |
29 | } | 35 | } |
30 | 36 | ||
31 | removeVideoFromBlacklist (videoId: number) { | 37 | getAutoBlacklistedAsVideoList (videoPagination: ComponentPagination): Observable<{ videos: Video[], totalVideos: number}> { |
32 | return this.authHttp.delete(VideoBlacklistService.BASE_VIDEOS_URL + videoId + '/blacklist') | 38 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) |
33 | .pipe( | 39 | |
34 | map(this.restExtractor.extractDataBool), | 40 | // prioritize first created since waiting longest |
35 | catchError(res => this.restExtractor.handleError(res)) | 41 | const AUTO_BLACKLIST_SORT = 'createdAt' |
36 | ) | 42 | |
43 | let params = new HttpParams() | ||
44 | params = this.restService.addRestGetParams(params, pagination, AUTO_BLACKLIST_SORT) | ||
45 | |||
46 | params = params.set('type', VideoBlacklistType.AUTO_BEFORE_PUBLISHED.toString()) | ||
47 | |||
48 | return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlacklistService.BASE_VIDEOS_URL + 'blacklist', { params }) | ||
49 | .pipe( | ||
50 | map(res => { | ||
51 | const videos = res.data.map(videoBlacklist => new Video(videoBlacklist.video)) | ||
52 | const totalVideos = res.total | ||
53 | return { videos, totalVideos } | ||
54 | }), | ||
55 | catchError(res => this.restExtractor.handleError(res)) | ||
56 | ) | ||
57 | } | ||
58 | |||
59 | removeVideoFromBlacklist (videoIdArgs: number | number[]) { | ||
60 | const videoIds = Array.isArray(videoIdArgs) ? videoIdArgs : [ videoIdArgs ] | ||
61 | |||
62 | return observableFrom(videoIds) | ||
63 | .pipe( | ||
64 | concatMap(id => this.authHttp.delete(VideoBlacklistService.BASE_VIDEOS_URL + id + '/blacklist')), | ||
65 | toArray(), | ||
66 | catchError(err => this.restExtractor.handleError(err)) | ||
67 | ) | ||
37 | } | 68 | } |
38 | 69 | ||
39 | blacklistVideo (videoId: number, reason: string, unfederate: boolean) { | 70 | blacklistVideo (videoId: number, reason: string, unfederate: boolean) { |
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 7ae66ddfc..7ae13154d 100644 --- a/client/src/app/shared/video-import/video-import.service.ts +++ b/client/src/app/shared/video-import/video-import.service.ts | |||
@@ -67,6 +67,7 @@ export class VideoImportService { | |||
67 | const description = video.description || null | 67 | const description = video.description || null |
68 | const support = video.support || null | 68 | const support = video.support || null |
69 | const scheduleUpdate = video.scheduleUpdate || null | 69 | const scheduleUpdate = video.scheduleUpdate || null |
70 | const originallyPublishedAt = video.originallyPublishedAt || null | ||
70 | 71 | ||
71 | return { | 72 | return { |
72 | name: video.name, | 73 | name: video.name, |
@@ -81,9 +82,11 @@ export class VideoImportService { | |||
81 | nsfw: video.nsfw, | 82 | nsfw: video.nsfw, |
82 | waitTranscoding: video.waitTranscoding, | 83 | waitTranscoding: video.waitTranscoding, |
83 | commentsEnabled: video.commentsEnabled, | 84 | commentsEnabled: video.commentsEnabled, |
85 | downloadEnabled: video.downloadEnabled, | ||
84 | thumbnailfile: video.thumbnailfile, | 86 | thumbnailfile: video.thumbnailfile, |
85 | previewfile: video.previewfile, | 87 | previewfile: video.previewfile, |
86 | scheduleUpdate | 88 | scheduleUpdate, |
89 | originallyPublishedAt | ||
87 | } | 90 | } |
88 | } | 91 | } |
89 | 92 | ||
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 new file mode 100644 index 000000000..648d580fa --- /dev/null +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.html | |||
@@ -0,0 +1,76 @@ | |||
1 | <div class="root"> | ||
2 | <div class="header"> | ||
3 | <div class="first-row"> | ||
4 | <div i18n class="title">Save to</div> | ||
5 | |||
6 | <div class="options" (click)="displayOptions = !displayOptions"> | ||
7 | <my-global-icon iconName="cog"></my-global-icon> | ||
8 | |||
9 | <span i18n>Options</span> | ||
10 | </div> | ||
11 | </div> | ||
12 | |||
13 | <div class="options-row" *ngIf="displayOptions"> | ||
14 | <div> | ||
15 | <my-peertube-checkbox | ||
16 | inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled" | ||
17 | i18n-labelText labelText="Start at" | ||
18 | ></my-peertube-checkbox> | ||
19 | |||
20 | <my-timestamp-input | ||
21 | [timestamp]="timestampOptions.startTimestamp" | ||
22 | [maxTimestamp]="video.duration" | ||
23 | [disabled]="!timestampOptions.startTimestampEnabled" | ||
24 | [(ngModel)]="timestampOptions.startTimestamp" | ||
25 | ></my-timestamp-input> | ||
26 | </div> | ||
27 | |||
28 | <div> | ||
29 | <my-peertube-checkbox | ||
30 | inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled" | ||
31 | i18n-labelText labelText="Stop at" | ||
32 | ></my-peertube-checkbox> | ||
33 | |||
34 | <my-timestamp-input | ||
35 | [timestamp]="timestampOptions.stopTimestamp" | ||
36 | [maxTimestamp]="video.duration" | ||
37 | [disabled]="!timestampOptions.stopTimestampEnabled" | ||
38 | [(ngModel)]="timestampOptions.stopTimestamp" | ||
39 | ></my-timestamp-input> | ||
40 | </div> | ||
41 | </div> | ||
42 | </div> | ||
43 | |||
44 | <div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)"> | ||
45 | <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox> | ||
46 | |||
47 | <div class="display-name"> | ||
48 | {{ playlist.displayName }} | ||
49 | |||
50 | <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info"> | ||
51 | {{ formatTimestamp(playlist) }} | ||
52 | </div> | ||
53 | </div> | ||
54 | </div> | ||
55 | |||
56 | <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened"> | ||
57 | <my-global-icon iconName="add"></my-global-icon> | ||
58 | |||
59 | Create a private playlist | ||
60 | </div> | ||
61 | |||
62 | <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form"> | ||
63 | <div class="form-group"> | ||
64 | <label i18n for="displayName">Display name</label> | ||
65 | <input | ||
66 | type="text" id="displayName" | ||
67 | formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }" | ||
68 | > | ||
69 | <div *ngIf="formErrors['displayName']" class="form-error"> | ||
70 | {{ formErrors['displayName'] }} | ||
71 | </div> | ||
72 | </div> | ||
73 | |||
74 | <input type="submit" i18n-value value="Create" [disabled]="!form.valid"> | ||
75 | </form> | ||
76 | </div> | ||
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 new file mode 100644 index 000000000..c677fea6c --- /dev/null +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.scss | |||
@@ -0,0 +1,107 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .root { | ||
5 | max-height: 300px; | ||
6 | overflow-y: auto; | ||
7 | } | ||
8 | |||
9 | .header { | ||
10 | min-width: 240px; | ||
11 | padding: 6px 24px 10px 24px; | ||
12 | |||
13 | margin-bottom: 10px; | ||
14 | border-bottom: 1px solid $separator-border-color; | ||
15 | |||
16 | .first-row { | ||
17 | display: flex; | ||
18 | align-items: center; | ||
19 | |||
20 | .title { | ||
21 | font-size: 18px; | ||
22 | flex-grow: 1; | ||
23 | } | ||
24 | |||
25 | .options { | ||
26 | display: flex; | ||
27 | align-items: center; | ||
28 | font-size: 14px; | ||
29 | cursor: pointer; | ||
30 | |||
31 | my-global-icon { | ||
32 | @include apply-svg-color(#333); | ||
33 | |||
34 | width: 16px; | ||
35 | height: 23px; | ||
36 | margin-right: 3px; | ||
37 | } | ||
38 | } | ||
39 | } | ||
40 | |||
41 | .options-row { | ||
42 | margin-top: 10px; | ||
43 | padding-left: 10px; | ||
44 | |||
45 | > div { | ||
46 | display: flex; | ||
47 | align-items: center; | ||
48 | } | ||
49 | } | ||
50 | } | ||
51 | |||
52 | .dropdown-item { | ||
53 | padding: 6px 24px; | ||
54 | } | ||
55 | |||
56 | .playlist { | ||
57 | display: flex; | ||
58 | cursor: pointer; | ||
59 | |||
60 | my-peertube-checkbox { | ||
61 | margin-right: 10px; | ||
62 | } | ||
63 | |||
64 | .display-name { | ||
65 | display: flex; | ||
66 | align-items: flex-end; | ||
67 | |||
68 | .timestamp-info { | ||
69 | font-size: 0.9em; | ||
70 | color: $grey-foreground-color; | ||
71 | margin-left: 5px; | ||
72 | } | ||
73 | } | ||
74 | } | ||
75 | |||
76 | .new-playlist-button, | ||
77 | .new-playlist-block { | ||
78 | padding-top: 10px; | ||
79 | margin-top: 10px; | ||
80 | border-top: 1px solid $separator-border-color; | ||
81 | } | ||
82 | |||
83 | .new-playlist-button { | ||
84 | cursor: pointer; | ||
85 | |||
86 | my-global-icon { | ||
87 | @include apply-svg-color(#333); | ||
88 | |||
89 | position: relative; | ||
90 | left: -1px; | ||
91 | top: -1px; | ||
92 | margin-right: 4px; | ||
93 | width: 21px; | ||
94 | height: 21px; | ||
95 | } | ||
96 | } | ||
97 | |||
98 | input[type=text] { | ||
99 | @include peertube-input-text(200px); | ||
100 | |||
101 | display: block; | ||
102 | } | ||
103 | |||
104 | input[type=submit] { | ||
105 | @include peertube-button; | ||
106 | @include orange-button; | ||
107 | } | ||
diff --git a/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts new file mode 100644 index 000000000..7dcdf7a9e --- /dev/null +++ b/client/src/app/shared/video-playlist/video-add-to-playlist.component.ts | |||
@@ -0,0 +1,212 @@ | |||
1 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core' | ||
2 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
3 | import { AuthService, Notifier } from '@app/core' | ||
4 | import { forkJoin } from 'rxjs' | ||
5 | import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models' | ||
6 | import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms' | ||
7 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
8 | import { secondsToTime } from '../../../assets/player/utils' | ||
9 | |||
10 | type PlaylistSummary = { | ||
11 | id: number | ||
12 | inPlaylist: boolean | ||
13 | displayName: string | ||
14 | |||
15 | startTimestamp?: number | ||
16 | stopTimestamp?: number | ||
17 | } | ||
18 | |||
19 | @Component({ | ||
20 | selector: 'my-video-add-to-playlist', | ||
21 | styleUrls: [ './video-add-to-playlist.component.scss' ], | ||
22 | templateUrl: './video-add-to-playlist.component.html', | ||
23 | changeDetection: ChangeDetectionStrategy.OnPush | ||
24 | }) | ||
25 | export class VideoAddToPlaylistComponent extends FormReactive implements OnInit { | ||
26 | @Input() video: Video | ||
27 | @Input() currentVideoTimestamp: number | ||
28 | @Input() lazyLoad = false | ||
29 | |||
30 | isNewPlaylistBlockOpened = false | ||
31 | videoPlaylists: PlaylistSummary[] = [] | ||
32 | timestampOptions: { | ||
33 | startTimestampEnabled: boolean | ||
34 | startTimestamp: number | ||
35 | stopTimestampEnabled: boolean | ||
36 | stopTimestamp: number | ||
37 | } | ||
38 | displayOptions = false | ||
39 | |||
40 | constructor ( | ||
41 | protected formValidatorService: FormValidatorService, | ||
42 | private authService: AuthService, | ||
43 | private notifier: Notifier, | ||
44 | private i18n: I18n, | ||
45 | private videoPlaylistService: VideoPlaylistService, | ||
46 | private videoPlaylistValidatorsService: VideoPlaylistValidatorsService, | ||
47 | private cd: ChangeDetectorRef | ||
48 | ) { | ||
49 | super() | ||
50 | } | ||
51 | |||
52 | get user () { | ||
53 | return this.authService.getUser() | ||
54 | } | ||
55 | |||
56 | ngOnInit () { | ||
57 | this.resetOptions(true) | ||
58 | |||
59 | this.buildForm({ | ||
60 | displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME | ||
61 | }) | ||
62 | |||
63 | if (this.lazyLoad !== true) this.load() | ||
64 | } | ||
65 | |||
66 | load () { | ||
67 | forkJoin([ | ||
68 | this.videoPlaylistService.listAccountPlaylists(this.user.account, '-updatedAt'), | ||
69 | this.videoPlaylistService.doesVideoExistInPlaylist(this.video.id) | ||
70 | ]) | ||
71 | .subscribe( | ||
72 | ([ playlistsResult, existResult ]) => { | ||
73 | for (const playlist of playlistsResult.data) { | ||
74 | const existingPlaylist = existResult[ this.video.id ].find(p => p.playlistId === playlist.id) | ||
75 | |||
76 | this.videoPlaylists.push({ | ||
77 | id: playlist.id, | ||
78 | displayName: playlist.displayName, | ||
79 | inPlaylist: !!existingPlaylist, | ||
80 | startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined, | ||
81 | stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined | ||
82 | }) | ||
83 | } | ||
84 | |||
85 | this.cd.markForCheck() | ||
86 | } | ||
87 | ) | ||
88 | } | ||
89 | |||
90 | openChange (opened: boolean) { | ||
91 | if (opened === false) { | ||
92 | this.isNewPlaylistBlockOpened = false | ||
93 | this.displayOptions = false | ||
94 | } | ||
95 | } | ||
96 | |||
97 | openCreateBlock (event: Event) { | ||
98 | event.preventDefault() | ||
99 | |||
100 | this.isNewPlaylistBlockOpened = true | ||
101 | } | ||
102 | |||
103 | togglePlaylist (event: Event, playlist: PlaylistSummary) { | ||
104 | event.preventDefault() | ||
105 | |||
106 | if (playlist.inPlaylist === true) { | ||
107 | this.removeVideoFromPlaylist(playlist) | ||
108 | } else { | ||
109 | this.addVideoInPlaylist(playlist) | ||
110 | } | ||
111 | |||
112 | playlist.inPlaylist = !playlist.inPlaylist | ||
113 | this.resetOptions() | ||
114 | |||
115 | this.cd.markForCheck() | ||
116 | } | ||
117 | |||
118 | createPlaylist () { | ||
119 | const displayName = this.form.value[ 'displayName' ] | ||
120 | |||
121 | const videoPlaylistCreate: VideoPlaylistCreate = { | ||
122 | displayName, | ||
123 | privacy: VideoPlaylistPrivacy.PRIVATE | ||
124 | } | ||
125 | |||
126 | this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe( | ||
127 | res => { | ||
128 | this.videoPlaylists.push({ | ||
129 | id: res.videoPlaylist.id, | ||
130 | displayName, | ||
131 | inPlaylist: false | ||
132 | }) | ||
133 | |||
134 | this.isNewPlaylistBlockOpened = false | ||
135 | |||
136 | this.cd.markForCheck() | ||
137 | }, | ||
138 | |||
139 | err => this.notifier.error(err.message) | ||
140 | ) | ||
141 | } | ||
142 | |||
143 | resetOptions (resetTimestamp = false) { | ||
144 | this.displayOptions = false | ||
145 | |||
146 | this.timestampOptions = {} as any | ||
147 | this.timestampOptions.startTimestampEnabled = false | ||
148 | this.timestampOptions.stopTimestampEnabled = false | ||
149 | |||
150 | if (resetTimestamp) { | ||
151 | this.timestampOptions.startTimestamp = 0 | ||
152 | this.timestampOptions.stopTimestamp = this.video.duration | ||
153 | } | ||
154 | } | ||
155 | |||
156 | formatTimestamp (playlist: PlaylistSummary) { | ||
157 | const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : '' | ||
158 | const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : '' | ||
159 | |||
160 | return `(${start}-${stop})` | ||
161 | } | ||
162 | |||
163 | private removeVideoFromPlaylist (playlist: PlaylistSummary) { | ||
164 | this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, this.video.id) | ||
165 | .subscribe( | ||
166 | () => { | ||
167 | this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName })) | ||
168 | |||
169 | playlist.inPlaylist = false | ||
170 | }, | ||
171 | |||
172 | err => { | ||
173 | this.notifier.error(err.message) | ||
174 | |||
175 | playlist.inPlaylist = true | ||
176 | }, | ||
177 | |||
178 | () => this.cd.markForCheck() | ||
179 | ) | ||
180 | } | ||
181 | |||
182 | private addVideoInPlaylist (playlist: PlaylistSummary) { | ||
183 | const body: VideoPlaylistElementCreate = { videoId: this.video.id } | ||
184 | |||
185 | if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp | ||
186 | if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp | ||
187 | |||
188 | this.videoPlaylistService.addVideoInPlaylist(playlist.id, body) | ||
189 | .subscribe( | ||
190 | () => { | ||
191 | playlist.inPlaylist = true | ||
192 | |||
193 | playlist.startTimestamp = body.startTimestamp | ||
194 | playlist.stopTimestamp = body.stopTimestamp | ||
195 | |||
196 | const message = body.startTimestamp || body.stopTimestamp | ||
197 | ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) }) | ||
198 | : this.i18n('Video added in {{n}}', { n: playlist.displayName }) | ||
199 | |||
200 | this.notifier.success(message) | ||
201 | }, | ||
202 | |||
203 | err => { | ||
204 | this.notifier.error(err.message) | ||
205 | |||
206 | playlist.inPlaylist = false | ||
207 | }, | ||
208 | |||
209 | () => this.cd.markForCheck() | ||
210 | ) | ||
211 | } | ||
212 | } | ||
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html new file mode 100644 index 000000000..ab5a78928 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.html | |||
@@ -0,0 +1,73 @@ | |||
1 | <div class="video" [ngClass]="{ playing: playing }"> | ||
2 | <a [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"> | ||
3 | <div class="position"> | ||
4 | <my-global-icon *ngIf="playing" iconName="play"></my-global-icon> | ||
5 | <ng-container *ngIf="!playing">{{ position }}</ng-container> | ||
6 | </div> | ||
7 | |||
8 | <my-video-thumbnail | ||
9 | [video]="video" [nsfw]="isVideoBlur(video)" | ||
10 | [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" | ||
11 | ></my-video-thumbnail> | ||
12 | |||
13 | <div class="video-info"> | ||
14 | <a tabindex="-1" class="video-info-name" | ||
15 | [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()" | ||
16 | [attr.title]="video.name" | ||
17 | >{{ video.name }}</a> | ||
18 | |||
19 | <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', video.byAccount ]">{{ video.byAccount }}</a> | ||
20 | <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ video.byAccount }}</span> | ||
21 | |||
22 | <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(video) }}</span> | ||
23 | </div> | ||
24 | </a> | ||
25 | |||
26 | <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom-right" (openChange)="onDropdownOpenChange()" | ||
27 | autoClose="outside"> | ||
28 | <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon> | ||
29 | |||
30 | <div ngbDropdownMenu> | ||
31 | <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, video)"> | ||
32 | <my-global-icon iconName="edit"></my-global-icon> | ||
33 | <ng-container i18n>Edit starts/stops at</ng-container> | ||
34 | </div> | ||
35 | |||
36 | <div class="timestamp-options" *ngIf="displayTimestampOptions"> | ||
37 | <div> | ||
38 | <my-peertube-checkbox | ||
39 | inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled" | ||
40 | i18n-labelText labelText="Start at" | ||
41 | ></my-peertube-checkbox> | ||
42 | |||
43 | <my-timestamp-input | ||
44 | [timestamp]="timestampOptions.startTimestamp" | ||
45 | [maxTimestamp]="video.duration" | ||
46 | [disabled]="!timestampOptions.startTimestampEnabled" | ||
47 | [(ngModel)]="timestampOptions.startTimestamp" | ||
48 | ></my-timestamp-input> | ||
49 | </div> | ||
50 | |||
51 | <div> | ||
52 | <my-peertube-checkbox | ||
53 | inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled" | ||
54 | i18n-labelText labelText="Stop at" | ||
55 | ></my-peertube-checkbox> | ||
56 | |||
57 | <my-timestamp-input | ||
58 | [timestamp]="timestampOptions.stopTimestamp" | ||
59 | [maxTimestamp]="video.duration" | ||
60 | [disabled]="!timestampOptions.stopTimestampEnabled" | ||
61 | [(ngModel)]="timestampOptions.stopTimestamp" | ||
62 | ></my-timestamp-input> | ||
63 | </div> | ||
64 | |||
65 | <input type="submit" i18n-value value="Save" (click)="updateTimestamps(video)"> | ||
66 | </div> | ||
67 | |||
68 | <span class="dropdown-item" (click)="removeFromPlaylist(video)"> | ||
69 | <my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container> | ||
70 | </span> | ||
71 | </div> | ||
72 | </div> | ||
73 | </div> | ||
diff --git a/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss new file mode 100644 index 000000000..cb7072d7f --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.scss | |||
@@ -0,0 +1,125 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | @import '_miniature'; | ||
4 | |||
5 | my-video-thumbnail { | ||
6 | @include thumbnail-size-component(130px, 72px); | ||
7 | |||
8 | display: flex; // Avoids an issue with line-height that adds space below the element | ||
9 | margin-right: 10px; | ||
10 | } | ||
11 | |||
12 | .video { | ||
13 | display: flex; | ||
14 | align-items: center; | ||
15 | background-color: var(--mainBackgroundColor); | ||
16 | padding: 10px; | ||
17 | border-bottom: 1px solid $separator-border-color; | ||
18 | |||
19 | &:hover { | ||
20 | background-color: rgba(0, 0, 0, 0.05); | ||
21 | |||
22 | .more { | ||
23 | opacity: 1; | ||
24 | } | ||
25 | } | ||
26 | |||
27 | &.playing { | ||
28 | background-color: rgba(0, 0, 0, 0.02); | ||
29 | } | ||
30 | |||
31 | a { | ||
32 | @include disable-default-a-behaviour; | ||
33 | |||
34 | display: flex; | ||
35 | min-width: 0; | ||
36 | align-items: center; | ||
37 | cursor: pointer; | ||
38 | |||
39 | .position { | ||
40 | font-weight: $font-semibold; | ||
41 | margin-right: 10px; | ||
42 | color: $grey-foreground-color; | ||
43 | min-width: 25px; | ||
44 | |||
45 | my-global-icon { | ||
46 | @include apply-svg-color($grey-foreground-color); | ||
47 | |||
48 | width: 17px; | ||
49 | position: relative; | ||
50 | left: -2px; | ||
51 | } | ||
52 | } | ||
53 | |||
54 | .video-info { | ||
55 | display: flex; | ||
56 | flex-direction: column; | ||
57 | align-self: flex-start; | ||
58 | min-width: 0; | ||
59 | |||
60 | a { | ||
61 | color: var(--mainForegroundColor); | ||
62 | width: auto; | ||
63 | |||
64 | &:hover { | ||
65 | text-decoration: underline !important; | ||
66 | } | ||
67 | } | ||
68 | |||
69 | .video-info-name { | ||
70 | font-size: 18px; | ||
71 | font-weight: $font-semibold; | ||
72 | display: inline-block; | ||
73 | |||
74 | @include ellipsis; | ||
75 | } | ||
76 | |||
77 | .video-info-account, .video-info-timestamp { | ||
78 | color: $grey-foreground-color; | ||
79 | } | ||
80 | } | ||
81 | } | ||
82 | |||
83 | .more { | ||
84 | justify-self: flex-end; | ||
85 | margin-left: auto; | ||
86 | cursor: pointer; | ||
87 | opacity: 0; | ||
88 | |||
89 | &.show { | ||
90 | opacity: 1; | ||
91 | } | ||
92 | |||
93 | .icon-more { | ||
94 | @include apply-svg-color($grey-foreground-color); | ||
95 | |||
96 | display: flex; | ||
97 | |||
98 | &::after { | ||
99 | border: none; | ||
100 | } | ||
101 | } | ||
102 | |||
103 | .dropdown-item { | ||
104 | @include dropdown-with-icon-item; | ||
105 | } | ||
106 | |||
107 | .timestamp-options { | ||
108 | padding-top: 0; | ||
109 | padding-left: 35px; | ||
110 | margin-bottom: 15px; | ||
111 | |||
112 | > div { | ||
113 | display: flex; | ||
114 | align-items: center; | ||
115 | } | ||
116 | |||
117 | input { | ||
118 | @include peertube-button; | ||
119 | @include orange-button; | ||
120 | |||
121 | margin-top: 10px; | ||
122 | } | ||
123 | } | ||
124 | } | ||
125 | } | ||
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 new file mode 100644 index 000000000..57990707a --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-element-miniature.component.ts | |||
@@ -0,0 +1,159 @@ | |||
1 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core' | ||
2 | import { Video } from '@app/shared/video/video.model' | ||
3 | import { VideoPlaylistElementUpdate } from '@shared/models' | ||
4 | import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' | ||
5 | import { ActivatedRoute } from '@angular/router' | ||
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { VideoService } from '@app/shared/video/video.service' | ||
8 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
9 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | ||
10 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
11 | import { secondsToTime } from '../../../assets/player/utils' | ||
12 | |||
13 | @Component({ | ||
14 | selector: 'my-video-playlist-element-miniature', | ||
15 | styleUrls: [ './video-playlist-element-miniature.component.scss' ], | ||
16 | templateUrl: './video-playlist-element-miniature.component.html', | ||
17 | changeDetection: ChangeDetectionStrategy.OnPush | ||
18 | }) | ||
19 | export class VideoPlaylistElementMiniatureComponent { | ||
20 | @ViewChild('moreDropdown') moreDropdown: NgbDropdown | ||
21 | |||
22 | @Input() playlist: VideoPlaylist | ||
23 | @Input() video: Video | ||
24 | @Input() owned = false | ||
25 | @Input() playing = false | ||
26 | @Input() rowLink = false | ||
27 | @Input() accountLink = true | ||
28 | @Input() position: number | ||
29 | |||
30 | @Output() elementRemoved = new EventEmitter<Video>() | ||
31 | |||
32 | displayTimestampOptions = false | ||
33 | |||
34 | timestampOptions: { | ||
35 | startTimestampEnabled: boolean | ||
36 | startTimestamp: number | ||
37 | stopTimestampEnabled: boolean | ||
38 | stopTimestamp: number | ||
39 | } = {} as any | ||
40 | |||
41 | constructor ( | ||
42 | private authService: AuthService, | ||
43 | private serverService: ServerService, | ||
44 | private notifier: Notifier, | ||
45 | private confirmService: ConfirmService, | ||
46 | private route: ActivatedRoute, | ||
47 | private i18n: I18n, | ||
48 | private videoService: VideoService, | ||
49 | private videoPlaylistService: VideoPlaylistService, | ||
50 | private cdr: ChangeDetectorRef | ||
51 | ) {} | ||
52 | |||
53 | buildRouterLink () { | ||
54 | if (!this.playlist) return null | ||
55 | |||
56 | return [ '/videos/watch/playlist', this.playlist.uuid ] | ||
57 | } | ||
58 | |||
59 | buildRouterQuery () { | ||
60 | if (!this.video) return {} | ||
61 | |||
62 | return { | ||
63 | videoId: this.video.uuid, | ||
64 | start: this.video.playlistElement.startTimestamp, | ||
65 | stop: this.video.playlistElement.stopTimestamp | ||
66 | } | ||
67 | } | ||
68 | |||
69 | isVideoBlur (video: Video) { | ||
70 | return video.isVideoNSFWForUser(this.authService.getUser(), this.serverService.getConfig()) | ||
71 | } | ||
72 | |||
73 | removeFromPlaylist (video: Video) { | ||
74 | this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, video.id) | ||
75 | .subscribe( | ||
76 | () => { | ||
77 | this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName })) | ||
78 | |||
79 | this.elementRemoved.emit(this.video) | ||
80 | }, | ||
81 | |||
82 | err => this.notifier.error(err.message) | ||
83 | ) | ||
84 | |||
85 | this.moreDropdown.close() | ||
86 | } | ||
87 | |||
88 | updateTimestamps (video: Video) { | ||
89 | const body: VideoPlaylistElementUpdate = {} | ||
90 | |||
91 | body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null | ||
92 | body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null | ||
93 | |||
94 | this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, video.id, body) | ||
95 | .subscribe( | ||
96 | () => { | ||
97 | this.notifier.success(this.i18n('Timestamps updated')) | ||
98 | |||
99 | video.playlistElement.startTimestamp = body.startTimestamp | ||
100 | video.playlistElement.stopTimestamp = body.stopTimestamp | ||
101 | |||
102 | this.cdr.detectChanges() | ||
103 | }, | ||
104 | |||
105 | err => this.notifier.error(err.message) | ||
106 | ) | ||
107 | |||
108 | this.moreDropdown.close() | ||
109 | } | ||
110 | |||
111 | formatTimestamp (video: Video) { | ||
112 | const start = video.playlistElement.startTimestamp | ||
113 | const stop = video.playlistElement.stopTimestamp | ||
114 | |||
115 | const startFormatted = secondsToTime(start, true, ':') | ||
116 | const stopFormatted = secondsToTime(stop, true, ':') | ||
117 | |||
118 | if (start === null && stop === null) return '' | ||
119 | |||
120 | if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted | ||
121 | if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted | ||
122 | |||
123 | return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted | ||
124 | } | ||
125 | |||
126 | onDropdownOpenChange () { | ||
127 | this.displayTimestampOptions = false | ||
128 | } | ||
129 | |||
130 | toggleDisplayTimestampsOptions (event: Event, video: Video) { | ||
131 | event.preventDefault() | ||
132 | |||
133 | this.displayTimestampOptions = !this.displayTimestampOptions | ||
134 | |||
135 | if (this.displayTimestampOptions === true) { | ||
136 | this.timestampOptions = { | ||
137 | startTimestampEnabled: false, | ||
138 | stopTimestampEnabled: false, | ||
139 | startTimestamp: 0, | ||
140 | stopTimestamp: video.duration | ||
141 | } | ||
142 | |||
143 | if (video.playlistElement.startTimestamp) { | ||
144 | this.timestampOptions.startTimestampEnabled = true | ||
145 | this.timestampOptions.startTimestamp = video.playlistElement.startTimestamp | ||
146 | } | ||
147 | |||
148 | if (video.playlistElement.stopTimestamp) { | ||
149 | this.timestampOptions.stopTimestampEnabled = true | ||
150 | this.timestampOptions.stopTimestamp = video.playlistElement.stopTimestamp | ||
151 | } | ||
152 | } | ||
153 | |||
154 | // FIXME: why do we have to use setTimeout here? | ||
155 | setTimeout(() => { | ||
156 | this.cdr.detectChanges() | ||
157 | }) | ||
158 | } | ||
159 | } | ||
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html new file mode 100644 index 000000000..86f6664cb --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.html | |||
@@ -0,0 +1,34 @@ | |||
1 | <div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }"> | ||
2 | <a | ||
3 | [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description" | ||
4 | class="miniature-thumbnail" | ||
5 | > | ||
6 | <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" /> | ||
7 | |||
8 | <div class="miniature-playlist-info-overlay"> | ||
9 | <ng-container i18n>{playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist.videosLength }} videos}}</ng-container> | ||
10 | </div> | ||
11 | |||
12 | <div class="play-overlay"> | ||
13 | <div class="icon"></div> | ||
14 | </div> | ||
15 | </a> | ||
16 | |||
17 | <div class="miniature-info"> | ||
18 | <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"> | ||
19 | {{ playlist.displayName }} | ||
20 | </a> | ||
21 | |||
22 | <a i18n [routerLink]="[ '/video-channels', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy"> | ||
23 | {{ playlist.videoChannelBy }} | ||
24 | </a> | ||
25 | |||
26 | <div class="privacy-date"> | ||
27 | <span class="video-info-privacy" *ngIf="displayPrivacy">{{ playlist.privacy.label }}</span> | ||
28 | |||
29 | <span i18n class="updated-at">Updated {{ playlist.updatedAt | myFromNow }}</span> | ||
30 | </div> | ||
31 | |||
32 | <div *ngIf="displayDescription" class="video-info-description">{{ playlist.description }}</div> | ||
33 | </div> | ||
34 | </div> | ||
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss new file mode 100644 index 000000000..8947e72d1 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.scss | |||
@@ -0,0 +1,78 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | @import '_miniature'; | ||
4 | |||
5 | .miniature { | ||
6 | display: inline-block; | ||
7 | |||
8 | &.no-videos:not(.to-manage){ | ||
9 | a { | ||
10 | cursor: default !important; | ||
11 | } | ||
12 | } | ||
13 | |||
14 | &.to-manage, | ||
15 | &.no-videos { | ||
16 | .play-overlay { | ||
17 | display: none; | ||
18 | } | ||
19 | } | ||
20 | |||
21 | .miniature-thumbnail { | ||
22 | @include miniature-thumbnail; | ||
23 | |||
24 | .miniature-playlist-info-overlay { | ||
25 | @include static-thumbnail-overlay; | ||
26 | |||
27 | position: absolute; | ||
28 | right: 0; | ||
29 | bottom: 0; | ||
30 | height: $video-thumbnail-height; | ||
31 | padding: 0 10px; | ||
32 | display: flex; | ||
33 | align-items: center; | ||
34 | font-size: 14px; | ||
35 | font-weight: $font-semibold; | ||
36 | } | ||
37 | } | ||
38 | |||
39 | .miniature-info { | ||
40 | width: 200px; | ||
41 | margin-top: 2px; | ||
42 | line-height: normal; | ||
43 | |||
44 | .miniature-name { | ||
45 | @include miniature-name; | ||
46 | |||
47 | @include ellipsis-multiline(1.3em, 2); | ||
48 | |||
49 | margin: 0; | ||
50 | } | ||
51 | |||
52 | .by { | ||
53 | @include disable-default-a-behaviour; | ||
54 | |||
55 | display: block; | ||
56 | color: $grey-foreground-color; | ||
57 | } | ||
58 | |||
59 | .privacy-date { | ||
60 | margin-top: 5px; | ||
61 | |||
62 | .video-info-privacy { | ||
63 | font-size: 14px; | ||
64 | font-weight: $font-semibold; | ||
65 | |||
66 | &::after { | ||
67 | content: '-'; | ||
68 | margin: 0 3px; | ||
69 | } | ||
70 | } | ||
71 | } | ||
72 | |||
73 | .video-info-description { | ||
74 | margin-top: 10px; | ||
75 | color: $grey-foreground-color; | ||
76 | } | ||
77 | } | ||
78 | } | ||
diff --git a/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts new file mode 100644 index 000000000..523e96f2a --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist-miniature.component.ts | |||
@@ -0,0 +1,22 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-video-playlist-miniature', | ||
6 | styleUrls: [ './video-playlist-miniature.component.scss' ], | ||
7 | templateUrl: './video-playlist-miniature.component.html' | ||
8 | }) | ||
9 | export class VideoPlaylistMiniatureComponent { | ||
10 | @Input() playlist: VideoPlaylist | ||
11 | @Input() toManage = false | ||
12 | @Input() displayChannel = false | ||
13 | @Input() displayDescription = false | ||
14 | @Input() displayPrivacy = false | ||
15 | |||
16 | getPlaylistUrl () { | ||
17 | if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ] | ||
18 | if (this.playlist.videosLength === 0) return null | ||
19 | |||
20 | return [ '/videos/watch/playlist', this.playlist.uuid ] | ||
21 | } | ||
22 | } | ||
diff --git a/client/src/app/shared/video-playlist/video-playlist.model.ts b/client/src/app/shared/video-playlist/video-playlist.model.ts new file mode 100644 index 000000000..7e311aa54 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist.model.ts | |||
@@ -0,0 +1,84 @@ | |||
1 | import { | ||
2 | VideoChannelSummary, | ||
3 | VideoConstant, | ||
4 | VideoPlaylist as ServerVideoPlaylist, | ||
5 | VideoPlaylistPrivacy, | ||
6 | VideoPlaylistType | ||
7 | } from '../../../../../shared/models/videos' | ||
8 | import { AccountSummary, peertubeTranslate } from '@shared/models' | ||
9 | import { Actor } from '@app/shared/actor/actor.model' | ||
10 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' | ||
11 | |||
12 | export class VideoPlaylist implements ServerVideoPlaylist { | ||
13 | id: number | ||
14 | uuid: string | ||
15 | isLocal: boolean | ||
16 | |||
17 | displayName: string | ||
18 | description: string | ||
19 | privacy: VideoConstant<VideoPlaylistPrivacy> | ||
20 | |||
21 | thumbnailPath: string | ||
22 | |||
23 | videosLength: number | ||
24 | |||
25 | type: VideoConstant<VideoPlaylistType> | ||
26 | |||
27 | createdAt: Date | string | ||
28 | updatedAt: Date | string | ||
29 | |||
30 | ownerAccount: AccountSummary | ||
31 | videoChannel?: VideoChannelSummary | ||
32 | |||
33 | thumbnailUrl: string | ||
34 | |||
35 | ownerBy: string | ||
36 | ownerAvatarUrl: string | ||
37 | |||
38 | videoChannelBy?: string | ||
39 | videoChannelAvatarUrl?: string | ||
40 | |||
41 | constructor (hash: ServerVideoPlaylist, translations: {}) { | ||
42 | const absoluteAPIUrl = getAbsoluteAPIUrl() | ||
43 | |||
44 | this.id = hash.id | ||
45 | this.uuid = hash.uuid | ||
46 | this.isLocal = hash.isLocal | ||
47 | |||
48 | this.displayName = hash.displayName | ||
49 | |||
50 | this.description = hash.description | ||
51 | this.privacy = hash.privacy | ||
52 | |||
53 | this.thumbnailPath = hash.thumbnailPath | ||
54 | |||
55 | if (this.thumbnailPath) { | ||
56 | this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath | ||
57 | } else { | ||
58 | this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg' | ||
59 | } | ||
60 | |||
61 | this.videosLength = hash.videosLength | ||
62 | |||
63 | this.type = hash.type | ||
64 | |||
65 | this.createdAt = new Date(hash.createdAt) | ||
66 | this.updatedAt = new Date(hash.updatedAt) | ||
67 | |||
68 | this.ownerAccount = hash.ownerAccount | ||
69 | this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host) | ||
70 | this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount) | ||
71 | |||
72 | if (hash.videoChannel) { | ||
73 | this.videoChannel = hash.videoChannel | ||
74 | this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host) | ||
75 | this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel) | ||
76 | } | ||
77 | |||
78 | this.privacy.label = peertubeTranslate(this.privacy.label, translations) | ||
79 | |||
80 | if (this.type.id === VideoPlaylistType.WATCH_LATER) { | ||
81 | this.displayName = peertubeTranslate(this.displayName, translations) | ||
82 | } | ||
83 | } | ||
84 | } | ||
diff --git a/client/src/app/shared/video-playlist/video-playlist.service.ts b/client/src/app/shared/video-playlist/video-playlist.service.ts new file mode 100644 index 000000000..da7437507 --- /dev/null +++ b/client/src/app/shared/video-playlist/video-playlist.service.ts | |||
@@ -0,0 +1,179 @@ | |||
1 | import { bufferTime, catchError, filter, first, map, share, switchMap } from 'rxjs/operators' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { Observable, ReplaySubject, Subject } from 'rxjs' | ||
4 | import { RestExtractor } from '../rest/rest-extractor.service' | ||
5 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
6 | import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared' | ||
7 | import { environment } from '../../../environments/environment' | ||
8 | import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model' | ||
9 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | ||
10 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | ||
11 | import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model' | ||
12 | import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model' | ||
13 | import { objectToFormData } from '@app/shared/misc/utils' | ||
14 | import { ServerService } from '@app/core' | ||
15 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
16 | import { AccountService } from '@app/shared/account/account.service' | ||
17 | import { Account } from '@app/shared/account/account.model' | ||
18 | import { RestService } from '@app/shared/rest' | ||
19 | import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model' | ||
20 | import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model' | ||
21 | |||
22 | @Injectable() | ||
23 | export class VideoPlaylistService { | ||
24 | static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/' | ||
25 | static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/' | ||
26 | |||
27 | // Use a replay subject because we "next" a value before subscribing | ||
28 | private videoExistsInPlaylistSubject: Subject<number> = new ReplaySubject(1) | ||
29 | private readonly videoExistsInPlaylistObservable: Observable<VideoExistInPlaylist> | ||
30 | |||
31 | constructor ( | ||
32 | private authHttp: HttpClient, | ||
33 | private serverService: ServerService, | ||
34 | private restExtractor: RestExtractor, | ||
35 | private restService: RestService | ||
36 | ) { | ||
37 | this.videoExistsInPlaylistObservable = this.videoExistsInPlaylistSubject.pipe( | ||
38 | bufferTime(500), | ||
39 | filter(videoIds => videoIds.length !== 0), | ||
40 | switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)), | ||
41 | share() | ||
42 | ) | ||
43 | } | ||
44 | |||
45 | listChannelPlaylists (videoChannel: VideoChannel): Observable<ResultList<VideoPlaylist>> { | ||
46 | const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' | ||
47 | |||
48 | return this.authHttp.get<ResultList<VideoPlaylist>>(url) | ||
49 | .pipe( | ||
50 | switchMap(res => this.extractPlaylists(res)), | ||
51 | catchError(err => this.restExtractor.handleError(err)) | ||
52 | ) | ||
53 | } | ||
54 | |||
55 | listAccountPlaylists (account: Account, sort: string): Observable<ResultList<VideoPlaylist>> { | ||
56 | const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' | ||
57 | |||
58 | let params = new HttpParams() | ||
59 | params = this.restService.addRestGetParams(params, undefined, sort) | ||
60 | |||
61 | return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params }) | ||
62 | .pipe( | ||
63 | switchMap(res => this.extractPlaylists(res)), | ||
64 | catchError(err => this.restExtractor.handleError(err)) | ||
65 | ) | ||
66 | } | ||
67 | |||
68 | getVideoPlaylist (id: string | number) { | ||
69 | const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id | ||
70 | |||
71 | return this.authHttp.get<VideoPlaylist>(url) | ||
72 | .pipe( | ||
73 | switchMap(res => this.extractPlaylist(res)), | ||
74 | catchError(err => this.restExtractor.handleError(err)) | ||
75 | ) | ||
76 | } | ||
77 | |||
78 | createVideoPlaylist (body: VideoPlaylistCreate) { | ||
79 | const data = objectToFormData(body) | ||
80 | |||
81 | return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data) | ||
82 | .pipe( | ||
83 | catchError(err => this.restExtractor.handleError(err)) | ||
84 | ) | ||
85 | } | ||
86 | |||
87 | updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) { | ||
88 | const data = objectToFormData(body) | ||
89 | |||
90 | return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data) | ||
91 | .pipe( | ||
92 | map(this.restExtractor.extractDataBool), | ||
93 | catchError(err => this.restExtractor.handleError(err)) | ||
94 | ) | ||
95 | } | ||
96 | |||
97 | removeVideoPlaylist (videoPlaylist: VideoPlaylist) { | ||
98 | return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id) | ||
99 | .pipe( | ||
100 | map(this.restExtractor.extractDataBool), | ||
101 | catchError(err => this.restExtractor.handleError(err)) | ||
102 | ) | ||
103 | } | ||
104 | |||
105 | addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) { | ||
106 | return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos', body) | ||
107 | .pipe( | ||
108 | map(this.restExtractor.extractDataBool), | ||
109 | catchError(err => this.restExtractor.handleError(err)) | ||
110 | ) | ||
111 | } | ||
112 | |||
113 | updateVideoOfPlaylist (playlistId: number, videoId: number, body: VideoPlaylistElementUpdate) { | ||
114 | return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId, body) | ||
115 | .pipe( | ||
116 | map(this.restExtractor.extractDataBool), | ||
117 | catchError(err => this.restExtractor.handleError(err)) | ||
118 | ) | ||
119 | } | ||
120 | |||
121 | removeVideoFromPlaylist (playlistId: number, videoId: number) { | ||
122 | return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + videoId) | ||
123 | .pipe( | ||
124 | map(this.restExtractor.extractDataBool), | ||
125 | catchError(err => this.restExtractor.handleError(err)) | ||
126 | ) | ||
127 | } | ||
128 | |||
129 | reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) { | ||
130 | const body: VideoPlaylistReorder = { | ||
131 | startPosition: oldPosition, | ||
132 | insertAfterPosition: newPosition | ||
133 | } | ||
134 | |||
135 | return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body) | ||
136 | .pipe( | ||
137 | map(this.restExtractor.extractDataBool), | ||
138 | catchError(err => this.restExtractor.handleError(err)) | ||
139 | ) | ||
140 | } | ||
141 | |||
142 | doesVideoExistInPlaylist (videoId: number) { | ||
143 | this.videoExistsInPlaylistSubject.next(videoId) | ||
144 | |||
145 | return this.videoExistsInPlaylistObservable.pipe(first()) | ||
146 | } | ||
147 | |||
148 | extractPlaylists (result: ResultList<VideoPlaylistServerModel>) { | ||
149 | return this.serverService.localeObservable | ||
150 | .pipe( | ||
151 | map(translations => { | ||
152 | const playlistsJSON = result.data | ||
153 | const total = result.total | ||
154 | const playlists: VideoPlaylist[] = [] | ||
155 | |||
156 | for (const playlistJSON of playlistsJSON) { | ||
157 | playlists.push(new VideoPlaylist(playlistJSON, translations)) | ||
158 | } | ||
159 | |||
160 | return { data: playlists, total } | ||
161 | }) | ||
162 | ) | ||
163 | } | ||
164 | |||
165 | extractPlaylist (playlist: VideoPlaylistServerModel) { | ||
166 | return this.serverService.localeObservable | ||
167 | .pipe(map(translations => new VideoPlaylist(playlist, translations))) | ||
168 | } | ||
169 | |||
170 | private doVideosExistInPlaylist (videoIds: number[]): Observable<VideoExistInPlaylist> { | ||
171 | const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist' | ||
172 | let params = new HttpParams() | ||
173 | |||
174 | params = this.restService.addObjectParams(params, { videoIds }) | ||
175 | |||
176 | return this.authHttp.get<VideoExistInPlaylist>(url, { params }) | ||
177 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
178 | } | ||
179 | } | ||
diff --git a/client/src/app/shared/video/abstract-video-list.html b/client/src/app/shared/video/abstract-video-list.html index 1f97bc389..268677977 100644 --- a/client/src/app/shared/video/abstract-video-list.html +++ b/client/src/app/shared/video/abstract-video-list.html | |||
@@ -1,4 +1,4 @@ | |||
1 | <div [ngClass]="{ 'margin-content': marginContent }"> | 1 | <div class="margin-content"> |
2 | <div class="videos-header"> | 2 | <div class="videos-header"> |
3 | <div *ngIf="titlePage" class="title-page title-page-single"> | 3 | <div *ngIf="titlePage" class="title-page title-page-single"> |
4 | <div placement="bottom" [ngbTooltip]="titleTooltip" container="body"> | 4 | <div placement="bottom" [ngbTooltip]="titleTooltip" container="body"> |
@@ -11,7 +11,7 @@ | |||
11 | <div class="moderation-block" *ngIf="displayModerationBlock"> | 11 | <div class="moderation-block" *ngIf="displayModerationBlock"> |
12 | <my-peertube-checkbox | 12 | <my-peertube-checkbox |
13 | (change)="toggleModerationDisplay()" | 13 | (change)="toggleModerationDisplay()" |
14 | inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos" | 14 | inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos" |
15 | > | 15 | > |
16 | </my-peertube-checkbox> | 16 | </my-peertube-checkbox> |
17 | </div> | 17 | </div> |
@@ -19,13 +19,14 @@ | |||
19 | 19 | ||
20 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> | 20 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> |
21 | <div | 21 | <div |
22 | myInfiniteScroller | 22 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" |
23 | [pageHeight]="pageHeight" [firstLoadedPage]="firstLoadedPage" | 23 | class="videos" |
24 | (nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)" | ||
25 | class="videos" #videosElement | ||
26 | > | 24 | > |
27 | <div *ngFor="let videos of videoPages; trackBy: pageByVideoId" class="videos-page"> | 25 | <my-video-miniature |
28 | <my-video-miniature *ngFor="let video of videos; trackBy: videoById" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType"></my-video-miniature> | 26 | *ngFor="let video of videos; trackBy: videoById" [video]="video" [user]="user" [ownerDisplayType]="ownerDisplayType" |
29 | </div> | 27 | [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions" |
28 | (videoBlacklisted)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)" | ||
29 | > | ||
30 | </my-video-miniature> | ||
30 | </div> | 31 | </div> |
31 | </div> | 32 | </div> |
diff --git a/client/src/app/shared/video/abstract-video-list.scss b/client/src/app/shared/video/abstract-video-list.scss index 292ede698..9d481d6e4 100644 --- a/client/src/app/shared/video/abstract-video-list.scss +++ b/client/src/app/shared/video/abstract-video-list.scss | |||
@@ -1,12 +1,5 @@ | |||
1 | @import '_mixins'; | 1 | @import '_mixins'; |
2 | 2 | @import '_miniature'; | |
3 | .videos { | ||
4 | text-align: center; | ||
5 | |||
6 | my-video-miniature { | ||
7 | text-align: left; | ||
8 | } | ||
9 | } | ||
10 | 3 | ||
11 | .videos-header { | 4 | .videos-header { |
12 | display: flex; | 5 | display: flex; |
@@ -31,8 +24,33 @@ | |||
31 | } | 24 | } |
32 | } | 25 | } |
33 | 26 | ||
34 | @media screen and (max-width: 500px) { | 27 | .margin-content { |
35 | .videos { | 28 | width: $video-miniature-width * 6; |
36 | @include video-miniature-small-screen; | 29 | margin: auto !important; |
30 | |||
31 | @media screen and (max-width: 1800px) { | ||
32 | width: $video-miniature-width * 5; | ||
33 | } | ||
34 | |||
35 | @media screen and (max-width: 1800px - $video-miniature-width) { | ||
36 | width: $video-miniature-width * 4; | ||
37 | } | ||
38 | |||
39 | @media screen and (max-width: 1800px - (2* $video-miniature-width)) { | ||
40 | width: $video-miniature-width * 3; | ||
41 | } | ||
42 | |||
43 | @media screen and (max-width: 1800px - (3* $video-miniature-width)) { | ||
44 | width: $video-miniature-width * 2; | ||
45 | } | ||
46 | |||
47 | @media screen and (max-width: 500px) { | ||
48 | width: auto; | ||
49 | margin: 0 !important; | ||
50 | |||
51 | .videos { | ||
52 | @include video-miniature-small-screen; | ||
53 | } | ||
37 | } | 54 | } |
38 | } | 55 | } |
56 | |||
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts index b0633be4a..fa9d38735 100644 --- a/client/src/app/shared/video/abstract-video-list.ts +++ b/client/src/app/shared/video/abstract-video-list.ts | |||
@@ -1,66 +1,62 @@ | |||
1 | import { debounceTime } from 'rxjs/operators' | 1 | import { debounceTime } from 'rxjs/operators' |
2 | import { ElementRef, OnDestroy, OnInit, ViewChild } 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 { Location } from '@angular/common' | ||
5 | import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive' | ||
6 | import { fromEvent, Observable, Subscription } from 'rxjs' | 4 | import { fromEvent, Observable, Subscription } from 'rxjs' |
7 | import { AuthService } from '../../core/auth' | 5 | import { AuthService } from '../../core/auth' |
8 | import { ComponentPagination } from '../rest/component-pagination.model' | 6 | import { ComponentPagination } from '../rest/component-pagination.model' |
9 | import { VideoSortField } from './sort-field.type' | 7 | import { VideoSortField } from './sort-field.type' |
10 | import { Video } from './video.model' | 8 | import { Video } from './video.model' |
11 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
12 | import { ScreenService } from '@app/shared/misc/screen.service' | 9 | import { ScreenService } from '@app/shared/misc/screen.service' |
13 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' | 10 | import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component' |
14 | import { Syndication } from '@app/shared/video/syndication.model' | 11 | import { Syndication } from '@app/shared/video/syndication.model' |
15 | import { Notifier } from '@app/core' | 12 | import { Notifier, ServerService } from '@app/core' |
16 | 13 | import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | |
17 | export abstract class AbstractVideoList implements OnInit, OnDestroy { | ||
18 | private static LINES_PER_PAGE = 4 | ||
19 | |||
20 | @ViewChild('videosElement') videosElement: ElementRef | ||
21 | @ViewChild(InfiniteScrollerDirective) infiniteScroller: InfiniteScrollerDirective | ||
22 | 14 | ||
15 | export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook { | ||
23 | pagination: ComponentPagination = { | 16 | pagination: ComponentPagination = { |
24 | currentPage: 1, | 17 | currentPage: 1, |
25 | itemsPerPage: 10, | 18 | itemsPerPage: 25, |
26 | totalItems: null | 19 | totalItems: null |
27 | } | 20 | } |
28 | sort: VideoSortField = '-publishedAt' | 21 | sort: VideoSortField = '-publishedAt' |
22 | |||
29 | categoryOneOf?: number | 23 | categoryOneOf?: number |
30 | defaultSort: VideoSortField = '-publishedAt' | 24 | defaultSort: VideoSortField = '-publishedAt' |
25 | |||
31 | syndicationItems: Syndication[] = [] | 26 | syndicationItems: Syndication[] = [] |
32 | 27 | ||
33 | loadOnInit = true | 28 | loadOnInit = true |
34 | marginContent = true | 29 | videos: Video[] = [] |
35 | pageHeight: number | ||
36 | videoWidth: number | ||
37 | videoHeight: number | ||
38 | videoPages: Video[][] = [] | ||
39 | ownerDisplayType: OwnerDisplayType = 'account' | 30 | ownerDisplayType: OwnerDisplayType = 'account' |
40 | firstLoadedPage: number | ||
41 | displayModerationBlock = false | 31 | displayModerationBlock = false |
42 | titleTooltip: string | 32 | titleTooltip: string |
33 | displayVideoActions = true | ||
43 | 34 | ||
44 | protected baseVideoWidth = 215 | 35 | disabled = false |
45 | protected baseVideoHeight = 205 | 36 | |
37 | displayOptions: MiniatureDisplayOptions = { | ||
38 | date: true, | ||
39 | views: true, | ||
40 | by: true, | ||
41 | privacyLabel: true, | ||
42 | privacyText: false, | ||
43 | state: false, | ||
44 | blacklistInfo: false | ||
45 | } | ||
46 | 46 | ||
47 | protected abstract notifier: Notifier | 47 | protected abstract notifier: Notifier |
48 | protected abstract authService: AuthService | 48 | protected abstract authService: AuthService |
49 | protected abstract router: Router | ||
50 | protected abstract route: ActivatedRoute | 49 | protected abstract route: ActivatedRoute |
50 | protected abstract serverService: ServerService | ||
51 | protected abstract screenService: ScreenService | 51 | protected abstract screenService: ScreenService |
52 | protected abstract i18n: I18n | 52 | protected abstract router: Router |
53 | protected abstract location: Location | ||
54 | protected abstract currentRoute: string | ||
55 | abstract titlePage: string | 53 | abstract titlePage: string |
56 | 54 | ||
57 | protected loadedPages: { [ id: number ]: Video[] } = {} | ||
58 | protected loadingPage: { [ id: number ]: boolean } = {} | ||
59 | protected otherRouteParams = {} | ||
60 | |||
61 | private resizeSubscription: Subscription | 55 | private resizeSubscription: Subscription |
56 | private angularState: number | ||
57 | |||
58 | abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number }> | ||
62 | 59 | ||
63 | abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}> | ||
64 | abstract generateSyndicationList (): void | 60 | abstract generateSyndicationList (): void |
65 | 61 | ||
66 | get user () { | 62 | get user () { |
@@ -77,207 +73,96 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy { | |||
77 | .subscribe(() => this.calcPageSizes()) | 73 | .subscribe(() => this.calcPageSizes()) |
78 | 74 | ||
79 | this.calcPageSizes() | 75 | this.calcPageSizes() |
80 | if (this.loadOnInit === true) this.loadMoreVideos(this.pagination.currentPage) | 76 | if (this.loadOnInit === true) this.loadMoreVideos() |
81 | } | 77 | } |
82 | 78 | ||
83 | ngOnDestroy () { | 79 | ngOnDestroy () { |
84 | if (this.resizeSubscription) this.resizeSubscription.unsubscribe() | 80 | if (this.resizeSubscription) this.resizeSubscription.unsubscribe() |
85 | } | 81 | } |
86 | 82 | ||
87 | pageByVideoId (index: number, page: Video[]) { | 83 | disableForReuse () { |
88 | // Video are unique in all pages | 84 | this.disabled = true |
89 | return page.length !== 0 ? page[0].id : 0 | ||
90 | } | 85 | } |
91 | 86 | ||
92 | videoById (index: number, video: Video) { | 87 | enabledForReuse () { |
93 | return video.id | 88 | this.disabled = false |
94 | } | 89 | } |
95 | 90 | ||
96 | onNearOfTop () { | 91 | videoById (index: number, video: Video) { |
97 | this.previousPage() | 92 | return video.id |
98 | } | 93 | } |
99 | 94 | ||
100 | onNearOfBottom () { | 95 | onNearOfBottom () { |
101 | if (this.hasMoreVideos()) { | 96 | if (this.disabled) return |
102 | this.nextPage() | ||
103 | } | ||
104 | } | ||
105 | |||
106 | onPageChanged (page: number) { | ||
107 | this.pagination.currentPage = page | ||
108 | this.setNewRouteParams() | ||
109 | } | ||
110 | 97 | ||
111 | reloadVideos () { | 98 | // Last page |
112 | this.loadedPages = {} | 99 | if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return |
113 | this.loadMoreVideos(this.pagination.currentPage) | ||
114 | } | ||
115 | 100 | ||
116 | loadMoreVideos (page: number, loadOnTop = false) { | 101 | this.pagination.currentPage += 1 |
117 | this.adjustVideoPageHeight() | ||
118 | 102 | ||
119 | const currentY = window.scrollY | 103 | this.setScrollRouteParams() |
120 | 104 | ||
121 | if (this.loadedPages[page] !== undefined) return | 105 | this.loadMoreVideos() |
122 | if (this.loadingPage[page] === true) return | 106 | } |
123 | 107 | ||
124 | this.loadingPage[page] = true | 108 | loadMoreVideos () { |
125 | const observable = this.getVideosObservable(page) | 109 | const observable = this.getVideosObservable(this.pagination.currentPage) |
126 | 110 | ||
127 | observable.subscribe( | 111 | observable.subscribe( |
128 | ({ videos, totalVideos }) => { | 112 | ({ videos, totalVideos }) => { |
129 | this.loadingPage[page] = false | ||
130 | |||
131 | if (this.firstLoadedPage === undefined || this.firstLoadedPage > page) this.firstLoadedPage = page | ||
132 | |||
133 | // Paging is too high, return to the first one | ||
134 | if (this.pagination.currentPage > 1 && totalVideos <= ((this.pagination.currentPage - 1) * this.pagination.itemsPerPage)) { | ||
135 | this.pagination.currentPage = 1 | ||
136 | this.setNewRouteParams() | ||
137 | return this.reloadVideos() | ||
138 | } | ||
139 | |||
140 | this.loadedPages[page] = videos | ||
141 | this.buildVideoPages() | ||
142 | this.pagination.totalItems = totalVideos | 113 | this.pagination.totalItems = totalVideos |
114 | this.videos = this.videos.concat(videos) | ||
143 | 115 | ||
144 | // Initialize infinite scroller now we loaded the first page | 116 | this.onMoreVideos() |
145 | if (Object.keys(this.loadedPages).length === 1) { | ||
146 | // Wait elements creation | ||
147 | setTimeout(() => { | ||
148 | this.infiniteScroller.initialize() | ||
149 | |||
150 | // At our first load, we did not load the first page | ||
151 | // Load the previous page so the user can move on the top (and browser previous pages) | ||
152 | if (this.pagination.currentPage > 1) this.loadMoreVideos(this.pagination.currentPage - 1, true) | ||
153 | }, 500) | ||
154 | } | ||
155 | |||
156 | // Insert elements on the top but keep the scroll in the previous position | ||
157 | if (loadOnTop) setTimeout(() => { window.scrollTo(0, currentY + this.pageHeight) }, 0) | ||
158 | }, | 117 | }, |
159 | error => { | ||
160 | this.loadingPage[page] = false | ||
161 | this.notifier.error(error.message) | ||
162 | } | ||
163 | ) | ||
164 | } | ||
165 | 118 | ||
166 | toggleModerationDisplay () { | 119 | error => this.notifier.error(error.message) |
167 | throw new Error('toggleModerationDisplay is not implemented') | 120 | ) |
168 | } | 121 | } |
169 | 122 | ||
170 | protected hasMoreVideos () { | 123 | reloadVideos () { |
171 | // No results | 124 | this.pagination.currentPage = 1 |
172 | if (this.pagination.totalItems === 0) return false | 125 | this.videos = [] |
173 | 126 | this.loadMoreVideos() | |
174 | // Not loaded yet | ||
175 | if (!this.pagination.totalItems) return true | ||
176 | |||
177 | const maxPage = this.pagination.totalItems / this.pagination.itemsPerPage | ||
178 | return maxPage > this.maxPageLoaded() | ||
179 | } | 127 | } |
180 | 128 | ||
181 | protected previousPage () { | 129 | toggleModerationDisplay () { |
182 | const min = this.minPageLoaded() | 130 | throw new Error('toggleModerationDisplay is not implemented') |
183 | |||
184 | if (min > 1) { | ||
185 | this.loadMoreVideos(min - 1, true) | ||
186 | } | ||
187 | } | 131 | } |
188 | 132 | ||
189 | protected nextPage () { | 133 | removeVideoFromArray (video: Video) { |
190 | this.loadMoreVideos(this.maxPageLoaded() + 1) | 134 | this.videos = this.videos.filter(v => v.id !== video.id) |
191 | } | 135 | } |
192 | 136 | ||
193 | protected buildRouteParams () { | 137 | // On videos hook for children that want to do something |
194 | // There is always a sort and a current page | 138 | protected onMoreVideos () { /* empty */ } |
195 | const params = { | ||
196 | sort: this.sort, | ||
197 | page: this.pagination.currentPage | ||
198 | } | ||
199 | |||
200 | return Object.assign(params, this.otherRouteParams) | ||
201 | } | ||
202 | 139 | ||
203 | protected loadRouteParams (routeParams: { [ key: string ]: any }) { | 140 | protected loadRouteParams (routeParams: { [ key: string ]: any }) { |
204 | this.sort = routeParams['sort'] as VideoSortField || this.defaultSort | 141 | this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort |
205 | this.categoryOneOf = routeParams['categoryOneOf'] | 142 | this.categoryOneOf = routeParams[ 'categoryOneOf' ] |
206 | if (routeParams['page'] !== undefined) { | 143 | this.angularState = routeParams[ 'a-state' ] |
207 | this.pagination.currentPage = parseInt(routeParams['page'], 10) | ||
208 | } else { | ||
209 | this.pagination.currentPage = 1 | ||
210 | } | ||
211 | } | ||
212 | |||
213 | protected setNewRouteParams () { | ||
214 | const paramsObject = this.buildRouteParams() | ||
215 | |||
216 | const queryParams = Object.keys(paramsObject) | ||
217 | .map(p => p + '=' + paramsObject[p]) | ||
218 | .join('&') | ||
219 | this.location.replaceState(this.currentRoute, queryParams) | ||
220 | } | ||
221 | |||
222 | protected buildVideoPages () { | ||
223 | this.videoPages = Object.values(this.loadedPages) | ||
224 | } | ||
225 | |||
226 | protected adjustVideoPageHeight () { | ||
227 | const numberOfPagesLoaded = Object.keys(this.loadedPages).length | ||
228 | if (!numberOfPagesLoaded) return | ||
229 | |||
230 | this.pageHeight = this.videosElement.nativeElement.offsetHeight / numberOfPagesLoaded | ||
231 | } | ||
232 | |||
233 | protected buildVideoHeight () { | ||
234 | // Same ratios than base width/height | ||
235 | return this.videosElement.nativeElement.offsetWidth * (this.baseVideoHeight / this.baseVideoWidth) | ||
236 | } | ||
237 | |||
238 | private minPageLoaded () { | ||
239 | return Math.min(...Object.keys(this.loadedPages).map(e => parseInt(e, 10))) | ||
240 | } | ||
241 | |||
242 | private maxPageLoaded () { | ||
243 | return Math.max(...Object.keys(this.loadedPages).map(e => parseInt(e, 10))) | ||
244 | } | 144 | } |
245 | 145 | ||
246 | private calcPageSizes () { | 146 | private calcPageSizes () { |
247 | if (this.screenService.isInMobileView() || this.baseVideoWidth === -1) { | 147 | if (this.screenService.isInMobileView()) { |
248 | this.pagination.itemsPerPage = 5 | 148 | this.pagination.itemsPerPage = 5 |
249 | |||
250 | // Video takes all the width | ||
251 | this.videoWidth = -1 | ||
252 | this.videoHeight = this.buildVideoHeight() | ||
253 | this.pageHeight = this.pagination.itemsPerPage * this.videoHeight | ||
254 | } else { | ||
255 | this.videoWidth = this.baseVideoWidth | ||
256 | this.videoHeight = this.baseVideoHeight | ||
257 | |||
258 | const videosWidth = this.videosElement.nativeElement.offsetWidth | ||
259 | this.pagination.itemsPerPage = Math.floor(videosWidth / this.videoWidth) * AbstractVideoList.LINES_PER_PAGE | ||
260 | this.pageHeight = this.videoHeight * AbstractVideoList.LINES_PER_PAGE | ||
261 | } | 149 | } |
150 | } | ||
262 | 151 | ||
263 | // Rebuild pages because maybe we modified the number of items per page | 152 | private setScrollRouteParams () { |
264 | const videos = [].concat(...this.videoPages) | 153 | // Already set |
265 | this.loadedPages = {} | 154 | if (this.angularState) return |
266 | 155 | ||
267 | let i = 1 | 156 | this.angularState = 42 |
268 | // Don't include the last page if it not complete | ||
269 | while (videos.length >= this.pagination.itemsPerPage && i < 10000) { // 10000 -> Hard limit in case of infinite loop | ||
270 | this.loadedPages[i] = videos.splice(0, this.pagination.itemsPerPage) | ||
271 | i++ | ||
272 | } | ||
273 | 157 | ||
274 | // Re fetch the last page | 158 | const queryParams = { |
275 | if (videos.length !== 0) { | 159 | 'a-state': this.angularState, |
276 | this.loadMoreVideos(i) | 160 | categoryOneOf: this.categoryOneOf |
277 | } else { | ||
278 | this.buildVideoPages() | ||
279 | } | 161 | } |
280 | 162 | ||
281 | console.log('Rebuilt pages with %s elements per page.', this.pagination.itemsPerPage) | 163 | let path = this.router.url |
164 | if (!path || path === '/') path = this.serverService.getConfig().instance.defaultClientRoute | ||
165 | |||
166 | this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) | ||
282 | } | 167 | } |
283 | } | 168 | } |
diff --git a/client/src/app/shared/video/infinite-scroller.directive.ts b/client/src/app/shared/video/infinite-scroller.directive.ts index a02e9444a..5f8a1dd6e 100644 --- a/client/src/app/shared/video/infinite-scroller.directive.ts +++ b/client/src/app/shared/video/infinite-scroller.directive.ts | |||
@@ -1,30 +1,23 @@ | |||
1 | import { distinct, distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' | 1 | import { distinct, distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' |
2 | import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | 2 | import { Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' |
3 | import { fromEvent, Subscription } from 'rxjs' | 3 | import { fromEvent, Subscription } from 'rxjs' |
4 | 4 | ||
5 | @Directive({ | 5 | @Directive({ |
6 | selector: '[myInfiniteScroller]' | 6 | selector: '[myInfiniteScroller]' |
7 | }) | 7 | }) |
8 | export class InfiniteScrollerDirective implements OnInit, OnDestroy { | 8 | export class InfiniteScrollerDirective implements OnInit, OnDestroy { |
9 | @Input() containerHeight: number | ||
10 | @Input() pageHeight: number | ||
11 | @Input() firstLoadedPage = 1 | ||
12 | @Input() percentLimit = 70 | 9 | @Input() percentLimit = 70 |
13 | @Input() autoInit = false | 10 | @Input() autoInit = false |
11 | @Input() onItself = false | ||
14 | 12 | ||
15 | @Output() nearOfBottom = new EventEmitter<void>() | 13 | @Output() nearOfBottom = new EventEmitter<void>() |
16 | @Output() nearOfTop = new EventEmitter<void>() | ||
17 | @Output() pageChanged = new EventEmitter<number>() | ||
18 | 14 | ||
19 | private decimalLimit = 0 | 15 | private decimalLimit = 0 |
20 | private lastCurrentBottom = -1 | 16 | private lastCurrentBottom = -1 |
21 | private lastCurrentTop = 0 | ||
22 | private scrollDownSub: Subscription | 17 | private scrollDownSub: Subscription |
23 | private scrollUpSub: Subscription | 18 | private container: HTMLElement |
24 | private pageChangeSub: Subscription | ||
25 | private middleScreen: number | ||
26 | 19 | ||
27 | constructor () { | 20 | constructor (private el: ElementRef) { |
28 | this.decimalLimit = this.percentLimit / 100 | 21 | this.decimalLimit = this.percentLimit / 100 |
29 | } | 22 | } |
30 | 23 | ||
@@ -34,21 +27,21 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy { | |||
34 | 27 | ||
35 | ngOnDestroy () { | 28 | ngOnDestroy () { |
36 | if (this.scrollDownSub) this.scrollDownSub.unsubscribe() | 29 | if (this.scrollDownSub) this.scrollDownSub.unsubscribe() |
37 | if (this.scrollUpSub) this.scrollUpSub.unsubscribe() | ||
38 | if (this.pageChangeSub) this.pageChangeSub.unsubscribe() | ||
39 | } | 30 | } |
40 | 31 | ||
41 | initialize () { | 32 | initialize () { |
42 | this.middleScreen = window.innerHeight / 2 | 33 | if (this.onItself) { |
34 | this.container = this.el.nativeElement | ||
35 | } | ||
43 | 36 | ||
44 | // Emit the last value | 37 | // Emit the last value |
45 | const throttleOptions = { leading: true, trailing: true } | 38 | const throttleOptions = { leading: true, trailing: true } |
46 | 39 | ||
47 | const scrollObservable = fromEvent(window, 'scroll') | 40 | const scrollObservable = fromEvent(this.container || window, 'scroll') |
48 | .pipe( | 41 | .pipe( |
49 | startWith(null), | 42 | startWith(null), |
50 | throttleTime(200, undefined, throttleOptions), | 43 | throttleTime(200, undefined, throttleOptions), |
51 | map(() => ({ current: window.scrollY, maximumScroll: document.body.clientHeight - window.innerHeight })), | 44 | map(() => this.getScrollInfo()), |
52 | distinctUntilChanged((o1, o2) => o1.current === o2.current), | 45 | distinctUntilChanged((o1, o2) => o1.current === o2.current), |
53 | share() | 46 | share() |
54 | ) | 47 | ) |
@@ -66,39 +59,13 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy { | |||
66 | filter(({ current, maximumScroll }) => maximumScroll <= 0 || (current / maximumScroll) > this.decimalLimit) | 59 | filter(({ current, maximumScroll }) => maximumScroll <= 0 || (current / maximumScroll) > this.decimalLimit) |
67 | ) | 60 | ) |
68 | .subscribe(() => this.nearOfBottom.emit()) | 61 | .subscribe(() => this.nearOfBottom.emit()) |
69 | |||
70 | // Scroll up | ||
71 | this.scrollUpSub = scrollObservable | ||
72 | .pipe( | ||
73 | // Check we scroll up | ||
74 | filter(({ current }) => { | ||
75 | const res = this.lastCurrentTop > current | ||
76 | |||
77 | this.lastCurrentTop = current | ||
78 | return res | ||
79 | }), | ||
80 | filter(({ current, maximumScroll }) => { | ||
81 | return current !== 0 && (1 - (current / maximumScroll)) > this.decimalLimit | ||
82 | }) | ||
83 | ) | ||
84 | .subscribe(() => this.nearOfTop.emit()) | ||
85 | |||
86 | // Page change | ||
87 | this.pageChangeSub = scrollObservable | ||
88 | .pipe( | ||
89 | distinct(), | ||
90 | map(({ current }) => this.calculateCurrentPage(current)), | ||
91 | distinctUntilChanged() | ||
92 | ) | ||
93 | .subscribe(res => this.pageChanged.emit(res)) | ||
94 | } | 62 | } |
95 | 63 | ||
96 | private calculateCurrentPage (current: number) { | 64 | private getScrollInfo () { |
97 | const scrollY = current + this.middleScreen | 65 | if (this.container) { |
98 | 66 | return { current: this.container.scrollTop, maximumScroll: this.container.scrollHeight } | |
99 | const page = Math.max(1, Math.ceil(scrollY / this.pageHeight)) | 67 | } |
100 | 68 | ||
101 | // Offset page | 69 | return { current: window.scrollY, maximumScroll: document.body.clientHeight - window.innerHeight } |
102 | return page + (this.firstLoadedPage - 1) | ||
103 | } | 70 | } |
104 | } | 71 | } |
diff --git a/client/src/app/videos/+video-watch/modal/video-blacklist.component.html b/client/src/app/shared/video/modals/video-blacklist.component.html index 1a87bdcd4..1a87bdcd4 100644 --- a/client/src/app/videos/+video-watch/modal/video-blacklist.component.html +++ b/client/src/app/shared/video/modals/video-blacklist.component.html | |||
diff --git a/client/src/app/videos/+video-watch/modal/video-blacklist.component.scss b/client/src/app/shared/video/modals/video-blacklist.component.scss index afcdb9a16..afcdb9a16 100644 --- a/client/src/app/videos/+video-watch/modal/video-blacklist.component.scss +++ b/client/src/app/shared/video/modals/video-blacklist.component.scss | |||
diff --git a/client/src/app/videos/+video-watch/modal/video-blacklist.component.ts b/client/src/app/shared/video/modals/video-blacklist.component.ts index 50a7cadd1..4e4e8dc50 100644 --- a/client/src/app/videos/+video-watch/modal/video-blacklist.component.ts +++ b/client/src/app/shared/video/modals/video-blacklist.component.ts | |||
@@ -1,11 +1,12 @@ | |||
1 | import { Component, Input, OnInit, 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 { FormReactive, VideoBlacklistService, VideoBlacklistValidatorsService } from '../../../shared/index' | 3 | import { VideoBlacklistService } from '../../../shared/video-blacklist' |
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | 4 | import { VideoDetails } from '../../../shared/video/video-details.model' |
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 5 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 7 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
9 | import { FormReactive, VideoBlacklistValidatorsService } from '@app/shared/forms' | ||
9 | 10 | ||
10 | @Component({ | 11 | @Component({ |
11 | selector: 'my-video-blacklist', | 12 | selector: 'my-video-blacklist', |
@@ -17,6 +18,8 @@ export class VideoBlacklistComponent extends FormReactive implements OnInit { | |||
17 | 18 | ||
18 | @ViewChild('modal') modal: NgbModal | 19 | @ViewChild('modal') modal: NgbModal |
19 | 20 | ||
21 | @Output() videoBlacklisted = new EventEmitter() | ||
22 | |||
20 | error: string = null | 23 | error: string = null |
21 | 24 | ||
22 | private openedModal: NgbModalRef | 25 | private openedModal: NgbModalRef |
@@ -60,7 +63,11 @@ export class VideoBlacklistComponent extends FormReactive implements OnInit { | |||
60 | () => { | 63 | () => { |
61 | this.notifier.success(this.i18n('Video blacklisted.')) | 64 | this.notifier.success(this.i18n('Video blacklisted.')) |
62 | this.hide() | 65 | this.hide() |
63 | this.redirectService.redirectToHomepage() | 66 | |
67 | this.video.blacklisted = true | ||
68 | this.video.blacklistedReason = reason | ||
69 | |||
70 | this.videoBlacklisted.emit() | ||
64 | }, | 71 | }, |
65 | 72 | ||
66 | err => this.notifier.error(err.message) | 73 | err => this.notifier.error(err.message) |
diff --git a/client/src/app/videos/+video-watch/modal/video-download.component.html b/client/src/app/shared/video/modals/video-download.component.html index 2bb5d6d37..dd01c1388 100644 --- a/client/src/app/videos/+video-watch/modal/video-download.component.html +++ b/client/src/app/shared/video/modals/video-download.component.html | |||
@@ -9,7 +9,7 @@ | |||
9 | <div class="input-group input-group-sm"> | 9 | <div class="input-group input-group-sm"> |
10 | <div class="input-group-prepend peertube-select-container"> | 10 | <div class="input-group-prepend peertube-select-container"> |
11 | <select [(ngModel)]="resolutionId"> | 11 | <select [(ngModel)]="resolutionId"> |
12 | <option *ngFor="let file of video.files" [value]="file.resolution.id">{{ file.resolution.label }}</option> | 12 | <option *ngFor="let file of video?.files" [value]="file.resolution.id">{{ file.resolution.label }}</option> |
13 | </select> | 13 | </select> |
14 | </div> | 14 | </div> |
15 | <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" /> | 15 | <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" /> |
diff --git a/client/src/app/videos/+video-watch/modal/video-download.component.scss b/client/src/app/shared/video/modals/video-download.component.scss index 3e826c3b6..3e826c3b6 100644 --- a/client/src/app/videos/+video-watch/modal/video-download.component.scss +++ b/client/src/app/shared/video/modals/video-download.component.scss | |||
diff --git a/client/src/app/videos/+video-watch/modal/video-download.component.ts b/client/src/app/shared/video/modals/video-download.component.ts index 834385771..d6d10d29e 100644 --- a/client/src/app/videos/+video-watch/modal/video-download.component.ts +++ b/client/src/app/shared/video/modals/video-download.component.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, ViewChild } from '@angular/core' |
2 | import { VideoDetails } from '../../../shared/video/video-details.model' | 2 | import { VideoDetails } from '../../../shared/video/video-details.model' |
3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 3 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
4 | import { I18n } from '@ngx-translate/i18n-polyfill' | 4 | import { I18n } from '@ngx-translate/i18n-polyfill' |
@@ -9,26 +9,32 @@ import { Notifier } from '@app/core' | |||
9 | templateUrl: './video-download.component.html', | 9 | templateUrl: './video-download.component.html', |
10 | styleUrls: [ './video-download.component.scss' ] | 10 | styleUrls: [ './video-download.component.scss' ] |
11 | }) | 11 | }) |
12 | export class VideoDownloadComponent implements OnInit { | 12 | export class VideoDownloadComponent { |
13 | @Input() video: VideoDetails = null | ||
14 | |||
15 | @ViewChild('modal') modal: ElementRef | 13 | @ViewChild('modal') modal: ElementRef |
16 | 14 | ||
17 | downloadType: 'direct' | 'torrent' | 'magnet' = 'torrent' | 15 | downloadType: 'direct' | 'torrent' | 'magnet' = 'torrent' |
18 | resolutionId: number | string = -1 | 16 | resolutionId: number | string = -1 |
19 | 17 | ||
18 | video: VideoDetails | ||
19 | |||
20 | constructor ( | 20 | constructor ( |
21 | private notifier: Notifier, | 21 | private notifier: Notifier, |
22 | private modalService: NgbModal, | 22 | private modalService: NgbModal, |
23 | private i18n: I18n | 23 | private i18n: I18n |
24 | ) { } | 24 | ) { } |
25 | 25 | ||
26 | ngOnInit () { | 26 | show (video: VideoDetails) { |
27 | this.video = video | ||
28 | |||
29 | const m = this.modalService.open(this.modal) | ||
30 | m.result.then(() => this.onClose()) | ||
31 | .catch(() => this.onClose()) | ||
32 | |||
27 | this.resolutionId = this.video.files[0].resolution.id | 33 | this.resolutionId = this.video.files[0].resolution.id |
28 | } | 34 | } |
29 | 35 | ||
30 | show () { | 36 | onClose () { |
31 | this.modalService.open(this.modal) | 37 | this.video = undefined |
32 | } | 38 | } |
33 | 39 | ||
34 | download () { | 40 | download () { |
@@ -45,21 +51,16 @@ export class VideoDownloadComponent implements OnInit { | |||
45 | return | 51 | return |
46 | } | 52 | } |
47 | 53 | ||
48 | const link = (() => { | 54 | switch (this.downloadType) { |
49 | switch (this.downloadType) { | 55 | case 'direct': |
50 | case 'direct': { | 56 | return file.fileDownloadUrl |
51 | return file.fileDownloadUrl | 57 | |
52 | } | 58 | case 'torrent': |
53 | case 'torrent': { | 59 | return file.torrentDownloadUrl |
54 | return file.torrentDownloadUrl | ||
55 | } | ||
56 | case 'magnet': { | ||
57 | return file.magnetUri | ||
58 | } | ||
59 | } | ||
60 | })() | ||
61 | 60 | ||
62 | return link | 61 | case 'magnet': |
62 | return file.magnetUri | ||
63 | } | ||
63 | } | 64 | } |
64 | 65 | ||
65 | activateCopiedMessage () { | 66 | activateCopiedMessage () { |
diff --git a/client/src/app/videos/+video-watch/modal/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html index b9434da26..b9434da26 100644 --- a/client/src/app/videos/+video-watch/modal/video-report.component.html +++ b/client/src/app/shared/video/modals/video-report.component.html | |||
diff --git a/client/src/app/videos/+video-watch/modal/video-report.component.scss b/client/src/app/shared/video/modals/video-report.component.scss index 4713660a2..4713660a2 100644 --- a/client/src/app/videos/+video-watch/modal/video-report.component.scss +++ b/client/src/app/shared/video/modals/video-report.component.scss | |||
diff --git a/client/src/app/videos/+video-watch/modal/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts index 911f3b447..725dd020f 100644 --- a/client/src/app/videos/+video-watch/modal/video-report.component.ts +++ b/client/src/app/shared/video/modals/video-report.component.ts | |||
@@ -1,12 +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, VideoAbuseService } from '../../../shared/index' | 3 | import { FormReactive } from '../../../shared/forms' |
4 | import { VideoDetails } from '../../../shared/video/video-details.model' | 4 | import { VideoDetails } from '../../../shared/video/video-details.model' |
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | 5 | import { I18n } from '@ngx-translate/i18n-polyfill' |
6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' | 6 | import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' |
7 | import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' | 7 | import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service' |
8 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | 8 | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' |
9 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 9 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
10 | import { VideoAbuseService } from '@app/shared/video-abuse' | ||
10 | 11 | ||
11 | @Component({ | 12 | @Component({ |
12 | selector: 'my-video-report', | 13 | selector: 'my-video-report', |
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.html b/client/src/app/shared/video/video-actions-dropdown.component.html new file mode 100644 index 000000000..ec03fa55d --- /dev/null +++ b/client/src/app/shared/video/video-actions-dropdown.component.html | |||
@@ -0,0 +1,21 @@ | |||
1 | <ng-container *ngIf="videoActions.length !== 0"> | ||
2 | |||
3 | <div class="playlist-dropdown" ngbDropdown #playlistDropdown="ngbDropdown" role="button" autoClose="outside" [placement]="getPlaylistDropdownPlacement()" | ||
4 | *ngIf="isUserLoggedIn() && displayOptions.playlist" (openChange)="playlistAdd.openChange($event)" | ||
5 | > | ||
6 | <span class="anchor" ngbDropdownAnchor></span> | ||
7 | |||
8 | <div ngbDropdownMenu> | ||
9 | <my-video-add-to-playlist #playlistAdd [video]="video" [lazyLoad]="true"></my-video-add-to-playlist> | ||
10 | </div> | ||
11 | </div> | ||
12 | |||
13 | <my-action-dropdown | ||
14 | [actions]="videoActions" [label]="label" [entry]="{ video: video }" (click)="loadDropdownInformation()" | ||
15 | [buttonSize]="buttonSize" [placement]="placement" [buttonDirection]="buttonDirection" [buttonStyled]="buttonStyled" | ||
16 | ></my-action-dropdown> | ||
17 | |||
18 | <my-video-download #videoDownloadModal></my-video-download> | ||
19 | <my-video-report #videoReportModal [video]="video"></my-video-report> | ||
20 | <my-video-blacklist #videoBlacklistModal [video]="video" (videoBlacklisted)="onVideoBlacklisted()"></my-video-blacklist> | ||
21 | </ng-container> | ||
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.scss b/client/src/app/shared/video/video-actions-dropdown.component.scss new file mode 100644 index 000000000..7ffdce822 --- /dev/null +++ b/client/src/app/shared/video/video-actions-dropdown.component.scss | |||
@@ -0,0 +1,12 @@ | |||
1 | .playlist-dropdown { | ||
2 | position: absolute; | ||
3 | |||
4 | .anchor { | ||
5 | display: block; | ||
6 | opacity: 0; | ||
7 | } | ||
8 | } | ||
9 | |||
10 | /deep/ .icon-playlist-add { | ||
11 | left: 2px; | ||
12 | } | ||
diff --git a/client/src/app/shared/video/video-actions-dropdown.component.ts b/client/src/app/shared/video/video-actions-dropdown.component.ts new file mode 100644 index 000000000..ee2f44f9e --- /dev/null +++ b/client/src/app/shared/video/video-actions-dropdown.component.ts | |||
@@ -0,0 +1,241 @@ | |||
1 | import { AfterViewInit, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core' | ||
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
3 | import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component' | ||
4 | import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core' | ||
5 | import { BlocklistService } from '@app/shared/blocklist' | ||
6 | import { Video } from '@app/shared/video/video.model' | ||
7 | import { VideoService } from '@app/shared/video/video.service' | ||
8 | import { VideoDetails } from '@app/shared/video/video-details.model' | ||
9 | import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap' | ||
10 | import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component' | ||
11 | import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component' | ||
12 | import { VideoReportComponent } from '@app/shared/video/modals/video-report.component' | ||
13 | import { VideoBlacklistComponent } from '@app/shared/video/modals/video-blacklist.component' | ||
14 | import { VideoBlacklistService } from '@app/shared/video-blacklist' | ||
15 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
16 | |||
17 | export type VideoActionsDisplayType = { | ||
18 | playlist?: boolean | ||
19 | download?: boolean | ||
20 | update?: boolean | ||
21 | blacklist?: boolean | ||
22 | delete?: boolean | ||
23 | report?: boolean | ||
24 | } | ||
25 | |||
26 | @Component({ | ||
27 | selector: 'my-video-actions-dropdown', | ||
28 | templateUrl: './video-actions-dropdown.component.html', | ||
29 | styleUrls: [ './video-actions-dropdown.component.scss' ] | ||
30 | }) | ||
31 | export class VideoActionsDropdownComponent implements OnChanges { | ||
32 | @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown | ||
33 | @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent | ||
34 | |||
35 | @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent | ||
36 | @ViewChild('videoReportModal') videoReportModal: VideoReportComponent | ||
37 | @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent | ||
38 | |||
39 | @Input() video: Video | VideoDetails | ||
40 | |||
41 | @Input() displayOptions: VideoActionsDisplayType = { | ||
42 | playlist: false, | ||
43 | download: true, | ||
44 | update: true, | ||
45 | blacklist: true, | ||
46 | delete: true, | ||
47 | report: true | ||
48 | } | ||
49 | @Input() placement = 'left' | ||
50 | |||
51 | @Input() label: string | ||
52 | |||
53 | @Input() buttonStyled = false | ||
54 | @Input() buttonSize: DropdownButtonSize = 'normal' | ||
55 | @Input() buttonDirection: DropdownDirection = 'vertical' | ||
56 | |||
57 | @Output() videoRemoved = new EventEmitter() | ||
58 | @Output() videoUnblacklisted = new EventEmitter() | ||
59 | @Output() videoBlacklisted = new EventEmitter() | ||
60 | |||
61 | videoActions: DropdownAction<{ video: Video }>[][] = [] | ||
62 | |||
63 | private loaded = false | ||
64 | |||
65 | constructor ( | ||
66 | private authService: AuthService, | ||
67 | private notifier: Notifier, | ||
68 | private confirmService: ConfirmService, | ||
69 | private videoBlacklistService: VideoBlacklistService, | ||
70 | private serverService: ServerService, | ||
71 | private screenService: ScreenService, | ||
72 | private videoService: VideoService, | ||
73 | private blocklistService: BlocklistService, | ||
74 | private i18n: I18n | ||
75 | ) { } | ||
76 | |||
77 | get user () { | ||
78 | return this.authService.getUser() | ||
79 | } | ||
80 | |||
81 | ngOnChanges () { | ||
82 | this.buildActions() | ||
83 | } | ||
84 | |||
85 | isUserLoggedIn () { | ||
86 | return this.authService.isLoggedIn() | ||
87 | } | ||
88 | |||
89 | loadDropdownInformation () { | ||
90 | if (!this.isUserLoggedIn() || this.loaded === true) return | ||
91 | |||
92 | this.loaded = true | ||
93 | |||
94 | if (this.displayOptions.playlist) this.playlistAdd.load() | ||
95 | } | ||
96 | |||
97 | /* Show modals */ | ||
98 | |||
99 | showDownloadModal () { | ||
100 | this.videoDownloadModal.show(this.video as VideoDetails) | ||
101 | } | ||
102 | |||
103 | showReportModal () { | ||
104 | this.videoReportModal.show() | ||
105 | } | ||
106 | |||
107 | showBlacklistModal () { | ||
108 | this.videoBlacklistModal.show() | ||
109 | } | ||
110 | |||
111 | /* Actions checker */ | ||
112 | |||
113 | isVideoUpdatable () { | ||
114 | return this.video.isUpdatableBy(this.user) | ||
115 | } | ||
116 | |||
117 | isVideoRemovable () { | ||
118 | return this.video.isRemovableBy(this.user) | ||
119 | } | ||
120 | |||
121 | isVideoBlacklistable () { | ||
122 | return this.video.isBlackistableBy(this.user) | ||
123 | } | ||
124 | |||
125 | isVideoUnblacklistable () { | ||
126 | return this.video.isUnblacklistableBy(this.user) | ||
127 | } | ||
128 | |||
129 | isVideoDownloadable () { | ||
130 | return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled | ||
131 | } | ||
132 | |||
133 | /* Action handlers */ | ||
134 | |||
135 | async unblacklistVideo () { | ||
136 | const confirmMessage = this.i18n( | ||
137 | 'Do you really want to remove this video from the blacklist? It will be available again in the videos list.' | ||
138 | ) | ||
139 | |||
140 | const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblacklist')) | ||
141 | if (res === false) return | ||
142 | |||
143 | this.videoBlacklistService.removeVideoFromBlacklist(this.video.id).subscribe( | ||
144 | () => { | ||
145 | this.notifier.success(this.i18n('Video {{name}} removed from the blacklist.', { name: this.video.name })) | ||
146 | |||
147 | this.video.blacklisted = false | ||
148 | this.video.blacklistedReason = null | ||
149 | |||
150 | this.videoUnblacklisted.emit() | ||
151 | }, | ||
152 | |||
153 | err => this.notifier.error(err.message) | ||
154 | ) | ||
155 | } | ||
156 | |||
157 | async removeVideo () { | ||
158 | const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete')) | ||
159 | if (res === false) return | ||
160 | |||
161 | this.videoService.removeVideo(this.video.id) | ||
162 | .subscribe( | ||
163 | () => { | ||
164 | this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name })) | ||
165 | |||
166 | this.videoRemoved.emit() | ||
167 | }, | ||
168 | |||
169 | error => this.notifier.error(error.message) | ||
170 | ) | ||
171 | } | ||
172 | |||
173 | onVideoBlacklisted () { | ||
174 | this.videoBlacklisted.emit() | ||
175 | } | ||
176 | |||
177 | getPlaylistDropdownPlacement () { | ||
178 | if (this.screenService.isInSmallView()) { | ||
179 | return 'bottom-right' | ||
180 | } | ||
181 | |||
182 | return 'bottom-left bottom-right' | ||
183 | } | ||
184 | |||
185 | private buildActions () { | ||
186 | this.videoActions = [] | ||
187 | |||
188 | if (this.authService.isLoggedIn()) { | ||
189 | this.videoActions.push([ | ||
190 | { | ||
191 | label: this.i18n('Save to playlist'), | ||
192 | handler: () => this.playlistDropdown.toggle(), | ||
193 | isDisplayed: () => this.displayOptions.playlist, | ||
194 | iconName: 'playlist-add' | ||
195 | } | ||
196 | ]) | ||
197 | |||
198 | this.videoActions.push([ | ||
199 | { | ||
200 | label: this.i18n('Download'), | ||
201 | handler: () => this.showDownloadModal(), | ||
202 | isDisplayed: () => this.displayOptions.download && this.isVideoDownloadable(), | ||
203 | iconName: 'download' | ||
204 | }, | ||
205 | { | ||
206 | label: this.i18n('Update'), | ||
207 | linkBuilder: ({ video }) => [ '/videos/update', video.uuid ], | ||
208 | iconName: 'edit', | ||
209 | isDisplayed: () => this.displayOptions.update && this.isVideoUpdatable() | ||
210 | }, | ||
211 | { | ||
212 | label: this.i18n('Blacklist'), | ||
213 | handler: () => this.showBlacklistModal(), | ||
214 | iconName: 'no', | ||
215 | isDisplayed: () => this.displayOptions.blacklist && this.isVideoBlacklistable() | ||
216 | }, | ||
217 | { | ||
218 | label: this.i18n('Unblacklist'), | ||
219 | handler: () => this.unblacklistVideo(), | ||
220 | iconName: 'undo', | ||
221 | isDisplayed: () => this.displayOptions.blacklist && this.isVideoUnblacklistable() | ||
222 | }, | ||
223 | { | ||
224 | label: this.i18n('Delete'), | ||
225 | handler: () => this.removeVideo(), | ||
226 | isDisplayed: () => this.displayOptions.delete && this.isVideoRemovable(), | ||
227 | iconName: 'delete' | ||
228 | } | ||
229 | ]) | ||
230 | |||
231 | this.videoActions.push([ | ||
232 | { | ||
233 | label: this.i18n('Report'), | ||
234 | handler: () => this.showReportModal(), | ||
235 | isDisplayed: () => this.displayOptions.report, | ||
236 | iconName: 'alert' | ||
237 | } | ||
238 | ]) | ||
239 | } | ||
240 | } | ||
241 | } | ||
diff --git a/client/src/app/shared/video/video-details.model.ts b/client/src/app/shared/video/video-details.model.ts index fa4ca7f93..22f024656 100644 --- a/client/src/app/shared/video/video-details.model.ts +++ b/client/src/app/shared/video/video-details.model.ts | |||
@@ -3,6 +3,8 @@ import { AuthUser } from '../../core' | |||
3 | import { Video } from '../../shared/video/video.model' | 3 | import { Video } from '../../shared/video/video.model' |
4 | import { Account } from '@app/shared/account/account.model' | 4 | import { Account } from '@app/shared/account/account.model' |
5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' | 5 | import { VideoChannel } from '@app/shared/video-channel/video-channel.model' |
6 | import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model' | ||
7 | import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type' | ||
6 | 8 | ||
7 | export class VideoDetails extends Video implements VideoDetailsServerModel { | 9 | export class VideoDetails extends Video implements VideoDetailsServerModel { |
8 | descriptionPath: string | 10 | descriptionPath: string |
@@ -12,6 +14,7 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { | |||
12 | files: VideoFile[] | 14 | files: VideoFile[] |
13 | account: Account | 15 | account: Account |
14 | commentsEnabled: boolean | 16 | commentsEnabled: boolean |
17 | downloadEnabled: boolean | ||
15 | 18 | ||
16 | waitTranscoding: boolean | 19 | waitTranscoding: boolean |
17 | state: VideoConstant<VideoState> | 20 | state: VideoConstant<VideoState> |
@@ -19,6 +22,10 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { | |||
19 | likesPercent: number | 22 | likesPercent: number |
20 | dislikesPercent: number | 23 | dislikesPercent: number |
21 | 24 | ||
25 | trackerUrls: string[] | ||
26 | |||
27 | streamingPlaylists: VideoStreamingPlaylist[] | ||
28 | |||
22 | constructor (hash: VideoDetailsServerModel, translations = {}) { | 29 | constructor (hash: VideoDetailsServerModel, translations = {}) { |
23 | super(hash, translations) | 30 | super(hash, translations) |
24 | 31 | ||
@@ -29,28 +36,24 @@ export class VideoDetails extends Video implements VideoDetailsServerModel { | |||
29 | this.tags = hash.tags | 36 | this.tags = hash.tags |
30 | this.support = hash.support | 37 | this.support = hash.support |
31 | this.commentsEnabled = hash.commentsEnabled | 38 | this.commentsEnabled = hash.commentsEnabled |
39 | this.downloadEnabled = hash.downloadEnabled | ||
32 | 40 | ||
33 | this.buildLikeAndDislikePercents() | 41 | this.trackerUrls = hash.trackerUrls |
34 | } | 42 | this.streamingPlaylists = hash.streamingPlaylists |
35 | 43 | ||
36 | isRemovableBy (user: AuthUser) { | 44 | this.buildLikeAndDislikePercents() |
37 | return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO)) | ||
38 | } | ||
39 | |||
40 | isBlackistableBy (user: AuthUser) { | ||
41 | return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true | ||
42 | } | 45 | } |
43 | 46 | ||
44 | isUnblacklistableBy (user: AuthUser) { | 47 | buildLikeAndDislikePercents () { |
45 | return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true | 48 | this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100 |
49 | this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100 | ||
46 | } | 50 | } |
47 | 51 | ||
48 | isUpdatableBy (user: AuthUser) { | 52 | getHlsPlaylist () { |
49 | return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) | 53 | return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS) |
50 | } | 54 | } |
51 | 55 | ||
52 | buildLikeAndDislikePercents () { | 56 | hasHlsPlaylist () { |
53 | this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100 | 57 | return !!this.getHlsPlaylist() |
54 | this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100 | ||
55 | } | 58 | } |
56 | } | 59 | } |
diff --git a/client/src/app/shared/video/video-edit.model.ts b/client/src/app/shared/video/video-edit.model.ts index fc772a3cf..1f633d427 100644 --- a/client/src/app/shared/video/video-edit.model.ts +++ b/client/src/app/shared/video/video-edit.model.ts | |||
@@ -14,6 +14,7 @@ export class VideoEdit implements VideoUpdate { | |||
14 | tags: string[] | 14 | tags: string[] |
15 | nsfw: boolean | 15 | nsfw: boolean |
16 | commentsEnabled: boolean | 16 | commentsEnabled: boolean |
17 | downloadEnabled: boolean | ||
17 | waitTranscoding: boolean | 18 | waitTranscoding: boolean |
18 | channelId: number | 19 | channelId: number |
19 | privacy: VideoPrivacy | 20 | privacy: VideoPrivacy |
@@ -25,8 +26,17 @@ export class VideoEdit implements VideoUpdate { | |||
25 | uuid?: string | 26 | uuid?: string |
26 | id?: number | 27 | id?: number |
27 | scheduleUpdate?: VideoScheduleUpdate | 28 | scheduleUpdate?: VideoScheduleUpdate |
29 | originallyPublishedAt?: Date | string | ||
28 | 30 | ||
29 | constructor (video?: Video & { tags: string[], commentsEnabled: boolean, support: string, thumbnailUrl: string, previewUrl: string }) { | 31 | constructor ( |
32 | video?: Video & { | ||
33 | tags: string[], | ||
34 | commentsEnabled: boolean, | ||
35 | downloadEnabled: boolean, | ||
36 | support: string, | ||
37 | thumbnailUrl: string, | ||
38 | previewUrl: string | ||
39 | }) { | ||
30 | if (video) { | 40 | if (video) { |
31 | this.id = video.id | 41 | this.id = video.id |
32 | this.uuid = video.uuid | 42 | this.uuid = video.uuid |
@@ -38,6 +48,7 @@ export class VideoEdit implements VideoUpdate { | |||
38 | this.tags = video.tags | 48 | this.tags = video.tags |
39 | this.nsfw = video.nsfw | 49 | this.nsfw = video.nsfw |
40 | this.commentsEnabled = video.commentsEnabled | 50 | this.commentsEnabled = video.commentsEnabled |
51 | this.downloadEnabled = video.downloadEnabled | ||
41 | this.waitTranscoding = video.waitTranscoding | 52 | this.waitTranscoding = video.waitTranscoding |
42 | this.channelId = video.channel.id | 53 | this.channelId = video.channel.id |
43 | this.privacy = video.privacy.id | 54 | this.privacy = video.privacy.id |
@@ -46,6 +57,7 @@ export class VideoEdit implements VideoUpdate { | |||
46 | this.previewUrl = video.previewUrl | 57 | this.previewUrl = video.previewUrl |
47 | 58 | ||
48 | this.scheduleUpdate = video.scheduledUpdate | 59 | this.scheduleUpdate = video.scheduledUpdate |
60 | this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null | ||
49 | } | 61 | } |
50 | } | 62 | } |
51 | 63 | ||
@@ -67,6 +79,12 @@ export class VideoEdit implements VideoUpdate { | |||
67 | } else { | 79 | } else { |
68 | this.scheduleUpdate = null | 80 | this.scheduleUpdate = null |
69 | } | 81 | } |
82 | |||
83 | // Convert originallyPublishedAt to string so that function objectToFormData() works correctly | ||
84 | if (this.originallyPublishedAt) { | ||
85 | const originallyPublishedAt = new Date(values['originallyPublishedAt']) | ||
86 | this.originallyPublishedAt = originallyPublishedAt.toISOString() | ||
87 | } | ||
70 | } | 88 | } |
71 | 89 | ||
72 | toFormPatch () { | 90 | toFormPatch () { |
@@ -80,9 +98,11 @@ export class VideoEdit implements VideoUpdate { | |||
80 | tags: this.tags, | 98 | tags: this.tags, |
81 | nsfw: this.nsfw, | 99 | nsfw: this.nsfw, |
82 | commentsEnabled: this.commentsEnabled, | 100 | commentsEnabled: this.commentsEnabled, |
101 | downloadEnabled: this.downloadEnabled, | ||
83 | waitTranscoding: this.waitTranscoding, | 102 | waitTranscoding: this.waitTranscoding, |
84 | channelId: this.channelId, | 103 | channelId: this.channelId, |
85 | privacy: this.privacy | 104 | privacy: this.privacy, |
105 | originallyPublishedAt: this.originallyPublishedAt | ||
86 | } | 106 | } |
87 | 107 | ||
88 | // Special case if we scheduled an update | 108 | // Special case if we scheduled an update |
diff --git a/client/src/app/shared/video/video-miniature.component.html b/client/src/app/shared/video/video-miniature.component.html index 2c635fa2f..7af0f1113 100644 --- a/client/src/app/shared/video/video-miniature.component.html +++ b/client/src/app/shared/video/video-miniature.component.html | |||
@@ -1,25 +1,56 @@ | |||
1 | <div class="video-miniature"> | 1 | <div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow }" (mouseenter)="loadActions()"> |
2 | <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur"></my-video-thumbnail> | 2 | <my-video-thumbnail [video]="video" [nsfw]="isVideoBlur"></my-video-thumbnail> |
3 | 3 | ||
4 | <div class="video-miniature-information"> | 4 | <div class="video-bottom"> |
5 | <a | 5 | <div class="video-miniature-information"> |
6 | tabindex="-1" | 6 | <a |
7 | class="video-miniature-name" | 7 | tabindex="-1" |
8 | [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" | 8 | class="video-miniature-name" |
9 | > | 9 | [routerLink]="[ '/videos/watch', video.uuid ]" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }" |
10 | <span *ngIf="isUnlistedVideo()" class="badge badge-warning" i18n>Unlisted</span> | 10 | > |
11 | <span *ngIf="isPrivateVideo()" class="badge badge-danger" i18n>Private</span> | 11 | <ng-container *ngIf="displayOptions.privacyLabel"> |
12 | 12 | <span *ngIf="isUnlistedVideo()" class="badge badge-warning" i18n>Unlisted</span> | |
13 | {{ video.name }} | 13 | <span *ngIf="isPrivateVideo()" class="badge badge-danger" i18n>Private</span> |
14 | </a> | 14 | </ng-container> |
15 | 15 | ||
16 | <span i18n class="video-miniature-created-at-views">{{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span> | 16 | {{ video.name }} |
17 | 17 | </a> | |
18 | <a tabindex="-1" *ngIf="displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]"> | 18 | |
19 | {{ video.byAccount }} | 19 | <span class="video-miniature-created-at-views"> |
20 | </a> | 20 | <ng-container *ngIf="displayOptions.date">{{ video.publishedAt | myFromNow }}</ng-container> |
21 | <a tabindex="-1" *ngIf="displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]"> | 21 | <ng-container *ngIf="displayOptions.date && displayOptions.views"> - </ng-container> |
22 | {{ video.byVideoChannel }} | 22 | <ng-container i18n *ngIf="displayOptions.views">{{ video.views | myNumberFormatter }} views</ng-container> |
23 | </a> | 23 | </span> |
24 | |||
25 | <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]"> | ||
26 | {{ video.byAccount }} | ||
27 | </a> | ||
28 | <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]"> | ||
29 | {{ video.byVideoChannel }} | ||
30 | </a> | ||
31 | |||
32 | <div class="video-info-privacy"> | ||
33 | <ng-container *ngIf="displayOptions.privacyText">{{ video.privacy.label }}</ng-container> | ||
34 | <ng-container *ngIf="displayOptions.privacyText && displayOptions.state"> - </ng-container> | ||
35 | <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container> | ||
36 | </div> | ||
37 | |||
38 | <div *ngIf="displayOptions.blacklistInfo && video.blacklisted" class="video-info-blacklisted"> | ||
39 | <span class="blacklisted-label" i18n>Blacklisted</span> | ||
40 | <span class="blacklisted-reason" *ngIf="video.blacklistedReason">{{ video.blacklistedReason }}</span> | ||
41 | </div> | ||
42 | |||
43 | <div i18n *ngIf="displayOptions.nsfw && video.nsfw" class="video-info-nsfw"> | ||
44 | Sensitive | ||
45 | </div> | ||
46 | </div> | ||
47 | |||
48 | <div class="video-actions"> | ||
49 | <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown --> | ||
50 | <my-video-actions-dropdown | ||
51 | *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left" | ||
52 | (videoRemoved)="onVideoRemoved()" (videoBlacklisted)="onVideoBlacklisted()" (videoUnblacklisted)="onVideoUnblacklisted()" | ||
53 | ></my-video-actions-dropdown> | ||
54 | </div> | ||
24 | </div> | 55 | </div> |
25 | </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 f44bdf9a9..d665ce021 100644 --- a/client/src/app/shared/video/video-miniature.component.scss +++ b/client/src/app/shared/video/video-miniature.component.scss | |||
@@ -1,59 +1,156 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | @import '_miniature'; | ||
4 | |||
5 | $more-button-width: 41px; | ||
6 | $more-margin-right: 10px; | ||
3 | 7 | ||
4 | .video-miniature { | 8 | .video-miniature { |
5 | display: inline-block; | 9 | width: $video-miniature-width; |
6 | padding-right: 15px; | 10 | display: inline-flex; |
11 | flex-direction: column; | ||
7 | margin-bottom: 30px; | 12 | margin-bottom: 30px; |
8 | height: 175px; | 13 | height: 195px; |
9 | vertical-align: top; | 14 | vertical-align: top; |
10 | 15 | ||
11 | .video-miniature-information { | 16 | .video-bottom { |
12 | width: 200px; | 17 | display: flex; |
13 | margin-top: 2px; | 18 | |
14 | line-height: normal; | 19 | .video-miniature-information { |
20 | width: $video-miniature-width - $more-button-width - $more-margin-right; | ||
21 | line-height: normal; | ||
22 | |||
23 | .video-miniature-name { | ||
24 | @include miniature-name; | ||
25 | } | ||
26 | |||
27 | .video-miniature-created-at-views { | ||
28 | display: block; | ||
29 | font-size: 13px; | ||
30 | } | ||
31 | |||
32 | .video-miniature-account, | ||
33 | .video-miniature-channel { | ||
34 | @include disable-default-a-behaviour; | ||
35 | @include ellipsis; | ||
36 | |||
37 | display: block; | ||
38 | font-size: 13px; | ||
39 | color: $grey-foreground-color; | ||
40 | |||
41 | &:hover { | ||
42 | color: $grey-foreground-hover-color; | ||
43 | } | ||
44 | } | ||
45 | |||
46 | .video-info-privacy, | ||
47 | .video-info-blacklisted .blacklisted-label, | ||
48 | .video-info-nsfw { | ||
49 | font-weight: $font-semibold; | ||
50 | } | ||
51 | |||
52 | .video-info-blacklisted { | ||
53 | color: red; | ||
54 | |||
55 | .blacklisted-reason::before { | ||
56 | content: ' - '; | ||
57 | } | ||
58 | } | ||
59 | |||
60 | .video-info-nsfw { | ||
61 | color: red; | ||
62 | } | ||
63 | } | ||
15 | 64 | ||
16 | .video-miniature-name { | 65 | .video-actions { |
17 | @include ellipsis-multiline( | 66 | margin-top: 3px; |
18 | $font-size: 1rem, | 67 | margin-right: $more-margin-right; |
19 | $line-height: 1, | 68 | width: $more-button-width; |
20 | $lines-to-show: 2 | 69 | height: 30px; |
21 | ); | ||
22 | transition: color 0.2s; | ||
23 | font-size: 16px; | ||
24 | font-weight: $font-semibold; | ||
25 | color: var(--mainForegroundColor); | ||
26 | margin-top: 5px; | ||
27 | margin-bottom: 5px; | ||
28 | 70 | ||
29 | &:hover { | 71 | /deep/ .dropdown-root:not(.show) { |
30 | text-decoration: none; | 72 | opacity: 0; |
31 | } | 73 | } |
32 | 74 | ||
33 | &.blur-filter { | 75 | /deep/ .playlist-dropdown.show + my-action-dropdown .dropdown-root { |
34 | filter: blur(3px); | 76 | opacity: 1; |
35 | padding-left: 4px; | ||
36 | } | 77 | } |
37 | } | 78 | } |
38 | 79 | ||
39 | .video-miniature-created-at-views { | 80 | &:hover .video-actions /deep/ .dropdown-root { |
40 | display: block; | 81 | opacity: 1; |
41 | font-size: 13px; | ||
42 | } | 82 | } |
43 | 83 | ||
44 | .video-miniature-account, | 84 | @media screen and (max-width: $small-view) { |
45 | .video-miniature-channel { | 85 | .video-miniature-information .video-miniature-name { |
46 | @include disable-default-a-behaviour; | 86 | margin-top: 0; |
87 | } | ||
88 | |||
89 | .video-actions { | ||
90 | margin: 0; | ||
91 | top: -3px; | ||
47 | 92 | ||
48 | display: block; | 93 | /deep/ .dropdown-root { |
49 | overflow: hidden; | 94 | opacity: 1 !important; |
50 | text-overflow: ellipsis; | 95 | } |
51 | white-space: nowrap; | 96 | } |
52 | font-size: 13px; | 97 | } |
53 | color: $grey-foreground-color; | 98 | } |
99 | |||
100 | &.display-as-row { | ||
101 | flex-direction: row; | ||
102 | margin-bottom: 0; | ||
103 | height: auto; | ||
104 | width: 100%; | ||
105 | |||
106 | my-video-thumbnail { | ||
107 | margin-right: 10px; | ||
108 | } | ||
109 | |||
110 | .video-bottom { | ||
111 | .video-miniature-information { | ||
112 | width: auto; | ||
113 | min-width: 500px; | ||
114 | |||
115 | .video-miniature-name { | ||
116 | @include ellipsis-multiline(1.3em, 2); | ||
117 | |||
118 | margin-top: 2px; | ||
119 | margin-bottom: 5px; | ||
120 | } | ||
121 | |||
122 | .video-miniature-created-at-views, | ||
123 | .video-miniature-account, | ||
124 | .video-miniature-channel { | ||
125 | font-size: 14px; | ||
126 | width: fit-content; | ||
127 | } | ||
128 | |||
129 | .video-info-privacy { | ||
130 | margin-top: 5px; | ||
131 | } | ||
132 | |||
133 | .video-info-blacklisted { | ||
134 | margin-top: 3px; | ||
135 | } | ||
136 | } | ||
137 | |||
138 | .video-actions { | ||
139 | margin: 0; | ||
140 | top: -3px; | ||
141 | } | ||
142 | } | ||
143 | |||
144 | @media screen and (max-width: $small-view) { | ||
145 | flex-direction: column; | ||
146 | height: auto; | ||
147 | |||
148 | my-video-thumbnail { | ||
149 | margin-right: 0; | ||
150 | } | ||
54 | 151 | ||
55 | &:hover { | 152 | .video-miniature-information { |
56 | color: $grey-foreground-hover-color; | 153 | min-width: initial; |
57 | } | 154 | } |
58 | } | 155 | } |
59 | } | 156 | } |
diff --git a/client/src/app/shared/video/video-miniature.component.ts b/client/src/app/shared/video/video-miniature.component.ts index 2f951a1f1..48475033c 100644 --- a/client/src/app/shared/video/video-miniature.component.ts +++ b/client/src/app/shared/video/video-miniature.component.ts | |||
@@ -1,10 +1,23 @@ | |||
1 | import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core' | 1 | import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, LOCALE_ID, OnInit, Output } from '@angular/core' |
2 | import { User } from '../users' | 2 | import { User } from '../users' |
3 | import { Video } from './video.model' | 3 | import { Video } from './video.model' |
4 | import { ServerService } from '@app/core' | 4 | import { ServerService } from '@app/core' |
5 | import { VideoPrivacy } from '../../../../../shared' | 5 | import { VideoPrivacy, VideoState } from '../../../../../shared' |
6 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
7 | import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component' | ||
8 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
6 | 9 | ||
7 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' | 10 | export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' |
11 | export type MiniatureDisplayOptions = { | ||
12 | date?: boolean | ||
13 | views?: boolean | ||
14 | by?: boolean | ||
15 | privacyLabel?: boolean | ||
16 | privacyText?: boolean | ||
17 | state?: boolean | ||
18 | blacklistInfo?: boolean | ||
19 | nsfw?: boolean | ||
20 | } | ||
8 | 21 | ||
9 | @Component({ | 22 | @Component({ |
10 | selector: 'my-video-miniature', | 23 | selector: 'my-video-miniature', |
@@ -15,31 +28,53 @@ export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto' | |||
15 | export class VideoMiniatureComponent implements OnInit { | 28 | export class VideoMiniatureComponent implements OnInit { |
16 | @Input() user: User | 29 | @Input() user: User |
17 | @Input() video: Video | 30 | @Input() video: Video |
31 | |||
18 | @Input() ownerDisplayType: OwnerDisplayType = 'account' | 32 | @Input() ownerDisplayType: OwnerDisplayType = 'account' |
33 | @Input() displayOptions: MiniatureDisplayOptions = { | ||
34 | date: true, | ||
35 | views: true, | ||
36 | by: true, | ||
37 | privacyLabel: false, | ||
38 | privacyText: false, | ||
39 | state: false, | ||
40 | blacklistInfo: false | ||
41 | } | ||
42 | @Input() displayAsRow = false | ||
43 | @Input() displayVideoActions = true | ||
44 | |||
45 | @Output() videoBlacklisted = new EventEmitter() | ||
46 | @Output() videoUnblacklisted = new EventEmitter() | ||
47 | @Output() videoRemoved = new EventEmitter() | ||
48 | |||
49 | videoActionsDisplayOptions: VideoActionsDisplayType = { | ||
50 | playlist: true, | ||
51 | download: false, | ||
52 | update: true, | ||
53 | blacklist: true, | ||
54 | delete: true, | ||
55 | report: true | ||
56 | } | ||
57 | showActions = false | ||
19 | 58 | ||
20 | private ownerDisplayTypeChosen: 'account' | 'videoChannel' | 59 | private ownerDisplayTypeChosen: 'account' | 'videoChannel' |
21 | 60 | ||
22 | constructor (private serverService: ServerService) { } | 61 | constructor ( |
62 | private screenService: ScreenService, | ||
63 | private serverService: ServerService, | ||
64 | private i18n: I18n, | ||
65 | @Inject(LOCALE_ID) private localeId: string | ||
66 | ) { } | ||
23 | 67 | ||
24 | get isVideoBlur () { | 68 | get isVideoBlur () { |
25 | return this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig()) | 69 | return this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig()) |
26 | } | 70 | } |
27 | 71 | ||
28 | ngOnInit () { | 72 | ngOnInit () { |
29 | if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') { | 73 | this.setUpBy() |
30 | this.ownerDisplayTypeChosen = this.ownerDisplayType | ||
31 | return | ||
32 | } | ||
33 | 74 | ||
34 | // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12) | 75 | // We rely on mouseenter to lazy load actions |
35 | // -> Use the account name | 76 | if (this.screenService.isInTouchScreen()) { |
36 | if ( | 77 | this.loadActions() |
37 | this.video.channel.name === `${this.video.account.name}_channel` || | ||
38 | this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) | ||
39 | ) { | ||
40 | this.ownerDisplayTypeChosen = 'account' | ||
41 | } else { | ||
42 | this.ownerDisplayTypeChosen = 'videoChannel' | ||
43 | } | 78 | } |
44 | } | 79 | } |
45 | 80 | ||
@@ -58,4 +93,63 @@ export class VideoMiniatureComponent implements OnInit { | |||
58 | isPrivateVideo () { | 93 | isPrivateVideo () { |
59 | return this.video.privacy.id === VideoPrivacy.PRIVATE | 94 | return this.video.privacy.id === VideoPrivacy.PRIVATE |
60 | } | 95 | } |
96 | |||
97 | getStateLabel (video: Video) { | ||
98 | if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) { | ||
99 | return this.i18n('Published') | ||
100 | } | ||
101 | |||
102 | if (video.scheduledUpdate) { | ||
103 | const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId) | ||
104 | return this.i18n('Publication scheduled on ') + updateAt | ||
105 | } | ||
106 | |||
107 | if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) { | ||
108 | return this.i18n('Waiting transcoding') | ||
109 | } | ||
110 | |||
111 | if (video.state.id === VideoState.TO_TRANSCODE) { | ||
112 | return this.i18n('To transcode') | ||
113 | } | ||
114 | |||
115 | if (video.state.id === VideoState.TO_IMPORT) { | ||
116 | return this.i18n('To import') | ||
117 | } | ||
118 | |||
119 | return '' | ||
120 | } | ||
121 | |||
122 | loadActions () { | ||
123 | if (this.displayVideoActions) this.showActions = true | ||
124 | } | ||
125 | |||
126 | onVideoBlacklisted () { | ||
127 | this.videoBlacklisted.emit() | ||
128 | } | ||
129 | |||
130 | onVideoUnblacklisted () { | ||
131 | this.videoUnblacklisted.emit() | ||
132 | } | ||
133 | |||
134 | onVideoRemoved () { | ||
135 | this.videoRemoved.emit() | ||
136 | } | ||
137 | |||
138 | private setUpBy () { | ||
139 | if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') { | ||
140 | this.ownerDisplayTypeChosen = this.ownerDisplayType | ||
141 | return | ||
142 | } | ||
143 | |||
144 | // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12) | ||
145 | // -> Use the account name | ||
146 | if ( | ||
147 | this.video.channel.name === `${this.video.account.name}_channel` || | ||
148 | this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) | ||
149 | ) { | ||
150 | this.ownerDisplayTypeChosen = 'account' | ||
151 | } else { | ||
152 | this.ownerDisplayTypeChosen = 'videoChannel' | ||
153 | } | ||
154 | } | ||
61 | } | 155 | } |
diff --git a/client/src/app/shared/video/video-thumbnail.component.html b/client/src/app/shared/video/video-thumbnail.component.html index d25666916..b302ebd0f 100644 --- a/client/src/app/shared/video/video-thumbnail.component.html +++ b/client/src/app/shared/video/video-thumbnail.component.html | |||
@@ -1,10 +1,14 @@ | |||
1 | <a | 1 | <a |
2 | [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name" | 2 | [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" [attr.title]="video.name" |
3 | class="video-thumbnail" | 3 | class="video-thumbnail" |
4 | > | 4 | > |
5 | <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> | 5 | <img alt="" [attr.aria-labelledby]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" /> |
6 | 6 | ||
7 | <div class="video-thumbnail-overlay">{{ video.durationLabel }}</div> | 7 | <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div> |
8 | |||
9 | <div class="play-overlay"> | ||
10 | <div class="icon"></div> | ||
11 | </div> | ||
8 | 12 | ||
9 | <div class="progress-bar" *ngIf="video.userHistory?.currentTime"> | 13 | <div class="progress-bar" *ngIf="video.userHistory?.currentTime"> |
10 | <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div> | 14 | <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div> |
diff --git a/client/src/app/shared/video/video-thumbnail.component.scss b/client/src/app/shared/video/video-thumbnail.component.scss index 4772edaf0..469b659e9 100644 --- a/client/src/app/shared/video/video-thumbnail.component.scss +++ b/client/src/app/shared/video/video-thumbnail.component.scss | |||
@@ -1,39 +1,15 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | @import '_miniature'; | ||
3 | 4 | ||
4 | .video-thumbnail { | 5 | .video-thumbnail { |
5 | display: inline-block; | 6 | @include miniature-thumbnail; |
6 | position: relative; | ||
7 | border-radius: 4px; | ||
8 | overflow: hidden; | ||
9 | width: $video-thumbnail-width; | ||
10 | height: $video-thumbnail-height; | ||
11 | background-color: #ececec; | ||
12 | |||
13 | &:hover { | ||
14 | text-decoration: none !important; | ||
15 | } | ||
16 | |||
17 | @include disable-outline; | ||
18 | &.focus-visible { | ||
19 | box-shadow: 0 0 0 2px var(--mainColor); | ||
20 | } | ||
21 | |||
22 | img { | ||
23 | width: $video-thumbnail-width; | ||
24 | height: $video-thumbnail-height; | ||
25 | |||
26 | &.blur-filter { | ||
27 | filter: blur(5px); | ||
28 | transform : scale(1.03); | ||
29 | } | ||
30 | } | ||
31 | 7 | ||
32 | .progress-bar { | 8 | .progress-bar { |
33 | height: 3px; | 9 | height: 3px; |
34 | width: 100%; | 10 | width: 100%; |
35 | position: relative; | 11 | position: absolute; |
36 | top: -3px; | 12 | bottom: 0; |
37 | background-color: rgba(0, 0, 0, 0.20); | 13 | background-color: rgba(0, 0, 0, 0.20); |
38 | 14 | ||
39 | div { | 15 | div { |
@@ -42,16 +18,15 @@ | |||
42 | } | 18 | } |
43 | } | 19 | } |
44 | 20 | ||
45 | .video-thumbnail-overlay { | 21 | .video-thumbnail-duration-overlay { |
22 | @include static-thumbnail-overlay; | ||
23 | |||
46 | position: absolute; | 24 | position: absolute; |
47 | right: 5px; | 25 | right: 5px; |
48 | bottom: 5px; | 26 | bottom: 5px; |
49 | display: inline-block; | 27 | padding: 0 5px; |
50 | background-color: rgba(0, 0, 0, 0.7); | 28 | border-radius: 3px; |
51 | color: #fff; | ||
52 | font-size: 12px; | 29 | font-size: 12px; |
53 | font-weight: $font-bold; | 30 | font-weight: $font-bold; |
54 | border-radius: 3px; | ||
55 | padding: 0 5px; | ||
56 | } | 31 | } |
57 | } | 32 | } |
diff --git a/client/src/app/shared/video/video-thumbnail.component.ts b/client/src/app/shared/video/video-thumbnail.component.ts index ca43700c7..fe65ade94 100644 --- a/client/src/app/shared/video/video-thumbnail.component.ts +++ b/client/src/app/shared/video/video-thumbnail.component.ts | |||
@@ -10,8 +10,11 @@ import { ScreenService } from '@app/shared/misc/screen.service' | |||
10 | export class VideoThumbnailComponent { | 10 | export class VideoThumbnailComponent { |
11 | @Input() video: Video | 11 | @Input() video: Video |
12 | @Input() nsfw = false | 12 | @Input() nsfw = false |
13 | @Input() routerLink: any[] | ||
14 | @Input() queryParams: any[] | ||
13 | 15 | ||
14 | constructor (private screenService: ScreenService) {} | 16 | constructor (private screenService: ScreenService) { |
17 | } | ||
15 | 18 | ||
16 | getImageUrl () { | 19 | getImageUrl () { |
17 | if (!this.video) return '' | 20 | if (!this.video) return '' |
@@ -30,4 +33,10 @@ export class VideoThumbnailComponent { | |||
30 | 33 | ||
31 | return (currentTime / this.video.duration) * 100 | 34 | return (currentTime / this.video.duration) * 100 |
32 | } | 35 | } |
36 | |||
37 | getVideoRouterLink () { | ||
38 | if (this.routerLink) return this.routerLink | ||
39 | |||
40 | return [ '/videos/watch', this.video.uuid ] | ||
41 | } | ||
33 | } | 42 | } |
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index 6ea83d13b..0cef3eb8f 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts | |||
@@ -1,11 +1,12 @@ | |||
1 | import { User } from '../' | 1 | import { User } from '../' |
2 | import { Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' | 2 | import { PlaylistElement, UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared' |
3 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' | 3 | import { Avatar } from '../../../../../shared/models/avatars/avatar.model' |
4 | import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model' | 4 | import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model' |
5 | import { durationToString, getAbsoluteAPIUrl } from '../misc/utils' | 5 | import { durationToString, getAbsoluteAPIUrl } from '../misc/utils' |
6 | import { peertubeTranslate, ServerConfig } from '../../../../../shared/models' | 6 | import { peertubeTranslate, ServerConfig } from '../../../../../shared/models' |
7 | import { Actor } from '@app/shared/actor/actor.model' | 7 | import { Actor } from '@app/shared/actor/actor.model' |
8 | import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' | 8 | import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' |
9 | import { AuthUser } from '@app/core' | ||
9 | 10 | ||
10 | export class Video implements VideoServerModel { | 11 | export class Video implements VideoServerModel { |
11 | byVideoChannel: string | 12 | byVideoChannel: string |
@@ -17,6 +18,7 @@ export class Video implements VideoServerModel { | |||
17 | createdAt: Date | 18 | createdAt: Date |
18 | updatedAt: Date | 19 | updatedAt: Date |
19 | publishedAt: Date | 20 | publishedAt: Date |
21 | originallyPublishedAt: Date | string | ||
20 | category: VideoConstant<number> | 22 | category: VideoConstant<number> |
21 | licence: VideoConstant<number> | 23 | licence: VideoConstant<number> |
22 | language: VideoConstant<string> | 24 | language: VideoConstant<string> |
@@ -46,6 +48,8 @@ export class Video implements VideoServerModel { | |||
46 | blacklisted?: boolean | 48 | blacklisted?: boolean |
47 | blacklistedReason?: string | 49 | blacklistedReason?: string |
48 | 50 | ||
51 | playlistElement?: PlaylistElement | ||
52 | |||
49 | account: { | 53 | account: { |
50 | id: number | 54 | id: number |
51 | uuid: string | 55 | uuid: string |
@@ -116,12 +120,16 @@ export class Video implements VideoServerModel { | |||
116 | this.privacy.label = peertubeTranslate(this.privacy.label, translations) | 120 | this.privacy.label = peertubeTranslate(this.privacy.label, translations) |
117 | 121 | ||
118 | this.scheduledUpdate = hash.scheduledUpdate | 122 | this.scheduledUpdate = hash.scheduledUpdate |
123 | this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null | ||
124 | |||
119 | if (this.state) this.state.label = peertubeTranslate(this.state.label, translations) | 125 | if (this.state) this.state.label = peertubeTranslate(this.state.label, translations) |
120 | 126 | ||
121 | this.blacklisted = hash.blacklisted | 127 | this.blacklisted = hash.blacklisted |
122 | this.blacklistedReason = hash.blacklistedReason | 128 | this.blacklistedReason = hash.blacklistedReason |
123 | 129 | ||
124 | this.userHistory = hash.userHistory | 130 | this.userHistory = hash.userHistory |
131 | |||
132 | this.playlistElement = hash.playlistElement | ||
125 | } | 133 | } |
126 | 134 | ||
127 | isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { | 135 | isVideoNSFWForUser (user: User, serverConfig: ServerConfig) { |
@@ -134,4 +142,20 @@ export class Video implements VideoServerModel { | |||
134 | // Return default instance config | 142 | // Return default instance config |
135 | return serverConfig.instance.defaultNSFWPolicy !== 'display' | 143 | return serverConfig.instance.defaultNSFWPolicy !== 'display' |
136 | } | 144 | } |
145 | |||
146 | isRemovableBy (user: AuthUser) { | ||
147 | return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO)) | ||
148 | } | ||
149 | |||
150 | isBlackistableBy (user: AuthUser) { | ||
151 | return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true | ||
152 | } | ||
153 | |||
154 | isUnblacklistableBy (user: AuthUser) { | ||
155 | return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true | ||
156 | } | ||
157 | |||
158 | isUpdatableBy (user: AuthUser) { | ||
159 | return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO)) | ||
160 | } | ||
137 | } | 161 | } |
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts index 55844f988..ef489648c 100644 --- a/client/src/app/shared/video/video.service.ts +++ b/client/src/app/shared/video/video.service.ts | |||
@@ -31,6 +31,8 @@ import { ServerService } 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 { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
35 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
34 | 36 | ||
35 | export interface VideosProvider { | 37 | export interface VideosProvider { |
36 | getVideos ( | 38 | getVideos ( |
@@ -81,6 +83,7 @@ export class VideoService implements VideosProvider { | |||
81 | const description = video.description || null | 83 | const description = video.description || null |
82 | const support = video.support || null | 84 | const support = video.support || null |
83 | const scheduleUpdate = video.scheduleUpdate || null | 85 | const scheduleUpdate = video.scheduleUpdate || null |
86 | const originallyPublishedAt = video.originallyPublishedAt || null | ||
84 | 87 | ||
85 | const body: VideoUpdate = { | 88 | const body: VideoUpdate = { |
86 | name: video.name, | 89 | name: video.name, |
@@ -95,9 +98,11 @@ export class VideoService implements VideosProvider { | |||
95 | nsfw: video.nsfw, | 98 | nsfw: video.nsfw, |
96 | waitTranscoding: video.waitTranscoding, | 99 | waitTranscoding: video.waitTranscoding, |
97 | commentsEnabled: video.commentsEnabled, | 100 | commentsEnabled: video.commentsEnabled, |
101 | downloadEnabled: video.downloadEnabled, | ||
98 | thumbnailfile: video.thumbnailfile, | 102 | thumbnailfile: video.thumbnailfile, |
99 | previewfile: video.previewfile, | 103 | previewfile: video.previewfile, |
100 | scheduleUpdate | 104 | scheduleUpdate, |
105 | originallyPublishedAt | ||
101 | } | 106 | } |
102 | 107 | ||
103 | const data = objectToFormData(body) | 108 | const data = objectToFormData(body) |
@@ -167,6 +172,23 @@ export class VideoService implements VideosProvider { | |||
167 | ) | 172 | ) |
168 | } | 173 | } |
169 | 174 | ||
175 | getPlaylistVideos ( | ||
176 | videoPlaylistId: number | string, | ||
177 | videoPagination: ComponentPagination | ||
178 | ): Observable<{ videos: Video[], totalVideos: number }> { | ||
179 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
180 | |||
181 | let params = new HttpParams() | ||
182 | params = this.restService.addRestGetParams(params, pagination) | ||
183 | |||
184 | return this.authHttp | ||
185 | .get<ResultList<Video>>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos', { params }) | ||
186 | .pipe( | ||
187 | switchMap(res => this.extractVideos(res)), | ||
188 | catchError(err => this.restExtractor.handleError(err)) | ||
189 | ) | ||
190 | } | ||
191 | |||
170 | getUserSubscriptionVideos ( | 192 | getUserSubscriptionVideos ( |
171 | videoPagination: ComponentPagination, | 193 | videoPagination: ComponentPagination, |
172 | sort: VideoSortField | 194 | sort: VideoSortField |
diff --git a/client/src/app/shared/video/videos-selection.component.html b/client/src/app/shared/video/videos-selection.component.html new file mode 100644 index 000000000..53809b6fd --- /dev/null +++ b/client/src/app/shared/video/videos-selection.component.html | |||
@@ -0,0 +1,26 @@ | |||
1 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div> | ||
2 | |||
3 | <div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="videos"> | ||
4 | <div class="video" *ngFor="let video of videos; let i = index"> | ||
5 | <div class="checkbox-container"> | ||
6 | <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="_selection[video.id]"></my-peertube-checkbox> | ||
7 | </div> | ||
8 | |||
9 | <my-video-miniature [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions" [displayVideoActions]="false"></my-video-miniature> | ||
10 | |||
11 | <!-- Display only once --> | ||
12 | <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0"> | ||
13 | <div class="action-selection-mode-child"> | ||
14 | <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()"> | ||
15 | Cancel | ||
16 | </span> | ||
17 | |||
18 | <ng-container *ngTemplateOutlet="globalButtonsTemplate"></ng-container> | ||
19 | </div> | ||
20 | </div> | ||
21 | |||
22 | <ng-container *ngIf="isInSelectionMode() === false"> | ||
23 | <ng-container *ngTemplateOutlet="rowButtonsTemplate; context: {$implicit: video}"></ng-container> | ||
24 | </ng-container> | ||
25 | </div> | ||
26 | </div> | ||
diff --git a/client/src/app/shared/video/videos-selection.component.scss b/client/src/app/shared/video/videos-selection.component.scss new file mode 100644 index 000000000..d3cbabf23 --- /dev/null +++ b/client/src/app/shared/video/videos-selection.component.scss | |||
@@ -0,0 +1,57 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .action-selection-mode { | ||
5 | display: flex; | ||
6 | justify-content: flex-end; | ||
7 | flex-grow: 1; | ||
8 | |||
9 | .action-selection-mode-child { | ||
10 | position: fixed; | ||
11 | |||
12 | .action-button { | ||
13 | display: inline-block; | ||
14 | } | ||
15 | |||
16 | .action-button-cancel-selection { | ||
17 | @include peertube-button; | ||
18 | @include grey-button; | ||
19 | |||
20 | margin-right: 10px; | ||
21 | } | ||
22 | } | ||
23 | } | ||
24 | |||
25 | .video { | ||
26 | @include row-blocks; | ||
27 | |||
28 | &:first-child { | ||
29 | margin-top: 47px; | ||
30 | } | ||
31 | |||
32 | .checkbox-container { | ||
33 | display: flex; | ||
34 | align-items: center; | ||
35 | margin-right: 20px; | ||
36 | margin-left: 12px; | ||
37 | } | ||
38 | |||
39 | my-video-miniature { | ||
40 | flex-grow: 1; | ||
41 | } | ||
42 | } | ||
43 | |||
44 | @media screen and (max-width: $small-view) { | ||
45 | .video { | ||
46 | flex-direction: column; | ||
47 | height: auto; | ||
48 | |||
49 | .checkbox-container { | ||
50 | display: none; | ||
51 | } | ||
52 | |||
53 | my-button { | ||
54 | margin-top: 10px; | ||
55 | } | ||
56 | } | ||
57 | } | ||
diff --git a/client/src/app/shared/video/videos-selection.component.ts b/client/src/app/shared/video/videos-selection.component.ts new file mode 100644 index 000000000..b6bedafd8 --- /dev/null +++ b/client/src/app/shared/video/videos-selection.component.ts | |||
@@ -0,0 +1,112 @@ | |||
1 | import { | ||
2 | AfterContentInit, | ||
3 | Component, | ||
4 | ContentChildren, | ||
5 | EventEmitter, | ||
6 | Input, | ||
7 | OnDestroy, | ||
8 | OnInit, | ||
9 | Output, | ||
10 | QueryList, | ||
11 | TemplateRef | ||
12 | } from '@angular/core' | ||
13 | import { ActivatedRoute, Router } from '@angular/router' | ||
14 | import { AbstractVideoList } from '@app/shared/video/abstract-video-list' | ||
15 | import { AuthService, Notifier, ServerService } from '@app/core' | ||
16 | import { ScreenService } from '@app/shared/misc/screen.service' | ||
17 | import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component' | ||
18 | import { Observable } from 'rxjs' | ||
19 | import { Video } from '@app/shared/video/video.model' | ||
20 | import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive' | ||
21 | import { VideoSortField } from '@app/shared/video/sort-field.type' | ||
22 | |||
23 | export type SelectionType = { [ id: number ]: boolean } | ||
24 | |||
25 | @Component({ | ||
26 | selector: 'my-videos-selection', | ||
27 | templateUrl: './videos-selection.component.html', | ||
28 | styleUrls: [ './videos-selection.component.scss' ] | ||
29 | }) | ||
30 | export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { | ||
31 | @Input() titlePage: string | ||
32 | @Input() miniatureDisplayOptions: MiniatureDisplayOptions | ||
33 | @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<{ videos: Video[], totalVideos: number }> | ||
34 | @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective> | ||
35 | |||
36 | @Output() selectionChange = new EventEmitter<SelectionType>() | ||
37 | @Output() videosModelChange = new EventEmitter<Video[]>() | ||
38 | |||
39 | _selection: SelectionType = {} | ||
40 | |||
41 | rowButtonsTemplate: TemplateRef<any> | ||
42 | globalButtonsTemplate: TemplateRef<any> | ||
43 | |||
44 | constructor ( | ||
45 | protected router: Router, | ||
46 | protected route: ActivatedRoute, | ||
47 | protected notifier: Notifier, | ||
48 | protected authService: AuthService, | ||
49 | protected screenService: ScreenService, | ||
50 | protected serverService: ServerService | ||
51 | ) { | ||
52 | super() | ||
53 | } | ||
54 | |||
55 | ngAfterContentInit () { | ||
56 | { | ||
57 | const t = this.templates.find(t => t.name === 'rowButtons') | ||
58 | if (t) this.rowButtonsTemplate = t.template | ||
59 | } | ||
60 | |||
61 | { | ||
62 | const t = this.templates.find(t => t.name === 'globalButtons') | ||
63 | if (t) this.globalButtonsTemplate = t.template | ||
64 | } | ||
65 | } | ||
66 | |||
67 | @Input() get selection () { | ||
68 | return this._selection | ||
69 | } | ||
70 | |||
71 | set selection (selection: SelectionType) { | ||
72 | this._selection = selection | ||
73 | this.selectionChange.emit(this._selection) | ||
74 | } | ||
75 | |||
76 | @Input() get videosModel () { | ||
77 | return this.videos | ||
78 | } | ||
79 | |||
80 | set videosModel (videos: Video[]) { | ||
81 | this.videos = videos | ||
82 | this.videosModelChange.emit(this.videos) | ||
83 | } | ||
84 | |||
85 | ngOnInit () { | ||
86 | super.ngOnInit() | ||
87 | } | ||
88 | |||
89 | ngOnDestroy () { | ||
90 | super.ngOnDestroy() | ||
91 | } | ||
92 | |||
93 | getVideosObservable (page: number) { | ||
94 | return this.getVideosObservableFunction(page, this.sort) | ||
95 | } | ||
96 | |||
97 | abortSelectionMode () { | ||
98 | this._selection = {} | ||
99 | } | ||
100 | |||
101 | isInSelectionMode () { | ||
102 | return Object.keys(this._selection).some(k => this._selection[ k ] === true) | ||
103 | } | ||
104 | |||
105 | generateSyndicationList () { | ||
106 | throw new Error('Method not implemented.') | ||
107 | } | ||
108 | |||
109 | protected onMoreVideos () { | ||
110 | this.videosModel = this.videos | ||
111 | } | ||
112 | } | ||
diff --git a/client/src/app/signup/signup-routing.module.ts b/client/src/app/signup/signup-routing.module.ts index b7ac69b53..820d16d4d 100644 --- a/client/src/app/signup/signup-routing.module.ts +++ b/client/src/app/signup/signup-routing.module.ts | |||
@@ -1,9 +1,8 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { RouterModule, Routes } from '@angular/router' | 2 | import { RouterModule, Routes } from '@angular/router' |
3 | |||
4 | import { MetaGuard } from '@ngx-meta/core' | 3 | import { MetaGuard } from '@ngx-meta/core' |
5 | |||
6 | import { SignupComponent } from './signup.component' | 4 | import { SignupComponent } from './signup.component' |
5 | import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service' | ||
7 | 6 | ||
8 | const signupRoutes: Routes = [ | 7 | const signupRoutes: Routes = [ |
9 | { | 8 | { |
@@ -14,6 +13,9 @@ const signupRoutes: Routes = [ | |||
14 | meta: { | 13 | meta: { |
15 | title: 'Signup' | 14 | title: 'Signup' |
16 | } | 15 | } |
16 | }, | ||
17 | resolve: { | ||
18 | serverConfigLoaded: ServerConfigResolver | ||
17 | } | 19 | } |
18 | } | 20 | } |
19 | ] | 21 | ] |
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 092c0e862..99695204d 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 | |||
@@ -121,11 +121,6 @@ | |||
121 | ></my-peertube-checkbox> | 121 | ></my-peertube-checkbox> |
122 | 122 | ||
123 | <my-peertube-checkbox | 123 | <my-peertube-checkbox |
124 | inputName="commentsEnabled" formControlName="commentsEnabled" | ||
125 | i18n-labelText labelText="Enable video comments" | ||
126 | ></my-peertube-checkbox> | ||
127 | |||
128 | <my-peertube-checkbox | ||
129 | *ngIf="waitTranscodingEnabled" | 124 | *ngIf="waitTranscodingEnabled" |
130 | inputName="waitTranscoding" formControlName="waitTranscoding" | 125 | inputName="waitTranscoding" formControlName="waitTranscoding" |
131 | i18n-labelText labelText="Wait transcoding before publishing the video" | 126 | i18n-labelText labelText="Wait transcoding before publishing the video" |
@@ -190,31 +185,59 @@ | |||
190 | 185 | ||
191 | <ngb-tab i18n-title title="Advanced settings"> | 186 | <ngb-tab i18n-title title="Advanced settings"> |
192 | <ng-template ngbTabContent> | 187 | <ng-template ngbTabContent> |
193 | <div class="advanced-settings"> | 188 | <div class="row advanced-settings"> |
194 | <div class="form-group"> | 189 | <div class="col-md-12 col-xl-8"> |
195 | <my-video-image | 190 | <div class="form-group"> |
196 | i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile" | 191 | <my-image-upload |
197 | previewWidth="200px" previewHeight="110px" | 192 | i18n-inputLabel inputLabel="Upload thumbnail" inputName="thumbnailfile" formControlName="thumbnailfile" |
198 | ></my-video-image> | 193 | previewWidth="200px" previewHeight="110px" |
199 | </div> | 194 | ></my-image-upload> |
195 | </div> | ||
196 | |||
197 | <div class="form-group"> | ||
198 | <my-image-upload | ||
199 | i18n-inputLabel inputLabel="Upload preview" inputName="previewfile" formControlName="previewfile" | ||
200 | previewWidth="360px" previewHeight="200px" | ||
201 | ></my-image-upload> | ||
202 | </div> | ||
200 | 203 | ||
201 | <div class="form-group"> | 204 | <div class="form-group"> |
202 | <my-video-image | 205 | <label i18n for="support">Support</label> |
203 | i18n-inputLabel inputLabel="Upload preview" inputName="previewfile" formControlName="previewfile" | 206 | <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help> |
204 | previewWidth="360px" previewHeight="200px" | 207 | <my-markdown-textarea |
205 | ></my-video-image> | 208 | id="support" formControlName="support" textareaWidth="500px" [previewColumn]="true" markdownType="enhanced" |
209 | [classes]="{ 'input-error': formErrors['support'] }" | ||
210 | ></my-markdown-textarea> | ||
211 | <div *ngIf="formErrors.support" class="form-error"> | ||
212 | {{ formErrors.support }} | ||
213 | </div> | ||
214 | </div> | ||
206 | </div> | 215 | </div> |
207 | 216 | ||
208 | <div class="form-group"> | 217 | <div class="col-md-12 col-xl-4"> |
209 | <label i18n for="support">Support</label> | 218 | <div class="form-group originally-published-at"> |
210 | <my-help helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support you (membership platform...)."></my-help> | 219 | <label i18n for="originallyPublishedAt">Original publication date</label> |
211 | <my-markdown-textarea | 220 | <my-help i18n-preHtml preHtml="This is the date when the content was originally published (e.g. the release date for a film)"></my-help> |
212 | id="support" formControlName="support" textareaWidth="500px" [previewColumn]="true" markdownType="enhanced" | 221 | <p-calendar |
213 | [classes]="{ 'input-error': formErrors['support'] }" | 222 | id="originallyPublishedAt" formControlName="originallyPublishedAt" [dateFormat]="calendarDateFormat" |
214 | ></my-markdown-textarea> | 223 | [locale]="calendarLocale" [showTime]="true" [hideOnDateTimeSelect]="true" [monthNavigator]="true" [yearNavigator]="true" [yearRange]="myYearRange" |
215 | <div *ngIf="formErrors.support" class="form-error"> | 224 | > |
216 | {{ formErrors.support }} | 225 | </p-calendar> |
226 | |||
227 | <div *ngIf="formErrors.originallyPublishedAt" class="form-error"> | ||
228 | {{ formErrors.originallyPublishedAt }} | ||
229 | </div> | ||
217 | </div> | 230 | </div> |
231 | |||
232 | <my-peertube-checkbox | ||
233 | inputName="commentsEnabled" formControlName="commentsEnabled" | ||
234 | i18n-labelText labelText="Enable video comments" | ||
235 | ></my-peertube-checkbox> | ||
236 | |||
237 | <my-peertube-checkbox | ||
238 | inputName="downloadEnabled" formControlName="downloadEnabled" | ||
239 | i18n-labelText labelText="Enable download" | ||
240 | ></my-peertube-checkbox> | ||
218 | </div> | 241 | </div> |
219 | </div> | 242 | </div> |
220 | </ng-template> | 243 | </ng-template> |
diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.ts b/client/src/app/videos/+video-edit/shared/video-edit.component.ts index 85e015901..c80efd802 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.ts +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.ts | |||
@@ -1,4 +1,4 @@ | |||
1 | import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core' | 1 | import { Component, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core' |
2 | import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms' | 2 | import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms' |
3 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
4 | import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared' | 4 | import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared' |
@@ -26,7 +26,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
26 | @Input() videoPrivacies: VideoConstant<VideoPrivacy>[] = [] | 26 | @Input() videoPrivacies: VideoConstant<VideoPrivacy>[] = [] |
27 | @Input() userVideoChannels: { id: number, label: string, support: string }[] = [] | 27 | @Input() userVideoChannels: { id: number, label: string, support: string }[] = [] |
28 | @Input() schedulePublicationPossible = true | 28 | @Input() schedulePublicationPossible = true |
29 | @Input() videoCaptions: VideoCaptionEdit[] = [] | 29 | @Input() videoCaptions: (VideoCaptionEdit & { captionPath?: string })[] = [] |
30 | @Input() waitTranscodingEnabled = true | 30 | @Input() waitTranscodingEnabled = true |
31 | 31 | ||
32 | @ViewChild('videoCaptionAddModal') videoCaptionAddModal: VideoCaptionAddModalComponent | 32 | @ViewChild('videoCaptionAddModal') videoCaptionAddModal: VideoCaptionAddModalComponent |
@@ -45,6 +45,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
45 | 45 | ||
46 | calendarLocale: any = {} | 46 | calendarLocale: any = {} |
47 | minScheduledDate = new Date() | 47 | minScheduledDate = new Date() |
48 | myYearRange = '1880:' + (new Date()).getFullYear() | ||
48 | 49 | ||
49 | calendarTimezone: string | 50 | calendarTimezone: string |
50 | calendarDateFormat: string | 51 | calendarDateFormat: string |
@@ -61,7 +62,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
61 | private router: Router, | 62 | private router: Router, |
62 | private notifier: Notifier, | 63 | private notifier: Notifier, |
63 | private serverService: ServerService, | 64 | private serverService: ServerService, |
64 | private i18nPrimengCalendarService: I18nPrimengCalendarService | 65 | private i18nPrimengCalendarService: I18nPrimengCalendarService, |
66 | private ngZone: NgZone | ||
65 | ) { | 67 | ) { |
66 | this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS | 68 | this.tagValidators = this.videoValidatorsService.VIDEO_TAGS.VALIDATORS |
67 | this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES | 69 | this.tagValidatorsMessages = this.videoValidatorsService.VIDEO_TAGS.MESSAGES |
@@ -81,6 +83,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
81 | const defaultValues: any = { | 83 | const defaultValues: any = { |
82 | nsfw: 'false', | 84 | nsfw: 'false', |
83 | commentsEnabled: 'true', | 85 | commentsEnabled: 'true', |
86 | downloadEnabled: 'true', | ||
84 | waitTranscoding: 'true', | 87 | waitTranscoding: 'true', |
85 | tags: [] | 88 | tags: [] |
86 | } | 89 | } |
@@ -90,6 +93,7 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
90 | channelId: this.videoValidatorsService.VIDEO_CHANNEL, | 93 | channelId: this.videoValidatorsService.VIDEO_CHANNEL, |
91 | nsfw: null, | 94 | nsfw: null, |
92 | commentsEnabled: null, | 95 | commentsEnabled: null, |
96 | downloadEnabled: null, | ||
93 | waitTranscoding: null, | 97 | waitTranscoding: null, |
94 | category: this.videoValidatorsService.VIDEO_CATEGORY, | 98 | category: this.videoValidatorsService.VIDEO_CATEGORY, |
95 | licence: this.videoValidatorsService.VIDEO_LICENCE, | 99 | licence: this.videoValidatorsService.VIDEO_LICENCE, |
@@ -99,7 +103,8 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
99 | thumbnailfile: null, | 103 | thumbnailfile: null, |
100 | previewfile: null, | 104 | previewfile: null, |
101 | support: this.videoValidatorsService.VIDEO_SUPPORT, | 105 | support: this.videoValidatorsService.VIDEO_SUPPORT, |
102 | schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT | 106 | schedulePublicationAt: this.videoValidatorsService.VIDEO_SCHEDULE_PUBLICATION_AT, |
107 | originallyPublishedAt: this.videoValidatorsService.VIDEO_ORIGINALLY_PUBLISHED_AT | ||
103 | } | 108 | } |
104 | 109 | ||
105 | this.formValidatorService.updateForm( | 110 | this.formValidatorService.updateForm( |
@@ -128,9 +133,11 @@ export class VideoEditComponent implements OnInit, OnDestroy { | |||
128 | this.videoLicences = this.serverService.getVideoLicences() | 133 | this.videoLicences = this.serverService.getVideoLicences() |
129 | this.videoLanguages = this.serverService.getVideoLanguages() | 134 | this.videoLanguages = this.serverService.getVideoLanguages() |
130 | 135 | ||
131 | this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute | ||
132 | |||
133 | this.initialVideoCaptions = this.videoCaptions.map(c => c.language.id) | 136 | this.initialVideoCaptions = this.videoCaptions.map(c => c.language.id) |
137 | |||
138 | this.ngZone.runOutsideAngular(() => { | ||
139 | this.schedulerInterval = setInterval(() => this.minScheduledDate = new Date(), 1000 * 60) // Update every minute | ||
140 | }) | ||
134 | } | 141 | } |
135 | 142 | ||
136 | ngOnDestroy () { | 143 | ngOnDestroy () { |
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 f441d3fde..39b6daa93 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,6 @@ 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 { VideoImageComponent } from './video-image.component' | ||
6 | import { CalendarModule } from 'primeng/components/calendar/calendar' | 5 | import { CalendarModule } from 'primeng/components/calendar/calendar' |
7 | import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' | 6 | import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component' |
8 | 7 | ||
@@ -16,7 +15,6 @@ import { VideoCaptionAddModalComponent } from './video-caption-add-modal.compone | |||
16 | 15 | ||
17 | declarations: [ | 16 | declarations: [ |
18 | VideoEditComponent, | 17 | VideoEditComponent, |
19 | VideoImageComponent, | ||
20 | VideoCaptionAddModalComponent | 18 | VideoCaptionAddModalComponent |
21 | ], | 19 | ], |
22 | 20 | ||
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 28eb143c9..537d7ffa2 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html +++ b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.html | |||
@@ -58,7 +58,7 @@ | |||
58 | <form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> | 58 | <form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> |
59 | <my-video-edit | 59 | <my-video-edit |
60 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" | 60 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" |
61 | [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" | 61 | [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels" |
62 | ></my-video-edit> | 62 | ></my-video-edit> |
63 | 63 | ||
64 | <div class="submit-container"> | 64 | <div class="submit-container"> |
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-import-torrent.component.ts index 307806bb9..d2e9f6cfe 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 | |||
@@ -79,6 +79,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca | |||
79 | privacy: this.firstStepPrivacyId, | 79 | privacy: this.firstStepPrivacyId, |
80 | waitTranscoding: false, | 80 | waitTranscoding: false, |
81 | commentsEnabled: true, | 81 | commentsEnabled: true, |
82 | downloadEnabled: true, | ||
82 | channelId: this.firstStepChannelId | 83 | channelId: this.firstStepChannelId |
83 | } | 84 | } |
84 | 85 | ||
@@ -93,12 +94,13 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Ca | |||
93 | 94 | ||
94 | this.video = new VideoEdit(Object.assign(res.video, { | 95 | this.video = new VideoEdit(Object.assign(res.video, { |
95 | commentsEnabled: videoUpdate.commentsEnabled, | 96 | commentsEnabled: videoUpdate.commentsEnabled, |
97 | downloadEnabled: videoUpdate.downloadEnabled, | ||
96 | support: null, | 98 | support: null, |
97 | thumbnailUrl: null, | 99 | thumbnailUrl: null, |
98 | previewUrl: null | 100 | previewUrl: null |
99 | })) | 101 | })) |
100 | 102 | ||
101 | this.videoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) | 103 | this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) |
102 | 104 | ||
103 | this.hydrateFormFromVideo() | 105 | this.hydrateFormFromVideo() |
104 | }, | 106 | }, |
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 3550c3585..984b9d590 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html +++ b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.html | |||
@@ -51,7 +51,7 @@ | |||
51 | <form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> | 51 | <form [hidden]="!hasImportedVideo" novalidate [formGroup]="form"> |
52 | <my-video-edit | 52 | <my-video-edit |
53 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" | 53 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" [schedulePublicationPossible]="false" |
54 | [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" | 54 | [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels" |
55 | ></my-video-edit> | 55 | ></my-video-edit> |
56 | 56 | ||
57 | <div class="submit-container"> | 57 | <div class="submit-container"> |
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/videos/+video-edit/video-add-components/video-import-url.component.ts index 257c6e5db..2dffdbf0e 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 | |||
@@ -70,6 +70,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom | |||
70 | privacy: this.firstStepPrivacyId, | 70 | privacy: this.firstStepPrivacyId, |
71 | waitTranscoding: false, | 71 | waitTranscoding: false, |
72 | commentsEnabled: true, | 72 | commentsEnabled: true, |
73 | downloadEnabled: true, | ||
73 | channelId: this.firstStepChannelId | 74 | channelId: this.firstStepChannelId |
74 | } | 75 | } |
75 | 76 | ||
@@ -84,12 +85,13 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, CanCom | |||
84 | 85 | ||
85 | this.video = new VideoEdit(Object.assign(res.video, { | 86 | this.video = new VideoEdit(Object.assign(res.video, { |
86 | commentsEnabled: videoUpdate.commentsEnabled, | 87 | commentsEnabled: videoUpdate.commentsEnabled, |
88 | downloadEnabled: videoUpdate.downloadEnabled, | ||
87 | support: null, | 89 | support: null, |
88 | thumbnailUrl: null, | 90 | thumbnailUrl: null, |
89 | previewUrl: null | 91 | previewUrl: null |
90 | })) | 92 | })) |
91 | 93 | ||
92 | this.videoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) | 94 | this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) |
93 | 95 | ||
94 | this.hydrateFormFromVideo() | 96 | this.hydrateFormFromVideo() |
95 | }, | 97 | }, |
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-send.ts b/client/src/app/videos/+video-edit/video-add-components/video-send.ts index 580c123a0..8401caeec 100644 --- a/client/src/app/videos/+video-edit/video-add-components/video-send.ts +++ b/client/src/app/videos/+video-edit/video-add-components/video-send.ts | |||
@@ -14,6 +14,7 @@ import { CanComponentDeactivateResult } from '@app/shared/guards/can-deactivate- | |||
14 | export abstract class VideoSend extends FormReactive implements OnInit { | 14 | export abstract class VideoSend extends FormReactive implements OnInit { |
15 | userVideoChannels: { id: number, label: string, support: string }[] = [] | 15 | userVideoChannels: { id: number, label: string, support: string }[] = [] |
16 | videoPrivacies: VideoConstant<VideoPrivacy>[] = [] | 16 | videoPrivacies: VideoConstant<VideoPrivacy>[] = [] |
17 | explainedVideoPrivacies: VideoConstant<VideoPrivacy>[] = [] | ||
17 | videoCaptions: VideoCaptionEdit[] = [] | 18 | videoCaptions: VideoCaptionEdit[] = [] |
18 | 19 | ||
19 | firstStepPrivacyId = 0 | 20 | firstStepPrivacyId = 0 |
diff --git a/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html b/client/src/app/videos/+video-edit/video-add-components/video-upload.component.html index b252cd60a..536769d2f 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 | |||
@@ -50,7 +50,7 @@ | |||
50 | <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form"> | 50 | <form [hidden]="!isUploadingVideo" novalidate [formGroup]="form"> |
51 | <my-video-edit | 51 | <my-video-edit |
52 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" | 52 | [form]="form" [formErrors]="formErrors" [videoCaptions]="videoCaptions" |
53 | [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" | 53 | [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels" |
54 | [waitTranscodingEnabled]="waitTranscodingEnabled" | 54 | [waitTranscodingEnabled]="waitTranscodingEnabled" |
55 | ></my-video-edit> | 55 | ></my-video-edit> |
56 | 56 | ||
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 e4d54b654..d6d4bad21 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 | |||
@@ -163,9 +163,10 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
163 | } | 163 | } |
164 | 164 | ||
165 | const privacy = this.firstStepPrivacyId.toString() | 165 | const privacy = this.firstStepPrivacyId.toString() |
166 | const nsfw = false | 166 | const nsfw = this.serverService.getConfig().instance.isNSFW |
167 | const waitTranscoding = true | 167 | const waitTranscoding = true |
168 | const commentsEnabled = true | 168 | const commentsEnabled = true |
169 | const downloadEnabled = true | ||
169 | const channelId = this.firstStepChannelId.toString() | 170 | const channelId = this.firstStepChannelId.toString() |
170 | 171 | ||
171 | const formData = new FormData() | 172 | const formData = new FormData() |
@@ -174,6 +175,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
174 | formData.append('privacy', VideoPrivacy.PRIVATE.toString()) | 175 | formData.append('privacy', VideoPrivacy.PRIVATE.toString()) |
175 | formData.append('nsfw', '' + nsfw) | 176 | formData.append('nsfw', '' + nsfw) |
176 | formData.append('commentsEnabled', '' + commentsEnabled) | 177 | formData.append('commentsEnabled', '' + commentsEnabled) |
178 | formData.append('downloadEnabled', '' + downloadEnabled) | ||
177 | formData.append('waitTranscoding', '' + waitTranscoding) | 179 | formData.append('waitTranscoding', '' + waitTranscoding) |
178 | formData.append('channelId', '' + channelId) | 180 | formData.append('channelId', '' + channelId) |
179 | formData.append('videofile', videofile) | 181 | formData.append('videofile', videofile) |
@@ -188,7 +190,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy | |||
188 | channelId | 190 | channelId |
189 | }) | 191 | }) |
190 | 192 | ||
191 | this.videoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) | 193 | this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) |
192 | 194 | ||
193 | this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe( | 195 | this.videoUploadObservable = this.videoService.uploadVideo(formData).subscribe( |
194 | event => { | 196 | event => { |
diff --git a/client/src/app/videos/+video-edit/video-update.component.html b/client/src/app/videos/+video-edit/video-update.component.html index 4992bb369..b5cab7ed5 100644 --- a/client/src/app/videos/+video-edit/video-update.component.html +++ b/client/src/app/videos/+video-edit/video-update.component.html | |||
@@ -7,7 +7,7 @@ | |||
7 | 7 | ||
8 | <my-video-edit | 8 | <my-video-edit |
9 | [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible" | 9 | [form]="form" [formErrors]="formErrors" [schedulePublicationPossible]="schedulePublicationPossible" |
10 | [validationMessages]="validationMessages" [videoPrivacies]="videoPrivacies" [userVideoChannels]="userVideoChannels" | 10 | [validationMessages]="validationMessages" [videoPrivacies]="explainedVideoPrivacies" [userVideoChannels]="userVideoChannels" |
11 | [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled" | 11 | [videoCaptions]="videoCaptions" [waitTranscodingEnabled]="waitTranscodingEnabled" |
12 | ></my-video-edit> | 12 | ></my-video-edit> |
13 | 13 | ||
diff --git a/client/src/app/videos/+video-edit/video-update.component.ts b/client/src/app/videos/+video-edit/video-update.component.ts index 9e849014e..10f797d02 100644 --- a/client/src/app/videos/+video-edit/video-update.component.ts +++ b/client/src/app/videos/+video-edit/video-update.component.ts | |||
@@ -24,6 +24,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
24 | 24 | ||
25 | isUpdatingVideo = false | 25 | isUpdatingVideo = false |
26 | videoPrivacies: VideoConstant<VideoPrivacy>[] = [] | 26 | videoPrivacies: VideoConstant<VideoPrivacy>[] = [] |
27 | explainedVideoPrivacies: VideoConstant<VideoPrivacy>[] = [] | ||
27 | userVideoChannels: { id: number, label: string, support: string }[] = [] | 28 | userVideoChannels: { id: number, label: string, support: string }[] = [] |
28 | schedulePublicationPossible = false | 29 | schedulePublicationPossible = false |
29 | videoCaptions: VideoCaptionEdit[] = [] | 30 | videoCaptions: VideoCaptionEdit[] = [] |
@@ -65,7 +66,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit { | |||
65 | this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE | 66 | this.schedulePublicationPossible = this.video.privacy === VideoPrivacy.PRIVATE |
66 | } | 67 | } |
67 | 68 | ||
68 | this.videoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) | 69 | this.explainedVideoPrivacies = this.videoService.explainedPrivacyLabels(this.videoPrivacies) |
69 | 70 | ||
70 | const videoFiles = (video as VideoDetails).files | 71 | const videoFiles = (video as VideoDetails).files |
71 | if (videoFiles.length > 1) { // Already transcoded | 72 | if (videoFiles.length > 1) { // Already transcoded |
diff --git a/client/src/app/videos/+video-edit/video-update.resolver.ts b/client/src/app/videos/+video-edit/video-update.resolver.ts index 269fe3684..384458127 100644 --- a/client/src/app/videos/+video-edit/video-update.resolver.ts +++ b/client/src/app/videos/+video-edit/video-update.resolver.ts | |||
@@ -4,6 +4,7 @@ import { ActivatedRouteSnapshot, Resolve } from '@angular/router' | |||
4 | import { map, switchMap } from 'rxjs/operators' | 4 | import { map, switchMap } from 'rxjs/operators' |
5 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' | 5 | import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' |
6 | import { VideoCaptionService } from '@app/shared/video-caption' | 6 | import { VideoCaptionService } from '@app/shared/video-caption' |
7 | import { forkJoin } from 'rxjs' | ||
7 | 8 | ||
8 | @Injectable() | 9 | @Injectable() |
9 | export class VideoUpdateResolver implements Resolve<any> { | 10 | export class VideoUpdateResolver implements Resolve<any> { |
@@ -11,35 +12,35 @@ export class VideoUpdateResolver implements Resolve<any> { | |||
11 | private videoService: VideoService, | 12 | private videoService: VideoService, |
12 | private videoChannelService: VideoChannelService, | 13 | private videoChannelService: VideoChannelService, |
13 | private videoCaptionService: VideoCaptionService | 14 | private videoCaptionService: VideoCaptionService |
14 | ) {} | 15 | ) { |
16 | } | ||
15 | 17 | ||
16 | resolve (route: ActivatedRouteSnapshot) { | 18 | resolve (route: ActivatedRouteSnapshot) { |
17 | const uuid: string = route.params[ 'uuid' ] | 19 | const uuid: string = route.params[ 'uuid' ] |
18 | 20 | ||
19 | return this.videoService.getVideo(uuid) | 21 | return this.videoService.getVideo(uuid) |
20 | .pipe( | 22 | .pipe( |
21 | switchMap(video => { | 23 | switchMap(video => { |
22 | return this.videoService | 24 | return forkJoin([ |
23 | .loadCompleteDescription(video.descriptionPath) | 25 | this.videoService |
24 | .pipe(map(description => Object.assign(video, { description }))) | 26 | .loadCompleteDescription(video.descriptionPath) |
25 | }), | 27 | .pipe(map(description => Object.assign(video, { description }))), |
26 | switchMap(video => { | 28 | |
27 | return this.videoChannelService | 29 | this.videoChannelService |
28 | .listAccountVideoChannels(video.account) | 30 | .listAccountVideoChannels(video.account) |
29 | .pipe( | 31 | .pipe( |
30 | map(result => result.data), | 32 | map(result => result.data), |
31 | map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))), | 33 | map(videoChannels => videoChannels.map(c => ({ id: c.id, label: c.displayName, support: c.support }))) |
32 | map(videoChannels => ({ video, videoChannels })) | 34 | ), |
33 | ) | 35 | |
34 | }), | 36 | this.videoCaptionService |
35 | switchMap(({ video, videoChannels }) => { | 37 | .listCaptions(video.id) |
36 | return this.videoCaptionService | 38 | .pipe( |
37 | .listCaptions(video.id) | 39 | map(result => result.data) |
38 | .pipe( | 40 | ) |
39 | map(result => result.data), | 41 | ]) |
40 | map(videoCaptions => ({ video, videoChannels, videoCaptions })) | 42 | }), |
41 | ) | 43 | map(([ video, videoChannels, videoCaptions ]) => ({ video, videoChannels, videoCaptions })) |
42 | }) | 44 | ) |
43 | ) | ||
44 | } | 45 | } |
45 | } | 46 | } |
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 aba7f9d1c..172eb0a39 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 | |||
@@ -85,8 +85,8 @@ export class VideoCommentComponent implements OnInit, OnChanges { | |||
85 | ) | 85 | ) |
86 | } | 86 | } |
87 | 87 | ||
88 | private init () { | 88 | private async init () { |
89 | this.sanitizedCommentHTML = this.htmlRenderer.toSafeHtml(this.comment.text) | 89 | this.sanitizedCommentHTML = await this.htmlRenderer.toSafeHtml(this.comment.text) |
90 | 90 | ||
91 | this.newParentComments = this.parentComments.concat([ this.comment ]) | 91 | this.newParentComments = this.parentComments.concat([ this.comment ]) |
92 | } | 92 | } |
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 824fb24c3..3ed3ddcc7 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,6 +1,7 @@ | |||
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 } 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 | 5 | ||
5 | export class VideoComment implements VideoCommentServerModel { | 6 | export class VideoComment implements VideoCommentServerModel { |
6 | id: number | 7 | id: number |
@@ -16,6 +17,8 @@ export class VideoComment implements VideoCommentServerModel { | |||
16 | by: string | 17 | by: string |
17 | accountAvatarUrl: string | 18 | accountAvatarUrl: string |
18 | 19 | ||
20 | isLocal: boolean | ||
21 | |||
19 | constructor (hash: VideoCommentServerModel) { | 22 | constructor (hash: VideoCommentServerModel) { |
20 | this.id = hash.id | 23 | this.id = hash.id |
21 | this.url = hash.url | 24 | this.url = hash.url |
@@ -30,5 +33,9 @@ export class VideoComment implements VideoCommentServerModel { | |||
30 | 33 | ||
31 | this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) | 34 | this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host) |
32 | this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) | 35 | this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) |
36 | |||
37 | const absoluteAPIUrl = getAbsoluteAPIUrl() | ||
38 | const thisHost = new URL(absoluteAPIUrl).host | ||
39 | this.isLocal = this.account.host.trim() === thisHost | ||
33 | } | 40 | } |
34 | } | 41 | } |
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 44016d8ad..7b941454a 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 | |||
@@ -54,7 +54,7 @@ | |||
54 | <ng-container i18n>View all {{ comment.totalReplies }} replies</ng-container> | 54 | <ng-container i18n>View all {{ comment.totalReplies }} replies</ng-container> |
55 | 55 | ||
56 | <span *ngIf="!threadLoading[comment.id]" class="glyphicon glyphicon-menu-down"></span> | 56 | <span *ngIf="!threadLoading[comment.id]" class="glyphicon glyphicon-menu-down"></span> |
57 | <my-loader class="comment-thread-loading" [loading]="threadLoading[comment.id]"></my-loader> | 57 | <my-small-loader class="comment-thread-loading" [loading]="threadLoading[comment.id]"></my-small-loader> |
58 | </div> | 58 | </div> |
59 | </div> | 59 | </div> |
60 | </div> | 60 | </div> |
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 2616820d2..3acddbe6a 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 | |||
@@ -121,10 +121,17 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { | |||
121 | 121 | ||
122 | async onWantedToDelete (commentToDelete: VideoComment) { | 122 | async onWantedToDelete (commentToDelete: VideoComment) { |
123 | let message = 'Do you really want to delete this comment?' | 123 | let message = 'Do you really want to delete this comment?' |
124 | |||
124 | if (commentToDelete.totalReplies !== 0) { | 125 | if (commentToDelete.totalReplies !== 0) { |
125 | message += this.i18n(' {{totalReplies}} replies will be deleted too.', { totalReplies: commentToDelete.totalReplies }) | 126 | message += this.i18n(' {{totalReplies}} replies will be deleted too.', { totalReplies: commentToDelete.totalReplies }) |
126 | } | 127 | } |
127 | 128 | ||
129 | if (commentToDelete.isLocal) { | ||
130 | message += this.i18n(' The deletion will be sent to remote instances so they remove the comment too.') | ||
131 | } else { | ||
132 | message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.') | ||
133 | } | ||
134 | |||
128 | const res = await this.confirmService.confirm(message, this.i18n('Delete')) | 135 | const res = await this.confirmService.confirm(message, this.i18n('Delete')) |
129 | if (res === false) return | 136 | if (res === false) return |
130 | 137 | ||
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 9f3c37fe8..955b2b80c 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 | |||
@@ -6,11 +6,19 @@ | |||
6 | 6 | ||
7 | <div class="modal-body"> | 7 | <div class="modal-body"> |
8 | 8 | ||
9 | <div *ngIf="currentVideoTimestampString" class="start-at"> | 9 | <div class="start-at"> |
10 | <my-peertube-checkbox | 10 | <my-peertube-checkbox |
11 | inputName="startAt" [(ngModel)]="startAtCheckbox" | 11 | inputName="startAt" [(ngModel)]="startAtCheckbox" |
12 | i18n-labelText [labelText]="getStartCheckboxLabel()" | 12 | i18n-labelText labelText="Start at" |
13 | ></my-peertube-checkbox> | 13 | ></my-peertube-checkbox> |
14 | |||
15 | <my-timestamp-input | ||
16 | [timestamp]="currentVideoTimestamp" | ||
17 | [maxTimestamp]="video.duration" | ||
18 | [disabled]="!startAtCheckbox" | ||
19 | [(ngModel)]="currentVideoTimestamp" | ||
20 | > | ||
21 | </my-timestamp-input> | ||
14 | </div> | 22 | </div> |
15 | 23 | ||
16 | <div class="form-group"> | 24 | <div class="form-group"> |
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 4937506b9..472a45920 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 | |||
@@ -13,4 +13,9 @@ | |||
13 | display: flex; | 13 | display: flex; |
14 | justify-content: center; | 14 | justify-content: center; |
15 | margin-top: 10px; | 15 | margin-top: 10px; |
16 | align-items: center; | ||
17 | |||
18 | my-timestamp-input { | ||
19 | margin-left: 10px; | ||
20 | } | ||
16 | } | 21 | } |
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 c6205e355..6565d7f88 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 | |||
@@ -16,10 +16,8 @@ export class VideoShareComponent { | |||
16 | 16 | ||
17 | @Input() video: VideoDetails = null | 17 | @Input() video: VideoDetails = null |
18 | 18 | ||
19 | currentVideoTimestamp: number | ||
19 | startAtCheckbox = false | 20 | startAtCheckbox = false |
20 | currentVideoTimestampString: string | ||
21 | |||
22 | private currentVideoTimestamp: number | ||
23 | 21 | ||
24 | constructor ( | 22 | constructor ( |
25 | private modalService: NgbModal, | 23 | private modalService: NgbModal, |
@@ -28,8 +26,7 @@ export class VideoShareComponent { | |||
28 | ) { } | 26 | ) { } |
29 | 27 | ||
30 | show (currentVideoTimestamp?: number) { | 28 | show (currentVideoTimestamp?: number) { |
31 | this.currentVideoTimestamp = Math.floor(currentVideoTimestamp) | 29 | this.currentVideoTimestamp = currentVideoTimestamp ? Math.floor(currentVideoTimestamp) : 0 |
32 | this.currentVideoTimestampString = durationToString(this.currentVideoTimestamp) | ||
33 | 30 | ||
34 | this.modalService.open(this.modal) | 31 | this.modalService.open(this.modal) |
35 | } | 32 | } |
@@ -52,10 +49,6 @@ export class VideoShareComponent { | |||
52 | this.notifier.success(this.i18n('Copied')) | 49 | this.notifier.success(this.i18n('Copied')) |
53 | } | 50 | } |
54 | 51 | ||
55 | getStartCheckboxLabel () { | ||
56 | return this.i18n('Start at {{timestamp}}', { timestamp: this.currentVideoTimestampString }) | ||
57 | } | ||
58 | |||
59 | private getVideoTimestampIfEnabled () { | 52 | private getVideoTimestampIfEnabled () { |
60 | if (this.startAtCheckbox === true) return this.currentVideoTimestamp | 53 | if (this.startAtCheckbox === true) return this.currentVideoTimestamp |
61 | 54 | ||
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 deb8fbc67..5e7afa012 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,9 @@ export class VideoSupportComponent { | |||
21 | ) { } | 21 | ) { } |
22 | 22 | ||
23 | show () { | 23 | show () { |
24 | this.videoHTMLSupport = this.markdownService.enhancedMarkdownToHTML(this.video.support) | ||
25 | this.modalService.open(this.modal) | 24 | this.modalService.open(this.modal) |
25 | |||
26 | this.markdownService.enhancedMarkdownToHTML(this.video.support) | ||
27 | .then(r => this.videoHTMLSupport = r) | ||
26 | } | 28 | } |
27 | } | 29 | } |
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.html b/client/src/app/videos/+video-watch/video-watch-playlist.component.html new file mode 100644 index 000000000..c168a3130 --- /dev/null +++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.html | |||
@@ -0,0 +1,25 @@ | |||
1 | <div *ngIf="playlist && video" class="playlist" myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"> | ||
2 | <div class="playlist-info"> | ||
3 | <div class="playlist-display-name"> | ||
4 | {{ playlist.displayName }} | ||
5 | |||
6 | <span *ngIf="isUnlistedPlaylist()" class="badge badge-warning" i18n>Unlisted</span> | ||
7 | <span *ngIf="isPrivatePlaylist()" class="badge badge-danger" i18n>Private</span> | ||
8 | <span *ngIf="isPublicPlaylist()" class="badge badge-info" i18n>Public</span> | ||
9 | </div> | ||
10 | |||
11 | <div class="playlist-by-index"> | ||
12 | <div class="playlist-by">{{ playlist.ownerBy }}</div> | ||
13 | <div class="playlist-index"> | ||
14 | <span>{{ currentPlaylistPosition }}</span><span>{{ playlistPagination.totalItems }}</span> | ||
15 | </div> | ||
16 | </div> | ||
17 | </div> | ||
18 | |||
19 | <div *ngFor="let playlistVideo of playlistVideos"> | ||
20 | <my-video-playlist-element-miniature | ||
21 | [video]="playlistVideo" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)" | ||
22 | [playing]="currentPlaylistPosition === playlistVideo.playlistElement.position" [accountLink]="false" [position]="playlistVideo.playlistElement.position" | ||
23 | ></my-video-playlist-element-miniature> | ||
24 | </div> | ||
25 | </div> | ||
diff --git a/client/src/app/videos/+video-watch/video-watch-playlist.component.scss b/client/src/app/videos/+video-watch/video-watch-playlist.component.scss new file mode 100644 index 000000000..5da55c2f8 --- /dev/null +++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.scss | |||
@@ -0,0 +1,59 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | @import '_bootstrap-variables'; | ||
4 | @import '_miniature'; | ||
5 | |||
6 | .playlist { | ||
7 | min-width: 200px; | ||
8 | max-width: 470px; | ||
9 | height: 66vh; | ||
10 | background-color: var(--mainBackgroundColor); | ||
11 | overflow-y: auto; | ||
12 | border-bottom: 1px solid $separator-border-color; | ||
13 | |||
14 | .playlist-info { | ||
15 | padding: 5px 30px; | ||
16 | background-color: #e4e4e4; | ||
17 | |||
18 | .playlist-display-name { | ||
19 | font-size: 18px; | ||
20 | font-weight: $font-semibold; | ||
21 | margin-bottom: 5px; | ||
22 | } | ||
23 | |||
24 | .playlist-by-index { | ||
25 | color: $grey-foreground-color; | ||
26 | display: flex; | ||
27 | |||
28 | .playlist-by { | ||
29 | margin-right: 5px; | ||
30 | } | ||
31 | |||
32 | .playlist-index span:first-child::after { | ||
33 | content: '/'; | ||
34 | margin: 0 3px; | ||
35 | } | ||
36 | } | ||
37 | } | ||
38 | |||
39 | my-video-playlist-element-miniature { | ||
40 | /deep/ { | ||
41 | .video { | ||
42 | .position { | ||
43 | margin-right: 0; | ||
44 | } | ||
45 | |||
46 | .video-info { | ||
47 | .video-info-name { | ||
48 | font-size: 15px; | ||
49 | } | ||
50 | } | ||
51 | } | ||
52 | |||
53 | my-video-thumbnail { | ||
54 | @include thumbnail-size-component(90px, 50px); | ||
55 | } | ||
56 | } | ||
57 | } | ||
58 | } | ||
59 | |||
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 new file mode 100644 index 000000000..bccdaf7b2 --- /dev/null +++ b/client/src/app/videos/+video-watch/video-watch-playlist.component.ts | |||
@@ -0,0 +1,113 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
3 | import { ComponentPagination } from '@app/shared/rest/component-pagination.model' | ||
4 | import { Video } from '@app/shared/video/video.model' | ||
5 | import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models' | ||
6 | import { VideoService } from '@app/shared/video/video.service' | ||
7 | import { Router } from '@angular/router' | ||
8 | import { AuthService } from '@app/core' | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-video-watch-playlist', | ||
12 | templateUrl: './video-watch-playlist.component.html', | ||
13 | styleUrls: [ './video-watch-playlist.component.scss' ] | ||
14 | }) | ||
15 | export class VideoWatchPlaylistComponent { | ||
16 | @Input() video: VideoDetails | ||
17 | @Input() playlist: VideoPlaylist | ||
18 | |||
19 | playlistVideos: Video[] = [] | ||
20 | playlistPagination: ComponentPagination = { | ||
21 | currentPage: 1, | ||
22 | itemsPerPage: 30, | ||
23 | totalItems: null | ||
24 | } | ||
25 | |||
26 | noPlaylistVideos = false | ||
27 | currentPlaylistPosition = 1 | ||
28 | |||
29 | constructor ( | ||
30 | private auth: AuthService, | ||
31 | private videoService: VideoService, | ||
32 | private router: Router | ||
33 | ) {} | ||
34 | |||
35 | onPlaylistVideosNearOfBottom () { | ||
36 | // Last page | ||
37 | if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return | ||
38 | |||
39 | this.playlistPagination.currentPage += 1 | ||
40 | this.loadPlaylistElements(this.playlist,false) | ||
41 | } | ||
42 | |||
43 | onElementRemoved (video: Video) { | ||
44 | this.playlistVideos = this.playlistVideos.filter(v => v.id !== video.id) | ||
45 | |||
46 | this.playlistPagination.totalItems-- | ||
47 | } | ||
48 | |||
49 | isPlaylistOwned () { | ||
50 | return this.playlist.isLocal === true && | ||
51 | this.auth.isLoggedIn() && | ||
52 | this.playlist.ownerAccount.name === this.auth.getUser().username | ||
53 | } | ||
54 | |||
55 | isUnlistedPlaylist () { | ||
56 | return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED | ||
57 | } | ||
58 | |||
59 | isPrivatePlaylist () { | ||
60 | return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE | ||
61 | } | ||
62 | |||
63 | isPublicPlaylist () { | ||
64 | return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC | ||
65 | } | ||
66 | |||
67 | loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false) { | ||
68 | this.videoService.getPlaylistVideos(playlist.uuid, this.playlistPagination) | ||
69 | .subscribe(({ totalVideos, videos }) => { | ||
70 | this.playlistVideos = this.playlistVideos.concat(videos) | ||
71 | this.playlistPagination.totalItems = totalVideos | ||
72 | |||
73 | if (totalVideos === 0) { | ||
74 | this.noPlaylistVideos = true | ||
75 | return | ||
76 | } | ||
77 | |||
78 | this.updatePlaylistIndex(this.video) | ||
79 | |||
80 | if (redirectToFirst) { | ||
81 | const extras = { | ||
82 | queryParams: { videoId: this.playlistVideos[ 0 ].uuid }, | ||
83 | replaceUrl: true | ||
84 | } | ||
85 | this.router.navigate([], extras) | ||
86 | } | ||
87 | }) | ||
88 | } | ||
89 | |||
90 | updatePlaylistIndex (video: VideoDetails) { | ||
91 | if (this.playlistVideos.length === 0 || !video) return | ||
92 | |||
93 | for (const playlistVideo of this.playlistVideos) { | ||
94 | if (playlistVideo.id === video.id) { | ||
95 | this.currentPlaylistPosition = playlistVideo.playlistElement.position | ||
96 | return | ||
97 | } | ||
98 | } | ||
99 | |||
100 | // Load more videos to find our video | ||
101 | this.onPlaylistVideosNearOfBottom() | ||
102 | } | ||
103 | |||
104 | navigateToNextPlaylistVideo () { | ||
105 | if (this.currentPlaylistPosition < this.playlistPagination.totalItems) { | ||
106 | const next = this.playlistVideos.find(v => v.playlistElement.position === this.currentPlaylistPosition + 1) | ||
107 | |||
108 | const start = next.playlistElement.startTimestamp | ||
109 | const stop = next.playlistElement.stopTimestamp | ||
110 | this.router.navigate([],{ queryParams: { videoId: next.uuid, start, stop } }) | ||
111 | } | ||
112 | } | ||
113 | } | ||
diff --git a/client/src/app/videos/+video-watch/video-watch-routing.module.ts b/client/src/app/videos/+video-watch/video-watch-routing.module.ts index bdd4f945e..ce9250bdc 100644 --- a/client/src/app/videos/+video-watch/video-watch-routing.module.ts +++ b/client/src/app/videos/+video-watch/video-watch-routing.module.ts | |||
@@ -7,7 +7,16 @@ import { VideoWatchComponent } from './video-watch.component' | |||
7 | 7 | ||
8 | const videoWatchRoutes: Routes = [ | 8 | const videoWatchRoutes: Routes = [ |
9 | { | 9 | { |
10 | path: '', | 10 | path: 'playlist/:playlistId', |
11 | component: VideoWatchComponent, | ||
12 | canActivate: [ MetaGuard ] | ||
13 | }, | ||
14 | { | ||
15 | path: ':videoId/comments/:commentId', | ||
16 | redirectTo: ':videoId' | ||
17 | }, | ||
18 | { | ||
19 | path: ':videoId', | ||
11 | component: VideoWatchComponent, | 20 | component: VideoWatchComponent, |
12 | canActivate: [ MetaGuard ] | 21 | canActivate: [ MetaGuard ] |
13 | } | 22 | } |
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 709eb91a8..2e39b9c6b 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html | |||
@@ -1,37 +1,53 @@ | |||
1 | <div class="root-row row"> | 1 | <div class="root" [ngClass]="{ 'theater-enabled': theaterEnabled }"> |
2 | <!-- We need the video container for videojs so we just hide it --> | 2 | <!-- We need the video container for videojs so we just hide it --> |
3 | <div id="video-element-wrapper"> | 3 | <div id="video-wrapper"> |
4 | <div *ngIf="remoteServerDown" class="remote-server-down"> | 4 | <div *ngIf="remoteServerDown" class="remote-server-down"> |
5 | Sorry, but this video is not available because the remote instance is not responding. | 5 | Sorry, but this video is not available because the remote instance is not responding. |
6 | <br /> | 6 | <br /> |
7 | Please try again later. | 7 | Please try again later. |
8 | </div> | 8 | </div> |
9 | </div> | ||
10 | 9 | ||
11 | <div i18n class="alert alert-warning" *ngIf="isVideoToImport()"> | 10 | <div id="videojs-wrapper"></div> |
12 | The video is being imported, it will be available when the import is finished. | ||
13 | </div> | ||
14 | 11 | ||
15 | <div i18n class="alert alert-warning" *ngIf="isVideoToTranscode()"> | 12 | <my-video-watch-playlist |
16 | The video is being transcoded, it may not work properly. | 13 | #videoWatchPlaylist |
14 | [video]="video" [playlist]="playlist" class="playlist" | ||
15 | ></my-video-watch-playlist> | ||
17 | </div> | 16 | </div> |
18 | 17 | ||
19 | <div i18n class="alert alert-info" *ngIf="hasVideoScheduledPublication()"> | 18 | <div class="row"> |
20 | This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}. | 19 | <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToImport()"> |
21 | </div> | 20 | The video is being imported, it will be available when the import is finished. |
21 | </div> | ||
22 | |||
23 | <div i18n class="col-md-12 alert alert-warning" *ngIf="isVideoToTranscode()"> | ||
24 | The video is being transcoded, it may not work properly. | ||
25 | </div> | ||
26 | |||
27 | <div i18n class="col-md-12 alert alert-info" *ngIf="hasVideoScheduledPublication()"> | ||
28 | This video will be published on {{ video.scheduledUpdate.updateAt | date: 'full' }}. | ||
29 | </div> | ||
22 | 30 | ||
23 | <div class="alert alert-danger" *ngIf="video?.blacklisted"> | 31 | <div class="col-md-12 alert alert-danger" *ngIf="video?.blacklisted"> |
24 | <div class="blacklisted-label" i18n>This video is blacklisted.</div> | 32 | <div class="blacklisted-label" i18n>This video is blacklisted.</div> |
25 | {{ video.blacklistedReason }} | 33 | {{ video.blacklistedReason }} |
34 | </div> | ||
26 | </div> | 35 | </div> |
27 | 36 | ||
28 | <!-- Video information --> | 37 | <!-- Video information --> |
29 | <div *ngIf="video" class="margin-content video-bottom"> | 38 | <div *ngIf="video" class="margin-content video-bottom"> |
30 | <div class="row fullWidth"> | 39 | <div class="video-info"> |
31 | <div class="col-12 col-lg-auto video-info"> | 40 | <div class="video-info-first-row"> |
32 | <div class="video-info-first-row"> | 41 | <div> |
33 | <div> | 42 | <div class="d-block d-md-none"> <!-- only shown on medium devices, has its conterpart for larger viewports below --> |
34 | <div class="d-block d-sm-none"> <!-- only shown on small devices, has its conterpart for larger viewports below --> | 43 | <h1 class="video-info-name">{{ video.name }}</h1> |
44 | <div i18n class="video-info-date-views"> | ||
45 | Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views | ||
46 | </div> | ||
47 | </div> | ||
48 | |||
49 | <div class="d-flex justify-content-between align-items-md-end"> | ||
50 | <div class="d-none d-md-block"> | ||
35 | <h1 class="video-info-name">{{ video.name }}</h1> | 51 | <h1 class="video-info-name">{{ video.name }}</h1> |
36 | 52 | ||
37 | <div i18n class="video-info-date-views"> | 53 | <div i18n class="video-info-date-views"> |
@@ -39,173 +55,154 @@ | |||
39 | </div> | 55 | </div> |
40 | </div> | 56 | </div> |
41 | 57 | ||
42 | <div class="d-flex justify-content-between align-items-sm-end"> | 58 | <div class="video-actions-rates"> |
43 | <div class="d-none d-sm-block"> | 59 | <div class="video-actions fullWidth justify-content-end"> |
44 | <h1 class="video-info-name">{{ video.name }}</h1> | 60 | <div |
61 | *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'like' }" (click)="setLike()" | ||
62 | class="action-button action-button-like" role="button" [attr.aria-pressed]="userRating === 'like'" | ||
63 | i18n-title title="Like this video" | ||
64 | > | ||
65 | <my-global-icon iconName="like"></my-global-icon> | ||
66 | </div> | ||
45 | 67 | ||
46 | <div i18n class="video-info-date-views"> | 68 | <div |
47 | Published {{ video.publishedAt | myFromNow }} - {{ video.views | myNumberFormatter }} views | 69 | *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()" |
70 | class="action-button action-button-dislike" role="button" [attr.aria-pressed]="userRating === 'dislike'" | ||
71 | i18n-title title="Dislike this video" | ||
72 | > | ||
73 | <my-global-icon iconName="dislike"></my-global-icon> | ||
48 | </div> | 74 | </div> |
49 | </div> | ||
50 | 75 | ||
51 | <div class="video-actions-rates"> | 76 | <div *ngIf="video.support" (click)="showSupportModal()" class="action-button"> |
52 | <div class="video-actions fullWidth justify-content-end"> | 77 | <my-global-icon iconName="heart"></my-global-icon> |
53 | <div | 78 | <span class="icon-text" i18n>Support</span> |
54 | *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'like' }" (click)="setLike()" | 79 | </div> |
55 | class="action-button action-button-like" role="button" [attr.aria-pressed]="userRating === 'like'" | ||
56 | i18n-title title="Like this video" | ||
57 | > | ||
58 | <my-global-icon iconName="like"></my-global-icon> | ||
59 | </div> | ||
60 | 80 | ||
61 | <div | 81 | <div (click)="showShareModal()" class="action-button" role="button"> |
62 | *ngIf="isUserLoggedIn()" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()" | 82 | <my-global-icon iconName="share"></my-global-icon> |
63 | class="action-button action-button-dislike" role="button" [attr.aria-pressed]="userRating === 'dislike'" | 83 | <span class="icon-text" i18n>Share</span> |
64 | i18n-title title="Dislike this video" | 84 | </div> |
65 | > | ||
66 | <my-global-icon iconName="dislike"></my-global-icon> | ||
67 | </div> | ||
68 | 85 | ||
69 | <div *ngIf="video.support" (click)="showSupportModal()" class="action-button action-button-support"> | 86 | <div |
70 | <my-global-icon iconName="heart"></my-global-icon> | 87 | class="action-dropdown" ngbDropdown placement="top" role="button" autoClose="outside" |
71 | <span class="icon-text" i18n>Support</span> | 88 | *ngIf="isUserLoggedIn()" (openChange)="addContent.openChange($event)" |
89 | > | ||
90 | <div class="action-button action-button-save" ngbDropdownToggle role="button"> | ||
91 | <my-global-icon iconName="playlist-add"></my-global-icon> | ||
92 | <span class="icon-text" i18n>Save</span> | ||
72 | </div> | 93 | </div> |
73 | 94 | ||
74 | <div (click)="showShareModal()" class="action-button action-button-share" role="button"> | 95 | <div ngbDropdownMenu> |
75 | <my-global-icon iconName="share"></my-global-icon> | 96 | <my-video-add-to-playlist #addContent [video]="video"></my-video-add-to-playlist> |
76 | <span class="icon-text" i18n>Share</span> | ||
77 | </div> | ||
78 | |||
79 | <div class="action-more" ngbDropdown placement="top" role="button"> | ||
80 | <div class="action-button" ngbDropdownToggle role="button"> | ||
81 | <my-global-icon class="more-icon" iconName="more"></my-global-icon> | ||
82 | </div> | ||
83 | |||
84 | <div ngbDropdownMenu> | ||
85 | <a class="dropdown-item" i18n-title title="Download the video" href="#" (click)="showDownloadModal($event)"> | ||
86 | <my-global-icon iconName="download"></my-global-icon> <ng-container i18n>Download</ng-container> | ||
87 | </a> | ||
88 | |||
89 | <a *ngIf="isUserLoggedIn()" class="dropdown-item" i18n-title title="Report this video" href="#" (click)="showReportModal($event)"> | ||
90 | <my-global-icon iconName="alert"></my-global-icon> <ng-container i18n>Report</ng-container> | ||
91 | </a> | ||
92 | |||
93 | <a *ngIf="isVideoUpdatable()" class="dropdown-item" i18n-title title="Update this video" href="#" [routerLink]="[ '/videos/update', video.uuid ]"> | ||
94 | <my-global-icon iconName="edit"></my-global-icon> <ng-container i18n>Update</ng-container> | ||
95 | </a> | ||
96 | |||
97 | <a *ngIf="isVideoBlacklistable()" class="dropdown-item" i18n-title title="Blacklist this video" href="#" (click)="showBlacklistModal($event)"> | ||
98 | <my-global-icon iconName="no"></my-global-icon> <ng-container i18n>Blacklist</ng-container> | ||
99 | </a> | ||
100 | |||
101 | <a *ngIf="isVideoUnblacklistable()" class="dropdown-item" i18n-title title="Unblacklist this video" href="#" (click)="unblacklistVideo($event)"> | ||
102 | <my-global-icon iconName="undo"></my-global-icon> <ng-container i18n>Unblacklist</ng-container> | ||
103 | </a> | ||
104 | |||
105 | <a *ngIf="isVideoRemovable()" class="dropdown-item" i18n-title title="Delete this video" href="#" (click)="removeVideo($event)"> | ||
106 | <my-global-icon iconName="delete"></my-global-icon> <ng-container i18n>Delete</ng-container> | ||
107 | </a> | ||
108 | </div> | ||
109 | </div> | 97 | </div> |
110 | </div> | 98 | </div> |
111 | 99 | ||
112 | <div | 100 | <my-video-actions-dropdown |
113 | class="video-info-likes-dislikes-bar" | 101 | placement="top" buttonDirection="horizontal" [buttonStyled]="true" [video]="video" (videoRemoved)="onVideoRemoved()" |
114 | *ngIf="video.likes !== 0 || video.dislikes !== 0" | 102 | ></my-video-actions-dropdown> |
115 | [ngbTooltip]="likesBarTooltipText" | ||
116 | placement="bottom" | ||
117 | > | ||
118 | <div class="likes-bar" [ngStyle]="{ 'width.%': video.likesPercent }"></div> | ||
119 | </div> | ||
120 | </div> | 103 | </div> |
121 | </div> | ||
122 | 104 | ||
105 | <div | ||
106 | class="video-info-likes-dislikes-bar" | ||
107 | *ngIf="video.likes !== 0 || video.dislikes !== 0" | ||
108 | [ngbTooltip]="likesBarTooltipText" | ||
109 | placement="bottom" | ||
110 | > | ||
111 | <div class="likes-bar" [ngStyle]="{ 'width.%': video.likesPercent }"></div> | ||
112 | </div> | ||
113 | </div> | ||
114 | </div> | ||
123 | 115 | ||
124 | <div class="pt-3 border-top video-info-channel"> | ||
125 | <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" i18n-title title="Go the channel page"> | ||
126 | {{ video.channel.displayName }} | ||
127 | 116 | ||
128 | <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" /> | 117 | <div class="pt-3 border-top video-info-channel"> |
129 | </a> | 118 | <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" i18n-title title="Go the channel page"> |
119 | {{ video.channel.displayName }} | ||
130 | 120 | ||
131 | <my-subscribe-button #subscribeButton [videoChannel]="video.channel" size="small"></my-subscribe-button> | 121 | <img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" /> |
132 | </div> | 122 | </a> |
133 | 123 | ||
134 | <div class="video-info-by"> | 124 | <my-subscribe-button #subscribeButton [videoChannel]="video.channel" size="small"></my-subscribe-button> |
135 | <a [routerLink]="[ '/accounts', video.byAccount ]" i18n-title title="Go to the account page"> | ||
136 | <span i18n>By {{ video.byAccount }}</span> | ||
137 | <img [src]="video.accountAvatarUrl" alt="Account avatar" /> | ||
138 | </a> | ||
139 | </div> | ||
140 | </div> | 125 | </div> |
141 | 126 | ||
127 | <div class="video-info-by"> | ||
128 | <a [routerLink]="[ '/accounts', video.byAccount ]" i18n-title title="Go to the account page"> | ||
129 | <span i18n>By {{ video.byAccount }}</span> | ||
130 | <img [src]="video.accountAvatarUrl" alt="Account avatar" /> | ||
131 | </a> | ||
132 | </div> | ||
142 | </div> | 133 | </div> |
143 | 134 | ||
144 | <div class="video-info-description"> | 135 | </div> |
145 | <div class="video-info-description-html" [innerHTML]="videoHTMLDescription"></div> | ||
146 | 136 | ||
147 | <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()"> | 137 | <div class="video-info-description"> |
148 | <ng-container i18n>Show more</ng-container> | 138 | <div class="video-info-description-html" [innerHTML]="videoHTMLDescription"></div> |
149 | <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-down"></span> | ||
150 | <my-loader class="description-loading" [loading]="descriptionLoading"></my-loader> | ||
151 | </div> | ||
152 | 139 | ||
153 | <div *ngIf="completeDescriptionShown === true" (click)="showLessDescription()" class="video-info-description-more"> | 140 | <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()"> |
154 | <ng-container i18n>Show less</ng-container> | 141 | <ng-container i18n>Show more</ng-container> |
155 | <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-up"></span> | 142 | <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-down"></span> |
156 | </div> | 143 | <my-small-loader class="description-loading" [loading]="descriptionLoading"></my-small-loader> |
157 | </div> | 144 | </div> |
158 | 145 | ||
159 | <div class="video-attributes"> | 146 | <div *ngIf="completeDescriptionShown === true" (click)="showLessDescription()" class="video-info-description-more"> |
160 | <div class="video-attribute"> | 147 | <ng-container i18n>Show less</ng-container> |
161 | <span i18n class="video-attribute-label">Privacy</span> | 148 | <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-up"></span> |
162 | <span class="video-attribute-value">{{ video.privacy.label }}</span> | 149 | </div> |
163 | </div> | 150 | </div> |
164 | 151 | ||
165 | <div class="video-attribute"> | 152 | <div class="video-attributes"> |
166 | <span i18n class="video-attribute-label">Category</span> | 153 | <div class="video-attribute"> |
167 | <span *ngIf="!video.category.id" class="video-attribute-value">{{ video.category.label }}</span> | 154 | <span i18n class="video-attribute-label">Privacy</span> |
168 | <a | 155 | <span class="video-attribute-value">{{ video.privacy.label }}</span> |
169 | *ngIf="video.category.id" class="video-attribute-value" | 156 | </div> |
170 | [routerLink]="[ '/search' ]" [queryParams]="{ categoryOneOf: [ video.category.id ] }" | ||
171 | >{{ video.category.label }}</a> | ||
172 | </div> | ||
173 | 157 | ||
174 | <div class="video-attribute"> | 158 | <div *ngIf="!!video.originallyPublishedAt" class="video-attribute"> |
175 | <span i18n class="video-attribute-label">Licence</span> | 159 | <span i18n class="video-attribute-label">Originally published</span> |
176 | <span *ngIf="!video.licence.id" class="video-attribute-value">{{ video.licence.label }}</span> | 160 | <span class="video-attribute-value">{{ video.originallyPublishedAt | date: 'dd MMMM yyyy' }}</span> |
177 | <a | 161 | </div> |
178 | *ngIf="video.licence.id" class="video-attribute-value" | ||
179 | [routerLink]="[ '/search' ]" [queryParams]="{ licenceOneOf: [ video.licence.id ] }" | ||
180 | >{{ video.licence.label }}</a> | ||
181 | </div> | ||
182 | 162 | ||
183 | <div class="video-attribute"> | 163 | <div class="video-attribute"> |
184 | <span i18n class="video-attribute-label">Language</span> | 164 | <span i18n class="video-attribute-label">Category</span> |
185 | <span *ngIf="!video.language.id" class="video-attribute-value">{{ video.language.label }}</span> | 165 | <span *ngIf="!video.category.id" class="video-attribute-value">{{ video.category.label }}</span> |
186 | <a | 166 | <a |
187 | *ngIf="video.language.id" class="video-attribute-value" | 167 | *ngIf="video.category.id" class="video-attribute-value" |
188 | [routerLink]="[ '/search' ]" [queryParams]="{ languageOneOf: [ video.language.id ] }" | 168 | [routerLink]="[ '/search' ]" [queryParams]="{ categoryOneOf: [ video.category.id ] }" |
189 | >{{ video.language.label }}</a> | 169 | >{{ video.category.label }}</a> |
190 | </div> | 170 | </div> |
191 | 171 | ||
192 | <div class="video-attribute video-attribute-tags"> | 172 | <div class="video-attribute"> |
193 | <span i18n class="video-attribute-label">Tags</span> | 173 | <span i18n class="video-attribute-label">Licence</span> |
194 | <a | 174 | <span *ngIf="!video.licence.id" class="video-attribute-value">{{ video.licence.label }}</span> |
195 | *ngFor="let tag of getVideoTags()" | 175 | <a |
196 | class="video-attribute-value" [routerLink]="[ '/search' ]" [queryParams]="{ tagsOneOf: [ tag ] }" | 176 | *ngIf="video.licence.id" class="video-attribute-value" |
197 | >{{ tag }}</a> | 177 | [routerLink]="[ '/search' ]" [queryParams]="{ licenceOneOf: [ video.licence.id ] }" |
198 | </div> | 178 | >{{ video.licence.label }}</a> |
199 | </div> | 179 | </div> |
200 | 180 | ||
201 | <my-video-comments [video]="video" [user]="user"></my-video-comments> | 181 | <div class="video-attribute"> |
182 | <span i18n class="video-attribute-label">Language</span> | ||
183 | <span *ngIf="!video.language.id" class="video-attribute-value">{{ video.language.label }}</span> | ||
184 | <a | ||
185 | *ngIf="video.language.id" class="video-attribute-value" | ||
186 | [routerLink]="[ '/search' ]" [queryParams]="{ languageOneOf: [ video.language.id ] }" | ||
187 | >{{ video.language.label }}</a> | ||
188 | </div> | ||
189 | |||
190 | <div class="video-attribute video-attribute-tags"> | ||
191 | <span i18n class="video-attribute-label">Tags</span> | ||
192 | <a | ||
193 | *ngFor="let tag of getVideoTags()" | ||
194 | class="video-attribute-value" [routerLink]="[ '/search' ]" [queryParams]="{ tagsOneOf: [ tag ] }" | ||
195 | >{{ tag }}</a> | ||
196 | </div> | ||
202 | </div> | 197 | </div> |
203 | 198 | ||
204 | <my-recommended-videos [inputRecommendation]="{ uuid: video.uuid, tags: video.tags }" [user]="user"></my-recommended-videos> | 199 | <my-video-comments [video]="video" [user]="user"></my-video-comments> |
205 | </div> | 200 | </div> |
201 | |||
202 | <my-recommended-videos [inputRecommendation]="{ uuid: video.uuid, tags: video.tags }" [user]="user"></my-recommended-videos> | ||
206 | </div> | 203 | </div> |
207 | 204 | ||
208 | <div class="privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false"> | 205 | <div class="row privacy-concerns" *ngIf="hasAlreadyAcceptedPrivacyConcern === false"> |
209 | <div class="privacy-concerns-text"> | 206 | <div class="privacy-concerns-text"> |
210 | <strong i18n>Friendly Reminder: </strong> | 207 | <strong i18n>Friendly Reminder: </strong> |
211 | <ng-container i18n> | 208 | <ng-container i18n> |
@@ -218,11 +215,9 @@ | |||
218 | OK | 215 | OK |
219 | </div> | 216 | </div> |
220 | </div> | 217 | </div> |
218 | </div> | ||
221 | 219 | ||
222 | <ng-template [ngIf]="video !== null"> | 220 | <ng-template [ngIf]="video !== null"> |
223 | <my-video-support #videoSupportModal [video]="video"></my-video-support> | 221 | <my-video-support #videoSupportModal [video]="video"></my-video-support> |
224 | <my-video-share #videoShareModal [video]="video"></my-video-share> | 222 | <my-video-share #videoShareModal [video]="video"></my-video-share> |
225 | <my-video-download #videoDownloadModal [video]="video"></my-video-download> | ||
226 | <my-video-report #videoReportModal [video]="video"></my-video-report> | ||
227 | <my-video-blacklist #videoBlacklistModal [video]="video"></my-video-blacklist> | ||
228 | </ng-template> | 223 | </ng-template> |
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss index b03ed197d..8ca5c4118 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.scss +++ b/client/src/app/videos/+video-watch/video-watch.component.scss | |||
@@ -1,22 +1,63 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | @import '_bootstrap-variables'; | 3 | @import '_bootstrap-variables'; |
4 | @import '_miniature'; | ||
4 | 5 | ||
5 | $other-videos-width: 260px; | 6 | $other-videos-width: 260px; |
7 | $player-factor: 1.7; // 16/9 | ||
6 | 8 | ||
7 | .root-row { | 9 | @function getPlayerHeight($width){ |
8 | flex-direction: column; | 10 | @return calc(#{$width} / #{$player-factor}) |
11 | } | ||
12 | |||
13 | @function getPlayerWidth($height){ | ||
14 | @return calc(#{$height} * #{$player-factor}) | ||
15 | } | ||
16 | |||
17 | @mixin playlist-below-player { | ||
18 | width: 100% !important; | ||
19 | height: auto !important; | ||
20 | max-height: 300px !important; | ||
21 | border-bottom: 1px solid $separator-border-color !important; | ||
22 | } | ||
23 | |||
24 | .root { | ||
25 | &.theater-enabled #video-wrapper { | ||
26 | flex-direction: column; | ||
27 | justify-content: center; | ||
28 | |||
29 | #videojs-wrapper { | ||
30 | width: 100%; | ||
31 | } | ||
32 | |||
33 | /deep/ .video-js { | ||
34 | $height: calc(100vh - #{$header-height} - #{$theater-bottom-space}); | ||
35 | |||
36 | height: $height; | ||
37 | width: 100%; | ||
38 | } | ||
39 | |||
40 | my-video-watch-playlist /deep/ .playlist { | ||
41 | @include playlist-below-player; | ||
42 | } | ||
43 | } | ||
9 | } | 44 | } |
10 | 45 | ||
11 | .blacklisted-label { | 46 | .blacklisted-label { |
12 | font-weight: $font-semibold; | 47 | font-weight: $font-semibold; |
13 | } | 48 | } |
14 | 49 | ||
15 | #video-element-wrapper { | 50 | #video-wrapper { |
16 | background-color: #000; | 51 | background-color: #000; |
17 | display: flex; | 52 | display: flex; |
18 | justify-content: center; | 53 | justify-content: center; |
19 | flex-grow: 1; | 54 | margin: 0 -15px; |
55 | |||
56 | #videojs-wrapper { | ||
57 | display: flex; | ||
58 | justify-content: center; | ||
59 | flex-grow: 1; | ||
60 | } | ||
20 | 61 | ||
21 | .remote-server-down { | 62 | .remote-server-down { |
22 | color: #fff; | 63 | color: #fff; |
@@ -40,13 +81,8 @@ $other-videos-width: 260px; | |||
40 | } | 81 | } |
41 | 82 | ||
42 | /deep/ .video-js { | 83 | /deep/ .video-js { |
43 | width: 888px; | 84 | width: getPlayerWidth(66vh); |
44 | height: 500px; | 85 | height: 66vh; |
45 | |||
46 | &.vjs-theater-enabled { | ||
47 | height: calc(100vh - #{$header-height} - #{$theater-bottom-space}); | ||
48 | width: 100%; | ||
49 | } | ||
50 | 86 | ||
51 | // VideoJS create an inner video player | 87 | // VideoJS create an inner video player |
52 | video { | 88 | video { |
@@ -59,13 +95,14 @@ $other-videos-width: 260px; | |||
59 | .remote-server-down, | 95 | .remote-server-down, |
60 | /deep/ .video-js { | 96 | /deep/ .video-js { |
61 | width: 100vw; | 97 | width: 100vw; |
62 | height: calc(100vw / 1.7); // 16/9 | 98 | height: getPlayerHeight(100vw) |
63 | } | 99 | } |
64 | } | 100 | } |
65 | } | 101 | } |
66 | 102 | ||
67 | .alert { | 103 | .alert { |
68 | text-align: center; | 104 | text-align: center; |
105 | border-radius: 0; | ||
69 | } | 106 | } |
70 | 107 | ||
71 | #video-not-found { | 108 | #video-not-found { |
@@ -78,6 +115,7 @@ $other-videos-width: 260px; | |||
78 | } | 115 | } |
79 | 116 | ||
80 | .video-bottom { | 117 | .video-bottom { |
118 | display: flex; | ||
81 | margin-top: 40px; | 119 | margin-top: 40px; |
82 | 120 | ||
83 | .video-info { | 121 | .video-info { |
@@ -176,7 +214,9 @@ $other-videos-width: 260px; | |||
176 | display: flex; | 214 | display: flex; |
177 | align-items: center; | 215 | align-items: center; |
178 | 216 | ||
179 | .action-button:not(:first-child), .action-more { | 217 | .action-button:not(:first-child), |
218 | .action-dropdown, | ||
219 | my-video-actions-dropdown { | ||
180 | margin-left: 10px; | 220 | margin-left: 10px; |
181 | } | 221 | } |
182 | 222 | ||
@@ -212,25 +252,16 @@ $other-videos-width: 260px; | |||
212 | } | 252 | } |
213 | } | 253 | } |
214 | 254 | ||
215 | .icon-text { | 255 | &.action-button-save { |
216 | margin-left: 3px; | ||
217 | } | ||
218 | } | ||
219 | |||
220 | .action-more { | ||
221 | display: inline-block; | ||
222 | |||
223 | .dropdown-menu .dropdown-item { | ||
224 | padding: 6px 24px; | ||
225 | |||
226 | my-global-icon { | 256 | my-global-icon { |
227 | width: 24px; | 257 | top: 0 !important; |
228 | 258 | right: -1px; | |
229 | margin-right: 10px; | ||
230 | position: relative; | ||
231 | top: -2px; | ||
232 | } | 259 | } |
233 | } | 260 | } |
261 | |||
262 | .icon-text { | ||
263 | margin-left: 3px; | ||
264 | } | ||
234 | } | 265 | } |
235 | } | 266 | } |
236 | 267 | ||
@@ -286,7 +317,7 @@ $other-videos-width: 260px; | |||
286 | margin-bottom: 12px; | 317 | margin-bottom: 12px; |
287 | 318 | ||
288 | .video-attribute-label { | 319 | .video-attribute-label { |
289 | min-width: 91px; | 320 | min-width: 142px; |
290 | padding-right: 5px; | 321 | padding-right: 5px; |
291 | display: inline-block; | 322 | display: inline-block; |
292 | color: $grey-foreground-color; | 323 | color: $grey-foreground-color; |
@@ -314,7 +345,7 @@ $other-videos-width: 260px; | |||
314 | 345 | ||
315 | /deep/ .other-videos { | 346 | /deep/ .other-videos { |
316 | padding-left: 15px; | 347 | padding-left: 15px; |
317 | width: $other-videos-width; | 348 | flex-basis: $other-videos-width; |
318 | 349 | ||
319 | .title-page { | 350 | .title-page { |
320 | margin-top: 0 !important; | 351 | margin-top: 0 !important; |
@@ -322,14 +353,11 @@ $other-videos-width: 260px; | |||
322 | 353 | ||
323 | .video-miniature { | 354 | .video-miniature { |
324 | display: flex; | 355 | display: flex; |
356 | width: $other-videos-width; | ||
325 | height: 100%; | 357 | height: 100%; |
326 | margin-bottom: 20px; | 358 | margin-bottom: 20px; |
327 | flex-wrap: wrap; | 359 | flex-wrap: wrap; |
328 | 360 | ||
329 | .video-miniature-information { | ||
330 | flex-grow: 1; | ||
331 | } | ||
332 | |||
333 | .video-thumbnail { | 361 | .video-thumbnail { |
334 | margin-right: 10px | 362 | margin-right: 10px |
335 | } | 363 | } |
@@ -350,14 +378,14 @@ my-video-comments { | |||
350 | 378 | ||
351 | @media screen and (max-width: $small-view) { | 379 | @media screen and (max-width: $small-view) { |
352 | .privacy-concerns { | 380 | .privacy-concerns { |
353 | margin-left: $menu-width; | 381 | margin-left: $menu-width - 15px; // Menu is absolute |
354 | } | 382 | } |
355 | } | 383 | } |
356 | 384 | ||
357 | :host-context(.expanded) { | 385 | :host-context(.expanded) { |
358 | .privacy-concerns { | 386 | .privacy-concerns { |
359 | width: 100%; | 387 | width: 100%; |
360 | margin-left: 0; | 388 | margin-left: -15px; |
361 | } | 389 | } |
362 | } | 390 | } |
363 | 391 | ||
@@ -368,6 +396,7 @@ my-video-comments { | |||
368 | padding: 5px 15px; | 396 | padding: 5px 15px; |
369 | 397 | ||
370 | display: flex; | 398 | display: flex; |
399 | flex-wrap: nowrap; | ||
371 | align-items: center; | 400 | align-items: center; |
372 | justify-content: flex-start; | 401 | justify-content: flex-start; |
373 | background-color: rgba(0, 0, 0, 0.9); | 402 | background-color: rgba(0, 0, 0, 0.9); |
@@ -403,12 +432,6 @@ my-video-comments { | |||
403 | } | 432 | } |
404 | } | 433 | } |
405 | 434 | ||
406 | @media screen and (min-width: map-get($grid-breakpoints, xl)) { | ||
407 | .video-bottom .video-info { | ||
408 | max-width: calc(100% - #{$other-videos-width}); | ||
409 | } | ||
410 | } | ||
411 | |||
412 | @media screen and (max-width: 1600px) { | 435 | @media screen and (max-width: 1600px) { |
413 | .video-bottom .video-info .video-attributes .video-attribute { | 436 | .video-bottom .video-info .video-attributes .video-attribute { |
414 | margin-bottom: 5px; | 437 | margin-bottom: 5px; |
@@ -426,9 +449,33 @@ my-video-comments { | |||
426 | } | 449 | } |
427 | } | 450 | } |
428 | 451 | ||
452 | @media screen and (max-width: 1100px) { | ||
453 | #video-wrapper { | ||
454 | flex-direction: column; | ||
455 | justify-content: center; | ||
456 | |||
457 | my-video-watch-playlist /deep/ .playlist { | ||
458 | @include playlist-below-player; | ||
459 | } | ||
460 | } | ||
461 | |||
462 | .video-bottom { | ||
463 | flex-direction: column; | ||
464 | |||
465 | /deep/ .other-videos { | ||
466 | padding-left: 0 !important; | ||
467 | |||
468 | /deep/ .video-miniature { | ||
469 | flex-direction: row; | ||
470 | width: auto; | ||
471 | } | ||
472 | } | ||
473 | } | ||
474 | } | ||
475 | |||
429 | @media screen and (max-width: 600px) { | 476 | @media screen and (max-width: 600px) { |
430 | .video-bottom { | 477 | .video-bottom { |
431 | margin: 20px 0 0 0; | 478 | margin: 20px 0 0 0 !important; |
432 | 479 | ||
433 | .video-info { | 480 | .video-info { |
434 | padding: 0; | 481 | padding: 0; |
@@ -443,12 +490,8 @@ my-video-comments { | |||
443 | } | 490 | } |
444 | } | 491 | } |
445 | 492 | ||
446 | /deep/ .other-videos { | 493 | /deep/ .other-videos .video-miniature { |
447 | padding-left: 0 !important; | 494 | flex-direction: column; |
448 | |||
449 | /deep/ .video-miniature { | ||
450 | flex-direction: column; | ||
451 | } | ||
452 | } | 495 | } |
453 | 496 | ||
454 | .privacy-concerns { | 497 | .privacy-concerns { |
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 ee504bc58..b147b75b0 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts | |||
@@ -7,29 +7,29 @@ import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-supp | |||
7 | import { MetaService } from '@ngx-meta/core' | 7 | import { MetaService } from '@ngx-meta/core' |
8 | import { Notifier, ServerService } from '@app/core' | 8 | import { Notifier, ServerService } from '@app/core' |
9 | import { forkJoin, Subscription } from 'rxjs' | 9 | import { forkJoin, Subscription } from 'rxjs' |
10 | // FIXME: something weird with our path definition in tsconfig and typings | ||
11 | // @ts-ignore | ||
12 | import videojs from 'video.js' | ||
13 | import 'videojs-hotkeys' | ||
14 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | 10 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' |
15 | import * as WebTorrent from 'webtorrent' | ||
16 | import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' | 11 | import { UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared' |
17 | import '../../../assets/player/peertube-videojs-plugin' | ||
18 | import { AuthService, ConfirmService } from '../../core' | 12 | import { AuthService, ConfirmService } from '../../core' |
19 | import { RestExtractor, VideoBlacklistService } from '../../shared' | 13 | import { RestExtractor, VideoBlacklistService } from '../../shared' |
20 | import { VideoDetails } from '../../shared/video/video-details.model' | 14 | import { VideoDetails } from '../../shared/video/video-details.model' |
21 | import { VideoService } from '../../shared/video/video.service' | 15 | import { VideoService } from '../../shared/video/video.service' |
22 | import { VideoDownloadComponent } from './modal/video-download.component' | ||
23 | import { VideoReportComponent } from './modal/video-report.component' | ||
24 | import { VideoShareComponent } from './modal/video-share.component' | 16 | import { VideoShareComponent } from './modal/video-share.component' |
25 | import { VideoBlacklistComponent } from './modal/video-blacklist.component' | ||
26 | import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component' | 17 | import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component' |
27 | import { addContextMenu, getVideojsOptions, loadLocaleInVideoJS } from '../../../assets/player/peertube-player' | ||
28 | import { I18n } from '@ngx-translate/i18n-polyfill' | 18 | import { I18n } from '@ngx-translate/i18n-polyfill' |
29 | import { environment } from '../../../environments/environment' | 19 | import { environment } from '../../../environments/environment' |
30 | import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils' | ||
31 | import { VideoCaptionService } from '@app/shared/video-caption' | 20 | import { VideoCaptionService } from '@app/shared/video-caption' |
32 | import { MarkdownService } from '@app/shared/renderer' | 21 | import { MarkdownService } from '@app/shared/renderer' |
22 | import { | ||
23 | P2PMediaLoaderOptions, | ||
24 | PeertubePlayerManager, | ||
25 | PeertubePlayerManagerOptions, | ||
26 | PlayerMode | ||
27 | } from '../../../assets/player/peertube-player-manager' | ||
28 | import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model' | ||
29 | import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service' | ||
30 | import { Video } from '@app/shared/video/video.model' | ||
31 | import { isWebRTCDisabled } from '../../../assets/player/utils' | ||
32 | import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' | ||
33 | 33 | ||
34 | @Component({ | 34 | @Component({ |
35 | selector: 'my-video-watch', | 35 | selector: 'my-video-watch', |
@@ -39,19 +39,20 @@ import { MarkdownService } from '@app/shared/renderer' | |||
39 | export class VideoWatchComponent implements OnInit, OnDestroy { | 39 | export class VideoWatchComponent implements OnInit, OnDestroy { |
40 | private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' | 40 | private static LOCAL_STORAGE_PRIVACY_CONCERN_KEY = 'video-watch-privacy-concern' |
41 | 41 | ||
42 | @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent | 42 | @ViewChild('videoWatchPlaylist') videoWatchPlaylist: VideoWatchPlaylistComponent |
43 | @ViewChild('videoShareModal') videoShareModal: VideoShareComponent | 43 | @ViewChild('videoShareModal') videoShareModal: VideoShareComponent |
44 | @ViewChild('videoReportModal') videoReportModal: VideoReportComponent | ||
45 | @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent | 44 | @ViewChild('videoSupportModal') videoSupportModal: VideoSupportComponent |
46 | @ViewChild('videoBlacklistModal') videoBlacklistModal: VideoBlacklistComponent | ||
47 | @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent | 45 | @ViewChild('subscribeButton') subscribeButton: SubscribeButtonComponent |
48 | 46 | ||
49 | player: videojs.Player | 47 | player: any |
50 | playerElement: HTMLVideoElement | 48 | playerElement: HTMLVideoElement |
49 | theaterEnabled = false | ||
51 | userRating: UserVideoRateType = null | 50 | userRating: UserVideoRateType = null |
52 | video: VideoDetails = null | 51 | video: VideoDetails = null |
53 | descriptionLoading = false | 52 | descriptionLoading = false |
54 | 53 | ||
54 | playlist: VideoPlaylist = null | ||
55 | |||
55 | completeDescriptionShown = false | 56 | completeDescriptionShown = false |
56 | completeVideoDescription: string | 57 | completeVideoDescription: string |
57 | shortVideoDescription: string | 58 | shortVideoDescription: string |
@@ -61,8 +62,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
61 | remoteServerDown = false | 62 | remoteServerDown = false |
62 | hotkeys: Hotkey[] | 63 | hotkeys: Hotkey[] |
63 | 64 | ||
64 | private videojsLocaleLoaded = false | 65 | private currentTime: number |
65 | private paramsSub: Subscription | 66 | private paramsSub: Subscription |
67 | private queryParamsSub: Subscription | ||
68 | private configSub: Subscription | ||
66 | 69 | ||
67 | constructor ( | 70 | constructor ( |
68 | private elementRef: ElementRef, | 71 | private elementRef: ElementRef, |
@@ -70,6 +73,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
70 | private route: ActivatedRoute, | 73 | private route: ActivatedRoute, |
71 | private router: Router, | 74 | private router: Router, |
72 | private videoService: VideoService, | 75 | private videoService: VideoService, |
76 | private playlistService: VideoPlaylistService, | ||
73 | private videoBlacklistService: VideoBlacklistService, | 77 | private videoBlacklistService: VideoBlacklistService, |
74 | private confirmService: ConfirmService, | 78 | private confirmService: ConfirmService, |
75 | private metaService: MetaService, | 79 | private metaService: MetaService, |
@@ -91,37 +95,28 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
91 | } | 95 | } |
92 | 96 | ||
93 | ngOnInit () { | 97 | ngOnInit () { |
94 | if ( | 98 | this.configSub = this.serverService.configLoaded |
95 | WebTorrent.WEBRTC_SUPPORT === false || | 99 | .subscribe(() => { |
96 | peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true' | 100 | if ( |
97 | ) { | 101 | isWebRTCDisabled() || |
98 | this.hasAlreadyAcceptedPrivacyConcern = true | 102 | this.serverService.getConfig().tracker.enabled === false || |
99 | } | 103 | peertubeLocalStorage.getItem(VideoWatchComponent.LOCAL_STORAGE_PRIVACY_CONCERN_KEY) === 'true' |
104 | ) { | ||
105 | this.hasAlreadyAcceptedPrivacyConcern = true | ||
106 | } | ||
107 | }) | ||
100 | 108 | ||
101 | this.paramsSub = this.route.params.subscribe(routeParams => { | 109 | this.paramsSub = this.route.params.subscribe(routeParams => { |
102 | const uuid = routeParams[ 'uuid' ] | 110 | const videoId = routeParams[ 'videoId' ] |
103 | 111 | if (videoId) this.loadVideo(videoId) | |
104 | // Video did not change | ||
105 | if (this.video && this.video.uuid === uuid) return | ||
106 | |||
107 | if (this.player) this.player.pause() | ||
108 | 112 | ||
109 | // Video did change | 113 | const playlistId = routeParams[ 'playlistId' ] |
110 | forkJoin( | 114 | if (playlistId) this.loadPlaylist(playlistId) |
111 | this.videoService.getVideo(uuid), | 115 | }) |
112 | this.videoCaptionService.listCaptions(uuid) | ||
113 | ) | ||
114 | .pipe( | ||
115 | // If 401, the video is private or blacklisted so redirect to 404 | ||
116 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ])) | ||
117 | ) | ||
118 | .subscribe(([ video, captionsResult ]) => { | ||
119 | const startTime = this.route.snapshot.queryParams.start | ||
120 | const subtitle = this.route.snapshot.queryParams.subtitle | ||
121 | 116 | ||
122 | this.onVideoFetched(video, captionsResult.data, { startTime, subtitle }) | 117 | this.queryParamsSub = this.route.queryParams.subscribe(queryParams => { |
123 | .catch(err => this.handleError(err)) | 118 | const videoId = queryParams[ 'videoId' ] |
124 | }) | 119 | if (videoId) this.loadVideo(videoId) |
125 | }) | 120 | }) |
126 | 121 | ||
127 | this.hotkeys = [ | 122 | this.hotkeys = [ |
@@ -147,7 +142,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
147 | this.flushPlayer() | 142 | this.flushPlayer() |
148 | 143 | ||
149 | // Unsubscribe subscriptions | 144 | // Unsubscribe subscriptions |
150 | this.paramsSub.unsubscribe() | 145 | if (this.paramsSub) this.paramsSub.unsubscribe() |
146 | if (this.queryParamsSub) this.queryParamsSub.unsubscribe() | ||
151 | 147 | ||
152 | // Unbind hotkeys | 148 | // Unbind hotkeys |
153 | if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys) | 149 | if (this.isUserLoggedIn()) this.hotkeysService.remove(this.hotkeys) |
@@ -209,96 +205,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
209 | ) | 205 | ) |
210 | } | 206 | } |
211 | 207 | ||
212 | showReportModal (event: Event) { | ||
213 | event.preventDefault() | ||
214 | this.videoReportModal.show() | ||
215 | } | ||
216 | |||
217 | showSupportModal () { | 208 | showSupportModal () { |
218 | this.videoSupportModal.show() | 209 | this.videoSupportModal.show() |
219 | } | 210 | } |
220 | 211 | ||
221 | showShareModal () { | 212 | showShareModal () { |
222 | const currentTime = this.player ? this.player.currentTime() : undefined | 213 | this.videoShareModal.show(this.currentTime) |
223 | |||
224 | this.videoShareModal.show(currentTime) | ||
225 | } | ||
226 | |||
227 | showDownloadModal (event: Event) { | ||
228 | event.preventDefault() | ||
229 | this.videoDownloadModal.show() | ||
230 | } | ||
231 | |||
232 | showBlacklistModal (event: Event) { | ||
233 | event.preventDefault() | ||
234 | this.videoBlacklistModal.show() | ||
235 | } | ||
236 | |||
237 | async unblacklistVideo (event: Event) { | ||
238 | event.preventDefault() | ||
239 | |||
240 | const confirmMessage = this.i18n( | ||
241 | 'Do you really want to remove this video from the blacklist? It will be available again in the videos list.' | ||
242 | ) | ||
243 | |||
244 | const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblacklist')) | ||
245 | if (res === false) return | ||
246 | |||
247 | this.videoBlacklistService.removeVideoFromBlacklist(this.video.id).subscribe( | ||
248 | () => { | ||
249 | this.notifier.success(this.i18n('Video {{name}} removed from the blacklist.', { name: this.video.name })) | ||
250 | |||
251 | this.video.blacklisted = false | ||
252 | this.video.blacklistedReason = null | ||
253 | }, | ||
254 | |||
255 | err => this.notifier.error(err.message) | ||
256 | ) | ||
257 | } | 214 | } |
258 | 215 | ||
259 | isUserLoggedIn () { | 216 | isUserLoggedIn () { |
260 | return this.authService.isLoggedIn() | 217 | return this.authService.isLoggedIn() |
261 | } | 218 | } |
262 | 219 | ||
263 | isVideoUpdatable () { | ||
264 | return this.video.isUpdatableBy(this.authService.getUser()) | ||
265 | } | ||
266 | |||
267 | isVideoBlacklistable () { | ||
268 | return this.video.isBlackistableBy(this.user) | ||
269 | } | ||
270 | |||
271 | isVideoUnblacklistable () { | ||
272 | return this.video.isUnblacklistableBy(this.user) | ||
273 | } | ||
274 | |||
275 | getVideoTags () { | 220 | getVideoTags () { |
276 | if (!this.video || Array.isArray(this.video.tags) === false) return [] | 221 | if (!this.video || Array.isArray(this.video.tags) === false) return [] |
277 | 222 | ||
278 | return this.video.tags | 223 | return this.video.tags |
279 | } | 224 | } |
280 | 225 | ||
281 | isVideoRemovable () { | 226 | onVideoRemoved () { |
282 | return this.video.isRemovableBy(this.authService.getUser()) | 227 | this.redirectService.redirectToHomepage() |
283 | } | ||
284 | |||
285 | async removeVideo (event: Event) { | ||
286 | event.preventDefault() | ||
287 | |||
288 | const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete')) | ||
289 | if (res === false) return | ||
290 | |||
291 | this.videoService.removeVideo(this.video.id) | ||
292 | .subscribe( | ||
293 | () => { | ||
294 | this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name })) | ||
295 | |||
296 | // Go back to the video-list. | ||
297 | this.redirectService.redirectToHomepage() | ||
298 | }, | ||
299 | |||
300 | error => this.notifier.error(error.message) | ||
301 | ) | ||
302 | } | 228 | } |
303 | 229 | ||
304 | acceptedPrivacyConcern () { | 230 | acceptedPrivacyConcern () { |
@@ -318,13 +244,61 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
318 | return this.video && this.video.scheduledUpdate !== undefined | 244 | return this.video && this.video.scheduledUpdate !== undefined |
319 | } | 245 | } |
320 | 246 | ||
247 | isVideoBlur (video: Video) { | ||
248 | return video.isVideoNSFWForUser(this.user, this.serverService.getConfig()) | ||
249 | } | ||
250 | |||
251 | private loadVideo (videoId: string) { | ||
252 | // Video did not change | ||
253 | if (this.video && this.video.uuid === videoId) return | ||
254 | |||
255 | if (this.player) this.player.pause() | ||
256 | |||
257 | // Video did change | ||
258 | forkJoin( | ||
259 | this.videoService.getVideo(videoId), | ||
260 | this.videoCaptionService.listCaptions(videoId) | ||
261 | ) | ||
262 | .pipe( | ||
263 | // If 401, the video is private or blacklisted so redirect to 404 | ||
264 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ])) | ||
265 | ) | ||
266 | .subscribe(([ video, captionsResult ]) => { | ||
267 | const queryParams = this.route.snapshot.queryParams | ||
268 | const startTime = queryParams.start | ||
269 | const stopTime = queryParams.stop | ||
270 | const subtitle = queryParams.subtitle | ||
271 | const playerMode = queryParams.mode | ||
272 | |||
273 | this.onVideoFetched(video, captionsResult.data, { startTime, stopTime, subtitle, playerMode }) | ||
274 | .catch(err => this.handleError(err)) | ||
275 | }) | ||
276 | } | ||
277 | |||
278 | private loadPlaylist (playlistId: string) { | ||
279 | // Playlist did not change | ||
280 | if (this.playlist && this.playlist.uuid === playlistId) return | ||
281 | |||
282 | this.playlistService.getVideoPlaylist(playlistId) | ||
283 | .pipe( | ||
284 | // If 401, the video is private or blacklisted so redirect to 404 | ||
285 | catchError(err => this.restExtractor.redirectTo404IfNotFound(err, [ 400, 401, 403, 404 ])) | ||
286 | ) | ||
287 | .subscribe(playlist => { | ||
288 | this.playlist = playlist | ||
289 | |||
290 | const videoId = this.route.snapshot.queryParams['videoId'] | ||
291 | this.videoWatchPlaylist.loadPlaylistElements(playlist, !videoId) | ||
292 | }) | ||
293 | } | ||
294 | |||
321 | private updateVideoDescription (description: string) { | 295 | private updateVideoDescription (description: string) { |
322 | this.video.description = description | 296 | this.video.description = description |
323 | this.setVideoDescriptionHTML() | 297 | this.setVideoDescriptionHTML() |
324 | } | 298 | } |
325 | 299 | ||
326 | private setVideoDescriptionHTML () { | 300 | private async setVideoDescriptionHTML () { |
327 | this.videoHTMLDescription = this.markdownService.textMarkdownToHTML(this.video.description) | 301 | this.videoHTMLDescription = await this.markdownService.textMarkdownToHTML(this.video.description) |
328 | } | 302 | } |
329 | 303 | ||
330 | private setVideoLikesBarTooltipText () { | 304 | private setVideoLikesBarTooltipText () { |
@@ -366,19 +340,26 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
366 | ) | 340 | ) |
367 | } | 341 | } |
368 | 342 | ||
369 | private async onVideoFetched (video: VideoDetails, videoCaptions: VideoCaption[], urlOptions: { startTime: number, subtitle: string }) { | 343 | private async onVideoFetched ( |
344 | video: VideoDetails, | ||
345 | videoCaptions: VideoCaption[], | ||
346 | urlOptions: { startTime?: number, stopTime?: number, subtitle?: string, playerMode?: string } | ||
347 | ) { | ||
370 | this.video = video | 348 | this.video = video |
371 | 349 | ||
372 | // Re init attributes | 350 | // Re init attributes |
373 | this.descriptionLoading = false | 351 | this.descriptionLoading = false |
374 | this.completeDescriptionShown = false | 352 | this.completeDescriptionShown = false |
375 | this.remoteServerDown = false | 353 | this.remoteServerDown = false |
354 | this.currentTime = undefined | ||
355 | |||
356 | this.videoWatchPlaylist.updatePlaylistIndex(video) | ||
376 | 357 | ||
377 | let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) | 358 | let startTime = urlOptions.startTime || (this.video.userHistory ? this.video.userHistory.currentTime : 0) |
378 | // If we are at the end of the video, reset the timer | 359 | // If we are at the end of the video, reset the timer |
379 | if (this.video.duration - startTime <= 1) startTime = 0 | 360 | if (this.video.duration - startTime <= 1) startTime = 0 |
380 | 361 | ||
381 | if (this.video.isVideoNSFWForUser(this.user, this.serverService.getConfig())) { | 362 | if (this.isVideoBlur(this.video)) { |
382 | const res = await this.confirmService.confirm( | 363 | const res = await this.confirmService.confirm( |
383 | this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), | 364 | this.i18n('This video contains mature or explicit content. Are you sure you want to watch it?'), |
384 | this.i18n('Mature or explicit content') | 365 | this.i18n('Mature or explicit content') |
@@ -390,7 +371,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
390 | this.flushPlayer() | 371 | this.flushPlayer() |
391 | 372 | ||
392 | // Build video element, because videojs remove it on dispose | 373 | // Build video element, because videojs remove it on dispose |
393 | const playerElementWrapper = this.elementRef.nativeElement.querySelector('#video-element-wrapper') | 374 | const playerElementWrapper = this.elementRef.nativeElement.querySelector('#videojs-wrapper') |
394 | this.playerElement = document.createElement('video') | 375 | this.playerElement = document.createElement('video') |
395 | this.playerElement.className = 'video-js vjs-peertube-skin' | 376 | this.playerElement.className = 'video-js vjs-peertube-skin' |
396 | this.playerElement.setAttribute('playsinline', 'true') | 377 | this.playerElement.setAttribute('playsinline', 'true') |
@@ -402,40 +383,94 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
402 | src: environment.apiUrl + c.captionPath | 383 | src: environment.apiUrl + c.captionPath |
403 | })) | 384 | })) |
404 | 385 | ||
405 | const videojsOptions = getVideojsOptions({ | 386 | const options: PeertubePlayerManagerOptions = { |
406 | autoplay: this.isAutoplay(), | 387 | common: { |
407 | inactivityTimeout: 2500, | 388 | autoplay: this.isAutoplay(), |
408 | videoFiles: this.video.files, | 389 | |
409 | videoCaptions: playerCaptions, | 390 | playerElement: this.playerElement, |
410 | playerElement: this.playerElement, | 391 | onPlayerElementChange: (element: HTMLVideoElement) => this.playerElement = element, |
411 | videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null, | 392 | |
412 | videoDuration: this.video.duration, | 393 | videoDuration: this.video.duration, |
413 | enableHotkeys: true, | 394 | enableHotkeys: true, |
414 | peertubeLink: false, | 395 | inactivityTimeout: 2500, |
415 | poster: this.video.previewUrl, | 396 | poster: this.video.previewUrl, |
416 | startTime, | 397 | startTime, |
417 | subtitle: urlOptions.subtitle, | 398 | stopTime: urlOptions.stopTime, |
418 | theaterMode: true, | 399 | |
419 | language: this.localeId, | 400 | theaterMode: true, |
420 | 401 | captions: videoCaptions.length !== 0, | |
421 | userWatching: this.user && this.user.videosHistoryEnabled === true ? { | 402 | peertubeLink: false, |
422 | url: this.videoService.getUserWatchingVideoUrl(this.video.uuid), | 403 | |
423 | authorizationHeader: this.authService.getRequestHeaderValue() | 404 | videoViewUrl: this.video.privacy.id !== VideoPrivacy.PRIVATE ? this.videoService.getVideoViewUrl(this.video.uuid) : null, |
424 | } : undefined | 405 | embedUrl: this.video.embedUrl, |
425 | }) | ||
426 | 406 | ||
427 | if (this.videojsLocaleLoaded === false) { | 407 | language: this.localeId, |
428 | await loadLocaleInVideoJS(environment.apiUrl, videojs, isOnDevLocale() ? getDevLocale() : this.localeId) | 408 | |
429 | this.videojsLocaleLoaded = true | 409 | subtitle: urlOptions.subtitle, |
410 | |||
411 | userWatching: this.user && this.user.videosHistoryEnabled === true ? { | ||
412 | url: this.videoService.getUserWatchingVideoUrl(this.video.uuid), | ||
413 | authorizationHeader: this.authService.getRequestHeaderValue() | ||
414 | } : undefined, | ||
415 | |||
416 | serverUrl: environment.apiUrl, | ||
417 | |||
418 | videoCaptions: playerCaptions | ||
419 | }, | ||
420 | |||
421 | webtorrent: { | ||
422 | videoFiles: this.video.files | ||
423 | } | ||
424 | } | ||
425 | |||
426 | let mode: PlayerMode | ||
427 | |||
428 | if (urlOptions.playerMode) { | ||
429 | if (urlOptions.playerMode === 'p2p-media-loader') mode = 'p2p-media-loader' | ||
430 | else mode = 'webtorrent' | ||
431 | } else { | ||
432 | if (this.video.hasHlsPlaylist()) mode = 'p2p-media-loader' | ||
433 | else mode = 'webtorrent' | ||
434 | } | ||
435 | |||
436 | if (mode === 'p2p-media-loader') { | ||
437 | const hlsPlaylist = this.video.getHlsPlaylist() | ||
438 | |||
439 | const p2pMediaLoader = { | ||
440 | playlistUrl: hlsPlaylist.playlistUrl, | ||
441 | segmentsSha256Url: hlsPlaylist.segmentsSha256Url, | ||
442 | redundancyBaseUrls: hlsPlaylist.redundancies.map(r => r.baseUrl), | ||
443 | trackerAnnounce: this.video.trackerUrls, | ||
444 | videoFiles: this.video.files | ||
445 | } as P2PMediaLoaderOptions | ||
446 | |||
447 | Object.assign(options, { p2pMediaLoader }) | ||
430 | } | 448 | } |
431 | 449 | ||
432 | const self = this | ||
433 | this.zone.runOutsideAngular(async () => { | 450 | this.zone.runOutsideAngular(async () => { |
434 | videojs(this.playerElement, videojsOptions, function (this: videojs.Player) { | 451 | this.player = await PeertubePlayerManager.initialize(mode, options) |
435 | self.player = this | 452 | this.theaterEnabled = this.player.theaterEnabled |
436 | this.on('customError', ({ err }: { err: any }) => self.handleError(err)) | 453 | |
454 | this.player.on('customError', ({ err }: { err: any }) => this.handleError(err)) | ||
455 | |||
456 | this.player.on('timeupdate', () => { | ||
457 | this.currentTime = Math.floor(this.player.currentTime()) | ||
458 | }) | ||
459 | |||
460 | this.player.one('ended', () => { | ||
461 | if (this.playlist) { | ||
462 | this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | ||
463 | } | ||
464 | }) | ||
465 | |||
466 | this.player.one('stopped', () => { | ||
467 | if (this.playlist) { | ||
468 | this.zone.run(() => this.videoWatchPlaylist.navigateToNextPlaylistVideo()) | ||
469 | } | ||
470 | }) | ||
437 | 471 | ||
438 | addContextMenu(self.player, self.video.embedUrl) | 472 | this.player.on('theaterChange', (_: any, enabled: boolean) => { |
473 | this.zone.run(() => this.theaterEnabled = enabled) | ||
439 | }) | 474 | }) |
440 | }) | 475 | }) |
441 | 476 | ||
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 2f448db78..67596a3da 100644 --- a/client/src/app/videos/+video-watch/video-watch.module.ts +++ b/client/src/app/videos/+video-watch/video-watch.module.ts | |||
@@ -1,26 +1,22 @@ | |||
1 | import { NgModule } from '@angular/core' | 1 | import { NgModule } from '@angular/core' |
2 | import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component' | 2 | import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component' |
3 | import { ClipboardModule } from 'ngx-clipboard' | ||
4 | import { SharedModule } from '../../shared' | 3 | import { SharedModule } from '../../shared' |
5 | import { VideoCommentAddComponent } from './comment/video-comment-add.component' | 4 | import { VideoCommentAddComponent } from './comment/video-comment-add.component' |
6 | import { VideoCommentComponent } from './comment/video-comment.component' | 5 | import { VideoCommentComponent } from './comment/video-comment.component' |
7 | import { VideoCommentService } from './comment/video-comment.service' | 6 | import { VideoCommentService } from './comment/video-comment.service' |
8 | import { VideoCommentsComponent } from './comment/video-comments.component' | 7 | import { VideoCommentsComponent } from './comment/video-comments.component' |
9 | import { VideoDownloadComponent } from './modal/video-download.component' | ||
10 | import { VideoReportComponent } from './modal/video-report.component' | ||
11 | import { VideoShareComponent } from './modal/video-share.component' | 8 | import { VideoShareComponent } from './modal/video-share.component' |
12 | import { VideoWatchRoutingModule } from './video-watch-routing.module' | 9 | import { VideoWatchRoutingModule } from './video-watch-routing.module' |
13 | import { VideoWatchComponent } from './video-watch.component' | 10 | import { VideoWatchComponent } from './video-watch.component' |
14 | import { NgxQRCodeModule } from 'ngx-qrcode2' | 11 | import { NgxQRCodeModule } from 'ngx-qrcode2' |
15 | import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' | 12 | import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' |
16 | import { VideoBlacklistComponent } from '@app/videos/+video-watch/modal/video-blacklist.component' | ||
17 | import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module' | 13 | import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module' |
14 | import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' | ||
18 | 15 | ||
19 | @NgModule({ | 16 | @NgModule({ |
20 | imports: [ | 17 | imports: [ |
21 | VideoWatchRoutingModule, | 18 | VideoWatchRoutingModule, |
22 | SharedModule, | 19 | SharedModule, |
23 | ClipboardModule, | ||
24 | NgbTooltipModule, | 20 | NgbTooltipModule, |
25 | NgxQRCodeModule, | 21 | NgxQRCodeModule, |
26 | RecommendationsModule | 22 | RecommendationsModule |
@@ -28,11 +24,9 @@ import { RecommendationsModule } from '@app/videos/recommendations/recommendatio | |||
28 | 24 | ||
29 | declarations: [ | 25 | declarations: [ |
30 | VideoWatchComponent, | 26 | VideoWatchComponent, |
27 | VideoWatchPlaylistComponent, | ||
31 | 28 | ||
32 | VideoDownloadComponent, | ||
33 | VideoShareComponent, | 29 | VideoShareComponent, |
34 | VideoReportComponent, | ||
35 | VideoBlacklistComponent, | ||
36 | VideoSupportComponent, | 30 | VideoSupportComponent, |
37 | VideoCommentsComponent, | 31 | VideoCommentsComponent, |
38 | VideoCommentAddComponent, | 32 | VideoCommentAddComponent, |
diff --git a/client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts b/client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts deleted file mode 100644 index 698b2e27b..000000000 --- a/client/src/app/videos/recommendations/recent-videos-recommendation.service.spec.ts +++ /dev/null | |||
@@ -1,66 +0,0 @@ | |||
1 | import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service' | ||
2 | import { VideosProvider } from '@app/shared/video/video.service' | ||
3 | import { EMPTY, of } from 'rxjs' | ||
4 | import Mock = jest.Mock | ||
5 | |||
6 | describe('"Recent Videos" Recommender', () => { | ||
7 | describe('getRecommendations', () => { | ||
8 | let videosService: VideosProvider | ||
9 | let service: RecentVideosRecommendationService | ||
10 | let getVideosMock: Mock<any> | ||
11 | beforeEach(() => { | ||
12 | getVideosMock = jest.fn(() => EMPTY) | ||
13 | videosService = { | ||
14 | getVideos: getVideosMock | ||
15 | } | ||
16 | service = new RecentVideosRecommendationService(videosService) | ||
17 | }) | ||
18 | it('should filter out the given UUID from the results', async (done) => { | ||
19 | const vids = [ | ||
20 | { uuid: 'uuid1' }, | ||
21 | { uuid: 'uuid2' } | ||
22 | ] | ||
23 | getVideosMock.mockReturnValueOnce(of({ videos: vids })) | ||
24 | const result = await service.getRecommendations({ uuid: 'uuid1' }).toPromise() | ||
25 | const uuids = result.map(v => v.uuid) | ||
26 | expect(uuids).toEqual(['uuid2']) | ||
27 | done() | ||
28 | }) | ||
29 | it('should return 5 results when the given UUID is NOT in the first 5 results', async (done) => { | ||
30 | const vids = [ | ||
31 | { uuid: 'uuid2' }, | ||
32 | { uuid: 'uuid3' }, | ||
33 | { uuid: 'uuid4' }, | ||
34 | { uuid: 'uuid5' }, | ||
35 | { uuid: 'uuid6' }, | ||
36 | { uuid: 'uuid7' } | ||
37 | ] | ||
38 | getVideosMock.mockReturnValueOnce(of({ videos: vids })) | ||
39 | const result = await service.getRecommendations({ uuid: 'uuid1' }).toPromise() | ||
40 | expect(result.length).toEqual(5) | ||
41 | done() | ||
42 | }) | ||
43 | it('should return 5 results when the given UUID IS PRESENT in the first 5 results', async (done) => { | ||
44 | const vids = [ | ||
45 | { uuid: 'uuid1' }, | ||
46 | { uuid: 'uuid2' }, | ||
47 | { uuid: 'uuid3' }, | ||
48 | { uuid: 'uuid4' }, | ||
49 | { uuid: 'uuid5' }, | ||
50 | { uuid: 'uuid6' } | ||
51 | ] | ||
52 | getVideosMock | ||
53 | .mockReturnValueOnce(of({ videos: vids })) | ||
54 | const result = await service.getRecommendations({ uuid: 'uuid1' }).toPromise() | ||
55 | expect(result.length).toEqual(5) | ||
56 | done() | ||
57 | }) | ||
58 | it('should fetch an extra result in case the given UUID is in the list', async (done) => { | ||
59 | await service.getRecommendations({ uuid: 'uuid1' }).toPromise() | ||
60 | let expectedSize = service.pageSize + 1 | ||
61 | let params = { currentPage: jasmine.anything(), itemsPerPage: expectedSize } | ||
62 | expect(getVideosMock).toHaveBeenCalledWith(params, jasmine.anything()) | ||
63 | done() | ||
64 | }) | ||
65 | }) | ||
66 | }) | ||
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.html b/client/src/app/videos/recommendations/recommended-videos.component.html index 73f9f0fe1..1fb89f8b0 100644 --- a/client/src/app/videos/recommendations/recommended-videos.component.html +++ b/client/src/app/videos/recommendations/recommended-videos.component.html | |||
@@ -4,6 +4,6 @@ | |||
4 | </div> | 4 | </div> |
5 | 5 | ||
6 | <div *ngFor="let video of (videos$ | async)"> | 6 | <div *ngFor="let video of (videos$ | async)"> |
7 | <my-video-miniature [video]="video" [user]="user"></my-video-miniature> | 7 | <my-video-miniature [video]="video" [user]="user" (videoBlacklisted)="onVideoRemoved()" (videoRemoved)="onVideoRemoved()"></my-video-miniature> |
8 | </div> | 8 | </div> |
9 | </div> | 9 | </div> |
diff --git a/client/src/app/videos/recommendations/recommended-videos.component.ts b/client/src/app/videos/recommendations/recommended-videos.component.ts index c6c1d9e5d..68fd750cc 100644 --- a/client/src/app/videos/recommendations/recommended-videos.component.ts +++ b/client/src/app/videos/recommendations/recommended-videos.component.ts | |||
@@ -29,4 +29,7 @@ export class RecommendedVideosComponent implements OnChanges { | |||
29 | } | 29 | } |
30 | } | 30 | } |
31 | 31 | ||
32 | onVideoRemoved () { | ||
33 | this.store.requestNewRecommendations(this.inputRecommendation) | ||
34 | } | ||
32 | } | 35 | } |
diff --git a/client/src/app/videos/recommendations/recommended-videos.store.spec.ts b/client/src/app/videos/recommendations/recommended-videos.store.spec.ts deleted file mode 100644 index e12a3f520..000000000 --- a/client/src/app/videos/recommendations/recommended-videos.store.spec.ts +++ /dev/null | |||
@@ -1,22 +0,0 @@ | |||
1 | import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store' | ||
2 | import { RecommendationService } from '@app/videos/recommendations/recommendations.service' | ||
3 | |||
4 | describe('RecommendedVideosStore', () => { | ||
5 | describe('requestNewRecommendations', () => { | ||
6 | let store: RecommendedVideosStore | ||
7 | let service: RecommendationService | ||
8 | beforeEach(() => { | ||
9 | service = { | ||
10 | getRecommendations: jest.fn(() => new Promise((r) => r())) | ||
11 | } | ||
12 | store = new RecommendedVideosStore(service) | ||
13 | }) | ||
14 | it('should pull new videos from the service one time when given the same UUID twice', () => { | ||
15 | store.requestNewRecommendations('some-uuid') | ||
16 | store.requestNewRecommendations('some-uuid') | ||
17 | // Requests aren't fulfilled until someone asks for them (ie: subscribes) | ||
18 | store.recommendations$.subscribe() | ||
19 | expect(service.getRecommendations).toHaveBeenCalledTimes(1) | ||
20 | }) | ||
21 | }) | ||
22 | }) | ||
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 c0be4b885..13d4023c2 100644 --- a/client/src/app/videos/video-list/video-local.component.ts +++ b/client/src/app/videos/video-list/video-local.component.ts | |||
@@ -1,7 +1,6 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { immutableAssign } from '@app/shared/misc/utils' | 3 | import { immutableAssign } from '@app/shared/misc/utils' |
4 | import { Location } from '@angular/common' | ||
5 | import { AuthService } from '../../core/auth' | 4 | import { AuthService } from '../../core/auth' |
6 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' | 5 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' |
7 | import { VideoSortField } from '../../shared/video/sort-field.type' | 6 | import { VideoSortField } from '../../shared/video/sort-field.type' |
@@ -10,7 +9,7 @@ import { VideoFilter } from '../../../../../shared/models/videos/video-query.typ | |||
10 | import { I18n } from '@ngx-translate/i18n-polyfill' | 9 | import { I18n } from '@ngx-translate/i18n-polyfill' |
11 | import { ScreenService } from '@app/shared/misc/screen.service' | 10 | import { ScreenService } from '@app/shared/misc/screen.service' |
12 | import { UserRight } from '../../../../../shared/models/users' | 11 | import { UserRight } from '../../../../../shared/models/users' |
13 | import { Notifier } from '@app/core' | 12 | import { Notifier, ServerService } from '@app/core' |
14 | 13 | ||
15 | @Component({ | 14 | @Component({ |
16 | selector: 'my-videos-local', | 15 | selector: 'my-videos-local', |
@@ -19,18 +18,17 @@ import { Notifier } from '@app/core' | |||
19 | }) | 18 | }) |
20 | export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy { | 19 | export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy { |
21 | titlePage: string | 20 | titlePage: string |
22 | currentRoute = '/videos/local' | ||
23 | sort = '-publishedAt' as VideoSortField | 21 | sort = '-publishedAt' as VideoSortField |
24 | filter: VideoFilter = 'local' | 22 | filter: VideoFilter = 'local' |
25 | 23 | ||
26 | constructor ( | 24 | constructor ( |
27 | protected router: Router, | 25 | protected router: Router, |
26 | protected serverService: ServerService, | ||
28 | protected route: ActivatedRoute, | 27 | protected route: ActivatedRoute, |
29 | protected notifier: Notifier, | 28 | protected notifier: Notifier, |
30 | protected authService: AuthService, | 29 | protected authService: AuthService, |
31 | protected location: Location, | ||
32 | protected i18n: I18n, | ||
33 | protected screenService: ScreenService, | 30 | protected screenService: ScreenService, |
31 | private i18n: I18n, | ||
34 | private videoService: VideoService | 32 | private videoService: VideoService |
35 | ) { | 33 | ) { |
36 | super() | 34 | super() |
diff --git a/client/src/app/videos/video-list/video-overview.component.html b/client/src/app/videos/video-list/video-overview.component.html index cb26592e3..b644dd798 100644 --- a/client/src/app/videos/video-list/video-overview.component.html +++ b/client/src/app/videos/video-list/video-overview.component.html | |||
@@ -7,7 +7,7 @@ | |||
7 | <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a> | 7 | <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a> |
8 | </div> | 8 | </div> |
9 | 9 | ||
10 | <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature> | 10 | <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user" [displayVideoActions]="false"></my-video-miniature> |
11 | </div> | 11 | </div> |
12 | 12 | ||
13 | <div class="section" *ngFor="let object of overview.tags"> | 13 | <div class="section" *ngFor="let object of overview.tags"> |
@@ -15,7 +15,7 @@ | |||
15 | <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a> | 15 | <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a> |
16 | </div> | 16 | </div> |
17 | 17 | ||
18 | <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature> | 18 | <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user" [displayVideoActions]="false"></my-video-miniature> |
19 | </div> | 19 | </div> |
20 | 20 | ||
21 | <div class="section channel" *ngFor="let object of overview.channels"> | 21 | <div class="section channel" *ngFor="let object of overview.channels"> |
@@ -27,7 +27,7 @@ | |||
27 | </a> | 27 | </a> |
28 | </div> | 28 | </div> |
29 | 29 | ||
30 | <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user"></my-video-miniature> | 30 | <my-video-miniature *ngFor="let video of object.videos" [video]="video" [user]="user" [displayVideoActions]="false"></my-video-miniature> |
31 | </div> | 31 | </div> |
32 | 32 | ||
33 | </div> | 33 | </div> |
diff --git a/client/src/app/videos/video-list/video-overview.component.scss b/client/src/app/videos/video-list/video-overview.component.scss index aff45c072..a24766783 100644 --- a/client/src/app/videos/video-list/video-overview.component.scss +++ b/client/src/app/videos/video-list/video-overview.component.scss | |||
@@ -1,7 +1,10 @@ | |||
1 | @import '_variables'; | 1 | @import '_variables'; |
2 | @import '_mixins'; | 2 | @import '_mixins'; |
3 | @import '_miniature'; | ||
3 | 4 | ||
4 | .section { | 5 | .section { |
6 | max-height: 500px; // 2 rows max | ||
7 | overflow: hidden; | ||
5 | padding-top: 10px; | 8 | padding-top: 10px; |
6 | 9 | ||
7 | &:first-child { | 10 | &:first-child { |
@@ -43,11 +46,18 @@ | |||
43 | } | 46 | } |
44 | 47 | ||
45 | @media screen and (max-width: 500px) { | 48 | @media screen and (max-width: 500px) { |
49 | .margin-content { | ||
50 | margin: 0 !important; | ||
51 | } | ||
52 | |||
46 | .section-title { | 53 | .section-title { |
47 | font-size: 17px; | 54 | font-size: 17px; |
48 | } | 55 | } |
49 | 56 | ||
50 | .section { | 57 | .section { |
58 | max-height: initial; | ||
59 | overflow: initial; | ||
60 | |||
51 | @include video-miniature-small-screen; | 61 | @include video-miniature-small-screen; |
52 | } | 62 | } |
53 | } \ No newline at end of file | 63 | } |
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 f99c8abb6..80cef813e 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 | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { Location } from '@angular/common' | ||
4 | import { immutableAssign } from '@app/shared/misc/utils' | 3 | import { immutableAssign } from '@app/shared/misc/utils' |
5 | import { AuthService } from '../../core/auth' | 4 | import { AuthService } from '../../core/auth' |
6 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' | 5 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' |
@@ -8,7 +7,7 @@ import { VideoSortField } from '../../shared/video/sort-field.type' | |||
8 | import { VideoService } from '../../shared/video/video.service' | 7 | import { VideoService } from '../../shared/video/video.service' |
9 | import { I18n } from '@ngx-translate/i18n-polyfill' | 8 | import { I18n } from '@ngx-translate/i18n-polyfill' |
10 | import { ScreenService } from '@app/shared/misc/screen.service' | 9 | import { ScreenService } from '@app/shared/misc/screen.service' |
11 | import { Notifier } from '@app/core' | 10 | import { Notifier, ServerService } from '@app/core' |
12 | 11 | ||
13 | @Component({ | 12 | @Component({ |
14 | selector: 'my-videos-recently-added', | 13 | selector: 'my-videos-recently-added', |
@@ -17,17 +16,16 @@ import { Notifier } from '@app/core' | |||
17 | }) | 16 | }) |
18 | export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy { | 17 | export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy { |
19 | titlePage: string | 18 | titlePage: string |
20 | currentRoute = '/videos/recently-added' | ||
21 | sort: VideoSortField = '-publishedAt' | 19 | sort: VideoSortField = '-publishedAt' |
22 | 20 | ||
23 | constructor ( | 21 | constructor ( |
24 | protected router: Router, | ||
25 | protected route: ActivatedRoute, | 22 | protected route: ActivatedRoute, |
26 | protected location: Location, | 23 | protected serverService: ServerService, |
24 | protected router: Router, | ||
27 | protected notifier: Notifier, | 25 | protected notifier: Notifier, |
28 | protected authService: AuthService, | 26 | protected authService: AuthService, |
29 | protected i18n: I18n, | ||
30 | protected screenService: ScreenService, | 27 | protected screenService: ScreenService, |
28 | private i18n: I18n, | ||
31 | private videoService: VideoService | 29 | private videoService: VideoService |
32 | ) { | 30 | ) { |
33 | super() | 31 | super() |
diff --git a/client/src/app/videos/video-list/video-trending.component.ts b/client/src/app/videos/video-list/video-trending.component.ts index 6fd74e67a..e2ad95bc4 100644 --- a/client/src/app/videos/video-list/video-trending.component.ts +++ b/client/src/app/videos/video-list/video-trending.component.ts | |||
@@ -1,6 +1,5 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { Location } from '@angular/common' | ||
4 | import { immutableAssign } from '@app/shared/misc/utils' | 3 | import { immutableAssign } from '@app/shared/misc/utils' |
5 | import { AuthService } from '../../core/auth' | 4 | import { AuthService } from '../../core/auth' |
6 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' | 5 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' |
@@ -17,18 +16,16 @@ import { Notifier, ServerService } from '@app/core' | |||
17 | }) | 16 | }) |
18 | export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { | 17 | export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { |
19 | titlePage: string | 18 | titlePage: string |
20 | currentRoute = '/videos/trending' | ||
21 | defaultSort: VideoSortField = '-trending' | 19 | defaultSort: VideoSortField = '-trending' |
22 | 20 | ||
23 | constructor ( | 21 | constructor ( |
24 | protected router: Router, | 22 | protected router: Router, |
23 | protected serverService: ServerService, | ||
25 | protected route: ActivatedRoute, | 24 | protected route: ActivatedRoute, |
26 | protected notifier: Notifier, | 25 | protected notifier: Notifier, |
27 | protected authService: AuthService, | 26 | protected authService: AuthService, |
28 | protected location: Location, | ||
29 | protected screenService: ScreenService, | 27 | protected screenService: ScreenService, |
30 | private serverService: ServerService, | 28 | private i18n: I18n, |
31 | protected i18n: I18n, | ||
32 | private videoService: VideoService | 29 | private videoService: VideoService |
33 | ) { | 30 | ) { |
34 | super() | 31 | super() |
@@ -45,11 +42,11 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit, | |||
45 | 42 | ||
46 | if (trendingDays === 1) { | 43 | if (trendingDays === 1) { |
47 | this.titlePage = this.i18n('Trending for the last 24 hours') | 44 | this.titlePage = this.i18n('Trending for the last 24 hours') |
48 | this.titleTooltip = this.i18n('Trending videos are those totalizing the greatest number of views during the last 24 hours.') | 45 | this.titleTooltip = this.i18n('Trending videos are those totalizing the greatest number of views during the last 24 hours') |
49 | } else { | 46 | } else { |
50 | this.titlePage = this.i18n('Trending for the last {{days}} days', { days: trendingDays }) | 47 | this.titlePage = this.i18n('Trending for the last {{days}} days', { days: trendingDays }) |
51 | this.titleTooltip = this.i18n( | 48 | this.titleTooltip = this.i18n( |
52 | 'Trending videos are those totalizing the greatest number of views during the last {{days}} days.', | 49 | 'Trending videos are those totalizing the greatest number of views during the last {{days}} days', |
53 | { days: trendingDays } | 50 | { days: trendingDays } |
54 | ) | 51 | ) |
55 | } | 52 | } |
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 bee828e12..2f0685ccc 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 | |||
@@ -1,7 +1,6 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | 1 | import { Component, OnDestroy, OnInit } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 2 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { immutableAssign } from '@app/shared/misc/utils' | 3 | import { immutableAssign } from '@app/shared/misc/utils' |
4 | import { Location } from '@angular/common' | ||
5 | import { AuthService } from '../../core/auth' | 4 | import { AuthService } from '../../core/auth' |
6 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' | 5 | import { AbstractVideoList } from '../../shared/video/abstract-video-list' |
7 | import { VideoSortField } from '../../shared/video/sort-field.type' | 6 | import { VideoSortField } from '../../shared/video/sort-field.type' |
@@ -9,7 +8,7 @@ import { VideoService } from '../../shared/video/video.service' | |||
9 | import { I18n } from '@ngx-translate/i18n-polyfill' | 8 | import { I18n } from '@ngx-translate/i18n-polyfill' |
10 | import { ScreenService } from '@app/shared/misc/screen.service' | 9 | import { ScreenService } from '@app/shared/misc/screen.service' |
11 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' | 10 | import { OwnerDisplayType } from '@app/shared/video/video-miniature.component' |
12 | import { Notifier } from '@app/core' | 11 | import { Notifier, ServerService } from '@app/core' |
13 | 12 | ||
14 | @Component({ | 13 | @Component({ |
15 | selector: 'my-videos-user-subscriptions', | 14 | selector: 'my-videos-user-subscriptions', |
@@ -18,18 +17,17 @@ import { Notifier } from '@app/core' | |||
18 | }) | 17 | }) |
19 | export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy { | 18 | export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy { |
20 | titlePage: string | 19 | titlePage: string |
21 | currentRoute = '/videos/subscriptions' | ||
22 | sort = '-publishedAt' as VideoSortField | 20 | sort = '-publishedAt' as VideoSortField |
23 | ownerDisplayType: OwnerDisplayType = 'auto' | 21 | ownerDisplayType: OwnerDisplayType = 'auto' |
24 | 22 | ||
25 | constructor ( | 23 | constructor ( |
26 | protected router: Router, | 24 | protected router: Router, |
25 | protected serverService: ServerService, | ||
27 | protected route: ActivatedRoute, | 26 | protected route: ActivatedRoute, |
28 | protected notifier: Notifier, | 27 | protected notifier: Notifier, |
29 | protected authService: AuthService, | 28 | protected authService: AuthService, |
30 | protected location: Location, | ||
31 | protected i18n: I18n, | ||
32 | protected screenService: ScreenService, | 29 | protected screenService: ScreenService, |
30 | private i18n: I18n, | ||
33 | private videoService: VideoService | 31 | private videoService: VideoService |
34 | ) { | 32 | ) { |
35 | super() | 33 | super() |
diff --git a/client/src/app/videos/videos-routing.module.ts b/client/src/app/videos/videos-routing.module.ts index 58988ffd1..505173a5b 100644 --- a/client/src/app/videos/videos-routing.module.ts +++ b/client/src/app/videos/videos-routing.module.ts | |||
@@ -29,6 +29,10 @@ const videosRoutes: Routes = [ | |||
29 | data: { | 29 | data: { |
30 | meta: { | 30 | meta: { |
31 | title: 'Trending videos' | 31 | title: 'Trending videos' |
32 | }, | ||
33 | reuse: { | ||
34 | enabled: true, | ||
35 | key: 'trending-videos-list' | ||
32 | } | 36 | } |
33 | } | 37 | } |
34 | }, | 38 | }, |
@@ -38,6 +42,10 @@ const videosRoutes: Routes = [ | |||
38 | data: { | 42 | data: { |
39 | meta: { | 43 | meta: { |
40 | title: 'Recently added videos' | 44 | title: 'Recently added videos' |
45 | }, | ||
46 | reuse: { | ||
47 | enabled: true, | ||
48 | key: 'recently-added-videos-list' | ||
41 | } | 49 | } |
42 | } | 50 | } |
43 | }, | 51 | }, |
@@ -47,6 +55,10 @@ const videosRoutes: Routes = [ | |||
47 | data: { | 55 | data: { |
48 | meta: { | 56 | meta: { |
49 | title: 'Subscriptions' | 57 | title: 'Subscriptions' |
58 | }, | ||
59 | reuse: { | ||
60 | enabled: true, | ||
61 | key: 'subscription-videos-list' | ||
50 | } | 62 | } |
51 | } | 63 | } |
52 | }, | 64 | }, |
@@ -56,6 +68,10 @@ const videosRoutes: Routes = [ | |||
56 | data: { | 68 | data: { |
57 | meta: { | 69 | meta: { |
58 | title: 'Local videos' | 70 | title: 'Local videos' |
71 | }, | ||
72 | reuse: { | ||
73 | enabled: true, | ||
74 | key: 'local-videos-list' | ||
59 | } | 75 | } |
60 | } | 76 | } |
61 | }, | 77 | }, |
@@ -78,11 +94,7 @@ const videosRoutes: Routes = [ | |||
78 | } | 94 | } |
79 | }, | 95 | }, |
80 | { | 96 | { |
81 | path: 'watch/:uuid/comments/:commentId', | 97 | path: 'watch', |
82 | redirectTo: 'watch/:uuid' | ||
83 | }, | ||
84 | { | ||
85 | path: 'watch/:uuid', | ||
86 | loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule', | 98 | loadChildren: 'app/videos/+video-watch/video-watch.module#VideoWatchModule', |
87 | data: { | 99 | data: { |
88 | preload: 3000 | 100 | preload: 3000 |