diff options
97 files changed, 2610 insertions, 1896 deletions
diff --git a/client/src/app/+accounts/account-search/account-search.component.ts b/client/src/app/+accounts/account-search/account-search.component.ts deleted file mode 100644 index f54ab846a..000000000 --- a/client/src/app/+accounts/account-search/account-search.component.ts +++ /dev/null | |||
@@ -1,110 +0,0 @@ | |||
1 | import { forkJoin, Subscription } from 'rxjs' | ||
2 | import { first, tap } from 'rxjs/operators' | ||
3 | import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' | ||
4 | import { ActivatedRoute, Router } from '@angular/router' | ||
5 | import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
6 | import { immutableAssign } from '@app/helpers' | ||
7 | import { Account, AccountService, VideoService } from '@app/shared/shared-main' | ||
8 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | ||
9 | import { VideoFilter } from '@shared/models' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-account-search', | ||
13 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html', | ||
14 | styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ] | ||
15 | }) | ||
16 | export class AccountSearchComponent extends AbstractVideoList implements OnInit, OnDestroy { | ||
17 | titlePage: string | ||
18 | loadOnInit = false | ||
19 | loadUserVideoPreferences = true | ||
20 | |||
21 | search = '' | ||
22 | filter: VideoFilter = null | ||
23 | |||
24 | private account: Account | ||
25 | private accountSub: Subscription | ||
26 | |||
27 | constructor ( | ||
28 | protected router: Router, | ||
29 | protected serverService: ServerService, | ||
30 | protected route: ActivatedRoute, | ||
31 | protected authService: AuthService, | ||
32 | protected userService: UserService, | ||
33 | protected notifier: Notifier, | ||
34 | protected confirmService: ConfirmService, | ||
35 | protected screenService: ScreenService, | ||
36 | protected storageService: LocalStorageService, | ||
37 | protected cfr: ComponentFactoryResolver, | ||
38 | private accountService: AccountService, | ||
39 | private videoService: VideoService | ||
40 | ) { | ||
41 | super() | ||
42 | } | ||
43 | |||
44 | ngOnInit () { | ||
45 | super.ngOnInit() | ||
46 | |||
47 | this.enableAllFilterIfPossible() | ||
48 | |||
49 | // Parent get the account for us | ||
50 | this.accountSub = forkJoin([ | ||
51 | this.accountService.accountLoaded.pipe(first()), | ||
52 | this.onUserLoadedSubject.pipe(first()) | ||
53 | ]).subscribe(([ account ]) => { | ||
54 | this.account = account | ||
55 | |||
56 | this.reloadVideos() | ||
57 | }) | ||
58 | } | ||
59 | |||
60 | ngOnDestroy () { | ||
61 | if (this.accountSub) this.accountSub.unsubscribe() | ||
62 | |||
63 | super.ngOnDestroy() | ||
64 | } | ||
65 | |||
66 | updateSearch (value: string) { | ||
67 | this.search = value | ||
68 | |||
69 | if (!this.search) { | ||
70 | this.router.navigate([ '../videos' ], { relativeTo: this.route }) | ||
71 | return | ||
72 | } | ||
73 | |||
74 | this.videos = [] | ||
75 | this.reloadVideos() | ||
76 | } | ||
77 | |||
78 | getVideosObservable (page: number) { | ||
79 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
80 | const options = { | ||
81 | account: this.account, | ||
82 | videoPagination: newPagination, | ||
83 | sort: this.sort, | ||
84 | nsfwPolicy: this.nsfwPolicy, | ||
85 | videoFilter: this.filter, | ||
86 | search: this.search | ||
87 | } | ||
88 | |||
89 | return this.videoService | ||
90 | .getAccountVideos(options) | ||
91 | .pipe( | ||
92 | tap(({ total }) => { | ||
93 | this.titlePage = this.search | ||
94 | ? $localize`Published ${total} videos matching "${this.search}"` | ||
95 | : $localize`Published ${total} videos` | ||
96 | }) | ||
97 | ) | ||
98 | } | ||
99 | |||
100 | toggleModerationDisplay () { | ||
101 | this.filter = this.buildLocalFilter(this.filter, null) | ||
102 | |||
103 | this.reloadVideos() | ||
104 | } | ||
105 | |||
106 | generateSyndicationList () { | ||
107 | /* method disabled */ | ||
108 | throw new Error('Method not implemented.') | ||
109 | } | ||
110 | } | ||
diff --git a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html index 922608127..105bc12c3 100644 --- a/client/src/app/+accounts/account-video-channels/account-video-channels.component.html +++ b/client/src/app/+accounts/account-video-channels/account-video-channels.component.html | |||
@@ -4,7 +4,7 @@ | |||
4 | 4 | ||
5 | <div class="no-results" i18n *ngIf="channelPagination.totalItems === 0">This account does not have channels.</div> | 5 | <div class="no-results" i18n *ngIf="channelPagination.totalItems === 0">This account does not have channels.</div> |
6 | 6 | ||
7 | <div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onChannelDataSubject.asObservable()"> | 7 | <div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onChannelDataSubject.asObservable()"> |
8 | <div class="channel" *ngFor="let videoChannel of videoChannels"> | 8 | <div class="channel" *ngFor="let videoChannel of videoChannels"> |
9 | 9 | ||
10 | <div class="channel-avatar-row"> | 10 | <div class="channel-avatar-row"> |
diff --git a/client/src/app/+accounts/account-videos/account-videos.component.html b/client/src/app/+accounts/account-videos/account-videos.component.html new file mode 100644 index 000000000..5b4b0937f --- /dev/null +++ b/client/src/app/+accounts/account-videos/account-videos.component.html | |||
@@ -0,0 +1,20 @@ | |||
1 | <my-videos-list | ||
2 | *ngIf="account" | ||
3 | |||
4 | [title]="title" | ||
5 | [displayTitle]="false" | ||
6 | |||
7 | [getVideosObservableFunction]="getVideosObservableFunction" | ||
8 | [getSyndicationItemsFunction]="getSyndicationItemsFunction" | ||
9 | |||
10 | [defaultSort]="defaultSort" | ||
11 | |||
12 | [displayFilters]="true" | ||
13 | [displayModerationBlock]="true" | ||
14 | [displayAsRow]="displayAsRow()" | ||
15 | |||
16 | [loadUserVideoPreferences]="true" | ||
17 | |||
18 | [disabled]="disabled" | ||
19 | > | ||
20 | </my-videos-list> | ||
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 4ab6d2147..13d1f857d 100644 --- a/client/src/app/+accounts/account-videos/account-videos.component.ts +++ b/client/src/app/+accounts/account-videos/account-videos.component.ts | |||
@@ -1,96 +1,69 @@ | |||
1 | import { forkJoin, Subscription } from 'rxjs' | 1 | import { Subscription } from 'rxjs' |
2 | import { first } from 'rxjs/operators' | 2 | import { first } from 'rxjs/operators' |
3 | import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' | 3 | import { Component, OnDestroy, OnInit } from '@angular/core' |
4 | import { ActivatedRoute, Router } from '@angular/router' | 4 | import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core' |
5 | import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
6 | import { immutableAssign } from '@app/helpers' | ||
7 | import { Account, AccountService, VideoService } from '@app/shared/shared-main' | 5 | import { Account, AccountService, VideoService } from '@app/shared/shared-main' |
8 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | 6 | import { VideoFilters } from '@app/shared/shared-video-miniature' |
9 | import { VideoFilter } from '@shared/models' | 7 | import { VideoSortField } from '@shared/models' |
10 | 8 | ||
11 | @Component({ | 9 | @Component({ |
12 | selector: 'my-account-videos', | 10 | selector: 'my-account-videos', |
13 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html', | 11 | templateUrl: './account-videos.component.html' |
14 | styleUrls: [ | ||
15 | '../../shared/shared-video-miniature/abstract-video-list.scss' | ||
16 | ] | ||
17 | }) | 12 | }) |
18 | export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { | 13 | export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook { |
19 | // No value because we don't want a page title | 14 | getVideosObservableFunction = this.getVideosObservable.bind(this) |
20 | titlePage: string | 15 | getSyndicationItemsFunction = this.getSyndicationItems.bind(this) |
21 | loadOnInit = false | ||
22 | loadUserVideoPreferences = true | ||
23 | 16 | ||
24 | filter: VideoFilter = null | 17 | title = $localize`Videos` |
18 | defaultSort = '-publishedAt' as VideoSortField | ||
19 | |||
20 | account: Account | ||
21 | disabled = false | ||
25 | 22 | ||
26 | private account: Account | ||
27 | private accountSub: Subscription | 23 | private accountSub: Subscription |
28 | 24 | ||
29 | constructor ( | 25 | constructor ( |
30 | protected router: Router, | 26 | private screenService: ScreenService, |
31 | protected serverService: ServerService, | ||
32 | protected route: ActivatedRoute, | ||
33 | protected authService: AuthService, | ||
34 | protected userService: UserService, | ||
35 | protected notifier: Notifier, | ||
36 | protected confirmService: ConfirmService, | ||
37 | protected screenService: ScreenService, | ||
38 | protected storageService: LocalStorageService, | ||
39 | private accountService: AccountService, | 27 | private accountService: AccountService, |
40 | private videoService: VideoService, | 28 | private videoService: VideoService |
41 | protected cfr: ComponentFactoryResolver | ||
42 | ) { | 29 | ) { |
43 | super() | ||
44 | } | 30 | } |
45 | 31 | ||
46 | ngOnInit () { | 32 | ngOnInit () { |
47 | super.ngOnInit() | ||
48 | |||
49 | this.enableAllFilterIfPossible() | ||
50 | |||
51 | // Parent get the account for us | 33 | // Parent get the account for us |
52 | this.accountSub = forkJoin([ | 34 | this.accountService.accountLoaded.pipe(first()) |
53 | this.accountService.accountLoaded.pipe(first()), | 35 | .subscribe(account => this.account = account) |
54 | this.onUserLoadedSubject.pipe(first()) | ||
55 | ]).subscribe(([ account ]) => { | ||
56 | this.account = account | ||
57 | |||
58 | this.reloadVideos() | ||
59 | this.generateSyndicationList() | ||
60 | }) | ||
61 | } | 36 | } |
62 | 37 | ||
63 | ngOnDestroy () { | 38 | ngOnDestroy () { |
64 | if (this.accountSub) this.accountSub.unsubscribe() | 39 | if (this.accountSub) this.accountSub.unsubscribe() |
65 | |||
66 | super.ngOnDestroy() | ||
67 | } | 40 | } |
68 | 41 | ||
69 | getVideosObservable (page: number) { | 42 | getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) { |
70 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
71 | const options = { | 43 | const options = { |
44 | ...filters.toVideosAPIObject(), | ||
45 | |||
46 | videoPagination: pagination, | ||
72 | account: this.account, | 47 | account: this.account, |
73 | videoPagination: newPagination, | 48 | skipCount: true |
74 | sort: this.sort, | ||
75 | nsfwPolicy: this.nsfwPolicy, | ||
76 | videoFilter: this.filter | ||
77 | } | 49 | } |
78 | 50 | ||
79 | return this.videoService | 51 | return this.videoService.getAccountVideos(options) |
80 | .getAccountVideos(options) | ||
81 | } | 52 | } |
82 | 53 | ||
83 | toggleModerationDisplay () { | 54 | getSyndicationItems () { |
84 | this.filter = this.buildLocalFilter(this.filter, null) | 55 | return this.videoService.getAccountFeedUrls(this.account.id) |
56 | } | ||
85 | 57 | ||
86 | this.reloadVideos() | 58 | displayAsRow () { |
59 | return this.screenService.isInMobileView() | ||
87 | } | 60 | } |
88 | 61 | ||
89 | generateSyndicationList () { | 62 | disableForReuse () { |
90 | this.syndicationItems = this.videoService.getAccountFeedUrls(this.account.id) | 63 | this.disabled = true |
91 | } | 64 | } |
92 | 65 | ||
93 | displayAsRow () { | 66 | enabledForReuse () { |
94 | return this.screenService.isInMobileView() | 67 | this.disabled = false |
95 | } | 68 | } |
96 | } | 69 | } |
diff --git a/client/src/app/+accounts/accounts-routing.module.ts b/client/src/app/+accounts/accounts-routing.module.ts index 2f3792a8d..d80df2293 100644 --- a/client/src/app/+accounts/accounts-routing.module.ts +++ b/client/src/app/+accounts/accounts-routing.module.ts | |||
@@ -1,6 +1,5 @@ | |||
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 | import { AccountSearchComponent } from './account-search/account-search.component' | ||
4 | import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' | 3 | import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' |
5 | import { AccountVideosComponent } from './account-videos/account-videos.component' | 4 | import { AccountVideosComponent } from './account-videos/account-videos.component' |
6 | import { AccountsComponent } from './accounts.component' | 5 | import { AccountsComponent } from './accounts.component' |
@@ -41,14 +40,11 @@ const accountsRoutes: Routes = [ | |||
41 | } | 40 | } |
42 | } | 41 | } |
43 | }, | 42 | }, |
43 | |||
44 | // Old URL redirection | ||
44 | { | 45 | { |
45 | path: 'search', | 46 | path: 'search', |
46 | component: AccountSearchComponent, | 47 | redirectTo: 'videos' |
47 | data: { | ||
48 | meta: { | ||
49 | title: $localize`Search videos within account` | ||
50 | } | ||
51 | } | ||
52 | } | 48 | } |
53 | ] | 49 | ] |
54 | } | 50 | } |
diff --git a/client/src/app/+accounts/accounts.component.html b/client/src/app/+accounts/accounts.component.html index 0906992fe..245edfd58 100644 --- a/client/src/app/+accounts/accounts.component.html +++ b/client/src/app/+accounts/accounts.component.html | |||
@@ -66,7 +66,7 @@ | |||
66 | </div> | 66 | </div> |
67 | </div> | 67 | </div> |
68 | 68 | ||
69 | <div class="links"> | 69 | <div class="links" [ngClass]="{ 'on-channel-page': isOnChannelPage() }"> |
70 | <ng-template #linkTemplate let-item="item"> | 70 | <ng-template #linkTemplate let-item="item"> |
71 | <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a> | 71 | <a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a> |
72 | </ng-template> | 72 | </ng-template> |
@@ -81,7 +81,7 @@ | |||
81 | ></my-simple-search-input> | 81 | ></my-simple-search-input> |
82 | </div> | 82 | </div> |
83 | 83 | ||
84 | <router-outlet (activate)="onOutletLoaded($event)"></router-outlet> | 84 | <router-outlet></router-outlet> |
85 | </div> | 85 | </div> |
86 | 86 | ||
87 | <ng-container *ngIf="prependModerationActions"> | 87 | <ng-container *ngIf="prependModerationActions"> |
diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss index 4c1f94024..c4e2159d1 100644 --- a/client/src/app/+accounts/accounts.component.scss +++ b/client/src/app/+accounts/accounts.component.scss | |||
@@ -20,7 +20,10 @@ | |||
20 | display: flex; | 20 | display: flex; |
21 | justify-content: space-between; | 21 | justify-content: space-between; |
22 | align-items: center; | 22 | align-items: center; |
23 | max-width: $max-channels-width; | 23 | |
24 | &.on-channel-page { | ||
25 | max-width: $max-channels-width; | ||
26 | } | ||
24 | 27 | ||
25 | simple-search-input { | 28 | simple-search-input { |
26 | @include margin-left(auto); | 29 | @include margin-left(auto); |
diff --git a/client/src/app/+accounts/accounts.component.ts b/client/src/app/+accounts/accounts.component.ts index 733cff8d5..e90816c5a 100644 --- a/client/src/app/+accounts/accounts.component.ts +++ b/client/src/app/+accounts/accounts.component.ts | |||
@@ -1,7 +1,7 @@ | |||
1 | import { Subscription } from 'rxjs' | 1 | import { Subscription } from 'rxjs' |
2 | import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators' | 2 | import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators' |
3 | import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' | 3 | import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' |
4 | import { ActivatedRoute } from '@angular/router' | 4 | import { ActivatedRoute, Router } from '@angular/router' |
5 | import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core' | 5 | import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core' |
6 | import { | 6 | import { |
7 | Account, | 7 | Account, |
@@ -14,7 +14,6 @@ import { | |||
14 | } from '@app/shared/shared-main' | 14 | } from '@app/shared/shared-main' |
15 | import { AccountReportComponent } from '@app/shared/shared-moderation' | 15 | import { AccountReportComponent } from '@app/shared/shared-moderation' |
16 | import { HttpStatusCode, User, UserRight } from '@shared/models' | 16 | import { HttpStatusCode, User, UserRight } from '@shared/models' |
17 | import { AccountSearchComponent } from './account-search/account-search.component' | ||
18 | 17 | ||
19 | @Component({ | 18 | @Component({ |
20 | templateUrl: './accounts.component.html', | 19 | templateUrl: './accounts.component.html', |
@@ -23,8 +22,6 @@ import { AccountSearchComponent } from './account-search/account-search.componen | |||
23 | export class AccountsComponent implements OnInit, OnDestroy { | 22 | export class AccountsComponent implements OnInit, OnDestroy { |
24 | @ViewChild('accountReportModal') accountReportModal: AccountReportComponent | 23 | @ViewChild('accountReportModal') accountReportModal: AccountReportComponent |
25 | 24 | ||
26 | accountSearch: AccountSearchComponent | ||
27 | |||
28 | account: Account | 25 | account: Account |
29 | accountUser: User | 26 | accountUser: User |
30 | 27 | ||
@@ -45,6 +42,7 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
45 | 42 | ||
46 | constructor ( | 43 | constructor ( |
47 | private route: ActivatedRoute, | 44 | private route: ActivatedRoute, |
45 | private router: Router, | ||
48 | private userService: UserService, | 46 | private userService: UserService, |
49 | private accountService: AccountService, | 47 | private accountService: AccountService, |
50 | private videoChannelService: VideoChannelService, | 48 | private videoChannelService: VideoChannelService, |
@@ -128,16 +126,10 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
128 | return $localize`${count} subscribers` | 126 | return $localize`${count} subscribers` |
129 | } | 127 | } |
130 | 128 | ||
131 | onOutletLoaded (component: Component) { | ||
132 | if (component instanceof AccountSearchComponent) { | ||
133 | this.accountSearch = component | ||
134 | } else { | ||
135 | this.accountSearch = undefined | ||
136 | } | ||
137 | } | ||
138 | |||
139 | searchChanged (search: string) { | 129 | searchChanged (search: string) { |
140 | if (this.accountSearch) this.accountSearch.updateSearch(search) | 130 | const queryParams = { search } |
131 | |||
132 | this.router.navigate([ './videos' ], { queryParams, relativeTo: this.route, queryParamsHandling: 'merge' }) | ||
141 | } | 133 | } |
142 | 134 | ||
143 | onSearchInputDisplayChanged (displayed: boolean) { | 135 | onSearchInputDisplayChanged (displayed: boolean) { |
@@ -152,6 +144,10 @@ export class AccountsComponent implements OnInit, OnDestroy { | |||
152 | return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100 | 144 | return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100 |
153 | } | 145 | } |
154 | 146 | ||
147 | isOnChannelPage () { | ||
148 | return this.route.children[0].snapshot.url[0].path === 'video-channels' | ||
149 | } | ||
150 | |||
155 | private async onAccount (account: Account) { | 151 | private async onAccount (account: Account) { |
156 | this.accountFollowerTitle = $localize`${account.followersCount} direct account followers` | 152 | this.accountFollowerTitle = $localize`${account.followersCount} direct account followers` |
157 | 153 | ||
diff --git a/client/src/app/+accounts/accounts.module.ts b/client/src/app/+accounts/accounts.module.ts index 1bafc5141..aedc69b16 100644 --- a/client/src/app/+accounts/accounts.module.ts +++ b/client/src/app/+accounts/accounts.module.ts | |||
@@ -5,12 +5,11 @@ import { SharedMainModule } from '@app/shared/shared-main' | |||
5 | import { SharedModerationModule } from '@app/shared/shared-moderation' | 5 | import { SharedModerationModule } from '@app/shared/shared-moderation' |
6 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' | 6 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' |
7 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' | 7 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' |
8 | import { AccountSearchComponent } from './account-search/account-search.component' | 8 | import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' |
9 | import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' | 9 | import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' |
10 | import { AccountVideosComponent } from './account-videos/account-videos.component' | 10 | import { AccountVideosComponent } from './account-videos/account-videos.component' |
11 | import { AccountsRoutingModule } from './accounts-routing.module' | 11 | import { AccountsRoutingModule } from './accounts-routing.module' |
12 | import { AccountsComponent } from './accounts.component' | 12 | import { AccountsComponent } from './accounts.component' |
13 | import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' | ||
14 | 13 | ||
15 | @NgModule({ | 14 | @NgModule({ |
16 | imports: [ | 15 | imports: [ |
@@ -28,8 +27,7 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto | |||
28 | declarations: [ | 27 | declarations: [ |
29 | AccountsComponent, | 28 | AccountsComponent, |
30 | AccountVideosComponent, | 29 | AccountVideosComponent, |
31 | AccountVideoChannelsComponent, | 30 | AccountVideoChannelsComponent |
32 | AccountSearchComponent | ||
33 | ], | 31 | ], |
34 | 32 | ||
35 | exports: [ | 33 | exports: [ |
diff --git a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html index bc4c2ef88..b42bd27c5 100644 --- a/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html +++ b/client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html | |||
@@ -6,7 +6,7 @@ | |||
6 | {{ getNoResultMessage() }} | 6 | {{ getNoResultMessage() }} |
7 | </div> | 7 | </div> |
8 | 8 | ||
9 | <div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"> | 9 | <div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
10 | <div class="card plugin" *ngFor="let plugin of plugins"> | 10 | <div class="card plugin" *ngFor="let plugin of plugins"> |
11 | <div class="card-body"> | 11 | <div class="card-body"> |
12 | <div class="first-row"> | 12 | <div class="first-row"> |
diff --git a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html index a41c7d700..09fb7b6f1 100644 --- a/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html +++ b/client/src/app/+admin/plugins/plugin-search/plugin-search.component.html | |||
@@ -29,7 +29,7 @@ | |||
29 | No results. | 29 | No results. |
30 | </div> | 30 | </div> |
31 | 31 | ||
32 | <div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"> | 32 | <div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
33 | <div class="card plugin" *ngFor="let plugin of plugins"> | 33 | <div class="card plugin" *ngFor="let plugin of plugins"> |
34 | <div class="card-body"> | 34 | <div class="card-body"> |
35 | <div class="first-row"> | 35 | <div class="first-row"> |
diff --git a/client/src/app/+my-library/my-history/my-history.component.html b/client/src/app/+my-library/my-history/my-history.component.html index 45ca37e0d..8e564cf93 100644 --- a/client/src/app/+my-library/my-history/my-history.component.html +++ b/client/src/app/+my-library/my-history/my-history.component.html | |||
@@ -5,7 +5,7 @@ | |||
5 | 5 | ||
6 | <div class="top-buttons"> | 6 | <div class="top-buttons"> |
7 | <div class="search-wrapper"> | 7 | <div class="search-wrapper"> |
8 | <my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter> | 8 | <my-advanced-input-filter [emitOnInit]="false" (search)="onSearch($event)"></my-advanced-input-filter> |
9 | </div> | 9 | </div> |
10 | 10 | ||
11 | <div class="history-switch"> | 11 | <div class="history-switch"> |
@@ -26,8 +26,8 @@ | |||
26 | [titlePage]="titlePage" | 26 | [titlePage]="titlePage" |
27 | [getVideosObservableFunction]="getVideosObservableFunction" | 27 | [getVideosObservableFunction]="getVideosObservableFunction" |
28 | [user]="user" | 28 | [user]="user" |
29 | [loadOnInit]="false" | ||
30 | i18n-noResultMessage noResultMessage="You don't have any video in your watch history yet." | 29 | i18n-noResultMessage noResultMessage="You don't have any video in your watch history yet." |
31 | [enableSelection]="false" | 30 | [enableSelection]="false" |
31 | [disabled]="disabled" | ||
32 | #videosSelection | 32 | #videosSelection |
33 | ></my-videos-selection> | 33 | ></my-videos-selection> |
diff --git a/client/src/app/+my-library/my-history/my-history.component.ts b/client/src/app/+my-library/my-history/my-history.component.ts index a72d22e1c..95cfaee41 100644 --- a/client/src/app/+my-library/my-history/my-history.component.ts +++ b/client/src/app/+my-library/my-history/my-history.component.ts | |||
@@ -50,6 +50,8 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook { | |||
50 | videos: Video[] = [] | 50 | videos: Video[] = [] |
51 | search: string | 51 | search: string |
52 | 52 | ||
53 | disabled = false | ||
54 | |||
53 | constructor ( | 55 | constructor ( |
54 | protected router: Router, | 56 | protected router: Router, |
55 | protected serverService: ServerService, | 57 | protected serverService: ServerService, |
@@ -74,11 +76,11 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook { | |||
74 | } | 76 | } |
75 | 77 | ||
76 | disableForReuse () { | 78 | disableForReuse () { |
77 | this.videosSelection.disableForReuse() | 79 | this.disabled = true |
78 | } | 80 | } |
79 | 81 | ||
80 | enabledForReuse () { | 82 | enabledForReuse () { |
81 | this.videosSelection.enabledForReuse() | 83 | this.disabled = false |
82 | } | 84 | } |
83 | 85 | ||
84 | reloadData () { | 86 | reloadData () { |
diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html index 1bd459059..ca5ad794a 100644 --- a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html +++ b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html | |||
@@ -12,7 +12,7 @@ | |||
12 | 12 | ||
13 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div> | 13 | <div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div> |
14 | 14 | ||
15 | <div class="video-channels" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> | 15 | <div class="video-channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
16 | <div *ngFor="let videoChannel of videoChannels" class="video-channel"> | 16 | <div *ngFor="let videoChannel of videoChannels" class="video-channel"> |
17 | <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar> | 17 | <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar> |
18 | 18 | ||
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html index e7e3c17b3..806dd6f48 100644 --- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html +++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html | |||
@@ -34,7 +34,7 @@ | |||
34 | </div> | 34 | </div> |
35 | 35 | ||
36 | <div | 36 | <div |
37 | class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" | 37 | class="videos" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" |
38 | cdkDropList (cdkDropListDropped)="drop($event)" [dataObservable]="onDataSubject.asObservable()" | 38 | cdkDropList (cdkDropListDropped)="drop($event)" [dataObservable]="onDataSubject.asObservable()" |
39 | > | 39 | > |
40 | <div class="video" *ngFor="let playlistElement of playlistElements; trackBy: trackByFn" cdkDrag [cdkDragStartDelay]="getDragStartDelay()"> | 40 | <div class="video" *ngFor="let playlistElement of playlistElements; trackBy: trackByFn" cdkDrag [cdkDragStartDelay]="getDragStartDelay()"> |
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html b/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html index 309afcf13..7f5b8bbf4 100644 --- a/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html +++ b/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html | |||
@@ -12,7 +12,7 @@ | |||
12 | </a> | 12 | </a> |
13 | </div> | 13 | </div> |
14 | 14 | ||
15 | <div class="video-playlists" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> | 15 | <div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
16 | <div *ngFor="let playlist of videoPlaylists" class="video-playlist"> | 16 | <div *ngFor="let playlist of videoPlaylists" class="video-playlist"> |
17 | <my-video-playlist-miniature | 17 | <my-video-playlist-miniature |
18 | [playlist]="playlist" [toManage]="true" [displayChannel]="true" | 18 | [playlist]="playlist" [toManage]="true" [displayChannel]="true" |
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.html b/client/src/app/+my-library/my-videos/my-videos.component.html index 0552b8ce4..9f81f0ad7 100644 --- a/client/src/app/+my-library/my-videos/my-videos.component.html +++ b/client/src/app/+my-library/my-videos/my-videos.component.html | |||
@@ -19,7 +19,7 @@ | |||
19 | </h1> | 19 | </h1> |
20 | 20 | ||
21 | <div class="videos-header d-flex justify-content-between"> | 21 | <div class="videos-header d-flex justify-content-between"> |
22 | <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter> | 22 | <my-advanced-input-filter [emitOnInit]="false" [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter> |
23 | 23 | ||
24 | <div class="peertube-select-container peertube-select-button"> | 24 | <div class="peertube-select-container peertube-select-button"> |
25 | <select [(ngModel)]="sort" (ngModelChange)="onChangeSortColumn()" class="form-control"> | 25 | <select [(ngModel)]="sort" (ngModelChange)="onChangeSortColumn()" class="form-control"> |
@@ -41,7 +41,7 @@ | |||
41 | [titlePage]="titlePage" | 41 | [titlePage]="titlePage" |
42 | [getVideosObservableFunction]="getVideosObservableFunction" | 42 | [getVideosObservableFunction]="getVideosObservableFunction" |
43 | [user]="user" | 43 | [user]="user" |
44 | [loadOnInit]="false" | 44 | [disabled]="disabled" |
45 | #videosSelection | 45 | #videosSelection |
46 | > | 46 | > |
47 | <ng-template ptTemplate="globalButtons"> | 47 | <ng-template ptTemplate="globalButtons"> |
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts index edb9392dd..72d28ced7 100644 --- a/client/src/app/+my-library/my-videos/my-videos.component.ts +++ b/client/src/app/+my-library/my-videos/my-videos.component.ts | |||
@@ -54,6 +54,8 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook { | |||
54 | } | 54 | } |
55 | ] | 55 | ] |
56 | 56 | ||
57 | disabled = false | ||
58 | |||
57 | private search: string | 59 | private search: string |
58 | 60 | ||
59 | constructor ( | 61 | constructor ( |
@@ -89,11 +91,11 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook { | |||
89 | } | 91 | } |
90 | 92 | ||
91 | disableForReuse () { | 93 | disableForReuse () { |
92 | this.videosSelection.disableForReuse() | 94 | this.disabled = true |
93 | } | 95 | } |
94 | 96 | ||
95 | enabledForReuse () { | 97 | enabledForReuse () { |
96 | this.videosSelection.enabledForReuse() | 98 | this.disabled = false |
97 | } | 99 | } |
98 | 100 | ||
99 | getVideosObservable (page: number) { | 101 | getVideosObservable (page: number) { |
diff --git a/client/src/app/+search/search-filters.component.scss b/client/src/app/+search/search-filters.component.scss index 235558b3d..ece4ba5b5 100644 --- a/client/src/app/+search/search-filters.component.scss +++ b/client/src/app/+search/search-filters.component.scss | |||
@@ -11,7 +11,6 @@ form { | |||
11 | } | 11 | } |
12 | 12 | ||
13 | .peertube-radio-container { | 13 | .peertube-radio-container { |
14 | @include peertube-radio-container; | ||
15 | @include margin-right(30px); | 14 | @include margin-right(30px); |
16 | 15 | ||
17 | display: inline-block; | 16 | display: inline-block; |
diff --git a/client/src/app/+search/search.component.html b/client/src/app/+search/search.component.html index dc8b4d595..412b962d1 100644 --- a/client/src/app/+search/search.component.html +++ b/client/src/app/+search/search.component.html | |||
@@ -1,4 +1,4 @@ | |||
1 | <div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="search-result"> | 1 | <div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" class="search-result"> |
2 | <div class="results-header"> | 2 | <div class="results-header"> |
3 | <div class="first-line"> | 3 | <div class="first-line"> |
4 | <div class="results-counter" *ngIf="pagination.totalItems"> | 4 | <div class="results-counter" *ngIf="pagination.totalItems"> |
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 index b69d1682a..fa58963ce 100644 --- 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 | |||
@@ -5,7 +5,7 @@ | |||
5 | 5 | ||
6 | <div i18n class="no-results" *ngIf="pagination.totalItems === 0">This channel does not have playlists.</div> | 6 | <div i18n class="no-results" *ngIf="pagination.totalItems === 0">This channel does not have playlists.</div> |
7 | 7 | ||
8 | <div class="playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"> | 8 | <div class="playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
9 | <div *ngFor="let playlist of videoPlaylists" class="playlist-wrapper"> | 9 | <div *ngFor="let playlist of videoPlaylists" class="playlist-wrapper"> |
10 | <my-video-playlist-miniature [playlist]="playlist" [toManage]="false" [displayAsRow]="displayAsRow()"></my-video-playlist-miniature> | 10 | <my-video-playlist-miniature [playlist]="playlist" [toManage]="false" [displayAsRow]="displayAsRow()"></my-video-playlist-miniature> |
11 | </div> | 11 | </div> |
diff --git a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.html b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.html new file mode 100644 index 000000000..1b6b72f1e --- /dev/null +++ b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.html | |||
@@ -0,0 +1,21 @@ | |||
1 | <my-videos-list | ||
2 | *ngIf="videoChannel" | ||
3 | |||
4 | [title]="title" | ||
5 | [displayTitle]="false" | ||
6 | |||
7 | [getVideosObservableFunction]="getVideosObservableFunction" | ||
8 | [getSyndicationItemsFunction]="getSyndicationItemsFunction" | ||
9 | |||
10 | [defaultSort]="defaultSort" | ||
11 | |||
12 | [displayFilters]="true" | ||
13 | [displayModerationBlock]="true" | ||
14 | [displayOptions]="displayOptions" | ||
15 | [displayAsRow]="displayAsRow()" | ||
16 | |||
17 | [loadUserVideoPreferences]="true" | ||
18 | |||
19 | [disabled]="disabled" | ||
20 | > | ||
21 | </my-videos-list> | ||
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 ef8fd79b9..43fce475d 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,27 +1,21 @@ | |||
1 | import { forkJoin, Subscription } from 'rxjs' | 1 | import { Subscription } from 'rxjs' |
2 | import { first } from 'rxjs/operators' | 2 | import { first } from 'rxjs/operators' |
3 | import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' | 3 | import { Component, OnDestroy, OnInit } from '@angular/core' |
4 | import { ActivatedRoute, Router } from '@angular/router' | 4 | import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core' |
5 | import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
6 | import { immutableAssign } from '@app/helpers' | ||
7 | import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' | 5 | import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' |
8 | import { AbstractVideoList, MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' | 6 | import { MiniatureDisplayOptions, VideoFilters } from '@app/shared/shared-video-miniature' |
9 | import { VideoFilter } from '@shared/models' | 7 | import { VideoSortField } from '@shared/models/videos' |
10 | 8 | ||
11 | @Component({ | 9 | @Component({ |
12 | selector: 'my-video-channel-videos', | 10 | selector: 'my-video-channel-videos', |
13 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html', | 11 | templateUrl: './video-channel-videos.component.html' |
14 | styleUrls: [ | ||
15 | '../../shared/shared-video-miniature/abstract-video-list.scss' | ||
16 | ] | ||
17 | }) | 12 | }) |
18 | export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { | 13 | export class VideoChannelVideosComponent implements OnInit, OnDestroy, DisableForReuseHook { |
19 | // No value because we don't want a page title | 14 | getVideosObservableFunction = this.getVideosObservable.bind(this) |
20 | titlePage: string | 15 | getSyndicationItemsFunction = this.getSyndicationItems.bind(this) |
21 | loadOnInit = false | ||
22 | loadUserVideoPreferences = true | ||
23 | 16 | ||
24 | filter: VideoFilter = null | 17 | title = $localize`Videos` |
18 | defaultSort = '-publishedAt' as VideoSortField | ||
25 | 19 | ||
26 | displayOptions: MiniatureDisplayOptions = { | 20 | displayOptions: MiniatureDisplayOptions = { |
27 | date: true, | 21 | date: true, |
@@ -34,80 +28,55 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On | |||
34 | blacklistInfo: false | 28 | blacklistInfo: false |
35 | } | 29 | } |
36 | 30 | ||
37 | private videoChannel: VideoChannel | 31 | videoChannel: VideoChannel |
32 | disabled = false | ||
33 | |||
38 | private videoChannelSub: Subscription | 34 | private videoChannelSub: Subscription |
39 | 35 | ||
40 | constructor ( | 36 | constructor ( |
41 | protected router: Router, | 37 | private screenService: ScreenService, |
42 | protected serverService: ServerService, | ||
43 | protected route: ActivatedRoute, | ||
44 | protected authService: AuthService, | ||
45 | protected userService: UserService, | ||
46 | protected notifier: Notifier, | ||
47 | protected confirmService: ConfirmService, | ||
48 | protected screenService: ScreenService, | ||
49 | protected storageService: LocalStorageService, | ||
50 | protected cfr: ComponentFactoryResolver, | ||
51 | private videoChannelService: VideoChannelService, | 38 | private videoChannelService: VideoChannelService, |
52 | private videoService: VideoService | 39 | private videoService: VideoService |
53 | ) { | 40 | ) { |
54 | super() | ||
55 | |||
56 | this.titlePage = $localize`Published videos` | ||
57 | this.displayOptions = { | ||
58 | ...this.displayOptions, | ||
59 | avatar: false | ||
60 | } | ||
61 | } | 41 | } |
62 | 42 | ||
63 | ngOnInit () { | 43 | ngOnInit () { |
64 | super.ngOnInit() | ||
65 | |||
66 | this.enableAllFilterIfPossible() | ||
67 | |||
68 | // Parent get the video channel for us | 44 | // Parent get the video channel for us |
69 | this.videoChannelSub = forkJoin([ | 45 | this.videoChannelService.videoChannelLoaded.pipe(first()) |
70 | this.videoChannelService.videoChannelLoaded.pipe(first()), | 46 | .subscribe(videoChannel => { |
71 | this.onUserLoadedSubject.pipe(first()) | 47 | this.videoChannel = videoChannel |
72 | ]).subscribe(([ videoChannel ]) => { | 48 | }) |
73 | this.videoChannel = videoChannel | ||
74 | |||
75 | this.reloadVideos() | ||
76 | this.generateSyndicationList() | ||
77 | }) | ||
78 | } | 49 | } |
79 | 50 | ||
80 | ngOnDestroy () { | 51 | ngOnDestroy () { |
81 | if (this.videoChannelSub) this.videoChannelSub.unsubscribe() | 52 | if (this.videoChannelSub) this.videoChannelSub.unsubscribe() |
82 | |||
83 | super.ngOnDestroy() | ||
84 | } | 53 | } |
85 | 54 | ||
86 | getVideosObservable (page: number) { | 55 | getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) { |
87 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | 56 | const params = { |
88 | const options = { | 57 | ...filters.toVideosAPIObject(), |
58 | |||
59 | videoPagination: pagination, | ||
89 | videoChannel: this.videoChannel, | 60 | videoChannel: this.videoChannel, |
90 | videoPagination: newPagination, | 61 | skipCount: true |
91 | sort: this.sort, | ||
92 | nsfwPolicy: this.nsfwPolicy, | ||
93 | videoFilter: this.filter | ||
94 | } | 62 | } |
95 | 63 | ||
96 | return this.videoService | 64 | return this.videoService.getVideoChannelVideos(params) |
97 | .getVideoChannelVideos(options) | ||
98 | } | 65 | } |
99 | 66 | ||
100 | generateSyndicationList () { | 67 | getSyndicationItems () { |
101 | this.syndicationItems = this.videoService.getVideoChannelFeedUrls(this.videoChannel.id) | 68 | return this.videoService.getVideoChannelFeedUrls(this.videoChannel.id) |
102 | } | 69 | } |
103 | 70 | ||
104 | toggleModerationDisplay () { | 71 | displayAsRow () { |
105 | this.filter = this.buildLocalFilter(this.filter, null) | 72 | return this.screenService.isInMobileView() |
73 | } | ||
106 | 74 | ||
107 | this.reloadVideos() | 75 | disableForReuse () { |
76 | this.disabled = true | ||
108 | } | 77 | } |
109 | 78 | ||
110 | displayAsRow () { | 79 | enabledForReuse () { |
111 | return this.screenService.isInMobileView() | 80 | this.disabled = false |
112 | } | 81 | } |
113 | } | 82 | } |
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html index 9e6fde2e0..0e00c9c0e 100644 --- a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html +++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html | |||
@@ -27,13 +27,7 @@ | |||
27 | 27 | ||
28 | <div *ngIf="totalNotDeletedComments === 0 && comments.length === 0" i18n>No comments.</div> | 28 | <div *ngIf="totalNotDeletedComments === 0 && comments.length === 0" i18n>No comments.</div> |
29 | 29 | ||
30 | <div | 30 | <div class="comment-threads" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
31 | class="comment-threads" | ||
32 | myInfiniteScroller | ||
33 | [autoInit]="true" | ||
34 | (nearOfBottom)="onNearOfBottom()" | ||
35 | [dataObservable]="onDataSubject.asObservable()" | ||
36 | > | ||
37 | <div> | 31 | <div> |
38 | <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div> | 32 | <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div> |
39 | <my-video-comment | 33 | <my-video-comment |
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html index c270142a3..da81d76d1 100644 --- a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html +++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <div | 1 | <div |
2 | *ngIf="playlist && currentPlaylistPosition" class="playlist" | 2 | *ngIf="playlist && currentPlaylistPosition" class="playlist" |
3 | myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()" | 3 | myInfiniteScroller [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()" |
4 | > | 4 | > |
5 | <div class="playlist-info"> | 5 | <div class="playlist-info"> |
6 | <div class="playlist-display-name"> | 6 | <div class="playlist-display-name"> |
diff --git a/client/src/app/+videos/video-list/index.ts b/client/src/app/+videos/video-list/index.ts index dc27e29e2..3492f43f4 100644 --- a/client/src/app/+videos/video-list/index.ts +++ b/client/src/app/+videos/video-list/index.ts | |||
@@ -1,4 +1,2 @@ | |||
1 | export * from './overview' | 1 | export * from './overview' |
2 | export * from './trending' | 2 | export * from './videos-list-common-page.component' |
3 | export * from './video-local.component' | ||
4 | export * from './video-recently-added.component' | ||
diff --git a/client/src/app/+videos/video-list/overview/video-overview.component.html b/client/src/app/+videos/video-list/overview/video-overview.component.html index d3c602aa5..1a715560c 100644 --- a/client/src/app/+videos/video-list/overview/video-overview.component.html +++ b/client/src/app/+videos/video-list/overview/video-overview.component.html | |||
@@ -4,7 +4,7 @@ | |||
4 | <div class="no-results" i18n *ngIf="notResults">No results.</div> | 4 | <div class="no-results" i18n *ngIf="notResults">No results.</div> |
5 | 5 | ||
6 | <div | 6 | <div |
7 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" | 7 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" |
8 | > | 8 | > |
9 | <ng-container *ngFor="let overview of overviews"> | 9 | <ng-container *ngFor="let overview of overviews"> |
10 | 10 | ||
diff --git a/client/src/app/+videos/video-list/trending/index.ts b/client/src/app/+videos/video-list/trending/index.ts deleted file mode 100644 index 70835885a..000000000 --- a/client/src/app/+videos/video-list/trending/index.ts +++ /dev/null | |||
@@ -1,2 +0,0 @@ | |||
1 | export * from './video-trending-header.component' | ||
2 | export * from './video-trending.component' | ||
diff --git a/client/src/app/+videos/video-list/trending/video-trending-header.component.html b/client/src/app/+videos/video-list/trending/video-trending-header.component.html deleted file mode 100644 index db81ce6a1..000000000 --- a/client/src/app/+videos/video-list/trending/video-trending-header.component.html +++ /dev/null | |||
@@ -1,8 +0,0 @@ | |||
1 | <div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" [(ngModel)]="data.model" (ngModelChange)="setSort()"> | ||
2 | <ng-container *ngFor="let button of buttons"> | ||
3 | <label *ngIf="!button.hidden" ngbButtonLabel class="btn-light" placement="bottom right-bottom left-bottom" [ngbTooltip]="button.tooltip" container="body"> | ||
4 | <my-global-icon [iconName]="button.iconName"></my-global-icon> | ||
5 | <input ngbButton type="radio" [value]="button.value"> {{ button.label }} | ||
6 | </label> | ||
7 | </ng-container> | ||
8 | </div> | ||
diff --git a/client/src/app/+videos/video-list/trending/video-trending-header.component.scss b/client/src/app/+videos/video-list/trending/video-trending-header.component.scss deleted file mode 100644 index 54b072314..000000000 --- a/client/src/app/+videos/video-list/trending/video-trending-header.component.scss +++ /dev/null | |||
@@ -1,20 +0,0 @@ | |||
1 | @use '_mixins' as *; | ||
2 | |||
3 | .btn-group label { | ||
4 | border: 1px solid transparent; | ||
5 | border-radius: 9999px !important; | ||
6 | padding: 5px 16px; | ||
7 | opacity: .8; | ||
8 | |||
9 | &:not(:first-child) { | ||
10 | @include margin-left(.5rem); | ||
11 | } | ||
12 | |||
13 | my-global-icon { | ||
14 | @include margin-right(.1rem); | ||
15 | |||
16 | position: relative; | ||
17 | top: -2px; | ||
18 | height: 1rem; | ||
19 | } | ||
20 | } | ||
diff --git a/client/src/app/+videos/video-list/trending/video-trending-header.component.ts b/client/src/app/+videos/video-list/trending/video-trending-header.component.ts deleted file mode 100644 index c94655c74..000000000 --- a/client/src/app/+videos/video-list/trending/video-trending-header.component.ts +++ /dev/null | |||
@@ -1,109 +0,0 @@ | |||
1 | import { Subscription } from 'rxjs' | ||
2 | import { Component, HostBinding, Inject, OnDestroy, OnInit } from '@angular/core' | ||
3 | import { ActivatedRoute, Router } from '@angular/router' | ||
4 | import { AuthService, RedirectService } from '@app/core' | ||
5 | import { ServerService } from '@app/core/server/server.service' | ||
6 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
7 | import { VideoListHeaderComponent } from '@app/shared/shared-video-miniature' | ||
8 | |||
9 | interface VideoTrendingHeaderItem { | ||
10 | label: string | ||
11 | iconName: GlobalIconName | ||
12 | value: string | ||
13 | tooltip?: string | ||
14 | hidden?: boolean | ||
15 | } | ||
16 | |||
17 | @Component({ | ||
18 | selector: 'my-video-trending-title-page', | ||
19 | styleUrls: [ './video-trending-header.component.scss' ], | ||
20 | templateUrl: './video-trending-header.component.html' | ||
21 | }) | ||
22 | export class VideoTrendingHeaderComponent extends VideoListHeaderComponent implements OnInit, OnDestroy { | ||
23 | @HostBinding('class') class = 'title-page title-page-single' | ||
24 | |||
25 | buttons: VideoTrendingHeaderItem[] | ||
26 | |||
27 | private algorithmChangeSub: Subscription | ||
28 | |||
29 | constructor ( | ||
30 | @Inject('data') public data: any, | ||
31 | private route: ActivatedRoute, | ||
32 | private router: Router, | ||
33 | private auth: AuthService, | ||
34 | private serverService: ServerService, | ||
35 | private redirectService: RedirectService | ||
36 | ) { | ||
37 | super(data) | ||
38 | |||
39 | this.buttons = [ | ||
40 | { | ||
41 | label: $localize`:A variant of Trending videos based on the number of recent interactions, minus user history:Best`, | ||
42 | iconName: 'award', | ||
43 | value: 'best', | ||
44 | tooltip: $localize`Videos with the most interactions for recent videos, minus user history`, | ||
45 | hidden: true | ||
46 | }, | ||
47 | { | ||
48 | label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`, | ||
49 | iconName: 'flame', | ||
50 | value: 'hot', | ||
51 | tooltip: $localize`Videos with the most interactions for recent videos`, | ||
52 | hidden: true | ||
53 | }, | ||
54 | { | ||
55 | label: $localize`:Main variant of Trending videos based on number of recent views:Views`, | ||
56 | iconName: 'trending', | ||
57 | value: 'most-viewed', | ||
58 | tooltip: $localize`Videos with the most views during the last 24 hours` | ||
59 | }, | ||
60 | { | ||
61 | label: $localize`:A variant of Trending videos based on the number of likes:Likes`, | ||
62 | iconName: 'like', | ||
63 | value: 'most-liked', | ||
64 | tooltip: $localize`Videos that have the most likes` | ||
65 | } | ||
66 | ] | ||
67 | } | ||
68 | |||
69 | ngOnInit () { | ||
70 | const serverConfig = this.serverService.getHTMLConfig() | ||
71 | const algEnabled = serverConfig.trending.videos.algorithms.enabled | ||
72 | |||
73 | this.buttons = this.buttons.map(b => { | ||
74 | b.hidden = !algEnabled.includes(b.value) | ||
75 | |||
76 | // Best is adapted by the user history so | ||
77 | if (b.value === 'best' && !this.auth.isLoggedIn()) { | ||
78 | b.hidden = true | ||
79 | } | ||
80 | |||
81 | return b | ||
82 | }) | ||
83 | |||
84 | this.algorithmChangeSub = this.route.queryParams.subscribe( | ||
85 | queryParams => { | ||
86 | this.data.model = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm() | ||
87 | } | ||
88 | ) | ||
89 | } | ||
90 | |||
91 | ngOnDestroy () { | ||
92 | if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe() | ||
93 | } | ||
94 | |||
95 | setSort () { | ||
96 | const alg = this.data.model !== this.redirectService.getDefaultTrendingAlgorithm() | ||
97 | ? this.data.model | ||
98 | : undefined | ||
99 | |||
100 | this.router.navigate( | ||
101 | [], | ||
102 | { | ||
103 | relativeTo: this.route, | ||
104 | queryParams: { alg }, | ||
105 | queryParamsHandling: 'merge' | ||
106 | } | ||
107 | ) | ||
108 | } | ||
109 | } | ||
diff --git a/client/src/app/+videos/video-list/trending/video-trending.component.ts b/client/src/app/+videos/video-list/trending/video-trending.component.ts deleted file mode 100644 index 085f29a8b..000000000 --- a/client/src/app/+videos/video-list/trending/video-trending.component.ts +++ /dev/null | |||
@@ -1,127 +0,0 @@ | |||
1 | import { Subscription } from 'rxjs' | ||
2 | import { first, switchMap } from 'rxjs/operators' | ||
3 | import { Component, ComponentFactoryResolver, Injector, OnDestroy, OnInit } from '@angular/core' | ||
4 | import { ActivatedRoute, Params, Router } from '@angular/router' | ||
5 | import { AuthService, LocalStorageService, Notifier, RedirectService, ScreenService, ServerService, UserService } from '@app/core' | ||
6 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
7 | import { immutableAssign } from '@app/helpers' | ||
8 | import { VideoService } from '@app/shared/shared-main' | ||
9 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | ||
10 | import { VideoSortField } from '@shared/models' | ||
11 | import { VideoTrendingHeaderComponent } from './video-trending-header.component' | ||
12 | |||
13 | @Component({ | ||
14 | selector: 'my-videos-hot', | ||
15 | styleUrls: [ '../../../shared/shared-video-miniature/abstract-video-list.scss' ], | ||
16 | templateUrl: '../../../shared/shared-video-miniature/abstract-video-list.html' | ||
17 | }) | ||
18 | export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { | ||
19 | HeaderComponent = VideoTrendingHeaderComponent | ||
20 | titlePage: string | ||
21 | defaultSort: VideoSortField = '-trending' | ||
22 | |||
23 | loadUserVideoPreferences = true | ||
24 | |||
25 | private algorithmChangeSub: Subscription | ||
26 | |||
27 | constructor ( | ||
28 | protected router: Router, | ||
29 | protected serverService: ServerService, | ||
30 | protected route: ActivatedRoute, | ||
31 | protected notifier: Notifier, | ||
32 | protected authService: AuthService, | ||
33 | protected userService: UserService, | ||
34 | protected screenService: ScreenService, | ||
35 | protected storageService: LocalStorageService, | ||
36 | protected cfr: ComponentFactoryResolver, | ||
37 | private videoService: VideoService, | ||
38 | private redirectService: RedirectService, | ||
39 | private hooks: HooksService | ||
40 | ) { | ||
41 | super() | ||
42 | |||
43 | this.defaultSort = this.parseAlgorithm(this.redirectService.getDefaultTrendingAlgorithm()) | ||
44 | |||
45 | this.headerComponentInjector = this.getInjector() | ||
46 | } | ||
47 | |||
48 | ngOnInit () { | ||
49 | super.ngOnInit() | ||
50 | |||
51 | this.generateSyndicationList() | ||
52 | |||
53 | // Subscribe to alg change after we loaded the data | ||
54 | // The initial alg load is handled by the parent class | ||
55 | this.algorithmChangeSub = this.onDataSubject | ||
56 | .pipe( | ||
57 | first(), | ||
58 | switchMap(() => this.route.queryParams) | ||
59 | ).subscribe(queryParams => { | ||
60 | const oldSort = this.sort | ||
61 | |||
62 | this.loadPageRouteParams(queryParams) | ||
63 | |||
64 | if (oldSort !== this.sort) this.reloadVideos() | ||
65 | } | ||
66 | ) | ||
67 | } | ||
68 | |||
69 | ngOnDestroy () { | ||
70 | super.ngOnDestroy() | ||
71 | if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe() | ||
72 | } | ||
73 | |||
74 | getVideosObservable (page: number) { | ||
75 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
76 | const params = { | ||
77 | videoPagination: newPagination, | ||
78 | sort: this.sort, | ||
79 | categoryOneOf: this.categoryOneOf, | ||
80 | languageOneOf: this.languageOneOf, | ||
81 | nsfwPolicy: this.nsfwPolicy, | ||
82 | skipCount: true | ||
83 | } | ||
84 | |||
85 | return this.hooks.wrapObsFun( | ||
86 | this.videoService.getVideos.bind(this.videoService), | ||
87 | params, | ||
88 | 'common', | ||
89 | 'filter:api.trending-videos.videos.list.params', | ||
90 | 'filter:api.trending-videos.videos.list.result' | ||
91 | ) | ||
92 | } | ||
93 | |||
94 | generateSyndicationList () { | ||
95 | this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) | ||
96 | } | ||
97 | |||
98 | getInjector () { | ||
99 | return Injector.create({ | ||
100 | providers: [ { | ||
101 | provide: 'data', | ||
102 | useValue: { | ||
103 | model: this.defaultSort | ||
104 | } | ||
105 | } ] | ||
106 | }) | ||
107 | } | ||
108 | |||
109 | protected loadPageRouteParams (queryParams: Params) { | ||
110 | const algorithm = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm() | ||
111 | |||
112 | this.sort = this.parseAlgorithm(algorithm) | ||
113 | } | ||
114 | |||
115 | private parseAlgorithm (algorithm: string): VideoSortField { | ||
116 | switch (algorithm) { | ||
117 | case 'most-viewed': | ||
118 | return '-trending' | ||
119 | |||
120 | case 'most-liked': | ||
121 | return '-likes' | ||
122 | |||
123 | default: | ||
124 | return '-' + algorithm as VideoSortField | ||
125 | } | ||
126 | } | ||
127 | } | ||
diff --git a/client/src/app/+videos/video-list/video-local.component.ts b/client/src/app/+videos/video-list/video-local.component.ts deleted file mode 100644 index b576883d1..000000000 --- a/client/src/app/+videos/video-list/video-local.component.ts +++ /dev/null | |||
@@ -1,81 +0,0 @@ | |||
1 | import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
4 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
5 | import { immutableAssign } from '@app/helpers' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | ||
8 | import { VideoFilter, VideoSortField } from '@shared/models' | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-videos-local', | ||
12 | styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], | ||
13 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' | ||
14 | }) | ||
15 | export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy { | ||
16 | titlePage: string | ||
17 | sort = '-publishedAt' as VideoSortField | ||
18 | filter: VideoFilter = 'local' | ||
19 | |||
20 | loadUserVideoPreferences = true | ||
21 | |||
22 | constructor ( | ||
23 | protected router: Router, | ||
24 | protected serverService: ServerService, | ||
25 | protected route: ActivatedRoute, | ||
26 | protected notifier: Notifier, | ||
27 | protected authService: AuthService, | ||
28 | protected userService: UserService, | ||
29 | protected screenService: ScreenService, | ||
30 | protected storageService: LocalStorageService, | ||
31 | protected cfr: ComponentFactoryResolver, | ||
32 | private videoService: VideoService, | ||
33 | private hooks: HooksService | ||
34 | ) { | ||
35 | super() | ||
36 | |||
37 | this.titlePage = $localize`Local videos` | ||
38 | } | ||
39 | |||
40 | ngOnInit () { | ||
41 | super.ngOnInit() | ||
42 | |||
43 | this.enableAllFilterIfPossible() | ||
44 | this.generateSyndicationList() | ||
45 | } | ||
46 | |||
47 | ngOnDestroy () { | ||
48 | super.ngOnDestroy() | ||
49 | } | ||
50 | |||
51 | getVideosObservable (page: number) { | ||
52 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
53 | const params = { | ||
54 | videoPagination: newPagination, | ||
55 | sort: this.sort, | ||
56 | filter: this.filter, | ||
57 | categoryOneOf: this.categoryOneOf, | ||
58 | languageOneOf: this.languageOneOf, | ||
59 | nsfwPolicy: this.nsfwPolicy, | ||
60 | skipCount: true | ||
61 | } | ||
62 | |||
63 | return this.hooks.wrapObsFun( | ||
64 | this.videoService.getVideos.bind(this.videoService), | ||
65 | params, | ||
66 | 'common', | ||
67 | 'filter:api.local-videos.videos.list.params', | ||
68 | 'filter:api.local-videos.videos.list.result' | ||
69 | ) | ||
70 | } | ||
71 | |||
72 | generateSyndicationList () { | ||
73 | this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf) | ||
74 | } | ||
75 | |||
76 | toggleModerationDisplay () { | ||
77 | this.filter = this.buildLocalFilter(this.filter, 'local') | ||
78 | |||
79 | this.reloadVideos() | ||
80 | } | ||
81 | } | ||
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 deleted file mode 100644 index 506f92d25..000000000 --- a/client/src/app/+videos/video-list/video-recently-added.component.ts +++ /dev/null | |||
@@ -1,73 +0,0 @@ | |||
1 | import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' | ||
4 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
5 | import { immutableAssign } from '@app/helpers' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | ||
8 | import { VideoSortField } from '@shared/models' | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-videos-recently-added', | ||
12 | styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], | ||
13 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' | ||
14 | }) | ||
15 | export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy { | ||
16 | titlePage: string | ||
17 | sort: VideoSortField = '-publishedAt' | ||
18 | groupByDate = true | ||
19 | |||
20 | loadUserVideoPreferences = true | ||
21 | |||
22 | constructor ( | ||
23 | protected route: ActivatedRoute, | ||
24 | protected serverService: ServerService, | ||
25 | protected router: Router, | ||
26 | protected notifier: Notifier, | ||
27 | protected authService: AuthService, | ||
28 | protected userService: UserService, | ||
29 | protected screenService: ScreenService, | ||
30 | protected storageService: LocalStorageService, | ||
31 | protected cfr: ComponentFactoryResolver, | ||
32 | private videoService: VideoService, | ||
33 | private hooks: HooksService | ||
34 | ) { | ||
35 | super() | ||
36 | |||
37 | this.titlePage = $localize`Recently added` | ||
38 | } | ||
39 | |||
40 | ngOnInit () { | ||
41 | super.ngOnInit() | ||
42 | |||
43 | this.generateSyndicationList() | ||
44 | } | ||
45 | |||
46 | ngOnDestroy () { | ||
47 | super.ngOnDestroy() | ||
48 | } | ||
49 | |||
50 | getVideosObservable (page: number) { | ||
51 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
52 | const params = { | ||
53 | videoPagination: newPagination, | ||
54 | sort: this.sort, | ||
55 | categoryOneOf: this.categoryOneOf, | ||
56 | languageOneOf: this.languageOneOf, | ||
57 | nsfwPolicy: this.nsfwPolicy, | ||
58 | skipCount: true | ||
59 | } | ||
60 | |||
61 | return this.hooks.wrapObsFun( | ||
62 | this.videoService.getVideos.bind(this.videoService), | ||
63 | params, | ||
64 | 'common', | ||
65 | 'filter:api.recently-added-videos.videos.list.params', | ||
66 | 'filter:api.recently-added-videos.videos.list.result' | ||
67 | ) | ||
68 | } | ||
69 | |||
70 | generateSyndicationList () { | ||
71 | this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) | ||
72 | } | ||
73 | } | ||
diff --git a/client/src/app/+videos/video-list/video-user-subscriptions.component.html b/client/src/app/+videos/video-list/video-user-subscriptions.component.html new file mode 100644 index 000000000..2675b58bf --- /dev/null +++ b/client/src/app/+videos/video-list/video-user-subscriptions.component.html | |||
@@ -0,0 +1,17 @@ | |||
1 | <my-videos-list | ||
2 | [getVideosObservableFunction]="getVideosObservableFunction" | ||
3 | [getSyndicationItemsFunction]="getSyndicationItemsFunction" | ||
4 | |||
5 | [title]="titlePage" | ||
6 | |||
7 | [defaultSort]="defaultSort" | ||
8 | |||
9 | [displayFilters]="false" | ||
10 | [displayModerationBlock]="false" | ||
11 | |||
12 | [loadUserVideoPreferences]="false" | ||
13 | [groupByDate]="true" | ||
14 | |||
15 | [disabled]="disabled" | ||
16 | > | ||
17 | </my-videos-list> | ||
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 a1498e797..43cbab9f6 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,94 +1,53 @@ | |||
1 | 1 | ||
2 | import { switchMap } from 'rxjs/operators' | 2 | import { firstValueFrom } from 'rxjs' |
3 | import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' | 3 | import { switchMap, tap } from 'rxjs/operators' |
4 | import { ActivatedRoute, Router } from '@angular/router' | 4 | import { Component } from '@angular/core' |
5 | import { AuthService, LocalStorageService, Notifier, ScopedTokensService, ScreenService, ServerService, UserService } from '@app/core' | 5 | import { AuthService, ComponentPaginationLight, DisableForReuseHook, ScopedTokensService } from '@app/core' |
6 | import { HooksService } from '@app/core/plugins/hooks.service' | 6 | import { HooksService } from '@app/core/plugins/hooks.service' |
7 | import { immutableAssign } from '@app/helpers' | ||
8 | import { VideoService } from '@app/shared/shared-main' | 7 | import { VideoService } from '@app/shared/shared-main' |
9 | import { UserSubscriptionService } from '@app/shared/shared-user-subscription' | 8 | import { UserSubscriptionService } from '@app/shared/shared-user-subscription' |
10 | import { AbstractVideoList } from '@app/shared/shared-video-miniature' | 9 | import { VideoFilters } from '@app/shared/shared-video-miniature' |
11 | import { FeedFormat, VideoSortField } from '@shared/models' | 10 | import { VideoSortField } from '@shared/models' |
12 | import { environment } from '../../../environments/environment' | ||
13 | import { copyToClipboard } from '../../../root-helpers/utils' | ||
14 | 11 | ||
15 | @Component({ | 12 | @Component({ |
16 | selector: 'my-videos-user-subscriptions', | 13 | selector: 'my-videos-user-subscriptions', |
17 | styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], | 14 | templateUrl: './video-user-subscriptions.component.html' |
18 | templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' | ||
19 | }) | 15 | }) |
20 | export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy { | 16 | export class VideoUserSubscriptionsComponent implements DisableForReuseHook { |
21 | titlePage: string | 17 | getVideosObservableFunction = this.getVideosObservable.bind(this) |
22 | sort = '-publishedAt' as VideoSortField | 18 | getSyndicationItemsFunction = this.getSyndicationItems.bind(this) |
23 | groupByDate = true | 19 | |
20 | defaultSort = '-publishedAt' as VideoSortField | ||
21 | |||
22 | actions = [ | ||
23 | { | ||
24 | routerLink: '/my-library/subscriptions', | ||
25 | label: $localize`Subscriptions`, | ||
26 | iconName: 'cog' | ||
27 | } | ||
28 | ] | ||
29 | |||
30 | titlePage = $localize`Videos from your subscriptions` | ||
31 | |||
32 | disabled = false | ||
33 | |||
34 | private feedToken: string | ||
24 | 35 | ||
25 | constructor ( | 36 | constructor ( |
26 | protected router: Router, | 37 | private authService: AuthService, |
27 | protected serverService: ServerService, | ||
28 | protected route: ActivatedRoute, | ||
29 | protected notifier: Notifier, | ||
30 | protected authService: AuthService, | ||
31 | protected userService: UserService, | ||
32 | protected screenService: ScreenService, | ||
33 | protected storageService: LocalStorageService, | ||
34 | private userSubscription: UserSubscriptionService, | 38 | private userSubscription: UserSubscriptionService, |
35 | protected cfr: ComponentFactoryResolver, | ||
36 | private hooks: HooksService, | 39 | private hooks: HooksService, |
37 | private videoService: VideoService, | 40 | private videoService: VideoService, |
38 | private scopedTokensService: ScopedTokensService | 41 | private scopedTokensService: ScopedTokensService |
39 | ) { | 42 | ) { |
40 | super() | ||
41 | 43 | ||
42 | this.titlePage = $localize`Videos from your subscriptions` | ||
43 | |||
44 | this.actions.push({ | ||
45 | routerLink: '/my-library/subscriptions', | ||
46 | label: $localize`Subscriptions`, | ||
47 | iconName: 'cog' | ||
48 | }) | ||
49 | } | 44 | } |
50 | 45 | ||
51 | ngOnInit () { | 46 | getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) { |
52 | super.ngOnInit() | ||
53 | |||
54 | const user = this.authService.getUser() | ||
55 | let feedUrl = environment.originServerUrl | ||
56 | |||
57 | this.authService.userInformationLoaded | ||
58 | .pipe(switchMap(() => this.scopedTokensService.getScopedTokens())) | ||
59 | .subscribe({ | ||
60 | next: tokens => { | ||
61 | const feeds = this.videoService.getVideoSubscriptionFeedUrls(user.account.id, tokens.feedToken) | ||
62 | feedUrl = feedUrl + feeds.find(f => f.format === FeedFormat.RSS).url | ||
63 | |||
64 | this.actions.unshift({ | ||
65 | label: $localize`Copy feed URL`, | ||
66 | iconName: 'syndication', | ||
67 | justIcon: true, | ||
68 | href: feedUrl, | ||
69 | click: e => { | ||
70 | e.preventDefault() | ||
71 | copyToClipboard(feedUrl) | ||
72 | this.activateCopiedMessage() | ||
73 | } | ||
74 | }) | ||
75 | }, | ||
76 | |||
77 | error: err => { | ||
78 | this.notifier.error(err.message) | ||
79 | } | ||
80 | }) | ||
81 | } | ||
82 | |||
83 | ngOnDestroy () { | ||
84 | super.ngOnDestroy() | ||
85 | } | ||
86 | |||
87 | getVideosObservable (page: number) { | ||
88 | const newPagination = immutableAssign(this.pagination, { currentPage: page }) | ||
89 | const params = { | 47 | const params = { |
90 | videoPagination: newPagination, | 48 | ...filters.toVideosAPIObject(), |
91 | sort: this.sort, | 49 | |
50 | videoPagination: pagination, | ||
92 | skipCount: true | 51 | skipCount: true |
93 | } | 52 | } |
94 | 53 | ||
@@ -101,12 +60,32 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement | |||
101 | ) | 60 | ) |
102 | } | 61 | } |
103 | 62 | ||
104 | generateSyndicationList () { | 63 | getSyndicationItems () { |
105 | /* method disabled: the view provides its own */ | 64 | return this.loadFeedToken() |
106 | throw new Error('Method not implemented.') | 65 | .then(() => { |
66 | const user = this.authService.getUser() | ||
67 | |||
68 | return this.videoService.getVideoSubscriptionFeedUrls(user.account.id, this.feedToken) | ||
69 | }) | ||
107 | } | 70 | } |
108 | 71 | ||
109 | activateCopiedMessage () { | 72 | disableForReuse () { |
110 | this.notifier.success($localize`Feed URL copied`) | 73 | this.disabled = true |
74 | } | ||
75 | |||
76 | enabledForReuse () { | ||
77 | this.disabled = false | ||
78 | } | ||
79 | |||
80 | private loadFeedToken () { | ||
81 | if (this.feedToken) return Promise.resolve(this.feedToken) | ||
82 | |||
83 | const obs = this.authService.userInformationLoaded | ||
84 | .pipe( | ||
85 | switchMap(() => this.scopedTokensService.getScopedTokens()), | ||
86 | tap(tokens => this.feedToken = tokens.feedToken) | ||
87 | ) | ||
88 | |||
89 | return firstValueFrom(obs) | ||
111 | } | 90 | } |
112 | } | 91 | } |
diff --git a/client/src/app/+videos/video-list/videos-list-common-page.component.html b/client/src/app/+videos/video-list/videos-list-common-page.component.html new file mode 100644 index 000000000..2831f996f --- /dev/null +++ b/client/src/app/+videos/video-list/videos-list-common-page.component.html | |||
@@ -0,0 +1,22 @@ | |||
1 | <my-videos-list | ||
2 | [getVideosObservableFunction]="getVideosObservableFunction" | ||
3 | [getSyndicationItemsFunction]="getSyndicationItemsFunction" | ||
4 | [baseRouteBuilderFunction]="baseRouteBuilderFunction" | ||
5 | |||
6 | [title]="title" | ||
7 | [titleTooltip]="titleTooltip" | ||
8 | |||
9 | [defaultSort]="defaultSort" | ||
10 | [defaultScope]="defaultScope" | ||
11 | |||
12 | [displayFilters]="true" | ||
13 | [displayModerationBlock]="true" | ||
14 | |||
15 | [loadUserVideoPreferences]="true" | ||
16 | [groupByDate]="groupByDate" | ||
17 | |||
18 | [disabled]="disabled" | ||
19 | |||
20 | (filtersChanged)="onFiltersChanged($event)" | ||
21 | > | ||
22 | </my-videos-list> | ||
diff --git a/client/src/app/+videos/video-list/videos-list-common-page.component.ts b/client/src/app/+videos/video-list/videos-list-common-page.component.ts new file mode 100644 index 000000000..ba64d4fec --- /dev/null +++ b/client/src/app/+videos/video-list/videos-list-common-page.component.ts | |||
@@ -0,0 +1,219 @@ | |||
1 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router' | ||
3 | import { ComponentPaginationLight, DisableForReuseHook, MetaService, RedirectService, ServerService } from '@app/core' | ||
4 | import { HooksService } from '@app/core/plugins/hooks.service' | ||
5 | import { VideoService } from '@app/shared/shared-main' | ||
6 | import { VideoFilters, VideoFilterScope } from '@app/shared/shared-video-miniature/video-filters.model' | ||
7 | import { ClientFilterHookName, VideoSortField } from '@shared/models' | ||
8 | import { Subscription } from 'rxjs' | ||
9 | |||
10 | export type VideosListCommonPageRouteData = { | ||
11 | sort: VideoSortField | ||
12 | |||
13 | scope: VideoFilterScope | ||
14 | hookParams: ClientFilterHookName | ||
15 | hookResult: ClientFilterHookName | ||
16 | } | ||
17 | |||
18 | @Component({ | ||
19 | templateUrl: './videos-list-common-page.component.html' | ||
20 | }) | ||
21 | export class VideosListCommonPageComponent implements OnInit, OnDestroy, DisableForReuseHook { | ||
22 | getVideosObservableFunction = this.getVideosObservable.bind(this) | ||
23 | getSyndicationItemsFunction = this.getSyndicationItems.bind(this) | ||
24 | baseRouteBuilderFunction = this.baseRouteBuilder.bind(this) | ||
25 | |||
26 | title: string | ||
27 | titleTooltip: string | ||
28 | |||
29 | groupByDate: boolean | ||
30 | |||
31 | defaultSort: VideoSortField | ||
32 | defaultScope: VideoFilterScope | ||
33 | |||
34 | hookParams: ClientFilterHookName | ||
35 | hookResult: ClientFilterHookName | ||
36 | |||
37 | loadUserVideoPreferences = true | ||
38 | |||
39 | displayFilters = true | ||
40 | |||
41 | disabled = false | ||
42 | |||
43 | private trendingDays: number | ||
44 | private routeSub: Subscription | ||
45 | |||
46 | constructor ( | ||
47 | private server: ServerService, | ||
48 | private route: ActivatedRoute, | ||
49 | private videoService: VideoService, | ||
50 | private hooks: HooksService, | ||
51 | private meta: MetaService, | ||
52 | private redirectService: RedirectService | ||
53 | ) { | ||
54 | } | ||
55 | |||
56 | ngOnInit () { | ||
57 | this.trendingDays = this.server.getHTMLConfig().trending.videos.intervalDays | ||
58 | |||
59 | this.routeSub = this.route.params.subscribe(params => { | ||
60 | this.update(params['page']) | ||
61 | }) | ||
62 | } | ||
63 | |||
64 | ngOnDestroy () { | ||
65 | if (this.routeSub) this.routeSub.unsubscribe() | ||
66 | } | ||
67 | |||
68 | getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) { | ||
69 | const params = { | ||
70 | ...filters.toVideosAPIObject(), | ||
71 | |||
72 | videoPagination: pagination, | ||
73 | skipCount: true | ||
74 | } | ||
75 | |||
76 | return this.hooks.wrapObsFun( | ||
77 | this.videoService.getVideos.bind(this.videoService), | ||
78 | params, | ||
79 | 'common', | ||
80 | this.hookParams, | ||
81 | this.hookResult | ||
82 | ) | ||
83 | } | ||
84 | |||
85 | getSyndicationItems (filters: VideoFilters) { | ||
86 | const result = filters.toVideosAPIObject() | ||
87 | |||
88 | return this.videoService.getVideoFeedUrls(result.sort, result.filter) | ||
89 | } | ||
90 | |||
91 | onFiltersChanged (filters: VideoFilters) { | ||
92 | this.buildTitle(filters.scope, filters.sort) | ||
93 | this.updateGroupByDate(filters.sort) | ||
94 | } | ||
95 | |||
96 | baseRouteBuilder (filters: VideoFilters) { | ||
97 | const sanitizedSort = this.getSanitizedSort(filters.sort) | ||
98 | |||
99 | let suffix: string | ||
100 | |||
101 | if (filters.scope === 'local') suffix = 'local' | ||
102 | else if (sanitizedSort === 'publishedAt') suffix = 'recently-added' | ||
103 | else suffix = 'trending' | ||
104 | |||
105 | return [ '/videos', suffix ] | ||
106 | } | ||
107 | |||
108 | disableForReuse () { | ||
109 | this.disabled = true | ||
110 | } | ||
111 | |||
112 | enabledForReuse () { | ||
113 | this.disabled = false | ||
114 | } | ||
115 | |||
116 | update (page: string) { | ||
117 | const data = this.getData(page) | ||
118 | |||
119 | this.hookParams = data.hookParams | ||
120 | this.hookResult = data.hookResult | ||
121 | |||
122 | this.defaultSort = data.sort | ||
123 | this.defaultScope = data.scope | ||
124 | |||
125 | this.buildTitle() | ||
126 | this.updateGroupByDate(this.defaultSort) | ||
127 | |||
128 | this.meta.setTitle(this.title) | ||
129 | } | ||
130 | |||
131 | private getData (page: string) { | ||
132 | if (page === 'trending') return this.generateTrendingData(this.route.snapshot) | ||
133 | |||
134 | if (page === 'local') return this.generateLocalData() | ||
135 | |||
136 | return this.generateRecentlyAddedData() | ||
137 | } | ||
138 | |||
139 | private generateRecentlyAddedData (): VideosListCommonPageRouteData { | ||
140 | return { | ||
141 | sort: '-publishedAt', | ||
142 | scope: 'federated', | ||
143 | hookParams: 'filter:api.recently-added-videos.videos.list.params', | ||
144 | hookResult: 'filter:api.recently-added-videos.videos.list.result' | ||
145 | } | ||
146 | } | ||
147 | |||
148 | private generateLocalData (): VideosListCommonPageRouteData { | ||
149 | return { | ||
150 | sort: '-publishedAt' as VideoSortField, | ||
151 | scope: 'local', | ||
152 | hookParams: 'filter:api.local-videos.videos.list.params', | ||
153 | hookResult: 'filter:api.local-videos.videos.list.result' | ||
154 | } | ||
155 | } | ||
156 | |||
157 | private generateTrendingData (route: ActivatedRouteSnapshot): VideosListCommonPageRouteData { | ||
158 | const sort = route.queryParams['sort'] ?? this.parseTrendingAlgorithm(this.redirectService.getDefaultTrendingAlgorithm()) | ||
159 | |||
160 | return { | ||
161 | sort, | ||
162 | scope: 'federated', | ||
163 | hookParams: 'filter:api.trending-videos.videos.list.params', | ||
164 | hookResult: 'filter:api.trending-videos.videos.list.result' | ||
165 | } | ||
166 | } | ||
167 | |||
168 | private parseTrendingAlgorithm (algorithm: string): VideoSortField { | ||
169 | switch (algorithm) { | ||
170 | case 'most-viewed': | ||
171 | return '-trending' | ||
172 | |||
173 | case 'most-liked': | ||
174 | return '-likes' | ||
175 | |||
176 | default: | ||
177 | return '-' + algorithm as VideoSortField | ||
178 | } | ||
179 | } | ||
180 | |||
181 | private updateGroupByDate (sort: VideoSortField) { | ||
182 | this.groupByDate = sort === '-publishedAt' || sort === 'publishedAt' | ||
183 | } | ||
184 | |||
185 | private buildTitle (scope: VideoFilterScope = this.defaultScope, sort: VideoSortField = this.defaultSort) { | ||
186 | const sanitizedSort = this.getSanitizedSort(sort) | ||
187 | |||
188 | if (scope === 'local') { | ||
189 | this.title = $localize`Local videos` | ||
190 | this.titleTooltip = $localize`Only videos uploaded on this instance are displayed` | ||
191 | return | ||
192 | } | ||
193 | |||
194 | if (sanitizedSort === 'publishedAt') { | ||
195 | this.title = $localize`Recently added` | ||
196 | this.titleTooltip = undefined | ||
197 | return | ||
198 | } | ||
199 | |||
200 | if ([ 'best', 'hot', 'trending', 'likes' ].includes(sanitizedSort)) { | ||
201 | this.title = $localize`Trending` | ||
202 | |||
203 | if (sanitizedSort === 'best') this.titleTooltip = $localize`Videos with the most interactions for recent videos, minus user history` | ||
204 | if (sanitizedSort === 'hot') this.titleTooltip = $localize`Videos with the most interactions for recent videos` | ||
205 | if (sanitizedSort === 'likes') this.titleTooltip = $localize`Videos that have the most likes` | ||
206 | |||
207 | if (sanitizedSort === 'trending') { | ||
208 | if (this.trendingDays === 1) this.titleTooltip = $localize`Videos with the most views during the last 24 hours` | ||
209 | else this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days` | ||
210 | } | ||
211 | |||
212 | return | ||
213 | } | ||
214 | } | ||
215 | |||
216 | private getSanitizedSort (sort: VideoSortField) { | ||
217 | return sort.replace(/^-/, '') as VideoSortField | ||
218 | } | ||
219 | } | ||
diff --git a/client/src/app/+videos/videos-routing.module.ts b/client/src/app/+videos/videos-routing.module.ts index 926dfaab0..7db519615 100644 --- a/client/src/app/+videos/videos-routing.module.ts +++ b/client/src/app/+videos/videos-routing.module.ts | |||
@@ -1,10 +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, UrlSegment } from '@angular/router' |
3 | import { LoginGuard } from '@app/core' | 3 | import { LoginGuard } from '@app/core' |
4 | import { VideoTrendingComponent } from './video-list' | 4 | import { VideosListCommonPageComponent } from './video-list' |
5 | import { VideoOverviewComponent } from './video-list/overview/video-overview.component' | 5 | import { VideoOverviewComponent } from './video-list/overview/video-overview.component' |
6 | import { VideoLocalComponent } from './video-list/video-local.component' | ||
7 | import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' | ||
8 | import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' | 6 | import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' |
9 | import { VideosComponent } from './videos.component' | 7 | import { VideosComponent } from './videos.component' |
10 | 8 | ||
@@ -22,32 +20,35 @@ const videosRoutes: Routes = [ | |||
22 | } | 20 | } |
23 | } | 21 | } |
24 | }, | 22 | }, |
23 | |||
25 | { | 24 | { |
26 | path: 'trending', | 25 | // Old URL redirection |
27 | component: VideoTrendingComponent, | ||
28 | data: { | ||
29 | meta: { | ||
30 | title: $localize`Trending videos` | ||
31 | } | ||
32 | } | ||
33 | }, | ||
34 | { | ||
35 | path: 'most-liked', | 26 | path: 'most-liked', |
36 | redirectTo: 'trending?alg=most-liked' | 27 | redirectTo: 'trending?sort=most-liked' |
37 | }, | 28 | }, |
38 | { | 29 | { |
39 | path: 'recently-added', | 30 | matcher: (url: UrlSegment[]) => { |
40 | component: VideoRecentlyAddedComponent, | 31 | if (url.length === 1 && [ 'recently-added', 'trending', 'local' ].includes(url[0].path)) { |
32 | return { | ||
33 | consumed: url, | ||
34 | posParams: { | ||
35 | page: new UrlSegment(url[0].path, {}) | ||
36 | } | ||
37 | } | ||
38 | } | ||
39 | |||
40 | return null | ||
41 | }, | ||
42 | |||
43 | component: VideosListCommonPageComponent, | ||
41 | data: { | 44 | data: { |
42 | meta: { | ||
43 | title: $localize`Recently added videos` | ||
44 | }, | ||
45 | reuse: { | 45 | reuse: { |
46 | enabled: true, | 46 | enabled: true, |
47 | key: 'recently-added-videos-list' | 47 | key: 'videos-list' |
48 | } | 48 | } |
49 | } | 49 | } |
50 | }, | 50 | }, |
51 | |||
51 | { | 52 | { |
52 | path: 'subscriptions', | 53 | path: 'subscriptions', |
53 | canActivate: [ LoginGuard ], | 54 | canActivate: [ LoginGuard ], |
@@ -61,19 +62,6 @@ const videosRoutes: Routes = [ | |||
61 | key: 'subscription-videos-list' | 62 | key: 'subscription-videos-list' |
62 | } | 63 | } |
63 | } | 64 | } |
64 | }, | ||
65 | { | ||
66 | path: 'local', | ||
67 | component: VideoLocalComponent, | ||
68 | data: { | ||
69 | meta: { | ||
70 | title: $localize`Local videos` | ||
71 | }, | ||
72 | reuse: { | ||
73 | enabled: true, | ||
74 | key: 'local-videos-list' | ||
75 | } | ||
76 | } | ||
77 | } | 65 | } |
78 | ] | 66 | ] |
79 | } | 67 | } |
diff --git a/client/src/app/+videos/videos.module.ts b/client/src/app/+videos/videos.module.ts index 8a35015d6..523533c11 100644 --- a/client/src/app/+videos/videos.module.ts +++ b/client/src/app/+videos/videos.module.ts | |||
@@ -5,11 +5,8 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons' | |||
5 | import { SharedMainModule } from '@app/shared/shared-main' | 5 | import { SharedMainModule } from '@app/shared/shared-main' |
6 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' | 6 | import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' |
7 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' | 7 | import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' |
8 | import { OverviewService, VideoTrendingComponent } from './video-list' | 8 | import { OverviewService, VideosListCommonPageComponent } from './video-list' |
9 | import { VideoOverviewComponent } from './video-list/overview/video-overview.component' | 9 | import { VideoOverviewComponent } from './video-list/overview/video-overview.component' |
10 | import { VideoTrendingHeaderComponent } from './video-list/trending/video-trending-header.component' | ||
11 | import { VideoLocalComponent } from './video-list/video-local.component' | ||
12 | import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' | ||
13 | import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' | 10 | import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' |
14 | import { VideosRoutingModule } from './videos-routing.module' | 11 | import { VideosRoutingModule } from './videos-routing.module' |
15 | import { VideosComponent } from './videos.component' | 12 | import { VideosComponent } from './videos.component' |
@@ -29,12 +26,9 @@ import { VideosComponent } from './videos.component' | |||
29 | declarations: [ | 26 | declarations: [ |
30 | VideosComponent, | 27 | VideosComponent, |
31 | 28 | ||
32 | VideoTrendingHeaderComponent, | ||
33 | VideoTrendingComponent, | ||
34 | VideoRecentlyAddedComponent, | ||
35 | VideoLocalComponent, | ||
36 | VideoUserSubscriptionsComponent, | 29 | VideoUserSubscriptionsComponent, |
37 | VideoOverviewComponent | 30 | VideoOverviewComponent, |
31 | VideosListCommonPageComponent | ||
38 | ], | 32 | ], |
39 | 33 | ||
40 | exports: [ | 34 | exports: [ |
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts index 1f98e9d2e..438cb6512 100644 --- a/client/src/app/app-routing.module.ts +++ b/client/src/app/app-routing.module.ts | |||
@@ -177,6 +177,7 @@ routes.push({ | |||
177 | imports: [ | 177 | imports: [ |
178 | RouterModule.forRoot(routes, { | 178 | RouterModule.forRoot(routes, { |
179 | useHash: Boolean(history.pushState) === false, | 179 | useHash: Boolean(history.pushState) === false, |
180 | // Redefined in app component | ||
180 | scrollPositionRestoration: 'disabled', | 181 | scrollPositionRestoration: 'disabled', |
181 | preloadingStrategy: PreloadSelectedModulesList, | 182 | preloadingStrategy: PreloadSelectedModulesList, |
182 | anchorScrolling: 'disabled' | 183 | anchorScrolling: 'disabled' |
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index ed5cc53d9..dcc1f259f 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts | |||
@@ -1,10 +1,20 @@ | |||
1 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' | 1 | import { Hotkey, HotkeysService } from 'angular2-hotkeys' |
2 | import { filter, map, pairwise, switchMap } from 'rxjs/operators' | 2 | import { filter, map, switchMap } from 'rxjs/operators' |
3 | import { DOCUMENT, getLocaleDirection, PlatformLocation, ViewportScroller } from '@angular/common' | 3 | import { DOCUMENT, getLocaleDirection, PlatformLocation } from '@angular/common' |
4 | import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core' | 4 | import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core' |
5 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser' | 5 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser' |
6 | import { Event, GuardsCheckStart, NavigationEnd, RouteConfigLoadEnd, RouteConfigLoadStart, Router, Scroll } from '@angular/router' | 6 | import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router } from '@angular/router' |
7 | import { AuthService, MarkdownService, RedirectService, ScreenService, ServerService, ThemeService, User } from '@app/core' | 7 | import { |
8 | AuthService, | ||
9 | MarkdownService, | ||
10 | PeerTubeRouterService, | ||
11 | RedirectService, | ||
12 | ScreenService, | ||
13 | ScrollService, | ||
14 | ServerService, | ||
15 | ThemeService, | ||
16 | User | ||
17 | } from '@app/core' | ||
8 | import { HooksService } from '@app/core/plugins/hooks.service' | 18 | import { HooksService } from '@app/core/plugins/hooks.service' |
9 | import { PluginService } from '@app/core/plugins/plugin.service' | 19 | import { PluginService } from '@app/core/plugins/plugin.service' |
10 | import { CustomModalComponent } from '@app/modal/custom-modal.component' | 20 | import { CustomModalComponent } from '@app/modal/custom-modal.component' |
@@ -39,10 +49,10 @@ export class AppComponent implements OnInit, AfterViewInit { | |||
39 | constructor ( | 49 | constructor ( |
40 | @Inject(DOCUMENT) private document: Document, | 50 | @Inject(DOCUMENT) private document: Document, |
41 | @Inject(LOCALE_ID) private localeId: string, | 51 | @Inject(LOCALE_ID) private localeId: string, |
42 | private viewportScroller: ViewportScroller, | ||
43 | private router: Router, | 52 | private router: Router, |
44 | private authService: AuthService, | 53 | private authService: AuthService, |
45 | private serverService: ServerService, | 54 | private serverService: ServerService, |
55 | private peertubeRouter: PeerTubeRouterService, | ||
46 | private pluginService: PluginService, | 56 | private pluginService: PluginService, |
47 | private instanceService: InstanceService, | 57 | private instanceService: InstanceService, |
48 | private domSanitizer: DomSanitizer, | 58 | private domSanitizer: DomSanitizer, |
@@ -56,6 +66,7 @@ export class AppComponent implements OnInit, AfterViewInit { | |||
56 | private markdownService: MarkdownService, | 66 | private markdownService: MarkdownService, |
57 | private ngbConfig: NgbConfig, | 67 | private ngbConfig: NgbConfig, |
58 | private loadingBar: LoadingBarService, | 68 | private loadingBar: LoadingBarService, |
69 | private scrollService: ScrollService, | ||
59 | public menu: MenuService | 70 | public menu: MenuService |
60 | ) { | 71 | ) { |
61 | this.ngbConfig.animation = false | 72 | this.ngbConfig.animation = false |
@@ -85,6 +96,7 @@ export class AppComponent implements OnInit, AfterViewInit { | |||
85 | } | 96 | } |
86 | 97 | ||
87 | this.initRouteEvents() | 98 | this.initRouteEvents() |
99 | this.scrollService.enableScrollRestoration() | ||
88 | 100 | ||
89 | this.injectJS() | 101 | this.injectJS() |
90 | this.injectCSS() | 102 | this.injectCSS() |
@@ -132,66 +144,10 @@ export class AppComponent implements OnInit, AfterViewInit { | |||
132 | } | 144 | } |
133 | 145 | ||
134 | private initRouteEvents () { | 146 | private initRouteEvents () { |
135 | let resetScroll = true | ||
136 | const eventsObs = this.router.events | 147 | const eventsObs = this.router.events |
137 | 148 | ||
138 | const scrollEvent = eventsObs.pipe(filter((e: Event): e is Scroll => e instanceof Scroll)) | ||
139 | |||
140 | // Handle anchors/restore position | ||
141 | scrollEvent.subscribe(e => { | ||
142 | // scrollToAnchor first to preserve anchor position when using history navigation | ||
143 | if (e.anchor) { | ||
144 | setTimeout(() => { | ||
145 | this.viewportScroller.scrollToAnchor(e.anchor) | ||
146 | }) | ||
147 | |||
148 | return | ||
149 | } | ||
150 | |||
151 | if (e.position) { | ||
152 | return this.viewportScroller.scrollToPosition(e.position) | ||
153 | } | ||
154 | |||
155 | if (resetScroll) { | ||
156 | return this.viewportScroller.scrollToPosition([ 0, 0 ]) | ||
157 | } | ||
158 | }) | ||
159 | |||
160 | const navigationEndEvent = eventsObs.pipe(filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd)) | ||
161 | |||
162 | // When we add the a-state parameter, we don't want to alter the scroll | ||
163 | navigationEndEvent.pipe(pairwise()) | ||
164 | .subscribe(([ e1, e2 ]) => { | ||
165 | try { | ||
166 | resetScroll = false | ||
167 | |||
168 | const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects) | ||
169 | const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects) | ||
170 | |||
171 | if (previousUrl.pathname !== nextUrl.pathname) { | ||
172 | resetScroll = true | ||
173 | return | ||
174 | } | ||
175 | |||
176 | const nextSearchParams = nextUrl.searchParams | ||
177 | nextSearchParams.delete('a-state') | ||
178 | |||
179 | const previousSearchParams = previousUrl.searchParams | ||
180 | |||
181 | nextSearchParams.sort() | ||
182 | previousSearchParams.sort() | ||
183 | |||
184 | if (nextSearchParams.toString() !== previousSearchParams.toString()) { | ||
185 | resetScroll = true | ||
186 | } | ||
187 | } catch (e) { | ||
188 | console.error('Cannot parse URL to check next scroll.', e) | ||
189 | resetScroll = true | ||
190 | } | ||
191 | }) | ||
192 | |||
193 | // Plugin hooks | 149 | // Plugin hooks |
194 | navigationEndEvent.subscribe(e => { | 150 | this.peertubeRouter.getNavigationEndEvents().subscribe(e => { |
195 | this.hooks.runAction('action:router.navigation-end', 'common', { path: e.url }) | 151 | this.hooks.runAction('action:router.navigation-end', 'common', { path: e.url }) |
196 | }) | 152 | }) |
197 | 153 | ||
diff --git a/client/src/app/core/core.module.ts b/client/src/app/core/core.module.ts index 3e2056481..04be0671c 100644 --- a/client/src/app/core/core.module.ts +++ b/client/src/app/core/core.module.ts | |||
@@ -14,7 +14,17 @@ import { throwIfAlreadyLoaded } from './module-import-guard' | |||
14 | import { Notifier } from './notification' | 14 | import { Notifier } from './notification' |
15 | import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer' | 15 | import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer' |
16 | import { RestExtractor, RestService } from './rest' | 16 | import { RestExtractor, RestService } from './rest' |
17 | import { HomepageRedirectComponent, LoginGuard, MetaGuard, MetaService, RedirectService, UnloggedGuard, UserRightGuard } from './routing' | 17 | import { |
18 | HomepageRedirectComponent, | ||
19 | LoginGuard, | ||
20 | MetaGuard, | ||
21 | MetaService, | ||
22 | PeerTubeRouterService, | ||
23 | RedirectService, | ||
24 | ScrollService, | ||
25 | UnloggedGuard, | ||
26 | UserRightGuard | ||
27 | } from './routing' | ||
18 | import { CanDeactivateGuard } from './routing/can-deactivate-guard.service' | 28 | import { CanDeactivateGuard } from './routing/can-deactivate-guard.service' |
19 | import { ServerConfigResolver } from './routing/server-config-resolver.service' | 29 | import { ServerConfigResolver } from './routing/server-config-resolver.service' |
20 | import { ScopedTokensService } from './scoped-tokens' | 30 | import { ScopedTokensService } from './scoped-tokens' |
@@ -80,6 +90,8 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra | |||
80 | PeerTubeSocket, | 90 | PeerTubeSocket, |
81 | ServerConfigResolver, | 91 | ServerConfigResolver, |
82 | CanDeactivateGuard, | 92 | CanDeactivateGuard, |
93 | PeerTubeRouterService, | ||
94 | ScrollService, | ||
83 | 95 | ||
84 | MetaService, | 96 | MetaService, |
85 | MetaGuard | 97 | MetaGuard |
diff --git a/client/src/app/core/routing/custom-reuse-strategy.ts b/client/src/app/core/routing/custom-reuse-strategy.ts index c2510f1df..3000093a8 100644 --- a/client/src/app/core/routing/custom-reuse-strategy.ts +++ b/client/src/app/core/routing/custom-reuse-strategy.ts | |||
@@ -1,5 +1,7 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router' | 2 | import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router' |
3 | import { RouterSetting } from './' | ||
4 | import { PeerTubeRouterService } from './peertube-router.service' | ||
3 | 5 | ||
4 | @Injectable() | 6 | @Injectable() |
5 | export class CustomReuseStrategy implements RouteReuseStrategy { | 7 | export class CustomReuseStrategy implements RouteReuseStrategy { |
@@ -78,6 +80,8 @@ export class CustomReuseStrategy implements RouteReuseStrategy { | |||
78 | } | 80 | } |
79 | 81 | ||
80 | private isReuseEnabled (route: ActivatedRouteSnapshot) { | 82 | private isReuseEnabled (route: ActivatedRouteSnapshot) { |
81 | return route.data.reuse?.enabled && route.queryParams['a-state'] | 83 | // Cannot use peertube router here because of cyclic router dependency |
84 | return route.data.reuse?.enabled && | ||
85 | !!(route.queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME] & RouterSetting.REUSE_COMPONENT) | ||
82 | } | 86 | } |
83 | } | 87 | } |
diff --git a/client/src/app/core/routing/index.ts b/client/src/app/core/routing/index.ts index d0c688a2f..3b1690ecc 100644 --- a/client/src/app/core/routing/index.ts +++ b/client/src/app/core/routing/index.ts | |||
@@ -5,9 +5,11 @@ export * from './homepage-redirect.component' | |||
5 | export * from './login-guard.service' | 5 | export * from './login-guard.service' |
6 | export * from './menu-guard.service' | 6 | export * from './menu-guard.service' |
7 | export * from './meta-guard.service' | 7 | export * from './meta-guard.service' |
8 | export * from './peertube-router.service' | ||
8 | export * from './meta.service' | 9 | export * from './meta.service' |
9 | export * from './preload-selected-modules-list' | 10 | export * from './preload-selected-modules-list' |
10 | export * from './redirect.service' | 11 | export * from './redirect.service' |
12 | export * from './scroll.service' | ||
11 | export * from './server-config-resolver.service' | 13 | export * from './server-config-resolver.service' |
12 | export * from './unlogged-guard.service' | 14 | export * from './unlogged-guard.service' |
13 | export * from './user-right-guard.service' | 15 | export * from './user-right-guard.service' |
diff --git a/client/src/app/core/routing/peertube-router.service.ts b/client/src/app/core/routing/peertube-router.service.ts new file mode 100644 index 000000000..35716cc79 --- /dev/null +++ b/client/src/app/core/routing/peertube-router.service.ts | |||
@@ -0,0 +1,78 @@ | |||
1 | import { filter } from 'rxjs/operators' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { ActivatedRoute, ActivatedRouteSnapshot, Event, NavigationEnd, Router, Scroll } from '@angular/router' | ||
4 | import { ServerService } from '../server' | ||
5 | |||
6 | export const enum RouterSetting { | ||
7 | NONE = 0, | ||
8 | REUSE_COMPONENT = 1 << 0, | ||
9 | DISABLE_SCROLL_RESTORE = 1 << 1 | ||
10 | } | ||
11 | |||
12 | @Injectable() | ||
13 | export class PeerTubeRouterService { | ||
14 | static readonly ROUTE_SETTING_NAME = 's' | ||
15 | |||
16 | constructor ( | ||
17 | private route: ActivatedRoute, | ||
18 | private router: Router, | ||
19 | private server: ServerService | ||
20 | ) { } | ||
21 | |||
22 | addRouteSetting (toAdd: RouterSetting) { | ||
23 | if (this.hasRouteSetting(toAdd)) return | ||
24 | |||
25 | const current = this.getRouteSetting() | ||
26 | |||
27 | this.setRouteSetting(current | toAdd) | ||
28 | } | ||
29 | |||
30 | deleteRouteSetting (toDelete: RouterSetting) { | ||
31 | const current = this.getRouteSetting() | ||
32 | |||
33 | this.setRouteSetting(current & ~toDelete) | ||
34 | } | ||
35 | |||
36 | getRouteSetting (snapshot?: ActivatedRouteSnapshot) { | ||
37 | return (snapshot || this.route.snapshot).queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME] | ||
38 | } | ||
39 | |||
40 | setRouteSetting (value: number) { | ||
41 | let path = window.location.pathname | ||
42 | if (!path || path === '/') path = this.server.getHTMLConfig().instance.defaultClientRoute | ||
43 | |||
44 | const queryParams = { [PeerTubeRouterService.ROUTE_SETTING_NAME]: value } | ||
45 | |||
46 | this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) | ||
47 | } | ||
48 | |||
49 | hasRouteSetting (setting: RouterSetting, snapshot?: ActivatedRouteSnapshot) { | ||
50 | return !!(this.getRouteSetting(snapshot) & setting) | ||
51 | } | ||
52 | |||
53 | getNavigationEndEvents () { | ||
54 | return this.router.events.pipe( | ||
55 | filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd) | ||
56 | ) | ||
57 | } | ||
58 | |||
59 | getScrollEvents () { | ||
60 | return this.router.events.pipe( | ||
61 | filter((e: Event): e is Scroll => e instanceof Scroll) | ||
62 | ) | ||
63 | } | ||
64 | |||
65 | silentNavigate (baseRoute: string[], queryParams: { [id: string]: string }) { | ||
66 | let routeSetting = this.getRouteSetting() ?? RouterSetting.NONE | ||
67 | routeSetting |= RouterSetting.DISABLE_SCROLL_RESTORE | ||
68 | |||
69 | queryParams = { | ||
70 | ...queryParams, | ||
71 | |||
72 | [PeerTubeRouterService.ROUTE_SETTING_NAME]: routeSetting | ||
73 | } | ||
74 | |||
75 | return this.router.navigate(baseRoute, { queryParams }) | ||
76 | } | ||
77 | |||
78 | } | ||
diff --git a/client/src/app/core/routing/scroll.service.ts b/client/src/app/core/routing/scroll.service.ts new file mode 100644 index 000000000..bd5076502 --- /dev/null +++ b/client/src/app/core/routing/scroll.service.ts | |||
@@ -0,0 +1,91 @@ | |||
1 | import * as debug from 'debug' | ||
2 | import { pairwise } from 'rxjs' | ||
3 | import { ViewportScroller } from '@angular/common' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | import { RouterSetting } from '../' | ||
6 | import { PeerTubeRouterService } from './peertube-router.service' | ||
7 | |||
8 | const logger = debug('peertube:main:ScrollService') | ||
9 | |||
10 | @Injectable() | ||
11 | export class ScrollService { | ||
12 | |||
13 | private resetScroll = true | ||
14 | |||
15 | constructor ( | ||
16 | private viewportScroller: ViewportScroller, | ||
17 | private peertubeRouter: PeerTubeRouterService | ||
18 | ) { } | ||
19 | |||
20 | enableScrollRestoration () { | ||
21 | // We'll manage scroll restoration ourselves | ||
22 | this.viewportScroller.setHistoryScrollRestoration('manual') | ||
23 | |||
24 | this.consumeScroll() | ||
25 | this.produceScroll() | ||
26 | } | ||
27 | |||
28 | private produceScroll () { | ||
29 | // When we add the a-state parameter, we don't want to alter the scroll | ||
30 | this.peertubeRouter.getNavigationEndEvents().pipe(pairwise()) | ||
31 | .subscribe(([ e1, e2 ]) => { | ||
32 | try { | ||
33 | this.resetScroll = false | ||
34 | |||
35 | const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects) | ||
36 | const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects) | ||
37 | |||
38 | if (previousUrl.pathname !== nextUrl.pathname) { | ||
39 | this.resetScroll = true | ||
40 | return | ||
41 | } | ||
42 | |||
43 | if (this.peertubeRouter.hasRouteSetting(RouterSetting.DISABLE_SCROLL_RESTORE)) { | ||
44 | this.resetScroll = false | ||
45 | return | ||
46 | } | ||
47 | |||
48 | // Remove route settings from the comparison | ||
49 | const nextSearchParams = nextUrl.searchParams | ||
50 | nextSearchParams.delete(PeerTubeRouterService.ROUTE_SETTING_NAME) | ||
51 | |||
52 | const previousSearchParams = previousUrl.searchParams | ||
53 | |||
54 | nextSearchParams.sort() | ||
55 | previousSearchParams.sort() | ||
56 | |||
57 | if (nextSearchParams.toString() !== previousSearchParams.toString()) { | ||
58 | this.resetScroll = true | ||
59 | } | ||
60 | } catch (e) { | ||
61 | console.error('Cannot parse URL to check next scroll.', e) | ||
62 | this.resetScroll = true | ||
63 | } | ||
64 | }) | ||
65 | } | ||
66 | |||
67 | private consumeScroll () { | ||
68 | // Handle anchors/restore position | ||
69 | this.peertubeRouter.getScrollEvents().subscribe(e => { | ||
70 | logger('Will schedule scroll after router event %o.', e) | ||
71 | |||
72 | // scrollToAnchor first to preserve anchor position when using history navigation | ||
73 | if (e.anchor) { | ||
74 | setTimeout(() => this.viewportScroller.scrollToAnchor(e.anchor)) | ||
75 | |||
76 | return | ||
77 | } | ||
78 | |||
79 | if (e.position) { | ||
80 | setTimeout(() => this.viewportScroller.scrollToPosition(e.position)) | ||
81 | |||
82 | return | ||
83 | } | ||
84 | |||
85 | if (this.resetScroll) { | ||
86 | return this.viewportScroller.scrollToPosition([ 0, 0 ]) | ||
87 | } | ||
88 | }) | ||
89 | } | ||
90 | |||
91 | } | ||
diff --git a/client/src/app/helpers/utils.ts b/client/src/app/helpers/utils.ts deleted file mode 100644 index 8636f3a55..000000000 --- a/client/src/app/helpers/utils.ts +++ /dev/null | |||
@@ -1,226 +0,0 @@ | |||
1 | import { first, map } from 'rxjs/operators' | ||
2 | import { SelectChannelItem } from 'src/types/select-options-item.model' | ||
3 | import { DatePipe } from '@angular/common' | ||
4 | import { HttpErrorResponse } from '@angular/common/http' | ||
5 | import { Notifier } from '@app/core' | ||
6 | import { HttpStatusCode } from '@shared/models' | ||
7 | import { environment } from '../../environments/environment' | ||
8 | import { AuthService } from '../core/auth' | ||
9 | |||
10 | // Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript | ||
11 | function getParameterByName (name: string, url: string) { | ||
12 | if (!url) url = window.location.href | ||
13 | name = name.replace(/[[\]]/g, '\\$&') | ||
14 | |||
15 | const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)') | ||
16 | const results = regex.exec(url) | ||
17 | |||
18 | if (!results) return null | ||
19 | if (!results[2]) return '' | ||
20 | |||
21 | return decodeURIComponent(results[2].replace(/\+/g, ' ')) | ||
22 | } | ||
23 | |||
24 | function listUserChannels (authService: AuthService) { | ||
25 | return authService.userInformationLoaded | ||
26 | .pipe( | ||
27 | first(), | ||
28 | map(() => { | ||
29 | const user = authService.getUser() | ||
30 | if (!user) return undefined | ||
31 | |||
32 | const videoChannels = user.videoChannels | ||
33 | if (Array.isArray(videoChannels) === false) return undefined | ||
34 | |||
35 | return videoChannels | ||
36 | .sort((a, b) => { | ||
37 | if (a.updatedAt < b.updatedAt) return 1 | ||
38 | if (a.updatedAt > b.updatedAt) return -1 | ||
39 | return 0 | ||
40 | }) | ||
41 | .map(c => ({ | ||
42 | id: c.id, | ||
43 | label: c.displayName, | ||
44 | support: c.support, | ||
45 | avatarPath: c.avatar?.path | ||
46 | }) as SelectChannelItem) | ||
47 | }) | ||
48 | ) | ||
49 | } | ||
50 | |||
51 | function getAbsoluteAPIUrl () { | ||
52 | let absoluteAPIUrl = environment.hmr === true | ||
53 | ? 'http://localhost:9000' | ||
54 | : environment.apiUrl | ||
55 | |||
56 | if (!absoluteAPIUrl) { | ||
57 | // The API is on the same domain | ||
58 | absoluteAPIUrl = window.location.origin | ||
59 | } | ||
60 | |||
61 | return absoluteAPIUrl | ||
62 | } | ||
63 | |||
64 | function getAbsoluteEmbedUrl () { | ||
65 | let absoluteEmbedUrl = environment.originServerUrl | ||
66 | if (!absoluteEmbedUrl) { | ||
67 | // The Embed is on the same domain | ||
68 | absoluteEmbedUrl = window.location.origin | ||
69 | } | ||
70 | |||
71 | return absoluteEmbedUrl | ||
72 | } | ||
73 | |||
74 | const datePipe = new DatePipe('en') | ||
75 | function dateToHuman (date: string) { | ||
76 | return datePipe.transform(date, 'medium') | ||
77 | } | ||
78 | |||
79 | function durationToString (duration: number) { | ||
80 | const hours = Math.floor(duration / 3600) | ||
81 | const minutes = Math.floor((duration % 3600) / 60) | ||
82 | const seconds = duration % 60 | ||
83 | |||
84 | const minutesPadding = minutes >= 10 ? '' : '0' | ||
85 | const secondsPadding = seconds >= 10 ? '' : '0' | ||
86 | const displayedHours = hours > 0 ? hours.toString() + ':' : '' | ||
87 | |||
88 | return ( | ||
89 | displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString() | ||
90 | ).replace(/^0/, '') | ||
91 | } | ||
92 | |||
93 | function immutableAssign <A, B> (target: A, source: B) { | ||
94 | return Object.assign({}, target, source) | ||
95 | } | ||
96 | |||
97 | // Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34 | ||
98 | function objectToFormData (obj: any, form?: FormData, namespace?: string) { | ||
99 | const fd = form || new FormData() | ||
100 | let formKey | ||
101 | |||
102 | for (const key of Object.keys(obj)) { | ||
103 | if (namespace) formKey = `${namespace}[${key}]` | ||
104 | else formKey = key | ||
105 | |||
106 | if (obj[key] === undefined) continue | ||
107 | |||
108 | if (Array.isArray(obj[key]) && obj[key].length === 0) { | ||
109 | fd.append(key, null) | ||
110 | continue | ||
111 | } | ||
112 | |||
113 | if (obj[key] !== null && typeof obj[key] === 'object' && !(obj[key] instanceof File)) { | ||
114 | objectToFormData(obj[key], fd, formKey) | ||
115 | } else { | ||
116 | fd.append(formKey, obj[key]) | ||
117 | } | ||
118 | } | ||
119 | |||
120 | return fd | ||
121 | } | ||
122 | |||
123 | function objectLineFeedToHtml (obj: any, keyToNormalize: string) { | ||
124 | return immutableAssign(obj, { | ||
125 | [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize]) | ||
126 | }) | ||
127 | } | ||
128 | |||
129 | function lineFeedToHtml (text: string) { | ||
130 | if (!text) return text | ||
131 | |||
132 | return text.replace(/\r?\n|\r/g, '<br />') | ||
133 | } | ||
134 | |||
135 | function removeElementFromArray <T> (arr: T[], elem: T) { | ||
136 | const index = arr.indexOf(elem) | ||
137 | if (index !== -1) arr.splice(index, 1) | ||
138 | } | ||
139 | |||
140 | function sortBy (obj: any[], key1: string, key2?: string) { | ||
141 | return obj.sort((a, b) => { | ||
142 | const elem1 = key2 ? a[key1][key2] : a[key1] | ||
143 | const elem2 = key2 ? b[key1][key2] : b[key1] | ||
144 | |||
145 | if (elem1 < elem2) return -1 | ||
146 | if (elem1 === elem2) return 0 | ||
147 | return 1 | ||
148 | }) | ||
149 | } | ||
150 | |||
151 | function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') { | ||
152 | window.scrollTo({ | ||
153 | left: 0, | ||
154 | top: 0, | ||
155 | behavior | ||
156 | }) | ||
157 | } | ||
158 | |||
159 | function isInViewport (el: HTMLElement) { | ||
160 | const bounding = el.getBoundingClientRect() | ||
161 | return ( | ||
162 | bounding.top >= 0 && | ||
163 | bounding.left >= 0 && | ||
164 | bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && | ||
165 | bounding.right <= (window.innerWidth || document.documentElement.clientWidth) | ||
166 | ) | ||
167 | } | ||
168 | |||
169 | function isXPercentInViewport (el: HTMLElement, percentVisible: number) { | ||
170 | const rect = el.getBoundingClientRect() | ||
171 | const windowHeight = (window.innerHeight || document.documentElement.clientHeight) | ||
172 | |||
173 | return !( | ||
174 | Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible || | ||
175 | Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible | ||
176 | ) | ||
177 | } | ||
178 | |||
179 | function genericUploadErrorHandler (parameters: { | ||
180 | err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'> | ||
181 | name: string | ||
182 | notifier: Notifier | ||
183 | sticky?: boolean | ||
184 | }) { | ||
185 | const { err, name, notifier, sticky } = { sticky: false, ...parameters } | ||
186 | const title = $localize`The upload failed` | ||
187 | let message = err.message | ||
188 | |||
189 | if (err instanceof ErrorEvent) { // network error | ||
190 | message = $localize`The connection was interrupted` | ||
191 | notifier.error(message, title, null, sticky) | ||
192 | } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) { | ||
193 | message = $localize`The server encountered an error` | ||
194 | notifier.error(message, title, null, sticky) | ||
195 | } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) { | ||
196 | message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)` | ||
197 | notifier.error(message, title, null, sticky) | ||
198 | } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) { | ||
199 | const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G' | ||
200 | message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})` | ||
201 | notifier.error(message, title, null, sticky) | ||
202 | } else { | ||
203 | notifier.error(err.message, title) | ||
204 | } | ||
205 | |||
206 | return message | ||
207 | } | ||
208 | |||
209 | export { | ||
210 | sortBy, | ||
211 | durationToString, | ||
212 | lineFeedToHtml, | ||
213 | getParameterByName, | ||
214 | getAbsoluteAPIUrl, | ||
215 | dateToHuman, | ||
216 | immutableAssign, | ||
217 | objectToFormData, | ||
218 | getAbsoluteEmbedUrl, | ||
219 | objectLineFeedToHtml, | ||
220 | removeElementFromArray, | ||
221 | scrollToTop, | ||
222 | isInViewport, | ||
223 | isXPercentInViewport, | ||
224 | listUserChannels, | ||
225 | genericUploadErrorHandler | ||
226 | } | ||
diff --git a/client/src/app/helpers/utils/channel.ts b/client/src/app/helpers/utils/channel.ts new file mode 100644 index 000000000..93863a8af --- /dev/null +++ b/client/src/app/helpers/utils/channel.ts | |||
@@ -0,0 +1,34 @@ | |||
1 | import { first, map } from 'rxjs/operators' | ||
2 | import { SelectChannelItem } from 'src/types/select-options-item.model' | ||
3 | import { AuthService } from '../../core/auth' | ||
4 | |||
5 | function listUserChannels (authService: AuthService) { | ||
6 | return authService.userInformationLoaded | ||
7 | .pipe( | ||
8 | first(), | ||
9 | map(() => { | ||
10 | const user = authService.getUser() | ||
11 | if (!user) return undefined | ||
12 | |||
13 | const videoChannels = user.videoChannels | ||
14 | if (Array.isArray(videoChannels) === false) return undefined | ||
15 | |||
16 | return videoChannels | ||
17 | .sort((a, b) => { | ||
18 | if (a.updatedAt < b.updatedAt) return 1 | ||
19 | if (a.updatedAt > b.updatedAt) return -1 | ||
20 | return 0 | ||
21 | }) | ||
22 | .map(c => ({ | ||
23 | id: c.id, | ||
24 | label: c.displayName, | ||
25 | support: c.support, | ||
26 | avatarPath: c.avatar?.path | ||
27 | }) as SelectChannelItem) | ||
28 | }) | ||
29 | ) | ||
30 | } | ||
31 | |||
32 | export { | ||
33 | listUserChannels | ||
34 | } | ||
diff --git a/client/src/app/helpers/utils/date.ts b/client/src/app/helpers/utils/date.ts new file mode 100644 index 000000000..012b959ea --- /dev/null +++ b/client/src/app/helpers/utils/date.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { DatePipe } from '@angular/common' | ||
2 | |||
3 | const datePipe = new DatePipe('en') | ||
4 | function dateToHuman (date: string) { | ||
5 | return datePipe.transform(date, 'medium') | ||
6 | } | ||
7 | |||
8 | function durationToString (duration: number) { | ||
9 | const hours = Math.floor(duration / 3600) | ||
10 | const minutes = Math.floor((duration % 3600) / 60) | ||
11 | const seconds = duration % 60 | ||
12 | |||
13 | const minutesPadding = minutes >= 10 ? '' : '0' | ||
14 | const secondsPadding = seconds >= 10 ? '' : '0' | ||
15 | const displayedHours = hours > 0 ? hours.toString() + ':' : '' | ||
16 | |||
17 | return ( | ||
18 | displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString() | ||
19 | ).replace(/^0/, '') | ||
20 | } | ||
21 | |||
22 | export { | ||
23 | durationToString, | ||
24 | dateToHuman | ||
25 | } | ||
diff --git a/client/src/app/helpers/utils/html.ts b/client/src/app/helpers/utils/html.ts new file mode 100644 index 000000000..2d520aee9 --- /dev/null +++ b/client/src/app/helpers/utils/html.ts | |||
@@ -0,0 +1,18 @@ | |||
1 | import { immutableAssign } from './object' | ||
2 | |||
3 | function objectLineFeedToHtml (obj: any, keyToNormalize: string) { | ||
4 | return immutableAssign(obj, { | ||
5 | [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize]) | ||
6 | }) | ||
7 | } | ||
8 | |||
9 | function lineFeedToHtml (text: string) { | ||
10 | if (!text) return text | ||
11 | |||
12 | return text.replace(/\r?\n|\r/g, '<br />') | ||
13 | } | ||
14 | |||
15 | export { | ||
16 | objectLineFeedToHtml, | ||
17 | lineFeedToHtml | ||
18 | } | ||
diff --git a/client/src/app/helpers/utils/index.ts b/client/src/app/helpers/utils/index.ts new file mode 100644 index 000000000..dc09c92ab --- /dev/null +++ b/client/src/app/helpers/utils/index.ts | |||
@@ -0,0 +1,7 @@ | |||
1 | export * from './channel' | ||
2 | export * from './date' | ||
3 | export * from './html' | ||
4 | export * from './object' | ||
5 | export * from './ui' | ||
6 | export * from './upload' | ||
7 | export * from './url' | ||
diff --git a/client/src/app/helpers/utils/object.ts b/client/src/app/helpers/utils/object.ts new file mode 100644 index 000000000..1ca4a23ac --- /dev/null +++ b/client/src/app/helpers/utils/object.ts | |||
@@ -0,0 +1,47 @@ | |||
1 | function immutableAssign <A, B> (target: A, source: B) { | ||
2 | return Object.assign({}, target, source) | ||
3 | } | ||
4 | |||
5 | function removeElementFromArray <T> (arr: T[], elem: T) { | ||
6 | const index = arr.indexOf(elem) | ||
7 | if (index !== -1) arr.splice(index, 1) | ||
8 | } | ||
9 | |||
10 | function sortBy (obj: any[], key1: string, key2?: string) { | ||
11 | return obj.sort((a, b) => { | ||
12 | const elem1 = key2 ? a[key1][key2] : a[key1] | ||
13 | const elem2 = key2 ? b[key1][key2] : b[key1] | ||
14 | |||
15 | if (elem1 < elem2) return -1 | ||
16 | if (elem1 === elem2) return 0 | ||
17 | return 1 | ||
18 | }) | ||
19 | } | ||
20 | |||
21 | function intoArray (value: any) { | ||
22 | if (!value) return undefined | ||
23 | if (Array.isArray(value)) return value | ||
24 | |||
25 | if (typeof value === 'string') return value.split(',') | ||
26 | |||
27 | return [ value ] | ||
28 | } | ||
29 | |||
30 | function toBoolean (value: any) { | ||
31 | if (!value) return undefined | ||
32 | |||
33 | if (typeof value === 'boolean') return value | ||
34 | |||
35 | if (value === 'true') return true | ||
36 | if (value === 'false') return false | ||
37 | |||
38 | return undefined | ||
39 | } | ||
40 | |||
41 | export { | ||
42 | sortBy, | ||
43 | immutableAssign, | ||
44 | removeElementFromArray, | ||
45 | intoArray, | ||
46 | toBoolean | ||
47 | } | ||
diff --git a/client/src/app/helpers/utils/ui.ts b/client/src/app/helpers/utils/ui.ts new file mode 100644 index 000000000..ac8298926 --- /dev/null +++ b/client/src/app/helpers/utils/ui.ts | |||
@@ -0,0 +1,33 @@ | |||
1 | function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') { | ||
2 | window.scrollTo({ | ||
3 | left: 0, | ||
4 | top: 0, | ||
5 | behavior | ||
6 | }) | ||
7 | } | ||
8 | |||
9 | function isInViewport (el: HTMLElement) { | ||
10 | const bounding = el.getBoundingClientRect() | ||
11 | return ( | ||
12 | bounding.top >= 0 && | ||
13 | bounding.left >= 0 && | ||
14 | bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && | ||
15 | bounding.right <= (window.innerWidth || document.documentElement.clientWidth) | ||
16 | ) | ||
17 | } | ||
18 | |||
19 | function isXPercentInViewport (el: HTMLElement, percentVisible: number) { | ||
20 | const rect = el.getBoundingClientRect() | ||
21 | const windowHeight = (window.innerHeight || document.documentElement.clientHeight) | ||
22 | |||
23 | return !( | ||
24 | Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible || | ||
25 | Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible | ||
26 | ) | ||
27 | } | ||
28 | |||
29 | export { | ||
30 | scrollToTop, | ||
31 | isInViewport, | ||
32 | isXPercentInViewport | ||
33 | } | ||
diff --git a/client/src/app/helpers/utils/upload.ts b/client/src/app/helpers/utils/upload.ts new file mode 100644 index 000000000..a3fce7fee --- /dev/null +++ b/client/src/app/helpers/utils/upload.ts | |||
@@ -0,0 +1,37 @@ | |||
1 | import { HttpErrorResponse } from '@angular/common/http' | ||
2 | import { Notifier } from '@app/core' | ||
3 | import { HttpStatusCode } from '@shared/models' | ||
4 | |||
5 | function genericUploadErrorHandler (parameters: { | ||
6 | err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'> | ||
7 | name: string | ||
8 | notifier: Notifier | ||
9 | sticky?: boolean | ||
10 | }) { | ||
11 | const { err, name, notifier, sticky } = { sticky: false, ...parameters } | ||
12 | const title = $localize`The upload failed` | ||
13 | let message = err.message | ||
14 | |||
15 | if (err instanceof ErrorEvent) { // network error | ||
16 | message = $localize`The connection was interrupted` | ||
17 | notifier.error(message, title, null, sticky) | ||
18 | } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) { | ||
19 | message = $localize`The server encountered an error` | ||
20 | notifier.error(message, title, null, sticky) | ||
21 | } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) { | ||
22 | message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)` | ||
23 | notifier.error(message, title, null, sticky) | ||
24 | } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) { | ||
25 | const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G' | ||
26 | message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})` | ||
27 | notifier.error(message, title, null, sticky) | ||
28 | } else { | ||
29 | notifier.error(err.message, title) | ||
30 | } | ||
31 | |||
32 | return message | ||
33 | } | ||
34 | |||
35 | export { | ||
36 | genericUploadErrorHandler | ||
37 | } | ||
diff --git a/client/src/app/helpers/utils/url.ts b/client/src/app/helpers/utils/url.ts new file mode 100644 index 000000000..82d9cc11b --- /dev/null +++ b/client/src/app/helpers/utils/url.ts | |||
@@ -0,0 +1,71 @@ | |||
1 | import { environment } from '../../../environments/environment' | ||
2 | |||
3 | // Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript | ||
4 | function getParameterByName (name: string, url: string) { | ||
5 | if (!url) url = window.location.href | ||
6 | name = name.replace(/[[\]]/g, '\\$&') | ||
7 | |||
8 | const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)') | ||
9 | const results = regex.exec(url) | ||
10 | |||
11 | if (!results) return null | ||
12 | if (!results[2]) return '' | ||
13 | |||
14 | return decodeURIComponent(results[2].replace(/\+/g, ' ')) | ||
15 | } | ||
16 | |||
17 | function getAbsoluteAPIUrl () { | ||
18 | let absoluteAPIUrl = environment.hmr === true | ||
19 | ? 'http://localhost:9000' | ||
20 | : environment.apiUrl | ||
21 | |||
22 | if (!absoluteAPIUrl) { | ||
23 | // The API is on the same domain | ||
24 | absoluteAPIUrl = window.location.origin | ||
25 | } | ||
26 | |||
27 | return absoluteAPIUrl | ||
28 | } | ||
29 | |||
30 | function getAbsoluteEmbedUrl () { | ||
31 | let absoluteEmbedUrl = environment.originServerUrl | ||
32 | if (!absoluteEmbedUrl) { | ||
33 | // The Embed is on the same domain | ||
34 | absoluteEmbedUrl = window.location.origin | ||
35 | } | ||
36 | |||
37 | return absoluteEmbedUrl | ||
38 | } | ||
39 | |||
40 | // Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34 | ||
41 | function objectToFormData (obj: any, form?: FormData, namespace?: string) { | ||
42 | const fd = form || new FormData() | ||
43 | let formKey | ||
44 | |||
45 | for (const key of Object.keys(obj)) { | ||
46 | if (namespace) formKey = `${namespace}[${key}]` | ||
47 | else formKey = key | ||
48 | |||
49 | if (obj[key] === undefined) continue | ||
50 | |||
51 | if (Array.isArray(obj[key]) && obj[key].length === 0) { | ||
52 | fd.append(key, null) | ||
53 | continue | ||
54 | } | ||
55 | |||
56 | if (obj[key] !== null && typeof obj[key] === 'object' && !(obj[key] instanceof File)) { | ||
57 | objectToFormData(obj[key], fd, formKey) | ||
58 | } else { | ||
59 | fd.append(formKey, obj[key]) | ||
60 | } | ||
61 | } | ||
62 | |||
63 | return fd | ||
64 | } | ||
65 | |||
66 | export { | ||
67 | getParameterByName, | ||
68 | objectToFormData, | ||
69 | getAbsoluteAPIUrl, | ||
70 | getAbsoluteEmbedUrl | ||
71 | } | ||
diff --git a/client/src/app/shared/shared-forms/advanced-input-filter.component.ts b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts index c11f1ad1d..72cd6d460 100644 --- a/client/src/app/shared/shared-forms/advanced-input-filter.component.ts +++ b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts | |||
@@ -18,6 +18,7 @@ const logger = debug('peertube:AdvancedInputFilterComponent') | |||
18 | }) | 18 | }) |
19 | export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { | 19 | export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { |
20 | @Input() filters: AdvancedInputFilter[] = [] | 20 | @Input() filters: AdvancedInputFilter[] = [] |
21 | @Input() emitOnInit = true | ||
21 | 22 | ||
22 | @Output() search = new EventEmitter<string>() | 23 | @Output() search = new EventEmitter<string>() |
23 | 24 | ||
@@ -42,7 +43,7 @@ export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { | |||
42 | this.viewInitialized = true | 43 | this.viewInitialized = true |
43 | 44 | ||
44 | // Init after view init to not send an event too early | 45 | // Init after view init to not send an event too early |
45 | if (this.emitSearchAfterViewInit) this.emitSearch() | 46 | if (this.emitOnInit && this.emitSearchAfterViewInit) this.emitSearch() |
46 | } | 47 | } |
47 | 48 | ||
48 | onInputSearch (event: Event) { | 49 | onInputSearch (event: Event) { |
diff --git a/client/src/app/shared/shared-forms/select/index.ts b/client/src/app/shared/shared-forms/select/index.ts index e387e1f48..a3d554ee2 100644 --- a/client/src/app/shared/shared-forms/select/index.ts +++ b/client/src/app/shared/shared-forms/select/index.ts | |||
@@ -1,5 +1,8 @@ | |||
1 | export * from './select-categories.component' | ||
1 | export * from './select-channel.component' | 2 | export * from './select-channel.component' |
3 | export * from './select-checkbox-all.component' | ||
2 | export * from './select-checkbox.component' | 4 | export * from './select-checkbox.component' |
3 | export * from './select-custom-value.component' | 5 | export * from './select-custom-value.component' |
6 | export * from './select-languages.component' | ||
4 | export * from './select-options.component' | 7 | export * from './select-options.component' |
5 | export * from './select-tags.component' | 8 | export * from './select-tags.component' |
diff --git a/client/src/app/shared/shared-forms/select/select-categories.component.html b/client/src/app/shared/shared-forms/select/select-categories.component.html new file mode 100644 index 000000000..2ec2f1264 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-categories.component.html | |||
@@ -0,0 +1,8 @@ | |||
1 | <my-select-checkbox-all | ||
2 | [(ngModel)]="selectedCategories" | ||
3 | (ngModelChange)="onModelChange()" | ||
4 | [availableItems]="availableCategories" | ||
5 | i18n-placeholder placeholder="Add a new category" | ||
6 | [allGroupLabel]="allCategoriesGroup" | ||
7 | > | ||
8 | </my-select-checkbox-all> | ||
diff --git a/client/src/app/shared/shared-forms/select/select-categories.component.ts b/client/src/app/shared/shared-forms/select/select-categories.component.ts new file mode 100644 index 000000000..b921714ff --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-categories.component.ts | |||
@@ -0,0 +1,71 @@ | |||
1 | |||
2 | import { Component, forwardRef, OnInit } from '@angular/core' | ||
3 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
4 | import { ServerService } from '@app/core' | ||
5 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' | ||
6 | import { ItemSelectCheckboxValue } from './select-checkbox.component' | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-select-categories', | ||
10 | styleUrls: [ './select-shared.component.scss' ], | ||
11 | templateUrl: './select-categories.component.html', | ||
12 | providers: [ | ||
13 | { | ||
14 | provide: NG_VALUE_ACCESSOR, | ||
15 | useExisting: forwardRef(() => SelectCategoriesComponent), | ||
16 | multi: true | ||
17 | } | ||
18 | ] | ||
19 | }) | ||
20 | export class SelectCategoriesComponent implements ControlValueAccessor, OnInit { | ||
21 | selectedCategories: ItemSelectCheckboxValue[] = [] | ||
22 | availableCategories: SelectOptionsItem[] = [] | ||
23 | |||
24 | allCategoriesGroup = $localize`All categories` | ||
25 | |||
26 | // Fix a bug on ng-select when we update items after we selected items | ||
27 | private toWrite: any | ||
28 | private loaded = false | ||
29 | |||
30 | constructor ( | ||
31 | private server: ServerService | ||
32 | ) { | ||
33 | |||
34 | } | ||
35 | |||
36 | ngOnInit () { | ||
37 | this.server.getVideoCategories() | ||
38 | .subscribe( | ||
39 | categories => { | ||
40 | this.availableCategories = categories.map(c => ({ label: c.label, id: c.id + '', group: this.allCategoriesGroup })) | ||
41 | this.loaded = true | ||
42 | this.writeValue(this.toWrite) | ||
43 | } | ||
44 | ) | ||
45 | } | ||
46 | |||
47 | propagateChange = (_: any) => { /* empty */ } | ||
48 | |||
49 | writeValue (categories: string[] | number[]) { | ||
50 | if (!this.loaded) { | ||
51 | this.toWrite = categories | ||
52 | return | ||
53 | } | ||
54 | |||
55 | this.selectedCategories = categories | ||
56 | ? categories.map(c => c + '') | ||
57 | : categories as string[] | ||
58 | } | ||
59 | |||
60 | registerOnChange (fn: (_: any) => void) { | ||
61 | this.propagateChange = fn | ||
62 | } | ||
63 | |||
64 | registerOnTouched () { | ||
65 | // Unused | ||
66 | } | ||
67 | |||
68 | onModelChange () { | ||
69 | this.propagateChange(this.selectedCategories) | ||
70 | } | ||
71 | } | ||
diff --git a/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts new file mode 100644 index 000000000..ebf7b77a6 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts | |||
@@ -0,0 +1,115 @@ | |||
1 | import { Component, forwardRef, Input } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { Notifier } from '@app/core' | ||
4 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' | ||
5 | import { ItemSelectCheckboxValue } from './select-checkbox.component' | ||
6 | |||
7 | @Component({ | ||
8 | selector: 'my-select-checkbox-all', | ||
9 | styleUrls: [ './select-shared.component.scss' ], | ||
10 | |||
11 | template: ` | ||
12 | <my-select-checkbox | ||
13 | [(ngModel)]="selectedItems" | ||
14 | (ngModelChange)="onModelChange()" | ||
15 | [availableItems]="availableItems" | ||
16 | [selectableGroup]="true" [selectableGroupAsModel]="true" | ||
17 | [placeholder]="placeholder" | ||
18 | (focusout)="onBlur()" | ||
19 | > | ||
20 | </my-select-checkbox>`, | ||
21 | |||
22 | providers: [ | ||
23 | { | ||
24 | provide: NG_VALUE_ACCESSOR, | ||
25 | useExisting: forwardRef(() => SelectCheckboxAllComponent), | ||
26 | multi: true | ||
27 | } | ||
28 | ] | ||
29 | }) | ||
30 | export class SelectCheckboxAllComponent implements ControlValueAccessor { | ||
31 | @Input() availableItems: SelectOptionsItem[] = [] | ||
32 | @Input() allGroupLabel: string | ||
33 | |||
34 | @Input() placeholder: string | ||
35 | @Input() maxItems: number | ||
36 | |||
37 | selectedItems: ItemSelectCheckboxValue[] | ||
38 | |||
39 | constructor ( | ||
40 | private notifier: Notifier | ||
41 | ) { | ||
42 | |||
43 | } | ||
44 | |||
45 | propagateChange = (_: any) => { /* empty */ } | ||
46 | |||
47 | writeValue (items: string[]) { | ||
48 | this.selectedItems = items | ||
49 | ? items.map(l => ({ id: l })) | ||
50 | : [ { group: this.allGroupLabel } ] | ||
51 | } | ||
52 | |||
53 | registerOnChange (fn: (_: any) => void) { | ||
54 | this.propagateChange = fn | ||
55 | } | ||
56 | |||
57 | registerOnTouched () { | ||
58 | // Unused | ||
59 | } | ||
60 | |||
61 | onModelChange () { | ||
62 | if (!this.isMaxConstraintValid()) return | ||
63 | |||
64 | this.propagateChange(this.buildOutputItems()) | ||
65 | } | ||
66 | |||
67 | onBlur () { | ||
68 | // Automatically use "All languages" if the user did not select any language | ||
69 | if (Array.isArray(this.selectedItems) && this.selectedItems.length === 0) { | ||
70 | this.selectedItems = [ { group: this.allGroupLabel } ] | ||
71 | } | ||
72 | } | ||
73 | |||
74 | private isMaxConstraintValid () { | ||
75 | if (!this.maxItems) return true | ||
76 | |||
77 | const outputItems = this.buildOutputItems() | ||
78 | if (!outputItems) return true | ||
79 | |||
80 | if (outputItems.length >= this.maxItems) { | ||
81 | this.notifier.error($localize`You can't select more than ${this.maxItems} items`) | ||
82 | |||
83 | return false | ||
84 | } | ||
85 | |||
86 | return true | ||
87 | } | ||
88 | |||
89 | private buildOutputItems () { | ||
90 | if (!Array.isArray(this.selectedItems)) return undefined | ||
91 | |||
92 | // null means "All" | ||
93 | if (this.selectedItems.length === 0 || this.selectedItems.length === this.availableItems.length) { | ||
94 | return null | ||
95 | } | ||
96 | |||
97 | if (this.selectedItems.length === 1) { | ||
98 | const item = this.selectedItems[0] | ||
99 | |||
100 | const itemGroup = typeof item === 'string' || typeof item === 'number' | ||
101 | ? item | ||
102 | : item.group | ||
103 | |||
104 | if (itemGroup === this.allGroupLabel) return null | ||
105 | } | ||
106 | |||
107 | return this.selectedItems.map(l => { | ||
108 | if (typeof l === 'string' || typeof l === 'number') return l | ||
109 | |||
110 | if (l.group) return l.group | ||
111 | |||
112 | return l.id + '' | ||
113 | }) | ||
114 | } | ||
115 | } | ||
diff --git a/client/src/app/shared/shared-forms/select/select-checkbox.component.html b/client/src/app/shared/shared-forms/select/select-checkbox.component.html index f5af2932e..7b49a0c01 100644 --- a/client/src/app/shared/shared-forms/select/select-checkbox.component.html +++ b/client/src/app/shared/shared-forms/select/select-checkbox.component.html | |||
@@ -18,8 +18,6 @@ | |||
18 | 18 | ||
19 | groupBy="group" | 19 | groupBy="group" |
20 | [compareWith]="compareFn" | 20 | [compareWith]="compareFn" |
21 | |||
22 | [maxSelectedItems]="maxSelectedItems" | ||
23 | > | 21 | > |
24 | 22 | ||
25 | <ng-template ng-optgroup-tmp let-item="item" let-item$="item$" let-index="index"> | 23 | <ng-template ng-optgroup-tmp let-item="item" let-item$="item$" let-index="index"> |
diff --git a/client/src/app/shared/shared-forms/select/select-checkbox.component.ts b/client/src/app/shared/shared-forms/select/select-checkbox.component.ts index c2523f15c..12f697628 100644 --- a/client/src/app/shared/shared-forms/select/select-checkbox.component.ts +++ b/client/src/app/shared/shared-forms/select/select-checkbox.component.ts | |||
@@ -2,7 +2,7 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core' | |||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
3 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' | 3 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' |
4 | 4 | ||
5 | export type ItemSelectCheckboxValue = { id?: string | number, group?: string } | string | 5 | export type ItemSelectCheckboxValue = { id?: string, group?: string } | string |
6 | 6 | ||
7 | @Component({ | 7 | @Component({ |
8 | selector: 'my-select-checkbox', | 8 | selector: 'my-select-checkbox', |
@@ -21,7 +21,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { | |||
21 | @Input() selectedItems: ItemSelectCheckboxValue[] = [] | 21 | @Input() selectedItems: ItemSelectCheckboxValue[] = [] |
22 | @Input() selectableGroup: boolean | 22 | @Input() selectableGroup: boolean |
23 | @Input() selectableGroupAsModel: boolean | 23 | @Input() selectableGroupAsModel: boolean |
24 | @Input() maxSelectedItems: number | ||
25 | @Input() placeholder: string | 24 | @Input() placeholder: string |
26 | 25 | ||
27 | ngOnInit () { | 26 | ngOnInit () { |
@@ -46,8 +45,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { | |||
46 | } else { | 45 | } else { |
47 | this.selectedItems = items | 46 | this.selectedItems = items |
48 | } | 47 | } |
49 | |||
50 | this.propagateChange(this.selectedItems) | ||
51 | } | 48 | } |
52 | 49 | ||
53 | registerOnChange (fn: (_: any) => void) { | 50 | registerOnChange (fn: (_: any) => void) { |
@@ -63,7 +60,7 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { | |||
63 | } | 60 | } |
64 | 61 | ||
65 | compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) { | 62 | compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) { |
66 | if (typeof selected === 'string') { | 63 | if (typeof selected === 'string' || typeof selected === 'number') { |
67 | return item.id === selected | 64 | return item.id === selected |
68 | } | 65 | } |
69 | 66 | ||
diff --git a/client/src/app/shared/shared-forms/select/select-languages.component.html b/client/src/app/shared/shared-forms/select/select-languages.component.html new file mode 100644 index 000000000..6eba26a56 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-languages.component.html | |||
@@ -0,0 +1,9 @@ | |||
1 | <my-select-checkbox-all | ||
2 | [(ngModel)]="selectedLanguages" | ||
3 | (ngModelChange)="onModelChange()" | ||
4 | [availableItems]="availableLanguages" | ||
5 | [maxItems]="maxLanguages" | ||
6 | i18n-placeholder placeholder="Add a new language" | ||
7 | [allGroupLabel]="allLanguagesGroup" | ||
8 | > | ||
9 | </my-select-checkbox-all> | ||
diff --git a/client/src/app/shared/shared-forms/select/select-languages.component.ts b/client/src/app/shared/shared-forms/select/select-languages.component.ts new file mode 100644 index 000000000..742163ede --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-languages.component.ts | |||
@@ -0,0 +1,74 @@ | |||
1 | import { Component, forwardRef, Input, OnInit } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { ServerService } from '@app/core' | ||
4 | import { SelectOptionsItem } from '../../../../types/select-options-item.model' | ||
5 | import { ItemSelectCheckboxValue } from './select-checkbox.component' | ||
6 | |||
7 | @Component({ | ||
8 | selector: 'my-select-languages', | ||
9 | styleUrls: [ './select-shared.component.scss' ], | ||
10 | templateUrl: './select-languages.component.html', | ||
11 | providers: [ | ||
12 | { | ||
13 | provide: NG_VALUE_ACCESSOR, | ||
14 | useExisting: forwardRef(() => SelectLanguagesComponent), | ||
15 | multi: true | ||
16 | } | ||
17 | ] | ||
18 | }) | ||
19 | export class SelectLanguagesComponent implements ControlValueAccessor, OnInit { | ||
20 | @Input() maxLanguages: number | ||
21 | |||
22 | selectedLanguages: ItemSelectCheckboxValue[] | ||
23 | availableLanguages: SelectOptionsItem[] = [] | ||
24 | |||
25 | allLanguagesGroup = $localize`All languages` | ||
26 | |||
27 | // Fix a bug on ng-select when we update items after we selected items | ||
28 | private toWrite: any | ||
29 | private loaded = false | ||
30 | |||
31 | constructor ( | ||
32 | private server: ServerService | ||
33 | ) { | ||
34 | |||
35 | } | ||
36 | |||
37 | ngOnInit () { | ||
38 | this.server.getVideoLanguages() | ||
39 | .subscribe( | ||
40 | languages => { | ||
41 | this.availableLanguages = [ { label: $localize`Unknown language`, id: '_unknown', group: this.allLanguagesGroup } ] | ||
42 | |||
43 | this.availableLanguages = this.availableLanguages | ||
44 | .concat(languages.map(l => ({ label: l.label, id: l.id, group: this.allLanguagesGroup }))) | ||
45 | |||
46 | this.loaded = true | ||
47 | this.writeValue(this.toWrite) | ||
48 | } | ||
49 | ) | ||
50 | } | ||
51 | |||
52 | propagateChange = (_: any) => { /* empty */ } | ||
53 | |||
54 | writeValue (languages: ItemSelectCheckboxValue[]) { | ||
55 | if (!this.loaded) { | ||
56 | this.toWrite = languages | ||
57 | return | ||
58 | } | ||
59 | |||
60 | this.selectedLanguages = languages | ||
61 | } | ||
62 | |||
63 | registerOnChange (fn: (_: any) => void) { | ||
64 | this.propagateChange = fn | ||
65 | } | ||
66 | |||
67 | registerOnTouched () { | ||
68 | // Unused | ||
69 | } | ||
70 | |||
71 | onModelChange () { | ||
72 | this.propagateChange(this.selectedLanguages) | ||
73 | } | ||
74 | } | ||
diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts index 5417f7342..60c2f66ae 100644 --- a/client/src/app/shared/shared-forms/shared-form.module.ts +++ b/client/src/app/shared/shared-forms/shared-form.module.ts | |||
@@ -15,9 +15,12 @@ import { PeertubeCheckboxComponent } from './peertube-checkbox.component' | |||
15 | import { PreviewUploadComponent } from './preview-upload.component' | 15 | import { PreviewUploadComponent } from './preview-upload.component' |
16 | import { ReactiveFileComponent } from './reactive-file.component' | 16 | import { ReactiveFileComponent } from './reactive-file.component' |
17 | import { | 17 | import { |
18 | SelectCategoriesComponent, | ||
18 | SelectChannelComponent, | 19 | SelectChannelComponent, |
20 | SelectCheckboxAllComponent, | ||
19 | SelectCheckboxComponent, | 21 | SelectCheckboxComponent, |
20 | SelectCustomValueComponent, | 22 | SelectCustomValueComponent, |
23 | SelectLanguagesComponent, | ||
21 | SelectOptionsComponent, | 24 | SelectOptionsComponent, |
22 | SelectTagsComponent | 25 | SelectTagsComponent |
23 | } from './select' | 26 | } from './select' |
@@ -52,6 +55,9 @@ import { TimestampInputComponent } from './timestamp-input.component' | |||
52 | SelectTagsComponent, | 55 | SelectTagsComponent, |
53 | SelectCheckboxComponent, | 56 | SelectCheckboxComponent, |
54 | SelectCustomValueComponent, | 57 | SelectCustomValueComponent, |
58 | SelectLanguagesComponent, | ||
59 | SelectCategoriesComponent, | ||
60 | SelectCheckboxAllComponent, | ||
55 | 61 | ||
56 | DynamicFormFieldComponent, | 62 | DynamicFormFieldComponent, |
57 | 63 | ||
@@ -80,6 +86,9 @@ import { TimestampInputComponent } from './timestamp-input.component' | |||
80 | SelectTagsComponent, | 86 | SelectTagsComponent, |
81 | SelectCheckboxComponent, | 87 | SelectCheckboxComponent, |
82 | SelectCustomValueComponent, | 88 | SelectCustomValueComponent, |
89 | SelectLanguagesComponent, | ||
90 | SelectCategoriesComponent, | ||
91 | SelectCheckboxAllComponent, | ||
83 | 92 | ||
84 | DynamicFormFieldComponent, | 93 | DynamicFormFieldComponent, |
85 | 94 | ||
diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts index cb5f31c8e..70d672306 100644 --- a/client/src/app/shared/shared-icons/global-icon.component.ts +++ b/client/src/app/shared/shared-icons/global-icon.component.ts | |||
@@ -71,6 +71,7 @@ const icons = { | |||
71 | columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default, | 71 | columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default, |
72 | live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default, | 72 | live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default, |
73 | repeat: require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, | 73 | repeat: require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, |
74 | 'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default, | ||
74 | 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, | 75 | 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, |
75 | codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, | 76 | codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, |
76 | award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default | 77 | award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default |
diff --git a/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts index dc212788a..bebc6efa7 100644 --- a/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts +++ b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts | |||
@@ -1,16 +1,19 @@ | |||
1 | import { fromEvent, Observable, Subscription } from 'rxjs' | 1 | import { fromEvent, Observable, Subscription } from 'rxjs' |
2 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' | 2 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' |
3 | import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | 3 | import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' |
4 | import { PeerTubeRouterService, RouterSetting } from '@app/core' | ||
4 | 5 | ||
5 | @Directive({ | 6 | @Directive({ |
6 | selector: '[myInfiniteScroller]' | 7 | selector: '[myInfiniteScroller]' |
7 | }) | 8 | }) |
8 | export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { | 9 | export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { |
9 | @Input() percentLimit = 70 | 10 | @Input() percentLimit = 70 |
10 | @Input() autoInit = false | ||
11 | @Input() onItself = false | 11 | @Input() onItself = false |
12 | @Input() dataObservable: Observable<any[]> | 12 | @Input() dataObservable: Observable<any[]> |
13 | 13 | ||
14 | // Add angular state in query params to reuse the routed component | ||
15 | @Input() setAngularState: boolean | ||
16 | |||
14 | @Output() nearOfBottom = new EventEmitter<void>() | 17 | @Output() nearOfBottom = new EventEmitter<void>() |
15 | 18 | ||
16 | private decimalLimit = 0 | 19 | private decimalLimit = 0 |
@@ -20,7 +23,10 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
20 | 23 | ||
21 | private checkScroll = false | 24 | private checkScroll = false |
22 | 25 | ||
23 | constructor (private el: ElementRef) { | 26 | constructor ( |
27 | private peertubeRouter: PeerTubeRouterService, | ||
28 | private el: ElementRef | ||
29 | ) { | ||
24 | this.decimalLimit = this.percentLimit / 100 | 30 | this.decimalLimit = this.percentLimit / 100 |
25 | } | 31 | } |
26 | 32 | ||
@@ -36,7 +42,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
36 | } | 42 | } |
37 | 43 | ||
38 | ngOnInit () { | 44 | ngOnInit () { |
39 | if (this.autoInit === true) return this.initialize() | 45 | this.initialize() |
40 | } | 46 | } |
41 | 47 | ||
42 | ngOnDestroy () { | 48 | ngOnDestroy () { |
@@ -67,7 +73,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
67 | filter(({ current }) => this.isScrollingDown(current)), | 73 | filter(({ current }) => this.isScrollingDown(current)), |
68 | filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit) | 74 | filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit) |
69 | ) | 75 | ) |
70 | .subscribe(() => this.nearOfBottom.emit()) | 76 | .subscribe(() => { |
77 | if (this.setAngularState) this.setScrollRouteParams() | ||
78 | |||
79 | this.nearOfBottom.emit() | ||
80 | }) | ||
71 | 81 | ||
72 | if (this.dataObservable) { | 82 | if (this.dataObservable) { |
73 | this.dataObservable | 83 | this.dataObservable |
@@ -96,4 +106,8 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
96 | this.lastCurrentBottom = current | 106 | this.lastCurrentBottom = current |
97 | return result | 107 | return result |
98 | } | 108 | } |
109 | |||
110 | private setScrollRouteParams () { | ||
111 | this.peertubeRouter.addRouteSetting(RouterSetting.REUSE_COMPONENT) | ||
112 | } | ||
99 | } | 113 | } |
diff --git a/client/src/app/shared/shared-main/feeds/feed.component.scss b/client/src/app/shared/shared-main/feeds/feed.component.scss index a1838c485..bf1f4eeeb 100644 --- a/client/src/app/shared/shared-main/feeds/feed.component.scss +++ b/client/src/app/shared/shared-main/feeds/feed.component.scss | |||
@@ -7,12 +7,11 @@ | |||
7 | a { | 7 | a { |
8 | color: #000; | 8 | color: #000; |
9 | display: block; | 9 | display: block; |
10 | min-width: 100px; | ||
10 | } | 11 | } |
11 | } | 12 | } |
12 | 13 | ||
13 | my-global-icon { | 14 | my-global-icon { |
14 | @include apply-svg-color(pvar(--mainForegroundColor)); | ||
15 | |||
16 | cursor: pointer; | 15 | cursor: pointer; |
17 | width: 100%; | 16 | width: 100%; |
18 | } | 17 | } |
diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.html b/client/src/app/shared/shared-main/misc/simple-search-input.component.html index c20c02e23..1e2f6c6a9 100644 --- a/client/src/app/shared/shared-main/misc/simple-search-input.component.html +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.html | |||
@@ -1,13 +1,18 @@ | |||
1 | <div class="root"> | 1 | <div class="root"> |
2 | <input | 2 | <div class="input-group has-feedback has-clear"> |
3 | #ref | 3 | <input |
4 | type="text" | 4 | #ref |
5 | [(ngModel)]="value" | 5 | type="text" |
6 | (keyup.enter)="searchChange()" | 6 | [(ngModel)]="value" |
7 | [hidden]="!inputShown" | 7 | (keyup.enter)="sendSearch()" |
8 | [name]="name" | 8 | [hidden]="!inputShown" |
9 | [placeholder]="placeholder" | 9 | [name]="name" |
10 | > | 10 | [placeholder]="placeholder" |
11 | > | ||
12 | |||
13 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="onResetFilter()"></a> | ||
14 | <span class="sr-only" i18n>Clear filters</span> | ||
15 | </div> | ||
11 | 16 | ||
12 | <my-global-icon iconName="search" aria-label="Search" role="button" (click)="onIconClick()" [title]="iconTitle"></my-global-icon> | 17 | <my-global-icon iconName="search" aria-label="Search" role="button" (click)="onIconClick()" [title]="iconTitle"></my-global-icon> |
13 | 18 | ||
diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.scss b/client/src/app/shared/shared-main/misc/simple-search-input.component.scss index 173204291..d5fcff760 100644 --- a/client/src/app/shared/shared-main/misc/simple-search-input.component.scss +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.scss | |||
@@ -11,20 +11,17 @@ my-global-icon { | |||
11 | height: 28px; | 11 | height: 28px; |
12 | width: 28px; | 12 | width: 28px; |
13 | cursor: pointer; | 13 | cursor: pointer; |
14 | color: pvar(--mainColor); | ||
14 | 15 | ||
15 | &:hover { | 16 | &:hover { |
16 | color: pvar(--mainHoverColor); | 17 | color: pvar(--mainHoverColor); |
17 | } | 18 | } |
18 | |||
19 | &[iconName=search] { | ||
20 | color: pvar(--mainForegroundColor); | ||
21 | } | ||
22 | |||
23 | &[iconName=cross] { | ||
24 | color: pvar(--mainForegroundColor); | ||
25 | } | ||
26 | } | 19 | } |
27 | 20 | ||
28 | input { | 21 | input { |
29 | @include peertube-input-text(200px); | 22 | @include peertube-input-text(200px); |
23 | |||
24 | &:focus { | ||
25 | box-shadow: 0 0 5px 0 #a5a5a5; | ||
26 | } | ||
30 | } | 27 | } |
diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.ts b/client/src/app/shared/shared-main/misc/simple-search-input.component.ts index 292ec4c82..99abb94e7 100644 --- a/client/src/app/shared/shared-main/misc/simple-search-input.component.ts +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.ts | |||
@@ -1,7 +1,4 @@ | |||
1 | import { Subject } from 'rxjs' | ||
2 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | ||
3 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' |
4 | import { ActivatedRoute, Router } from '@angular/router' | ||
5 | 2 | ||
6 | @Component({ | 3 | @Component({ |
7 | selector: 'my-simple-search-input', | 4 | selector: 'my-simple-search-input', |
@@ -22,23 +19,9 @@ export class SimpleSearchInputComponent implements OnInit { | |||
22 | value = '' | 19 | value = '' |
23 | inputShown: boolean | 20 | inputShown: boolean |
24 | 21 | ||
25 | private searchSubject = new Subject<string>() | 22 | private hasAlreadySentSearch = false |
26 | |||
27 | constructor ( | ||
28 | private router: Router, | ||
29 | private route: ActivatedRoute | ||
30 | ) {} | ||
31 | 23 | ||
32 | ngOnInit () { | 24 | ngOnInit () { |
33 | this.searchSubject | ||
34 | .pipe( | ||
35 | debounceTime(400), | ||
36 | distinctUntilChanged() | ||
37 | ) | ||
38 | .subscribe(value => this.searchChanged.emit(value)) | ||
39 | |||
40 | this.searchSubject.next(this.value) | ||
41 | |||
42 | if (this.isInputShown()) this.showInput(false) | 25 | if (this.isInputShown()) this.showInput(false) |
43 | } | 26 | } |
44 | 27 | ||
@@ -54,7 +37,7 @@ export class SimpleSearchInputComponent implements OnInit { | |||
54 | return | 37 | return |
55 | } | 38 | } |
56 | 39 | ||
57 | this.searchChange() | 40 | this.sendSearch() |
58 | } | 41 | } |
59 | 42 | ||
60 | showInput (focus = true) { | 43 | showInput (focus = true) { |
@@ -80,9 +63,14 @@ export class SimpleSearchInputComponent implements OnInit { | |||
80 | this.hideInput() | 63 | this.hideInput() |
81 | } | 64 | } |
82 | 65 | ||
83 | searchChange () { | 66 | sendSearch () { |
84 | this.router.navigate([ './search' ], { relativeTo: this.route }) | 67 | this.hasAlreadySentSearch = true |
68 | this.searchChanged.emit(this.value) | ||
69 | } | ||
70 | |||
71 | onResetFilter () { | ||
72 | this.value = '' | ||
85 | 73 | ||
86 | this.searchSubject.next(this.value) | 74 | if (this.hasAlreadySentSearch) this.sendSearch() |
87 | } | 75 | } |
88 | } | 76 | } |
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html index 325f0eaae..ee8df864a 100644 --- a/client/src/app/shared/shared-main/users/user-notifications.component.html +++ b/client/src/app/shared/shared-main/users/user-notifications.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div> | 1 | <div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div> |
2 | 2 | ||
3 | <div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> | 3 | <div class="notifications" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
4 | <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)"> | 4 | <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)"> |
5 | 5 | ||
6 | <ng-container [ngSwitch]="notification.type"> | 6 | <ng-container [ngSwitch]="notification.type"> |
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 60cc9d160..3481b116f 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts | |||
@@ -5,6 +5,7 @@ import { Injectable } from '@angular/core' | |||
5 | import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' | 5 | import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' |
6 | import { objectToFormData } from '@app/helpers' | 6 | import { objectToFormData } from '@app/helpers' |
7 | import { | 7 | import { |
8 | BooleanBothQuery, | ||
8 | FeedFormat, | 9 | FeedFormat, |
9 | NSFWPolicyType, | 10 | NSFWPolicyType, |
10 | ResultList, | 11 | ResultList, |
@@ -28,19 +29,21 @@ import { VideoDetails } from './video-details.model' | |||
28 | import { VideoEdit } from './video-edit.model' | 29 | import { VideoEdit } from './video-edit.model' |
29 | import { Video } from './video.model' | 30 | import { Video } from './video.model' |
30 | 31 | ||
31 | export interface VideosProvider { | 32 | export type CommonVideoParams = { |
32 | getVideos (parameters: { | 33 | videoPagination: ComponentPaginationLight |
33 | videoPagination: ComponentPaginationLight | 34 | sort: VideoSortField |
34 | sort: VideoSortField | 35 | filter?: VideoFilter |
35 | filter?: VideoFilter | 36 | categoryOneOf?: number[] |
36 | categoryOneOf?: number[] | 37 | languageOneOf?: string[] |
37 | languageOneOf?: string[] | 38 | isLive?: boolean |
38 | nsfwPolicy: NSFWPolicyType | 39 | skipCount?: boolean |
39 | }): Observable<ResultList<Video>> | 40 | // FIXME: remove? |
41 | nsfwPolicy?: NSFWPolicyType | ||
42 | nsfw?: BooleanBothQuery | ||
40 | } | 43 | } |
41 | 44 | ||
42 | @Injectable() | 45 | @Injectable() |
43 | export class VideoService implements VideosProvider { | 46 | export class VideoService { |
44 | static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' | 47 | static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' |
45 | static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' | 48 | static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' |
46 | static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.' | 49 | static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.' |
@@ -144,32 +147,16 @@ export class VideoService implements VideosProvider { | |||
144 | ) | 147 | ) |
145 | } | 148 | } |
146 | 149 | ||
147 | getAccountVideos (parameters: { | 150 | getAccountVideos (parameters: CommonVideoParams & { |
148 | account: Pick<Account, 'nameWithHost'> | 151 | account: Pick<Account, 'nameWithHost'> |
149 | videoPagination: ComponentPaginationLight | ||
150 | sort: VideoSortField | ||
151 | nsfwPolicy?: NSFWPolicyType | ||
152 | videoFilter?: VideoFilter | ||
153 | search?: string | 152 | search?: string |
154 | }): Observable<ResultList<Video>> { | 153 | }): Observable<ResultList<Video>> { |
155 | const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters | 154 | const { account, search } = parameters |
156 | |||
157 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
158 | 155 | ||
159 | let params = new HttpParams() | 156 | let params = new HttpParams() |
160 | params = this.restService.addRestGetParams(params, pagination, sort) | 157 | params = this.buildCommonVideosParams({ params, ...parameters }) |
161 | |||
162 | if (nsfwPolicy) { | ||
163 | params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
164 | } | ||
165 | |||
166 | if (videoFilter) { | ||
167 | params = params.set('filter', videoFilter) | ||
168 | } | ||
169 | 158 | ||
170 | if (search) { | 159 | if (search) params = params.set('search', search) |
171 | params = params.set('search', search) | ||
172 | } | ||
173 | 160 | ||
174 | return this.authHttp | 161 | return this.authHttp |
175 | .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) | 162 | .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) |
@@ -179,27 +166,13 @@ export class VideoService implements VideosProvider { | |||
179 | ) | 166 | ) |
180 | } | 167 | } |
181 | 168 | ||
182 | getVideoChannelVideos (parameters: { | 169 | getVideoChannelVideos (parameters: CommonVideoParams & { |
183 | videoChannel: Pick<VideoChannel, 'nameWithHost'> | 170 | videoChannel: Pick<VideoChannel, 'nameWithHost'> |
184 | videoPagination: ComponentPaginationLight | ||
185 | sort: VideoSortField | ||
186 | nsfwPolicy?: NSFWPolicyType | ||
187 | videoFilter?: VideoFilter | ||
188 | }): Observable<ResultList<Video>> { | 171 | }): Observable<ResultList<Video>> { |
189 | const { videoChannel, videoPagination, sort, nsfwPolicy, videoFilter } = parameters | 172 | const { videoChannel } = parameters |
190 | |||
191 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
192 | 173 | ||
193 | let params = new HttpParams() | 174 | let params = new HttpParams() |
194 | params = this.restService.addRestGetParams(params, pagination, sort) | 175 | params = this.buildCommonVideosParams({ params, ...parameters }) |
195 | |||
196 | if (nsfwPolicy) { | ||
197 | params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
198 | } | ||
199 | |||
200 | if (videoFilter) { | ||
201 | params = params.set('filter', videoFilter) | ||
202 | } | ||
203 | 176 | ||
204 | return this.authHttp | 177 | return this.authHttp |
205 | .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params }) | 178 | .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params }) |
@@ -209,30 +182,9 @@ export class VideoService implements VideosProvider { | |||
209 | ) | 182 | ) |
210 | } | 183 | } |
211 | 184 | ||
212 | getVideos (parameters: { | 185 | getVideos (parameters: CommonVideoParams): Observable<ResultList<Video>> { |
213 | videoPagination: ComponentPaginationLight | ||
214 | sort: VideoSortField | ||
215 | filter?: VideoFilter | ||
216 | categoryOneOf?: number[] | ||
217 | languageOneOf?: string[] | ||
218 | isLive?: boolean | ||
219 | skipCount?: boolean | ||
220 | nsfwPolicy?: NSFWPolicyType | ||
221 | }): Observable<ResultList<Video>> { | ||
222 | const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive } = parameters | ||
223 | |||
224 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
225 | |||
226 | let params = new HttpParams() | 186 | let params = new HttpParams() |
227 | params = this.restService.addRestGetParams(params, pagination, sort) | 187 | params = this.buildCommonVideosParams({ params, ...parameters }) |
228 | |||
229 | if (filter) params = params.set('filter', filter) | ||
230 | if (skipCount) params = params.set('skipCount', skipCount + '') | ||
231 | |||
232 | if (isLive) params = params.set('isLive', isLive) | ||
233 | if (nsfwPolicy) params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
234 | if (languageOneOf) this.restService.addArrayParams(params, 'languageOneOf', languageOneOf) | ||
235 | if (categoryOneOf) this.restService.addArrayParams(params, 'categoryOneOf', categoryOneOf) | ||
236 | 188 | ||
237 | return this.authHttp | 189 | return this.authHttp |
238 | .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params }) | 190 | .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params }) |
@@ -421,4 +373,22 @@ export class VideoService implements VideosProvider { | |||
421 | catchError(err => this.restExtractor.handleError(err)) | 373 | catchError(err => this.restExtractor.handleError(err)) |
422 | ) | 374 | ) |
423 | } | 375 | } |
376 | |||
377 | private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) { | ||
378 | const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options | ||
379 | |||
380 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
381 | let newParams = this.restService.addRestGetParams(params, pagination, sort) | ||
382 | |||
383 | if (filter) newParams = newParams.set('filter', filter) | ||
384 | if (skipCount) newParams = newParams.set('skipCount', skipCount + '') | ||
385 | |||
386 | if (isLive) newParams = newParams.set('isLive', isLive) | ||
387 | if (nsfw) newParams = newParams.set('nsfw', nsfw) | ||
388 | if (nsfwPolicy) newParams = newParams.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
389 | if (languageOneOf) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf) | ||
390 | if (categoryOneOf) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf) | ||
391 | |||
392 | return newParams | ||
393 | } | ||
424 | } | 394 | } |
diff --git a/client/src/app/shared/shared-search/advanced-search.model.ts b/client/src/app/shared/shared-search/advanced-search.model.ts index 9c55f6cd8..2675c6135 100644 --- a/client/src/app/shared/shared-search/advanced-search.model.ts +++ b/client/src/app/shared/shared-search/advanced-search.model.ts | |||
@@ -1,3 +1,4 @@ | |||
1 | import { intoArray } from '@app/helpers' | ||
1 | import { | 2 | import { |
2 | BooleanBothQuery, | 3 | BooleanBothQuery, |
3 | BooleanQuery, | 4 | BooleanQuery, |
@@ -74,8 +75,8 @@ export class AdvancedSearch { | |||
74 | this.categoryOneOf = options.categoryOneOf || undefined | 75 | this.categoryOneOf = options.categoryOneOf || undefined |
75 | this.licenceOneOf = options.licenceOneOf || undefined | 76 | this.licenceOneOf = options.licenceOneOf || undefined |
76 | this.languageOneOf = options.languageOneOf || undefined | 77 | this.languageOneOf = options.languageOneOf || undefined |
77 | this.tagsOneOf = this.intoArray(options.tagsOneOf) | 78 | this.tagsOneOf = intoArray(options.tagsOneOf) |
78 | this.tagsAllOf = this.intoArray(options.tagsAllOf) | 79 | this.tagsAllOf = intoArray(options.tagsAllOf) |
79 | this.durationMin = parseInt(options.durationMin, 10) | 80 | this.durationMin = parseInt(options.durationMin, 10) |
80 | this.durationMax = parseInt(options.durationMax, 10) | 81 | this.durationMax = parseInt(options.durationMax, 10) |
81 | 82 | ||
@@ -150,9 +151,9 @@ export class AdvancedSearch { | |||
150 | originallyPublishedStartDate: this.originallyPublishedStartDate, | 151 | originallyPublishedStartDate: this.originallyPublishedStartDate, |
151 | originallyPublishedEndDate: this.originallyPublishedEndDate, | 152 | originallyPublishedEndDate: this.originallyPublishedEndDate, |
152 | nsfw: this.nsfw, | 153 | nsfw: this.nsfw, |
153 | categoryOneOf: this.intoArray(this.categoryOneOf), | 154 | categoryOneOf: intoArray(this.categoryOneOf), |
154 | licenceOneOf: this.intoArray(this.licenceOneOf), | 155 | licenceOneOf: intoArray(this.licenceOneOf), |
155 | languageOneOf: this.intoArray(this.languageOneOf), | 156 | languageOneOf: intoArray(this.languageOneOf), |
156 | tagsOneOf: this.tagsOneOf, | 157 | tagsOneOf: this.tagsOneOf, |
157 | tagsAllOf: this.tagsAllOf, | 158 | tagsAllOf: this.tagsAllOf, |
158 | durationMin: this.durationMin, | 159 | durationMin: this.durationMin, |
@@ -198,13 +199,4 @@ export class AdvancedSearch { | |||
198 | 199 | ||
199 | return true | 200 | return true |
200 | } | 201 | } |
201 | |||
202 | private intoArray (value: any) { | ||
203 | if (!value) return undefined | ||
204 | if (Array.isArray(value)) return value | ||
205 | |||
206 | if (typeof value === 'string') return value.split(',') | ||
207 | |||
208 | return [ value ] | ||
209 | } | ||
210 | } | 202 | } |
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.html b/client/src/app/shared/shared-user-settings/user-video-settings.component.html index a49e11485..bc9dd0f7f 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.html +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.html | |||
@@ -30,12 +30,7 @@ | |||
30 | </my-help> | 30 | </my-help> |
31 | 31 | ||
32 | <div> | 32 | <div> |
33 | <my-select-checkbox | 33 | <my-select-languages formControlName="videoLanguages"></my-select-languages> |
34 | formControlName="videoLanguages" [availableItems]="languageItems" | ||
35 | [selectableGroup]="true" [selectableGroupAsModel]="true" | ||
36 | i18n-placeholder placeholder="Add a new language" | ||
37 | > | ||
38 | </my-select-checkbox > | ||
39 | </div> | 34 | </div> |
40 | </div> | 35 | </div> |
41 | 36 | ||
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.scss b/client/src/app/shared/shared-user-settings/user-video-settings.component.scss index 4b007b691..c4f6020d4 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.scss +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.scss | |||
@@ -19,7 +19,7 @@ input[type=submit] { | |||
19 | margin-bottom: 30px; | 19 | margin-bottom: 30px; |
20 | } | 20 | } |
21 | 21 | ||
22 | my-select-checkbox { | 22 | my-select-languages { |
23 | @include responsive-width(340px); | 23 | @include responsive-width(340px); |
24 | 24 | ||
25 | display: block; | 25 | display: block; |
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts index 5d6e11c04..0cd889a8a 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts | |||
@@ -1,12 +1,11 @@ | |||
1 | import { pick } from 'lodash-es' | 1 | import { pick } from 'lodash-es' |
2 | import { forkJoin, Subject, Subscription } from 'rxjs' | 2 | import { Subject, Subscription } from 'rxjs' |
3 | import { first } from 'rxjs/operators' | 3 | import { first } from 'rxjs/operators' |
4 | import { Component, Input, OnDestroy, OnInit } from '@angular/core' | 4 | import { Component, Input, OnDestroy, OnInit } from '@angular/core' |
5 | import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' | 5 | import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' |
6 | import { FormReactive, FormValidatorService, ItemSelectCheckboxValue } from '@app/shared/shared-forms' | 6 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' |
7 | import { UserUpdateMe } from '@shared/models' | 7 | import { UserUpdateMe } from '@shared/models' |
8 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | 8 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' |
9 | import { SelectOptionsItem } from '../../../types/select-options-item.model' | ||
10 | 9 | ||
11 | @Component({ | 10 | @Component({ |
12 | selector: 'my-user-video-settings', | 11 | selector: 'my-user-video-settings', |
@@ -19,12 +18,9 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
19 | @Input() notifyOnUpdate = true | 18 | @Input() notifyOnUpdate = true |
20 | @Input() userInformationLoaded: Subject<any> | 19 | @Input() userInformationLoaded: Subject<any> |
21 | 20 | ||
22 | languageItems: SelectOptionsItem[] = [] | ||
23 | defaultNSFWPolicy: NSFWPolicyType | 21 | defaultNSFWPolicy: NSFWPolicyType |
24 | formValuesWatcher: Subscription | 22 | formValuesWatcher: Subscription |
25 | 23 | ||
26 | private allLanguagesGroup: string | ||
27 | |||
28 | constructor ( | 24 | constructor ( |
29 | protected formValidatorService: FormValidatorService, | 25 | protected formValidatorService: FormValidatorService, |
30 | private authService: AuthService, | 26 | private authService: AuthService, |
@@ -36,8 +32,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
36 | } | 32 | } |
37 | 33 | ||
38 | ngOnInit () { | 34 | ngOnInit () { |
39 | this.allLanguagesGroup = $localize`All languages` | ||
40 | |||
41 | this.buildForm({ | 35 | this.buildForm({ |
42 | nsfwPolicy: null, | 36 | nsfwPolicy: null, |
43 | webTorrentEnabled: null, | 37 | webTorrentEnabled: null, |
@@ -46,33 +40,23 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
46 | videoLanguages: null | 40 | videoLanguages: null |
47 | }) | 41 | }) |
48 | 42 | ||
49 | forkJoin([ | 43 | this.userInformationLoaded.pipe(first()) |
50 | this.serverService.getVideoLanguages(), | 44 | .subscribe( |
51 | this.userInformationLoaded.pipe(first()) | 45 | () => { |
52 | ]).subscribe(([ languages ]) => { | 46 | const serverConfig = this.serverService.getHTMLConfig() |
53 | const group = this.allLanguagesGroup | 47 | this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy |
54 | 48 | ||
55 | this.languageItems = [ { label: $localize`Unknown language`, id: '_unknown', group } ] | 49 | this.form.patchValue({ |
56 | this.languageItems = this.languageItems | 50 | nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy, |
57 | .concat(languages.map(l => ({ label: l.label, id: l.id, group }))) | 51 | webTorrentEnabled: this.user.webTorrentEnabled, |
58 | 52 | autoPlayVideo: this.user.autoPlayVideo === true, | |
59 | const videoLanguages: ItemSelectCheckboxValue[] = this.user.videoLanguages | 53 | autoPlayNextVideo: this.user.autoPlayNextVideo, |
60 | ? this.user.videoLanguages.map(l => ({ id: l })) | 54 | videoLanguages: this.user.videoLanguages |
61 | : [ { group } ] | 55 | }) |
62 | 56 | ||
63 | const serverConfig = this.serverService.getHTMLConfig() | 57 | if (this.reactiveUpdate) this.handleReactiveUpdate() |
64 | this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy | 58 | } |
65 | 59 | ) | |
66 | this.form.patchValue({ | ||
67 | nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy, | ||
68 | webTorrentEnabled: this.user.webTorrentEnabled, | ||
69 | autoPlayVideo: this.user.autoPlayVideo === true, | ||
70 | autoPlayNextVideo: this.user.autoPlayNextVideo, | ||
71 | videoLanguages | ||
72 | }) | ||
73 | |||
74 | if (this.reactiveUpdate) this.handleReactiveUpdate() | ||
75 | }) | ||
76 | } | 60 | } |
77 | 61 | ||
78 | ngOnDestroy () { | 62 | ngOnDestroy () { |
@@ -85,23 +69,15 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
85 | const autoPlayVideo = this.form.value['autoPlayVideo'] | 69 | const autoPlayVideo = this.form.value['autoPlayVideo'] |
86 | const autoPlayNextVideo = this.form.value['autoPlayNextVideo'] | 70 | const autoPlayNextVideo = this.form.value['autoPlayNextVideo'] |
87 | 71 | ||
88 | let videoLanguagesForm = this.form.value['videoLanguages'] | 72 | const videoLanguages = this.form.value['videoLanguages'] |
89 | 73 | ||
90 | if (Array.isArray(videoLanguagesForm)) { | 74 | if (Array.isArray(videoLanguages)) { |
91 | if (videoLanguagesForm.length > 20) { | 75 | if (videoLanguages.length > 20) { |
92 | this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`) | 76 | this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`) |
93 | return | 77 | return |
94 | } | 78 | } |
95 | |||
96 | // Automatically use "All languages" if the user did not select any language | ||
97 | if (videoLanguagesForm.length === 0) { | ||
98 | videoLanguagesForm = [ this.allLanguagesGroup ] | ||
99 | this.form.patchValue({ videoLanguages: [ { group: this.allLanguagesGroup } ] }) | ||
100 | } | ||
101 | } | 79 | } |
102 | 80 | ||
103 | const videoLanguages = this.buildLanguagesFromForm(videoLanguagesForm) | ||
104 | |||
105 | let details: UserUpdateMe = { | 81 | let details: UserUpdateMe = { |
106 | nsfwPolicy, | 82 | nsfwPolicy, |
107 | webTorrentEnabled, | 83 | webTorrentEnabled, |
@@ -123,31 +99,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, | |||
123 | return this.updateAnonymousProfile(details) | 99 | return this.updateAnonymousProfile(details) |
124 | } | 100 | } |
125 | 101 | ||
126 | private buildLanguagesFromForm (videoLanguages: ItemSelectCheckboxValue[]) { | ||
127 | if (!Array.isArray(videoLanguages)) return undefined | ||
128 | |||
129 | // null means "All" | ||
130 | if (videoLanguages.length === this.languageItems.length) return null | ||
131 | |||
132 | if (videoLanguages.length === 1) { | ||
133 | const videoLanguage = videoLanguages[0] | ||
134 | |||
135 | if (typeof videoLanguage === 'string') { | ||
136 | if (videoLanguage === this.allLanguagesGroup) return null | ||
137 | } else { | ||
138 | if (videoLanguage.group === this.allLanguagesGroup) return null | ||
139 | } | ||
140 | } | ||
141 | |||
142 | return videoLanguages.map(l => { | ||
143 | if (typeof l === 'string') return l | ||
144 | |||
145 | if (l.group) return l.group | ||
146 | |||
147 | return l.id + '' | ||
148 | }) | ||
149 | } | ||
150 | |||
151 | private handleReactiveUpdate () { | 102 | private handleReactiveUpdate () { |
152 | let oldForm = { ...this.form.value } | 103 | let oldForm = { ...this.form.value } |
153 | 104 | ||
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts deleted file mode 100644 index f12ae2ee5..000000000 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts +++ /dev/null | |||
@@ -1,404 +0,0 @@ | |||
1 | import { fromEvent, Observable, ReplaySubject, Subject, Subscription } from 'rxjs' | ||
2 | import { debounceTime, switchMap, tap } from 'rxjs/operators' | ||
3 | import { | ||
4 | AfterContentInit, | ||
5 | ComponentFactoryResolver, | ||
6 | Directive, | ||
7 | Injector, | ||
8 | OnDestroy, | ||
9 | OnInit, | ||
10 | Type, | ||
11 | ViewChild, | ||
12 | ViewContainerRef | ||
13 | } from '@angular/core' | ||
14 | import { ActivatedRoute, Params, Router } from '@angular/router' | ||
15 | import { | ||
16 | AuthService, | ||
17 | ComponentPaginationLight, | ||
18 | LocalStorageService, | ||
19 | Notifier, | ||
20 | ScreenService, | ||
21 | ServerService, | ||
22 | User, | ||
23 | UserService | ||
24 | } from '@app/core' | ||
25 | import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' | ||
26 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
27 | import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils' | ||
28 | import { HTMLServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/models' | ||
29 | import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' | ||
30 | import { Syndication, Video } from '../shared-main' | ||
31 | import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component' | ||
32 | import { MiniatureDisplayOptions } from './video-miniature.component' | ||
33 | |||
34 | enum GroupDate { | ||
35 | UNKNOWN = 0, | ||
36 | TODAY = 1, | ||
37 | YESTERDAY = 2, | ||
38 | THIS_WEEK = 3, | ||
39 | THIS_MONTH = 4, | ||
40 | LAST_MONTH = 5, | ||
41 | OLDER = 6 | ||
42 | } | ||
43 | |||
44 | @Directive() | ||
45 | // eslint-disable-next-line @angular-eslint/directive-class-suffix | ||
46 | export abstract class AbstractVideoList implements OnInit, OnDestroy, AfterContentInit, DisableForReuseHook { | ||
47 | @ViewChild('videoListHeader', { static: true, read: ViewContainerRef }) videoListHeader: ViewContainerRef | ||
48 | |||
49 | HeaderComponent: Type<GenericHeaderComponent> = VideoListHeaderComponent | ||
50 | headerComponentInjector: Injector | ||
51 | |||
52 | pagination: ComponentPaginationLight = { | ||
53 | currentPage: 1, | ||
54 | itemsPerPage: 25 | ||
55 | } | ||
56 | sort: VideoSortField = '-publishedAt' | ||
57 | |||
58 | categoryOneOf?: number[] | ||
59 | languageOneOf?: string[] | ||
60 | nsfwPolicy?: NSFWPolicyType | ||
61 | defaultSort: VideoSortField = '-publishedAt' | ||
62 | |||
63 | syndicationItems: Syndication[] = [] | ||
64 | |||
65 | loadOnInit = true | ||
66 | loadUserVideoPreferences = false | ||
67 | |||
68 | displayModerationBlock = false | ||
69 | titleTooltip: string | ||
70 | displayVideoActions = true | ||
71 | groupByDate = false | ||
72 | |||
73 | videos: Video[] = [] | ||
74 | hasDoneFirstQuery = false | ||
75 | disabled = false | ||
76 | |||
77 | displayOptions: MiniatureDisplayOptions = { | ||
78 | date: true, | ||
79 | views: true, | ||
80 | by: true, | ||
81 | avatar: false, | ||
82 | privacyLabel: true, | ||
83 | privacyText: false, | ||
84 | state: false, | ||
85 | blacklistInfo: false | ||
86 | } | ||
87 | |||
88 | actions: { | ||
89 | iconName: GlobalIconName | ||
90 | label: string | ||
91 | justIcon?: boolean | ||
92 | routerLink?: string | ||
93 | href?: string | ||
94 | click?: (e: Event) => void | ||
95 | }[] = [] | ||
96 | |||
97 | onDataSubject = new Subject<any[]>() | ||
98 | |||
99 | userMiniature: User | ||
100 | |||
101 | protected onUserLoadedSubject = new ReplaySubject<void>(1) | ||
102 | |||
103 | protected serverConfig: HTMLServerConfig | ||
104 | |||
105 | protected abstract notifier: Notifier | ||
106 | protected abstract authService: AuthService | ||
107 | protected abstract userService: UserService | ||
108 | protected abstract route: ActivatedRoute | ||
109 | protected abstract serverService: ServerService | ||
110 | protected abstract screenService: ScreenService | ||
111 | protected abstract storageService: LocalStorageService | ||
112 | protected abstract router: Router | ||
113 | protected abstract cfr: ComponentFactoryResolver | ||
114 | abstract titlePage: string | ||
115 | |||
116 | private resizeSubscription: Subscription | ||
117 | private angularState: number | ||
118 | |||
119 | private groupedDateLabels: { [id in GroupDate]: string } | ||
120 | private groupedDates: { [id: number]: GroupDate } = {} | ||
121 | |||
122 | private lastQueryLength: number | ||
123 | |||
124 | abstract getVideosObservable (page: number): Observable<{ data: Video[] }> | ||
125 | |||
126 | abstract generateSyndicationList (): void | ||
127 | |||
128 | ngOnInit () { | ||
129 | this.serverConfig = this.serverService.getHTMLConfig() | ||
130 | |||
131 | this.groupedDateLabels = { | ||
132 | [GroupDate.UNKNOWN]: null, | ||
133 | [GroupDate.TODAY]: $localize`Today`, | ||
134 | [GroupDate.YESTERDAY]: $localize`Yesterday`, | ||
135 | [GroupDate.THIS_WEEK]: $localize`This week`, | ||
136 | [GroupDate.THIS_MONTH]: $localize`This month`, | ||
137 | [GroupDate.LAST_MONTH]: $localize`Last month`, | ||
138 | [GroupDate.OLDER]: $localize`Older` | ||
139 | } | ||
140 | |||
141 | // Subscribe to route changes | ||
142 | const routeParams = this.route.snapshot.queryParams | ||
143 | this.loadRouteParams(routeParams) | ||
144 | |||
145 | this.resizeSubscription = fromEvent(window, 'resize') | ||
146 | .pipe(debounceTime(500)) | ||
147 | .subscribe(() => this.calcPageSizes()) | ||
148 | |||
149 | this.calcPageSizes() | ||
150 | |||
151 | const loadUserObservable = this.loadUserAndSettings() | ||
152 | loadUserObservable.subscribe(() => { | ||
153 | this.onUserLoadedSubject.next() | ||
154 | |||
155 | if (this.loadOnInit === true) this.loadMoreVideos() | ||
156 | }) | ||
157 | |||
158 | this.userService.listenAnonymousUpdate() | ||
159 | .pipe(switchMap(() => this.loadUserAndSettings())) | ||
160 | .subscribe(() => { | ||
161 | if (this.hasDoneFirstQuery) this.reloadVideos() | ||
162 | }) | ||
163 | |||
164 | // Display avatar in mobile view | ||
165 | if (this.screenService.isInMobileView()) { | ||
166 | this.displayOptions.avatar = true | ||
167 | } | ||
168 | } | ||
169 | |||
170 | ngOnDestroy () { | ||
171 | if (this.resizeSubscription) this.resizeSubscription.unsubscribe() | ||
172 | } | ||
173 | |||
174 | ngAfterContentInit () { | ||
175 | if (this.videoListHeader) { | ||
176 | // some components don't use the header: they use their own template, like my-history.component.html | ||
177 | this.setHeader(this.HeaderComponent, this.headerComponentInjector) | ||
178 | } | ||
179 | } | ||
180 | |||
181 | disableForReuse () { | ||
182 | this.disabled = true | ||
183 | } | ||
184 | |||
185 | enabledForReuse () { | ||
186 | this.disabled = false | ||
187 | } | ||
188 | |||
189 | videoById (index: number, video: Video) { | ||
190 | return video.id | ||
191 | } | ||
192 | |||
193 | onNearOfBottom () { | ||
194 | if (this.disabled) return | ||
195 | |||
196 | // No more results | ||
197 | if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return | ||
198 | |||
199 | this.pagination.currentPage += 1 | ||
200 | |||
201 | this.setScrollRouteParams() | ||
202 | |||
203 | this.loadMoreVideos() | ||
204 | } | ||
205 | |||
206 | loadMoreVideos (reset = false) { | ||
207 | this.getVideosObservable(this.pagination.currentPage) | ||
208 | .subscribe({ | ||
209 | next: ({ data }) => { | ||
210 | this.hasDoneFirstQuery = true | ||
211 | this.lastQueryLength = data.length | ||
212 | |||
213 | if (reset) this.videos = [] | ||
214 | this.videos = this.videos.concat(data) | ||
215 | |||
216 | if (this.groupByDate) this.buildGroupedDateLabels() | ||
217 | |||
218 | this.onMoreVideos() | ||
219 | |||
220 | this.onDataSubject.next(data) | ||
221 | }, | ||
222 | |||
223 | error: err => { | ||
224 | const message = $localize`Cannot load more videos. Try again later.` | ||
225 | |||
226 | console.error(message, { err }) | ||
227 | this.notifier.error(message) | ||
228 | } | ||
229 | }) | ||
230 | } | ||
231 | |||
232 | reloadVideos () { | ||
233 | this.pagination.currentPage = 1 | ||
234 | this.loadMoreVideos(true) | ||
235 | } | ||
236 | |||
237 | removeVideoFromArray (video: Video) { | ||
238 | this.videos = this.videos.filter(v => v.id !== video.id) | ||
239 | } | ||
240 | |||
241 | buildGroupedDateLabels () { | ||
242 | let currentGroupedDate: GroupDate = GroupDate.UNKNOWN | ||
243 | |||
244 | const periods = [ | ||
245 | { | ||
246 | value: GroupDate.TODAY, | ||
247 | validator: (d: Date) => isToday(d) | ||
248 | }, | ||
249 | { | ||
250 | value: GroupDate.YESTERDAY, | ||
251 | validator: (d: Date) => isYesterday(d) | ||
252 | }, | ||
253 | { | ||
254 | value: GroupDate.THIS_WEEK, | ||
255 | validator: (d: Date) => isLastWeek(d) | ||
256 | }, | ||
257 | { | ||
258 | value: GroupDate.THIS_MONTH, | ||
259 | validator: (d: Date) => isThisMonth(d) | ||
260 | }, | ||
261 | { | ||
262 | value: GroupDate.LAST_MONTH, | ||
263 | validator: (d: Date) => isLastMonth(d) | ||
264 | }, | ||
265 | { | ||
266 | value: GroupDate.OLDER, | ||
267 | validator: () => true | ||
268 | } | ||
269 | ] | ||
270 | |||
271 | for (const video of this.videos) { | ||
272 | const publishedDate = video.publishedAt | ||
273 | |||
274 | for (let i = 0; i < periods.length; i++) { | ||
275 | const period = periods[i] | ||
276 | |||
277 | if (currentGroupedDate <= period.value && period.validator(publishedDate)) { | ||
278 | |||
279 | if (currentGroupedDate !== period.value) { | ||
280 | currentGroupedDate = period.value | ||
281 | this.groupedDates[video.id] = currentGroupedDate | ||
282 | } | ||
283 | |||
284 | break | ||
285 | } | ||
286 | } | ||
287 | } | ||
288 | } | ||
289 | |||
290 | getCurrentGroupedDateLabel (video: Video) { | ||
291 | if (this.groupByDate === false) return undefined | ||
292 | |||
293 | return this.groupedDateLabels[this.groupedDates[video.id]] | ||
294 | } | ||
295 | |||
296 | toggleModerationDisplay () { | ||
297 | throw new Error('toggleModerationDisplay ' + $localize`function is not implemented`) | ||
298 | } | ||
299 | |||
300 | setHeader ( | ||
301 | t: Type<any> = this.HeaderComponent, | ||
302 | i: Injector = this.headerComponentInjector | ||
303 | ) { | ||
304 | const injector = i || Injector.create({ | ||
305 | providers: [ { | ||
306 | provide: 'data', | ||
307 | useValue: { | ||
308 | titlePage: this.titlePage, | ||
309 | titleTooltip: this.titleTooltip | ||
310 | } | ||
311 | } ] | ||
312 | }) | ||
313 | const viewContainerRef = this.videoListHeader | ||
314 | viewContainerRef.clear() | ||
315 | |||
316 | const componentFactory = this.cfr.resolveComponentFactory(t) | ||
317 | viewContainerRef.createComponent(componentFactory, 0, injector) | ||
318 | } | ||
319 | |||
320 | // Can be redefined by child | ||
321 | displayAsRow () { | ||
322 | return false | ||
323 | } | ||
324 | |||
325 | // On videos hook for children that want to do something | ||
326 | protected onMoreVideos () { /* empty */ } | ||
327 | |||
328 | protected load () { /* empty */ } | ||
329 | |||
330 | // Hook if the page has custom route params | ||
331 | protected loadPageRouteParams (_queryParams: Params) { /* empty */ } | ||
332 | |||
333 | protected loadRouteParams (queryParams: Params) { | ||
334 | this.sort = queryParams['sort'] as VideoSortField || this.defaultSort | ||
335 | this.categoryOneOf = queryParams['categoryOneOf'] | ||
336 | this.angularState = queryParams['a-state'] | ||
337 | |||
338 | this.loadPageRouteParams(queryParams) | ||
339 | } | ||
340 | |||
341 | protected buildLocalFilter (existing: VideoFilter, base: VideoFilter) { | ||
342 | if (base === 'local') { | ||
343 | return existing === 'local' | ||
344 | ? 'all-local' as 'all-local' | ||
345 | : 'local' as 'local' | ||
346 | } | ||
347 | |||
348 | return existing === 'all' | ||
349 | ? null | ||
350 | : 'all' | ||
351 | } | ||
352 | |||
353 | protected enableAllFilterIfPossible () { | ||
354 | if (!this.authService.isLoggedIn()) return | ||
355 | |||
356 | this.authService.userInformationLoaded | ||
357 | .subscribe(() => { | ||
358 | const user = this.authService.getUser() | ||
359 | this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) | ||
360 | }) | ||
361 | } | ||
362 | |||
363 | private calcPageSizes () { | ||
364 | if (this.screenService.isInMobileView()) { | ||
365 | this.pagination.itemsPerPage = 5 | ||
366 | } | ||
367 | } | ||
368 | |||
369 | private setScrollRouteParams () { | ||
370 | // Already set | ||
371 | if (this.angularState) return | ||
372 | |||
373 | this.angularState = 42 | ||
374 | |||
375 | const queryParams = { | ||
376 | 'a-state': this.angularState, | ||
377 | categoryOneOf: this.categoryOneOf | ||
378 | } | ||
379 | |||
380 | let path = this.getUrlWithoutParams() | ||
381 | if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute | ||
382 | |||
383 | this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) | ||
384 | } | ||
385 | |||
386 | private loadUserAndSettings () { | ||
387 | return this.userService.getAnonymousOrLoggedUser() | ||
388 | .pipe(tap(user => { | ||
389 | this.userMiniature = user | ||
390 | |||
391 | if (!this.loadUserVideoPreferences) return | ||
392 | |||
393 | this.languageOneOf = user.videoLanguages | ||
394 | this.nsfwPolicy = user.nsfwPolicy | ||
395 | })) | ||
396 | } | ||
397 | |||
398 | private getUrlWithoutParams () { | ||
399 | const urlTree = this.router.parseUrl(this.router.url) | ||
400 | urlTree.queryParams = {} | ||
401 | |||
402 | return urlTree.toString() | ||
403 | } | ||
404 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/index.ts b/client/src/app/shared/shared-video-miniature/index.ts index a8fd82bb9..0086d8e6a 100644 --- a/client/src/app/shared/shared-video-miniature/index.ts +++ b/client/src/app/shared/shared-video-miniature/index.ts | |||
@@ -1,7 +1,8 @@ | |||
1 | export * from './abstract-video-list' | ||
2 | export * from './video-actions-dropdown.component' | 1 | export * from './video-actions-dropdown.component' |
3 | export * from './video-download.component' | 2 | export * from './video-download.component' |
3 | export * from './video-filters-header.component' | ||
4 | export * from './video-filters.model' | ||
4 | export * from './video-miniature.component' | 5 | export * from './video-miniature.component' |
6 | export * from './videos-list.component' | ||
5 | export * from './videos-selection.component' | 7 | export * from './videos-selection.component' |
6 | export * from './video-list-header.component' | ||
7 | export * from './shared-video-miniature.module' | 8 | export * from './shared-video-miniature.module' |
diff --git a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts index 03be6d2ff..632213922 100644 --- a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts +++ b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts | |||
@@ -1,19 +1,20 @@ | |||
1 | 1 | ||
2 | import { NgModule } from '@angular/core' | 2 | import { NgModule } from '@angular/core' |
3 | import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' | ||
3 | import { SharedFormModule } from '../shared-forms' | 4 | import { SharedFormModule } from '../shared-forms' |
4 | import { SharedGlobalIconModule } from '../shared-icons' | 5 | import { SharedGlobalIconModule } from '../shared-icons' |
5 | import { SharedMainModule } from '../shared-main/shared-main.module' | 6 | import { SharedMainModule } from '../shared-main/shared-main.module' |
6 | import { SharedModerationModule } from '../shared-moderation' | 7 | import { SharedModerationModule } from '../shared-moderation' |
7 | import { SharedVideoModule } from '../shared-video' | ||
8 | import { SharedThumbnailModule } from '../shared-thumbnail' | 8 | import { SharedThumbnailModule } from '../shared-thumbnail' |
9 | import { SharedVideoModule } from '../shared-video' | ||
9 | import { SharedVideoLiveModule } from '../shared-video-live' | 10 | import { SharedVideoLiveModule } from '../shared-video-live' |
10 | import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' | 11 | import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' |
11 | import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' | 12 | import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' |
12 | import { VideoDownloadComponent } from './video-download.component' | 13 | import { VideoDownloadComponent } from './video-download.component' |
14 | import { VideoFiltersHeaderComponent } from './video-filters-header.component' | ||
13 | import { VideoMiniatureComponent } from './video-miniature.component' | 15 | import { VideoMiniatureComponent } from './video-miniature.component' |
16 | import { VideosListComponent } from './videos-list.component' | ||
14 | import { VideosSelectionComponent } from './videos-selection.component' | 17 | import { VideosSelectionComponent } from './videos-selection.component' |
15 | import { VideoListHeaderComponent } from './video-list-header.component' | ||
16 | import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' | ||
17 | 18 | ||
18 | @NgModule({ | 19 | @NgModule({ |
19 | imports: [ | 20 | imports: [ |
@@ -33,14 +34,17 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image | |||
33 | VideoDownloadComponent, | 34 | VideoDownloadComponent, |
34 | VideoMiniatureComponent, | 35 | VideoMiniatureComponent, |
35 | VideosSelectionComponent, | 36 | VideosSelectionComponent, |
36 | VideoListHeaderComponent | 37 | VideoFiltersHeaderComponent, |
38 | VideosListComponent | ||
37 | ], | 39 | ], |
38 | 40 | ||
39 | exports: [ | 41 | exports: [ |
40 | VideoActionsDropdownComponent, | 42 | VideoActionsDropdownComponent, |
41 | VideoDownloadComponent, | 43 | VideoDownloadComponent, |
42 | VideoMiniatureComponent, | 44 | VideoMiniatureComponent, |
43 | VideosSelectionComponent | 45 | VideosSelectionComponent, |
46 | VideoFiltersHeaderComponent, | ||
47 | VideosListComponent | ||
44 | ], | 48 | ], |
45 | 49 | ||
46 | providers: [ ] | 50 | providers: [ ] |
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.scss b/client/src/app/shared/shared-video-miniature/video-download.component.scss index c986228d9..bd42f4813 100644 --- a/client/src/app/shared/shared-video-miniature/video-download.component.scss +++ b/client/src/app/shared/shared-video-miniature/video-download.component.scss | |||
@@ -39,7 +39,6 @@ | |||
39 | margin-top: 20px; | 39 | margin-top: 20px; |
40 | 40 | ||
41 | .peertube-radio-container { | 41 | .peertube-radio-container { |
42 | @include peertube-radio-container; | ||
43 | @include margin-right(30px); | 42 | @include margin-right(30px); |
44 | 43 | ||
45 | display: inline-block; | 44 | display: inline-block; |
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html new file mode 100644 index 000000000..44c21c089 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html | |||
@@ -0,0 +1,131 @@ | |||
1 | <ng-template #updateSettings let-fragment> | ||
2 | <div class="label-description text-muted" i18n> | ||
3 | Update | ||
4 | <a routerLink="/my-account/settings" [fragment]="fragment"> | ||
5 | <span (click)="onAccountSettingsClick($event)">your settings</span> | ||
6 | </a | ||
7 | ></div> | ||
8 | </ng-template> | ||
9 | |||
10 | |||
11 | <div class="root" [formGroup]="form"> | ||
12 | |||
13 | <div class="first-row"> | ||
14 | <div class="active-filters"> | ||
15 | <div | ||
16 | class="pastille filters-toggle" (click)="areFiltersCollapsed = !areFiltersCollapsed" role="button" | ||
17 | [attr.aria-expanded]="!areFiltersCollapsed" aria-controls="collapseBasic" | ||
18 | [ngClass]="{ active: !areFiltersCollapsed }" | ||
19 | > | ||
20 | <ng-container i18n *ngIf="areFiltersCollapsed">More filters</ng-container> | ||
21 | <ng-container i18n *ngIf="!areFiltersCollapsed">Less filters</ng-container> | ||
22 | |||
23 | <my-global-icon iconName="chevrons-up"></my-global-icon> | ||
24 | </div> | ||
25 | |||
26 | <div | ||
27 | *ngFor="let activeFilter of filters.getActiveFilters()" (click)="resetFilter(activeFilter.key, activeFilter.canRemove)" | ||
28 | class="active-filter pastille" [ngClass]="{ 'can-remove': activeFilter.canRemove }" [title]="getFilterTitle(activeFilter.canRemove)" | ||
29 | > | ||
30 | <span> | ||
31 | {{ activeFilter.label }} | ||
32 | |||
33 | <ng-container *ngIf="activeFilter.value">: {{ activeFilter.value }}</ng-container> | ||
34 | </span> | ||
35 | |||
36 | <my-global-icon *ngIf="activeFilter.canRemove" iconName="cross"></my-global-icon> | ||
37 | </div> | ||
38 | </div> | ||
39 | |||
40 | <ng-select | ||
41 | class="sort" | ||
42 | formControlName="sort" | ||
43 | [clearable]="false" | ||
44 | [searchable]="false" | ||
45 | > | ||
46 | <ng-option i18n value="-publishedAt">Sort by <strong>"Recently Added"</strong></ng-option> | ||
47 | |||
48 | <ng-option i18n *ngIf="isTrendingSortEnabled('most-viewed')" value="-trending">Sort by <strong>"Views"</strong></ng-option> | ||
49 | <ng-option i18n *ngIf="isTrendingSortEnabled('hot')" value="-hot">Sort by <strong>"Hot"</strong></ng-option> | ||
50 | <ng-option i18n *ngIf="isTrendingSortEnabled('best')" value="-best">Sort by <strong>"Best"</strong></ng-option> | ||
51 | <ng-option i18n *ngIf="isTrendingSortEnabled('most-liked')" value="-likes">Sort by <strong>"Likes"</strong></ng-option> | ||
52 | </ng-select> | ||
53 | |||
54 | </div> | ||
55 | |||
56 | <div class="collapse-transition" [ngbCollapse]="areFiltersCollapsed"> | ||
57 | <div class="filters"> | ||
58 | <div class="form-group"> | ||
59 | <label class="with-description" for="languageOneOf" i18n>Languages:</label> | ||
60 | <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-languages-subtitles' }"></ng-template> | ||
61 | |||
62 | <my-select-languages [maxLanguages]="20" formControlName="languageOneOf"></my-select-languages> | ||
63 | </div> | ||
64 | |||
65 | <div class="form-group"> | ||
66 | <label class="with-description" for="nsfw" i18n>Sensitive content:</label> | ||
67 | <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-sensitive-content-policy' }"></ng-template> | ||
68 | |||
69 | <div class="peertube-radio-container"> | ||
70 | <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwBoth" i18n-value value="both" /> | ||
71 | <label for="nsfwBoth">{{ filters.getNSFWDisplayLabel() }}</label> | ||
72 | </div> | ||
73 | |||
74 | <div class="peertube-radio-container"> | ||
75 | <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwFalse" i18n-value value="false" /> | ||
76 | <label for="nsfwFalse" i18n>Hide</label> | ||
77 | </div> | ||
78 | </div> | ||
79 | |||
80 | <div class="form-group"> | ||
81 | <label for="scope" i18n>Scope:</label> | ||
82 | |||
83 | <div class="peertube-radio-container"> | ||
84 | <input formControlName="scope" type="radio" name="scope" id="scopeLocal" i18n-value value="local" /> | ||
85 | <label for="scopeLocal" i18n>Local videos (this instance)</label> | ||
86 | </div> | ||
87 | |||
88 | <div class="peertube-radio-container"> | ||
89 | <input formControlName="scope" type="radio" name="scope" id="scopeFederated" i18n-value value="federated" /> | ||
90 | <label for="scopeFederated" i18n>Federated videos (this instance + followed instances)</label> | ||
91 | </div> | ||
92 | </div> | ||
93 | |||
94 | <div class="form-group"> | ||
95 | <label for="type" i18n>Type:</label> | ||
96 | |||
97 | <div class="peertube-radio-container"> | ||
98 | <input formControlName="live" type="radio" name="live" id="liveBoth" i18n-value value="both" /> | ||
99 | <label for="liveBoth" i18n>VOD & Live videos</label> | ||
100 | </div> | ||
101 | |||
102 | <div class="peertube-radio-container"> | ||
103 | <input formControlName="live" type="radio" name="live" id="liveTrue" i18n-value value="true" /> | ||
104 | <label for="liveTrue" i18n>Live videos</label> | ||
105 | </div> | ||
106 | |||
107 | <div class="peertube-radio-container"> | ||
108 | <input formControlName="live" type="radio" name="live" id="liveFalse" i18n-value value="false" /> | ||
109 | <label for="liveFalse" i18n>VOD videos</label> | ||
110 | </div> | ||
111 | </div> | ||
112 | |||
113 | <div class="form-group"> | ||
114 | <label for="categoryOneOf" i18n>Categories:</label> | ||
115 | |||
116 | <my-select-categories formControlName="categoryOneOf"></my-select-categories> | ||
117 | </div> | ||
118 | |||
119 | <div class="form-group" *ngIf="canSeeAllVideos()"> | ||
120 | <label for="allVideos" i18n>Moderation:</label> | ||
121 | |||
122 | <my-peertube-checkbox | ||
123 | formControlName="allVideos" | ||
124 | inputName="allVideos" | ||
125 | i18n-labelText labelText="Display all videos (private, unlisted or not yet published)" | ||
126 | ></my-peertube-checkbox> | ||
127 | </div> | ||
128 | </div> | ||
129 | </div> | ||
130 | |||
131 | </div> | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss new file mode 100644 index 000000000..8cb1ff5b8 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss | |||
@@ -0,0 +1,139 @@ | |||
1 | @use '_variables' as *; | ||
2 | @use '_mixins' as *; | ||
3 | |||
4 | .root { | ||
5 | margin-bottom: 45px; | ||
6 | font-size: 15px; | ||
7 | } | ||
8 | |||
9 | .first-row { | ||
10 | display: flex; | ||
11 | justify-content: space-between; | ||
12 | } | ||
13 | |||
14 | .active-filters { | ||
15 | display: flex; | ||
16 | flex-wrap: wrap; | ||
17 | } | ||
18 | |||
19 | .filters { | ||
20 | display: flex; | ||
21 | flex-wrap: wrap; | ||
22 | margin-top: 25px; | ||
23 | |||
24 | border-bottom: 1px solid $separator-border-color; | ||
25 | |||
26 | input[type=radio] + label { | ||
27 | font-weight: $font-regular; | ||
28 | } | ||
29 | |||
30 | .form-group > label:first-child { | ||
31 | display: block; | ||
32 | |||
33 | &.with-description { | ||
34 | margin-bottom: 0; | ||
35 | } | ||
36 | |||
37 | &:not(.with-description) { | ||
38 | margin-bottom: 10px; | ||
39 | } | ||
40 | } | ||
41 | |||
42 | .form-group { | ||
43 | @include margin-right(30px); | ||
44 | } | ||
45 | } | ||
46 | |||
47 | .pastille { | ||
48 | @include margin-right(15px); | ||
49 | |||
50 | border-radius: 24px; | ||
51 | padding: 4px 15px; | ||
52 | font-size: 16px; | ||
53 | margin-bottom: 15px; | ||
54 | cursor: pointer; | ||
55 | } | ||
56 | |||
57 | .filters-toggle { | ||
58 | border: 2px solid pvar(--mainForegroundColor); | ||
59 | |||
60 | my-global-icon { | ||
61 | @include margin-left(5px); | ||
62 | } | ||
63 | |||
64 | &.active my-global-icon { | ||
65 | position: relative; | ||
66 | top: -1px; | ||
67 | } | ||
68 | |||
69 | &:not(.active) { | ||
70 | my-global-icon ::ng-deep svg { | ||
71 | transform: rotate(180deg); | ||
72 | } | ||
73 | } | ||
74 | } | ||
75 | |||
76 | // Than have an icon | ||
77 | .filters-toggle, | ||
78 | .active-filter.can-remove { | ||
79 | padding: 4px 11px 4px 15px; | ||
80 | } | ||
81 | |||
82 | .active-filter { | ||
83 | background-color: pvar(--channelBackgroundColor); | ||
84 | display: flex; | ||
85 | align-items: center; | ||
86 | |||
87 | &:not(.can-remove) { | ||
88 | cursor: default; | ||
89 | } | ||
90 | |||
91 | &.can-remove:hover { | ||
92 | opacity: 0.9; | ||
93 | } | ||
94 | |||
95 | my-global-icon { | ||
96 | @include margin-left(10px); | ||
97 | |||
98 | width: 16px; | ||
99 | color: pvar(--greyForegroundColor); | ||
100 | } | ||
101 | } | ||
102 | |||
103 | .sort { | ||
104 | min-width: 200px; | ||
105 | max-width: 300px; | ||
106 | height: min-content; | ||
107 | |||
108 | ::ng-deep { | ||
109 | .ng-select-container { | ||
110 | height: 33px !important; | ||
111 | } | ||
112 | |||
113 | .ng-value strong { | ||
114 | @include margin-left(5px); | ||
115 | } | ||
116 | } | ||
117 | } | ||
118 | |||
119 | my-select-languages, | ||
120 | my-select-categories { | ||
121 | width: 300px; | ||
122 | display: inline-block; | ||
123 | } | ||
124 | |||
125 | .label-description { | ||
126 | font-size: 12px; | ||
127 | font-style: italic; | ||
128 | margin-bottom: 10px; | ||
129 | |||
130 | a { | ||
131 | color: pvar(--mainColor); | ||
132 | } | ||
133 | } | ||
134 | |||
135 | @media screen and (max-width: $small-view) { | ||
136 | .first-row { | ||
137 | flex-direction: column; | ||
138 | } | ||
139 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts new file mode 100644 index 000000000..99f133e54 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.ts | |||
@@ -0,0 +1,119 @@ | |||
1 | import * as debug from 'debug' | ||
2 | import { Subscription } from 'rxjs' | ||
3 | import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | ||
4 | import { FormBuilder, FormGroup } from '@angular/forms' | ||
5 | import { AuthService } from '@app/core' | ||
6 | import { ServerService } from '@app/core/server/server.service' | ||
7 | import { UserRight } from '@shared/models' | ||
8 | import { NSFWPolicyType } from '@shared/models/videos' | ||
9 | import { PeertubeModalService } from '../shared-main' | ||
10 | import { VideoFilters } from './video-filters.model' | ||
11 | |||
12 | const logger = debug('peertube:videos:VideoFiltersHeaderComponent') | ||
13 | |||
14 | @Component({ | ||
15 | selector: 'my-video-filters-header', | ||
16 | styleUrls: [ './video-filters-header.component.scss' ], | ||
17 | templateUrl: './video-filters-header.component.html' | ||
18 | }) | ||
19 | export class VideoFiltersHeaderComponent implements OnInit, OnDestroy { | ||
20 | @Input() filters: VideoFilters | ||
21 | |||
22 | @Input() displayModerationBlock = false | ||
23 | |||
24 | @Input() defaultSort = '-publishedAt' | ||
25 | @Input() nsfwPolicy: NSFWPolicyType | ||
26 | |||
27 | @Output() filtersChanged = new EventEmitter() | ||
28 | |||
29 | areFiltersCollapsed = true | ||
30 | |||
31 | form: FormGroup | ||
32 | |||
33 | private routeSub: Subscription | ||
34 | |||
35 | constructor ( | ||
36 | private auth: AuthService, | ||
37 | private serverService: ServerService, | ||
38 | private fb: FormBuilder, | ||
39 | private modalService: PeertubeModalService | ||
40 | ) { | ||
41 | } | ||
42 | |||
43 | ngOnInit () { | ||
44 | this.form = this.fb.group({ | ||
45 | sort: [ '' ], | ||
46 | nsfw: [ '' ], | ||
47 | languageOneOf: [ '' ], | ||
48 | categoryOneOf: [ '' ], | ||
49 | scope: [ '' ], | ||
50 | allVideos: [ '' ], | ||
51 | live: [ '' ] | ||
52 | }) | ||
53 | |||
54 | this.patchForm(false) | ||
55 | |||
56 | this.filters.onChange(() => { | ||
57 | this.patchForm(false) | ||
58 | }) | ||
59 | |||
60 | this.form.valueChanges.subscribe(values => { | ||
61 | logger('Loading values from form: %O', values) | ||
62 | |||
63 | this.filters.load(values) | ||
64 | this.filtersChanged.emit() | ||
65 | }) | ||
66 | } | ||
67 | |||
68 | ngOnDestroy () { | ||
69 | if (this.routeSub) this.routeSub.unsubscribe() | ||
70 | } | ||
71 | |||
72 | canSeeAllVideos () { | ||
73 | if (!this.auth.isLoggedIn()) return false | ||
74 | if (!this.displayModerationBlock) return false | ||
75 | |||
76 | return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS) | ||
77 | } | ||
78 | |||
79 | isTrendingSortEnabled (sort: 'most-viewed' | 'hot' | 'best' | 'most-liked') { | ||
80 | const serverConfig = this.serverService.getHTMLConfig() | ||
81 | |||
82 | const enabled = serverConfig.trending.videos.algorithms.enabled.includes(sort) | ||
83 | |||
84 | // Best is adapted from the user | ||
85 | if (sort === 'best') return enabled && this.auth.isLoggedIn() | ||
86 | |||
87 | return enabled | ||
88 | } | ||
89 | |||
90 | resetFilter (key: string, canRemove: boolean) { | ||
91 | if (!canRemove) return | ||
92 | |||
93 | this.filters.reset(key) | ||
94 | this.patchForm(false) | ||
95 | this.filtersChanged.emit() | ||
96 | } | ||
97 | |||
98 | getFilterTitle (canRemove: boolean) { | ||
99 | if (canRemove) return $localize`Remove this filter` | ||
100 | |||
101 | return '' | ||
102 | } | ||
103 | |||
104 | onAccountSettingsClick (event: Event) { | ||
105 | if (this.auth.isLoggedIn()) return | ||
106 | |||
107 | event.preventDefault() | ||
108 | event.stopPropagation() | ||
109 | |||
110 | this.modalService.openQuickSettingsSubject.next() | ||
111 | } | ||
112 | |||
113 | private patchForm (emitEvent: boolean) { | ||
114 | const defaultValues = this.filters.toFormObject() | ||
115 | this.form.patchValue(defaultValues, { emitEvent }) | ||
116 | |||
117 | logger('Patched form: %O', defaultValues) | ||
118 | } | ||
119 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-filters.model.ts b/client/src/app/shared/shared-video-miniature/video-filters.model.ts new file mode 100644 index 000000000..a3b8129f0 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-filters.model.ts | |||
@@ -0,0 +1,240 @@ | |||
1 | import { intoArray, toBoolean } from '@app/helpers' | ||
2 | import { AttributesOnly } from '@shared/core-utils' | ||
3 | import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models' | ||
4 | |||
5 | type VideoFiltersKeys = { | ||
6 | [ id in keyof AttributesOnly<VideoFilters> ]: any | ||
7 | } | ||
8 | |||
9 | export type VideoFilterScope = 'local' | 'federated' | ||
10 | |||
11 | export class VideoFilters { | ||
12 | sort: VideoSortField | ||
13 | nsfw: BooleanBothQuery | ||
14 | |||
15 | languageOneOf: string[] | ||
16 | categoryOneOf: number[] | ||
17 | |||
18 | scope: VideoFilterScope | ||
19 | allVideos: boolean | ||
20 | |||
21 | live: BooleanBothQuery | ||
22 | |||
23 | search: string | ||
24 | |||
25 | private defaultValues = new Map<keyof VideoFilters, any>([ | ||
26 | [ 'sort', '-publishedAt' ], | ||
27 | [ 'nsfw', 'false' ], | ||
28 | [ 'languageOneOf', undefined ], | ||
29 | [ 'categoryOneOf', undefined ], | ||
30 | [ 'scope', 'federated' ], | ||
31 | [ 'allVideos', false ], | ||
32 | [ 'live', 'both' ] | ||
33 | ]) | ||
34 | |||
35 | private activeFilters: { key: string, canRemove: boolean, label: string, value?: string }[] = [] | ||
36 | private defaultNSFWPolicy: NSFWPolicyType | ||
37 | |||
38 | private onChangeCallbacks: Array<() => void> = [] | ||
39 | private oldFormObjectString: string | ||
40 | |||
41 | constructor (defaultSort: string, defaultScope: VideoFilterScope) { | ||
42 | this.setDefaultSort(defaultSort) | ||
43 | this.setDefaultScope(defaultScope) | ||
44 | |||
45 | this.reset() | ||
46 | } | ||
47 | |||
48 | onChange (cb: () => void) { | ||
49 | this.onChangeCallbacks.push(cb) | ||
50 | } | ||
51 | |||
52 | triggerChange () { | ||
53 | // Don't run on change if the values did not change | ||
54 | const currentFormObjectString = JSON.stringify(this.toFormObject()) | ||
55 | if (this.oldFormObjectString && currentFormObjectString === this.oldFormObjectString) return | ||
56 | |||
57 | this.oldFormObjectString = currentFormObjectString | ||
58 | |||
59 | for (const cb of this.onChangeCallbacks) { | ||
60 | cb() | ||
61 | } | ||
62 | } | ||
63 | |||
64 | setDefaultScope (scope: VideoFilterScope) { | ||
65 | this.defaultValues.set('scope', scope) | ||
66 | } | ||
67 | |||
68 | setDefaultSort (sort: string) { | ||
69 | this.defaultValues.set('sort', sort) | ||
70 | } | ||
71 | |||
72 | setNSFWPolicy (nsfwPolicy: NSFWPolicyType) { | ||
73 | this.updateDefaultNSFW(nsfwPolicy) | ||
74 | } | ||
75 | |||
76 | reset (specificKey?: string) { | ||
77 | for (const [ key, value ] of this.defaultValues) { | ||
78 | if (specificKey && specificKey !== key) continue | ||
79 | |||
80 | // FIXME: typings | ||
81 | this[key as any] = value | ||
82 | } | ||
83 | |||
84 | this.buildActiveFilters() | ||
85 | } | ||
86 | |||
87 | load (obj: Partial<AttributesOnly<VideoFilters>>) { | ||
88 | if (obj.sort !== undefined) this.sort = obj.sort | ||
89 | |||
90 | if (obj.nsfw !== undefined) this.nsfw = obj.nsfw | ||
91 | |||
92 | if (obj.languageOneOf !== undefined) this.languageOneOf = intoArray(obj.languageOneOf) | ||
93 | if (obj.categoryOneOf !== undefined) this.categoryOneOf = intoArray(obj.categoryOneOf) | ||
94 | |||
95 | if (obj.scope !== undefined) this.scope = obj.scope | ||
96 | if (obj.allVideos !== undefined) this.allVideos = toBoolean(obj.allVideos) | ||
97 | |||
98 | if (obj.live !== undefined) this.live = obj.live | ||
99 | |||
100 | if (obj.search !== undefined) this.search = obj.search | ||
101 | |||
102 | this.buildActiveFilters() | ||
103 | } | ||
104 | |||
105 | buildActiveFilters () { | ||
106 | this.activeFilters = [] | ||
107 | |||
108 | this.activeFilters.push({ | ||
109 | key: 'nsfw', | ||
110 | canRemove: false, | ||
111 | label: $localize`Sensitive content`, | ||
112 | value: this.getNSFWValue() | ||
113 | }) | ||
114 | |||
115 | this.activeFilters.push({ | ||
116 | key: 'scope', | ||
117 | canRemove: false, | ||
118 | label: $localize`Scope`, | ||
119 | value: this.scope === 'federated' | ||
120 | ? $localize`Federated` | ||
121 | : $localize`Local` | ||
122 | }) | ||
123 | |||
124 | if (this.languageOneOf && this.languageOneOf.length !== 0) { | ||
125 | this.activeFilters.push({ | ||
126 | key: 'languageOneOf', | ||
127 | canRemove: true, | ||
128 | label: $localize`Languages`, | ||
129 | value: this.languageOneOf.map(l => l.toUpperCase()).join(', ') | ||
130 | }) | ||
131 | } | ||
132 | |||
133 | if (this.categoryOneOf && this.categoryOneOf.length !== 0) { | ||
134 | this.activeFilters.push({ | ||
135 | key: 'categoryOneOf', | ||
136 | canRemove: true, | ||
137 | label: $localize`Categories`, | ||
138 | value: this.categoryOneOf.join(', ') | ||
139 | }) | ||
140 | } | ||
141 | |||
142 | if (this.allVideos) { | ||
143 | this.activeFilters.push({ | ||
144 | key: 'allVideos', | ||
145 | canRemove: true, | ||
146 | label: $localize`All videos` | ||
147 | }) | ||
148 | } | ||
149 | |||
150 | if (this.live === 'true') { | ||
151 | this.activeFilters.push({ | ||
152 | key: 'live', | ||
153 | canRemove: true, | ||
154 | label: $localize`Live videos` | ||
155 | }) | ||
156 | } else if (this.live === 'false') { | ||
157 | this.activeFilters.push({ | ||
158 | key: 'live', | ||
159 | canRemove: true, | ||
160 | label: $localize`VOD videos` | ||
161 | }) | ||
162 | } | ||
163 | } | ||
164 | |||
165 | getActiveFilters () { | ||
166 | return this.activeFilters | ||
167 | } | ||
168 | |||
169 | toFormObject (): VideoFiltersKeys { | ||
170 | const result: Partial<VideoFiltersKeys> = {} | ||
171 | |||
172 | for (const [ key ] of this.defaultValues) { | ||
173 | result[key] = this[key] | ||
174 | } | ||
175 | |||
176 | return result as VideoFiltersKeys | ||
177 | } | ||
178 | |||
179 | toUrlObject () { | ||
180 | const result: { [ id: string ]: any } = {} | ||
181 | |||
182 | for (const [ key, defaultValue ] of this.defaultValues) { | ||
183 | if (this[key] !== defaultValue) { | ||
184 | result[key] = this[key] | ||
185 | } | ||
186 | } | ||
187 | |||
188 | return result | ||
189 | } | ||
190 | |||
191 | toVideosAPIObject () { | ||
192 | let filter: VideoFilter | ||
193 | |||
194 | if (this.scope === 'local' && this.allVideos) { | ||
195 | filter = 'all-local' | ||
196 | } else if (this.scope === 'federated' && this.allVideos) { | ||
197 | filter = 'all' | ||
198 | } else if (this.scope === 'local') { | ||
199 | filter = 'local' | ||
200 | } | ||
201 | |||
202 | let isLive: boolean | ||
203 | if (this.live === 'true') isLive = true | ||
204 | else if (this.live === 'false') isLive = false | ||
205 | |||
206 | return { | ||
207 | sort: this.sort, | ||
208 | nsfw: this.nsfw, | ||
209 | languageOneOf: this.languageOneOf, | ||
210 | categoryOneOf: this.categoryOneOf, | ||
211 | search: this.search, | ||
212 | filter, | ||
213 | isLive | ||
214 | } | ||
215 | } | ||
216 | |||
217 | getNSFWDisplayLabel () { | ||
218 | if (this.defaultNSFWPolicy === 'blur') return $localize`Blurred` | ||
219 | |||
220 | return $localize`Displayed` | ||
221 | } | ||
222 | |||
223 | private getNSFWValue () { | ||
224 | if (this.nsfw === 'false') return $localize`hidden` | ||
225 | if (this.defaultNSFWPolicy === 'blur') return $localize`blurred` | ||
226 | |||
227 | return $localize`displayed` | ||
228 | } | ||
229 | |||
230 | private updateDefaultNSFW (nsfwPolicy: NSFWPolicyType) { | ||
231 | const nsfw = nsfwPolicy === 'do_not_list' | ||
232 | ? 'false' | ||
233 | : 'both' | ||
234 | |||
235 | this.defaultValues.set('nsfw', nsfw) | ||
236 | this.defaultNSFWPolicy = nsfwPolicy | ||
237 | |||
238 | this.reset('nsfw') | ||
239 | } | ||
240 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-list-header.component.html b/client/src/app/shared/shared-video-miniature/video-list-header.component.html deleted file mode 100644 index 58db437b8..000000000 --- a/client/src/app/shared/shared-video-miniature/video-list-header.component.html +++ /dev/null | |||
@@ -1,5 +0,0 @@ | |||
1 | <h1 class="title-page title-page-single"> | ||
2 | <div placement="bottom" [ngbTooltip]="data.titleTooltip" container="body"> | ||
3 | {{ data.titlePage }} | ||
4 | </div> | ||
5 | </h1> \ No newline at end of file | ||
diff --git a/client/src/app/shared/shared-video-miniature/video-list-header.component.ts b/client/src/app/shared/shared-video-miniature/video-list-header.component.ts deleted file mode 100644 index fed696672..000000000 --- a/client/src/app/shared/shared-video-miniature/video-list-header.component.ts +++ /dev/null | |||
@@ -1,22 +0,0 @@ | |||
1 | import { Component, Inject, ViewEncapsulation } from '@angular/core' | ||
2 | |||
3 | export interface GenericHeaderData { | ||
4 | titlePage: string | ||
5 | titleTooltip?: string | ||
6 | } | ||
7 | |||
8 | export abstract class GenericHeaderComponent { | ||
9 | constructor (@Inject('data') public data: GenericHeaderData) {} | ||
10 | } | ||
11 | |||
12 | @Component({ | ||
13 | selector: 'my-video-list-header', | ||
14 | // eslint-disable-next-line @angular-eslint/use-component-view-encapsulation | ||
15 | encapsulation: ViewEncapsulation.None, | ||
16 | templateUrl: './video-list-header.component.html' | ||
17 | }) | ||
18 | export class VideoListHeaderComponent extends GenericHeaderComponent { | ||
19 | constructor (@Inject('data') public data: GenericHeaderData) { | ||
20 | super(data) | ||
21 | } | ||
22 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.html b/client/src/app/shared/shared-video-miniature/videos-list.component.html index 9ffeac5e8..4ccb4092c 100644 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.html +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.html | |||
@@ -1,11 +1,17 @@ | |||
1 | <div class="margin-content"> | 1 | <div class="margin-content"> |
2 | <div class="videos-header"> | 2 | <div class="videos-header"> |
3 | <ng-template #videoListHeader></ng-template> | 3 | <h1 *ngIf="displayTitle" class="title" placement="bottom" [ngbTooltip]="titleTooltip" container="body"> |
4 | {{ title }} | ||
5 | </h1> | ||
4 | 6 | ||
5 | <div class="action-block"> | 7 | <div *ngIf="syndicationItems" [ngClass]="{ 'no-title': !displayTitle }" class="title-subscription"> |
6 | <my-feed *ngIf="syndicationItems" [syndicationItems]="syndicationItems"></my-feed> | 8 | <ng-container i18n>Subscribe to RSS feed "{{ title }}"</ng-container> |
9 | |||
10 | <my-feed [syndicationItems]="syndicationItems"></my-feed> | ||
11 | </div> | ||
7 | 12 | ||
8 | <ng-container *ngFor="let action of actions"> | 13 | <div class="action-block"> |
14 | <ng-container *ngFor="let action of headerActions"> | ||
9 | <a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active"> | 15 | <a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active"> |
10 | <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container> | 16 | <ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container> |
11 | </a> | 17 | </a> |
@@ -24,27 +30,18 @@ | |||
24 | </ng-template> | 30 | </ng-template> |
25 | </ng-container> | 31 | </ng-container> |
26 | </div> | 32 | </div> |
27 | |||
28 | <div class="moderation-block" *ngIf="displayModerationBlock"> | ||
29 | <div class="c-hand" ngbDropdown placement="bottom-right auto"> | ||
30 | <my-global-icon iconName="cog" ngbDropdownToggle></my-global-icon> | ||
31 | |||
32 | <div role="menu" class="dropdown-menu" ngbDropdownMenu> | ||
33 | <div class="dropdown-item"> | ||
34 | <my-peertube-checkbox | ||
35 | (change)="toggleModerationDisplay()" | ||
36 | inputName="display-unlisted-private" i18n-labelText labelText="Display all videos (private, unlisted or not yet published)" | ||
37 | ></my-peertube-checkbox> | ||
38 | </div> | ||
39 | </div> | ||
40 | </div> | ||
41 | </div> | ||
42 | </div> | 33 | </div> |
43 | 34 | ||
35 | <my-video-filters-header | ||
36 | *ngIf="displayFilters" [displayModerationBlock]="displayModerationBlock" | ||
37 | [defaultSort]="defaultSort" [filters]="filters" | ||
38 | (filtersChanged)="onFiltersChanged(true)" | ||
39 | ></my-video-filters-header> | ||
40 | |||
44 | <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div> | 41 | <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div> |
45 | <div | 42 | <div |
46 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" | 43 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true" |
47 | class="videos" [ngClass]="{ 'display-as-row': displayAsRow() }" | 44 | class="videos" [ngClass]="{ 'display-as-row': displayAsRow }" |
48 | > | 45 | > |
49 | <ng-container *ngFor="let video of videos; trackBy: videoById;"> | 46 | <ng-container *ngFor="let video of videos; trackBy: videoById;"> |
50 | <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)"> | 47 | <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)"> |
@@ -53,7 +50,7 @@ | |||
53 | 50 | ||
54 | <div class="video-wrapper"> | 51 | <div class="video-wrapper"> |
55 | <my-video-miniature | 52 | <my-video-miniature |
56 | [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow()" | 53 | [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow" |
57 | [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions" | 54 | [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions" |
58 | (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)" | 55 | (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)" |
59 | > | 56 | > |
diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss b/client/src/app/shared/shared-video-miniature/videos-list.component.scss index 79e3c1bdf..e82ef05ba 100644 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.scss | |||
@@ -3,44 +3,57 @@ | |||
3 | @use '_mixins' as *; | 3 | @use '_mixins' as *; |
4 | @use '_miniature' as *; | 4 | @use '_miniature' as *; |
5 | 5 | ||
6 | $icon-size: 16px; | ||
7 | |||
8 | ::ng-deep my-video-list-header { | ||
9 | display: flex; | ||
10 | flex-grow: 1; | ||
11 | } | ||
12 | |||
13 | .videos-header { | 6 | .videos-header { |
14 | display: flex; | 7 | display: grid; |
15 | justify-content: space-between; | 8 | grid-template-columns: auto 1fr auto; |
16 | align-items: center; | 9 | margin-bottom: 30px; |
17 | 10 | ||
18 | my-feed { | 11 | .title, |
19 | display: inline-block; | 12 | .title-subscription { |
20 | width: calc(#{$icon-size} - 2px); | 13 | grid-column: 1; |
21 | } | 14 | } |
22 | 15 | ||
23 | .moderation-block { | 16 | .title { |
24 | @include margin-left(.4rem); | 17 | font-size: 18px; |
18 | color: pvar(--mainForegroundColor); | ||
19 | display: inline-block; | ||
20 | font-weight: $font-semibold; | ||
25 | 21 | ||
26 | display: flex; | 22 | margin-top: 30px; |
27 | justify-content: flex-end; | 23 | margin-bottom: 0; |
28 | align-items: center; | 24 | } |
25 | |||
26 | .title-subscription { | ||
27 | grid-row: 2; | ||
28 | font-size: 14px; | ||
29 | color: pvar(--greyForegroundColor); | ||
29 | 30 | ||
30 | my-global-icon { | 31 | &.no-title { |
31 | position: relative; | 32 | margin-top: 10px; |
32 | width: $icon-size; | ||
33 | } | 33 | } |
34 | } | 34 | } |
35 | |||
36 | .action-block { | ||
37 | grid-column: 3; | ||
38 | } | ||
39 | |||
40 | my-feed { | ||
41 | @include margin-left(5px); | ||
42 | |||
43 | display: inline-block; | ||
44 | width: 16px; | ||
45 | color: pvar(--mainColor); | ||
46 | position: relative; | ||
47 | top: -2px; | ||
48 | } | ||
35 | } | 49 | } |
36 | 50 | ||
37 | .date-title { | 51 | .date-title { |
38 | font-size: 16px; | 52 | font-size: 16px; |
39 | font-weight: $font-semibold; | 53 | font-weight: $font-semibold; |
40 | margin-bottom: 20px; | 54 | margin-bottom: 20px; |
41 | margin-top: -10px; | ||
42 | 55 | ||
43 | // make the element span a full grid row within .videos grid | 56 | // Make the element span a full grid row within .videos grid |
44 | grid-column: 1 / -1; | 57 | grid-column: 1 / -1; |
45 | 58 | ||
46 | &:not(:first-child) { | 59 | &:not(:first-child) { |
@@ -64,6 +77,18 @@ $icon-size: 16px; | |||
64 | } | 77 | } |
65 | 78 | ||
66 | @media screen and (max-width: $mobile-view) { | 79 | @media screen and (max-width: $mobile-view) { |
80 | .videos-header, | ||
81 | my-video-filters-header { | ||
82 | @include margin-left(15px); | ||
83 | @include margin-right(15px); | ||
84 | |||
85 | display: inline-block; | ||
86 | } | ||
87 | |||
88 | .date-title { | ||
89 | text-align: center; | ||
90 | } | ||
91 | |||
67 | .videos-header { | 92 | .videos-header { |
68 | flex-direction: column; | 93 | flex-direction: column; |
69 | align-items: center; | 94 | align-items: center; |
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts new file mode 100644 index 000000000..10de97298 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts | |||
@@ -0,0 +1,396 @@ | |||
1 | import * as debug from 'debug' | ||
2 | import { fromEvent, Observable, Subject, Subscription } from 'rxjs' | ||
3 | import { debounceTime, switchMap } from 'rxjs/operators' | ||
4 | import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core' | ||
5 | import { ActivatedRoute } from '@angular/router' | ||
6 | import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core' | ||
7 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
8 | import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils' | ||
9 | import { ResultList, UserRight, VideoSortField } from '@shared/models' | ||
10 | import { Syndication, Video } from '../shared-main' | ||
11 | import { VideoFilters, VideoFilterScope } from './video-filters.model' | ||
12 | import { MiniatureDisplayOptions } from './video-miniature.component' | ||
13 | |||
14 | const logger = debug('peertube:videos:VideosListComponent') | ||
15 | |||
16 | export type HeaderAction = { | ||
17 | iconName: GlobalIconName | ||
18 | label: string | ||
19 | justIcon?: boolean | ||
20 | routerLink?: string | ||
21 | href?: string | ||
22 | click?: (e: Event) => void | ||
23 | } | ||
24 | |||
25 | enum GroupDate { | ||
26 | UNKNOWN = 0, | ||
27 | TODAY = 1, | ||
28 | YESTERDAY = 2, | ||
29 | THIS_WEEK = 3, | ||
30 | THIS_MONTH = 4, | ||
31 | LAST_MONTH = 5, | ||
32 | OLDER = 6 | ||
33 | } | ||
34 | |||
35 | @Component({ | ||
36 | selector: 'my-videos-list', | ||
37 | templateUrl: './videos-list.component.html', | ||
38 | styleUrls: [ './videos-list.component.scss' ] | ||
39 | }) | ||
40 | export class VideosListComponent implements OnInit, OnChanges, OnDestroy { | ||
41 | @Input() getVideosObservableFunction: (pagination: ComponentPaginationLight, filters: VideoFilters) => Observable<ResultList<Video>> | ||
42 | @Input() getSyndicationItemsFunction: (filters: VideoFilters) => Promise<Syndication[]> | Syndication[] | ||
43 | @Input() baseRouteBuilderFunction: (filters: VideoFilters) => string[] | ||
44 | |||
45 | @Input() title: string | ||
46 | @Input() titleTooltip: string | ||
47 | @Input() displayTitle = true | ||
48 | |||
49 | @Input() defaultSort: VideoSortField | ||
50 | @Input() defaultScope: VideoFilterScope = 'federated' | ||
51 | @Input() displayFilters = false | ||
52 | @Input() displayModerationBlock = false | ||
53 | |||
54 | @Input() loadUserVideoPreferences = false | ||
55 | |||
56 | @Input() displayAsRow = false | ||
57 | @Input() displayVideoActions = true | ||
58 | @Input() groupByDate = false | ||
59 | |||
60 | @Input() headerActions: HeaderAction[] = [] | ||
61 | |||
62 | @Input() displayOptions: MiniatureDisplayOptions = { | ||
63 | date: true, | ||
64 | views: true, | ||
65 | by: true, | ||
66 | avatar: false, | ||
67 | privacyLabel: true, | ||
68 | privacyText: false, | ||
69 | state: false, | ||
70 | blacklistInfo: false | ||
71 | } | ||
72 | |||
73 | @Input() disabled = false | ||
74 | |||
75 | @Output() filtersChanged = new EventEmitter<VideoFilters>() | ||
76 | |||
77 | videos: Video[] = [] | ||
78 | filters: VideoFilters | ||
79 | syndicationItems: Syndication[] | ||
80 | |||
81 | onDataSubject = new Subject<any[]>() | ||
82 | hasDoneFirstQuery = false | ||
83 | |||
84 | userMiniature: User | ||
85 | |||
86 | private routeSub: Subscription | ||
87 | private userSub: Subscription | ||
88 | private resizeSub: Subscription | ||
89 | |||
90 | private pagination: ComponentPaginationLight = { | ||
91 | currentPage: 1, | ||
92 | itemsPerPage: 25 | ||
93 | } | ||
94 | |||
95 | private groupedDateLabels: { [id in GroupDate]: string } | ||
96 | private groupedDates: { [id: number]: GroupDate } = {} | ||
97 | |||
98 | private lastQueryLength: number | ||
99 | |||
100 | constructor ( | ||
101 | private notifier: Notifier, | ||
102 | private authService: AuthService, | ||
103 | private userService: UserService, | ||
104 | private route: ActivatedRoute, | ||
105 | private screenService: ScreenService, | ||
106 | private peertubeRouter: PeerTubeRouterService | ||
107 | ) { | ||
108 | |||
109 | } | ||
110 | |||
111 | ngOnInit () { | ||
112 | this.filters = new VideoFilters(this.defaultSort, this.defaultScope) | ||
113 | this.filters.load({ ...this.route.snapshot.queryParams, scope: this.defaultScope }) | ||
114 | |||
115 | this.groupedDateLabels = { | ||
116 | [GroupDate.UNKNOWN]: null, | ||
117 | [GroupDate.TODAY]: $localize`Today`, | ||
118 | [GroupDate.YESTERDAY]: $localize`Yesterday`, | ||
119 | [GroupDate.THIS_WEEK]: $localize`This week`, | ||
120 | [GroupDate.THIS_MONTH]: $localize`This month`, | ||
121 | [GroupDate.LAST_MONTH]: $localize`Last month`, | ||
122 | [GroupDate.OLDER]: $localize`Older` | ||
123 | } | ||
124 | |||
125 | this.resizeSub = fromEvent(window, 'resize') | ||
126 | .pipe(debounceTime(500)) | ||
127 | .subscribe(() => this.calcPageSizes()) | ||
128 | |||
129 | this.calcPageSizes() | ||
130 | |||
131 | this.userService.getAnonymousOrLoggedUser() | ||
132 | .subscribe(user => { | ||
133 | this.userMiniature = user | ||
134 | |||
135 | if (this.loadUserVideoPreferences) { | ||
136 | this.loadUserSettings(user) | ||
137 | } | ||
138 | |||
139 | this.scheduleOnFiltersChanged(false) | ||
140 | |||
141 | this.subscribeToAnonymousUpdate() | ||
142 | this.subscribeToSearchChange() | ||
143 | }) | ||
144 | |||
145 | // Display avatar in mobile view | ||
146 | if (this.screenService.isInMobileView()) { | ||
147 | this.displayOptions.avatar = true | ||
148 | } | ||
149 | } | ||
150 | |||
151 | ngOnDestroy () { | ||
152 | if (this.resizeSub) this.resizeSub.unsubscribe() | ||
153 | if (this.routeSub) this.routeSub.unsubscribe() | ||
154 | if (this.userSub) this.userSub.unsubscribe() | ||
155 | } | ||
156 | |||
157 | ngOnChanges (changes: SimpleChanges) { | ||
158 | if (!this.filters) return | ||
159 | |||
160 | let updated = false | ||
161 | |||
162 | if (changes['defaultScope']) { | ||
163 | updated = true | ||
164 | this.filters.setDefaultScope(this.defaultScope) | ||
165 | } | ||
166 | |||
167 | if (changes['defaultSort']) { | ||
168 | updated = true | ||
169 | this.filters.setDefaultSort(this.defaultSort) | ||
170 | } | ||
171 | |||
172 | if (!updated) return | ||
173 | |||
174 | const customizedByUser = this.hasBeenCustomizedByUser() | ||
175 | |||
176 | if (!customizedByUser) { | ||
177 | if (this.loadUserVideoPreferences) { | ||
178 | this.loadUserSettings(this.userMiniature) | ||
179 | } | ||
180 | |||
181 | this.filters.reset('scope') | ||
182 | this.filters.reset('sort') | ||
183 | } | ||
184 | |||
185 | this.scheduleOnFiltersChanged(customizedByUser) | ||
186 | } | ||
187 | |||
188 | videoById (_index: number, video: Video) { | ||
189 | return video.id | ||
190 | } | ||
191 | |||
192 | onNearOfBottom () { | ||
193 | if (this.disabled) return | ||
194 | |||
195 | // No more results | ||
196 | if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return | ||
197 | |||
198 | this.pagination.currentPage += 1 | ||
199 | |||
200 | this.loadMoreVideos() | ||
201 | } | ||
202 | |||
203 | loadMoreVideos (reset = false) { | ||
204 | this.getVideosObservableFunction(this.pagination, this.filters) | ||
205 | .subscribe({ | ||
206 | next: ({ data }) => { | ||
207 | this.hasDoneFirstQuery = true | ||
208 | this.lastQueryLength = data.length | ||
209 | |||
210 | if (reset) this.videos = [] | ||
211 | this.videos = this.videos.concat(data) | ||
212 | |||
213 | if (this.groupByDate) this.buildGroupedDateLabels() | ||
214 | |||
215 | this.onDataSubject.next(data) | ||
216 | }, | ||
217 | |||
218 | error: err => { | ||
219 | const message = $localize`Cannot load more videos. Try again later.` | ||
220 | |||
221 | console.error(message, { err }) | ||
222 | this.notifier.error(message) | ||
223 | } | ||
224 | }) | ||
225 | } | ||
226 | |||
227 | reloadVideos () { | ||
228 | this.pagination.currentPage = 1 | ||
229 | this.loadMoreVideos(true) | ||
230 | } | ||
231 | |||
232 | removeVideoFromArray (video: Video) { | ||
233 | this.videos = this.videos.filter(v => v.id !== video.id) | ||
234 | } | ||
235 | |||
236 | buildGroupedDateLabels () { | ||
237 | let currentGroupedDate: GroupDate = GroupDate.UNKNOWN | ||
238 | |||
239 | const periods = [ | ||
240 | { | ||
241 | value: GroupDate.TODAY, | ||
242 | validator: (d: Date) => isToday(d) | ||
243 | }, | ||
244 | { | ||
245 | value: GroupDate.YESTERDAY, | ||
246 | validator: (d: Date) => isYesterday(d) | ||
247 | }, | ||
248 | { | ||
249 | value: GroupDate.THIS_WEEK, | ||
250 | validator: (d: Date) => isLastWeek(d) | ||
251 | }, | ||
252 | { | ||
253 | value: GroupDate.THIS_MONTH, | ||
254 | validator: (d: Date) => isThisMonth(d) | ||
255 | }, | ||
256 | { | ||
257 | value: GroupDate.LAST_MONTH, | ||
258 | validator: (d: Date) => isLastMonth(d) | ||
259 | }, | ||
260 | { | ||
261 | value: GroupDate.OLDER, | ||
262 | validator: () => true | ||
263 | } | ||
264 | ] | ||
265 | |||
266 | for (const video of this.videos) { | ||
267 | const publishedDate = video.publishedAt | ||
268 | |||
269 | for (let i = 0; i < periods.length; i++) { | ||
270 | const period = periods[i] | ||
271 | |||
272 | if (currentGroupedDate <= period.value && period.validator(publishedDate)) { | ||
273 | |||
274 | if (currentGroupedDate !== period.value) { | ||
275 | currentGroupedDate = period.value | ||
276 | this.groupedDates[video.id] = currentGroupedDate | ||
277 | } | ||
278 | |||
279 | break | ||
280 | } | ||
281 | } | ||
282 | } | ||
283 | } | ||
284 | |||
285 | getCurrentGroupedDateLabel (video: Video) { | ||
286 | if (this.groupByDate === false) return undefined | ||
287 | |||
288 | return this.groupedDateLabels[this.groupedDates[video.id]] | ||
289 | } | ||
290 | |||
291 | scheduleOnFiltersChanged (customizedByUser: boolean) { | ||
292 | // We'll reload videos, but avoid weird UI effect | ||
293 | this.videos = [] | ||
294 | |||
295 | setTimeout(() => this.onFiltersChanged(customizedByUser)) | ||
296 | } | ||
297 | |||
298 | onFiltersChanged (customizedByUser: boolean) { | ||
299 | logger('Running on filters changed') | ||
300 | |||
301 | this.updateUrl(customizedByUser) | ||
302 | |||
303 | this.filters.triggerChange() | ||
304 | |||
305 | this.reloadSyndicationItems() | ||
306 | this.reloadVideos() | ||
307 | } | ||
308 | |||
309 | protected enableAllFilterIfPossible () { | ||
310 | if (!this.authService.isLoggedIn()) return | ||
311 | |||
312 | this.authService.userInformationLoaded | ||
313 | .subscribe(() => { | ||
314 | const user = this.authService.getUser() | ||
315 | this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) | ||
316 | }) | ||
317 | } | ||
318 | |||
319 | private calcPageSizes () { | ||
320 | if (this.screenService.isInMobileView()) { | ||
321 | this.pagination.itemsPerPage = 5 | ||
322 | } | ||
323 | } | ||
324 | |||
325 | private loadUserSettings (user: User) { | ||
326 | this.filters.setNSFWPolicy(user.nsfwPolicy) | ||
327 | |||
328 | // Don't reset language filter if we don't want to refresh the component | ||
329 | if (!this.hasBeenCustomizedByUser()) { | ||
330 | this.filters.load({ languageOneOf: user.videoLanguages }) | ||
331 | } | ||
332 | } | ||
333 | |||
334 | private reloadSyndicationItems () { | ||
335 | Promise.resolve(this.getSyndicationItemsFunction(this.filters)) | ||
336 | .then(items => { | ||
337 | if (!items || items.length === 0) this.syndicationItems = undefined | ||
338 | else this.syndicationItems = items | ||
339 | }) | ||
340 | .catch(err => console.error('Cannot get syndication items.', err)) | ||
341 | } | ||
342 | |||
343 | private updateUrl (customizedByUser: boolean) { | ||
344 | const baseQuery = this.filters.toUrlObject() | ||
345 | |||
346 | // Set or reset customized by user query param | ||
347 | const queryParams = customizedByUser || this.hasBeenCustomizedByUser() | ||
348 | ? { ...baseQuery, c: customizedByUser } | ||
349 | : baseQuery | ||
350 | |||
351 | logger('Will inject %O in URL query', queryParams) | ||
352 | |||
353 | const baseRoute = this.baseRouteBuilderFunction | ||
354 | ? this.baseRouteBuilderFunction(this.filters) | ||
355 | : [] | ||
356 | |||
357 | const pathname = window.location.pathname | ||
358 | |||
359 | const baseRouteChanged = baseRoute.length !== 0 && | ||
360 | pathname !== '/' && // Exclude special '/' case, we'll be redirected without component change | ||
361 | baseRoute.length !== 0 && pathname !== baseRoute.join('/') | ||
362 | |||
363 | if (baseRouteChanged || Object.keys(baseQuery).length !== 0 || customizedByUser) { | ||
364 | this.peertubeRouter.silentNavigate(baseRoute, queryParams) | ||
365 | } | ||
366 | |||
367 | this.filtersChanged.emit(this.filters) | ||
368 | } | ||
369 | |||
370 | private hasBeenCustomizedByUser () { | ||
371 | return this.route.snapshot.queryParams['c'] === 'true' | ||
372 | } | ||
373 | |||
374 | private subscribeToAnonymousUpdate () { | ||
375 | this.userSub = this.userService.listenAnonymousUpdate() | ||
376 | .pipe(switchMap(() => this.userService.getAnonymousOrLoggedUser())) | ||
377 | .subscribe(user => { | ||
378 | if (this.loadUserVideoPreferences) { | ||
379 | this.loadUserSettings(user) | ||
380 | } | ||
381 | |||
382 | if (this.hasDoneFirstQuery) { | ||
383 | this.reloadVideos() | ||
384 | } | ||
385 | }) | ||
386 | } | ||
387 | |||
388 | private subscribeToSearchChange () { | ||
389 | this.routeSub = this.route.queryParams.subscribe(param => { | ||
390 | if (!param['search']) return | ||
391 | |||
392 | this.filters.load({ search: param['search'] }) | ||
393 | this.onFiltersChanged(true) | ||
394 | }) | ||
395 | } | ||
396 | } | ||
diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.html b/client/src/app/shared/shared-video-miniature/videos-selection.component.html index 4ee90ce7f..f2af874dd 100644 --- a/client/src/app/shared/shared-video-miniature/videos-selection.component.html +++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.html | |||
@@ -1,6 +1,9 @@ | |||
1 | <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">{{ noResultMessage }}</div> | 1 | <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">{{ noResultMessage }}</div> |
2 | 2 | ||
3 | <div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos"> | 3 | <div |
4 | class="videos" | ||
5 | myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true" | ||
6 | > | ||
4 | <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById"> | 7 | <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById"> |
5 | 8 | ||
6 | <div class="checkbox-container" *ngIf="enableSelection"> | 9 | <div class="checkbox-container" *ngIf="enableSelection"> |
diff --git a/client/src/app/shared/shared-video-miniature/videos-selection.component.ts b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts index 456b36926..cafaf6e85 100644 --- a/client/src/app/shared/shared-video-miniature/videos-selection.component.ts +++ b/client/src/app/shared/shared-video-miniature/videos-selection.component.ts | |||
@@ -1,22 +1,8 @@ | |||
1 | import { Observable } from 'rxjs' | 1 | import { Observable, Subject } from 'rxjs' |
2 | import { | 2 | import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core' |
3 | AfterContentInit, | 3 | import { ComponentPagination, Notifier, User } from '@app/core' |
4 | Component, | ||
5 | ComponentFactoryResolver, | ||
6 | ContentChildren, | ||
7 | EventEmitter, | ||
8 | Input, | ||
9 | OnDestroy, | ||
10 | OnInit, | ||
11 | Output, | ||
12 | QueryList, | ||
13 | TemplateRef | ||
14 | } from '@angular/core' | ||
15 | import { ActivatedRoute, Router } from '@angular/router' | ||
16 | import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core' | ||
17 | import { ResultList, VideoSortField } from '@shared/models' | 4 | import { ResultList, VideoSortField } from '@shared/models' |
18 | import { PeerTubeTemplateDirective, Video } from '../shared-main' | 5 | import { PeerTubeTemplateDirective, Video } from '../shared-main' |
19 | import { AbstractVideoList } from './abstract-video-list' | ||
20 | import { MiniatureDisplayOptions } from './video-miniature.component' | 6 | import { MiniatureDisplayOptions } from './video-miniature.component' |
21 | 7 | ||
22 | export type SelectionType = { [ id: number ]: boolean } | 8 | export type SelectionType = { [ id: number ]: boolean } |
@@ -26,14 +12,18 @@ export type SelectionType = { [ id: number ]: boolean } | |||
26 | templateUrl: './videos-selection.component.html', | 12 | templateUrl: './videos-selection.component.html', |
27 | styleUrls: [ './videos-selection.component.scss' ] | 13 | styleUrls: [ './videos-selection.component.scss' ] |
28 | }) | 14 | }) |
29 | export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { | 15 | export class VideosSelectionComponent implements AfterContentInit { |
30 | @Input() user: User | 16 | @Input() user: User |
31 | @Input() pagination: ComponentPagination | 17 | @Input() pagination: ComponentPagination |
18 | |||
32 | @Input() titlePage: string | 19 | @Input() titlePage: string |
20 | |||
33 | @Input() miniatureDisplayOptions: MiniatureDisplayOptions | 21 | @Input() miniatureDisplayOptions: MiniatureDisplayOptions |
22 | |||
34 | @Input() noResultMessage = $localize`No results.` | 23 | @Input() noResultMessage = $localize`No results.` |
35 | @Input() enableSelection = true | 24 | @Input() enableSelection = true |
36 | @Input() loadOnInit = true | 25 | |
26 | @Input() disabled = false | ||
37 | 27 | ||
38 | @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>> | 28 | @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>> |
39 | 29 | ||
@@ -47,19 +37,18 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni | |||
47 | rowButtonsTemplate: TemplateRef<any> | 37 | rowButtonsTemplate: TemplateRef<any> |
48 | globalButtonsTemplate: TemplateRef<any> | 38 | globalButtonsTemplate: TemplateRef<any> |
49 | 39 | ||
40 | videos: Video[] = [] | ||
41 | sort: VideoSortField = '-publishedAt' | ||
42 | |||
43 | onDataSubject = new Subject<any[]>() | ||
44 | |||
45 | hasDoneFirstQuery = false | ||
46 | |||
47 | private lastQueryLength: number | ||
48 | |||
50 | constructor ( | 49 | constructor ( |
51 | protected router: Router, | 50 | private notifier: Notifier |
52 | protected route: ActivatedRoute, | 51 | ) { } |
53 | protected notifier: Notifier, | ||
54 | protected authService: AuthService, | ||
55 | protected userService: UserService, | ||
56 | protected screenService: ScreenService, | ||
57 | protected storageService: LocalStorageService, | ||
58 | protected serverService: ServerService, | ||
59 | protected cfr: ComponentFactoryResolver | ||
60 | ) { | ||
61 | super() | ||
62 | } | ||
63 | 52 | ||
64 | @Input() get selection () { | 53 | @Input() get selection () { |
65 | return this._selection | 54 | return this._selection |
@@ -79,10 +68,6 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni | |||
79 | this.videosModelChange.emit(this.videos) | 68 | this.videosModelChange.emit(this.videos) |
80 | } | 69 | } |
81 | 70 | ||
82 | ngOnInit () { | ||
83 | super.ngOnInit() | ||
84 | } | ||
85 | |||
86 | ngAfterContentInit () { | 71 | ngAfterContentInit () { |
87 | { | 72 | { |
88 | const t = this.templates.find(t => t.name === 'rowButtons') | 73 | const t = this.templates.find(t => t.name === 'rowButtons') |
@@ -93,10 +78,8 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni | |||
93 | const t = this.templates.find(t => t.name === 'globalButtons') | 78 | const t = this.templates.find(t => t.name === 'globalButtons') |
94 | if (t) this.globalButtonsTemplate = t.template | 79 | if (t) this.globalButtonsTemplate = t.template |
95 | } | 80 | } |
96 | } | ||
97 | 81 | ||
98 | ngOnDestroy () { | 82 | this.loadMoreVideos() |
99 | super.ngOnDestroy() | ||
100 | } | 83 | } |
101 | 84 | ||
102 | getVideosObservable (page: number) { | 85 | getVideosObservable (page: number) { |
@@ -111,11 +94,50 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni | |||
111 | return Object.keys(this._selection).some(k => this._selection[k] === true) | 94 | return Object.keys(this._selection).some(k => this._selection[k] === true) |
112 | } | 95 | } |
113 | 96 | ||
114 | generateSyndicationList () { | 97 | videoById (index: number, video: Video) { |
115 | throw new Error('Method not implemented.') | 98 | return video.id |
99 | } | ||
100 | |||
101 | onNearOfBottom () { | ||
102 | if (this.disabled) return | ||
103 | |||
104 | // No more results | ||
105 | if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return | ||
106 | |||
107 | this.pagination.currentPage += 1 | ||
108 | |||
109 | this.loadMoreVideos() | ||
110 | } | ||
111 | |||
112 | loadMoreVideos (reset = false) { | ||
113 | this.getVideosObservable(this.pagination.currentPage) | ||
114 | .subscribe({ | ||
115 | next: ({ data }) => { | ||
116 | this.hasDoneFirstQuery = true | ||
117 | this.lastQueryLength = data.length | ||
118 | |||
119 | if (reset) this.videos = [] | ||
120 | this.videos = this.videos.concat(data) | ||
121 | this.videosModel = this.videos | ||
122 | |||
123 | this.onDataSubject.next(data) | ||
124 | }, | ||
125 | |||
126 | error: err => { | ||
127 | const message = $localize`Cannot load more videos. Try again later.` | ||
128 | |||
129 | console.error(message, { err }) | ||
130 | this.notifier.error(message) | ||
131 | } | ||
132 | }) | ||
133 | } | ||
134 | |||
135 | reloadVideos () { | ||
136 | this.pagination.currentPage = 1 | ||
137 | this.loadMoreVideos(true) | ||
116 | } | 138 | } |
117 | 139 | ||
118 | protected onMoreVideos () { | 140 | removeVideoFromArray (video: Video) { |
119 | this.videosModel = this.videos | 141 | this.videos = this.videos.filter(v => v.id !== video.id) |
120 | } | 142 | } |
121 | } | 143 | } |
diff --git a/client/src/assets/images/feather/chevrons-up.svg b/client/src/assets/images/feather/chevrons-up.svg new file mode 100644 index 000000000..100fda826 --- /dev/null +++ b/client/src/assets/images/feather/chevrons-up.svg | |||
@@ -0,0 +1,4 @@ | |||
1 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevrons-up"> | ||
2 | <polyline points="17 11 12 6 7 11"></polyline> | ||
3 | <polyline points="17 18 12 13 7 18"></polyline> | ||
4 | </svg> | ||
diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss index 4f6e08c1b..4e88d9706 100644 --- a/client/src/sass/bootstrap.scss +++ b/client/src/sass/bootstrap.scss | |||
@@ -287,6 +287,7 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/'; | |||
287 | 287 | ||
288 | &.show { | 288 | &.show { |
289 | max-height: 1500px; | 289 | max-height: 1500px; |
290 | overflow: inherit !important; | ||
290 | } | 291 | } |
291 | } | 292 | } |
292 | 293 | ||
diff --git a/client/src/sass/classes.scss b/client/src/sass/classes.scss index 2d8117ee5..1cd7a6058 100644 --- a/client/src/sass/classes.scss +++ b/client/src/sass/classes.scss | |||
@@ -24,3 +24,7 @@ | |||
24 | .tertiary-button { | 24 | .tertiary-button { |
25 | @include tertiary-button; | 25 | @include tertiary-button; |
26 | } | 26 | } |
27 | |||
28 | .peertube-radio-container { | ||
29 | @include peertube-radio-container; | ||
30 | } | ||
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 4d4c52b34..9f6d69131 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss | |||
@@ -420,42 +420,55 @@ | |||
420 | } | 420 | } |
421 | } | 421 | } |
422 | 422 | ||
423 | // Thanks: https://codepen.io/triss90/pen/XNEdRe/ | 423 | // Thanks: https://codepen.io/manabox/pen/raQmpL |
424 | @mixin peertube-radio-container { | 424 | @mixin peertube-radio-container { |
425 | input[type=radio] { | 425 | [type=radio]:checked, |
426 | display: none; | 426 | [type=radio]:not(:checked) { |
427 | 427 | position: absolute; | |
428 | + label { | 428 | left: -9999px; |
429 | font-weight: $font-regular; | 429 | } |
430 | cursor: pointer; | ||
431 | 430 | ||
432 | &::before { | 431 | [type=radio]:checked + label, |
433 | @include margin-right(10px); | 432 | [type=radio]:not(:checked) + label { |
434 | 433 | position: relative; | |
435 | position: relative; | 434 | padding-left: 28px; |
436 | top: -2px; | 435 | cursor: pointer; |
437 | content: ''; | 436 | line-height: 20px; |
438 | background: #fff; | 437 | display: inline-block; |
439 | border-radius: 100%; | 438 | } |
440 | border: 1px solid #000; | ||
441 | display: inline-block; | ||
442 | width: 15px; | ||
443 | height: 15px; | ||
444 | vertical-align: middle; | ||
445 | cursor: pointer; | ||
446 | text-align: center; | ||
447 | } | ||
448 | } | ||
449 | 439 | ||
450 | &:checked + label::before { | 440 | [type=radio]:checked + label::before, |
451 | background-color: #000; | 441 | [type=radio]:not(:checked) + label::before { |
452 | box-shadow: inset 0 0 0 4px #fff; | 442 | content: ''; |
453 | } | 443 | position: absolute; |
444 | left: 0; | ||
445 | top: 0; | ||
446 | width: 18px; | ||
447 | height: 18px; | ||
448 | border: 1px solid #C6C6C6; | ||
449 | border-radius: 100%; | ||
450 | background: #fff; | ||
451 | } | ||
454 | 452 | ||
455 | &:focus + label::before { | 453 | [type=radio]:checked + label::after, |
456 | outline: none; | 454 | [type=radio]:not(:checked) + label::after { |
457 | border-color: #000; | 455 | content: ''; |
458 | } | 456 | width: 10px; |
457 | height: 10px; | ||
458 | background: pvar(--mainColor); | ||
459 | position: absolute; | ||
460 | top: 4px; | ||
461 | left: 4px; | ||
462 | border-radius: 100%; | ||
463 | transition: all 0.2s ease; | ||
464 | } | ||
465 | [type=radio]:not(:checked) + label::after { | ||
466 | opacity: 0; | ||
467 | transform: scale(0); | ||
468 | } | ||
469 | [type=radio]:checked + label::after { | ||
470 | opacity: 1; | ||
471 | transform: scale(1); | ||
459 | } | 472 | } |
460 | } | 473 | } |
461 | 474 | ||