aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2021-08-19 09:24:29 +0200
committerChocobozzz <me@florianbigard.com>2021-08-25 11:24:11 +0200
commitdd24f1bb0a4b252e5342b251ba36853364da7b8e (patch)
tree41a9506d07413f056fb90425705e258f96fdc77d /client
parent2e80d256cc75b4b02c8efc3d3e4cdf57ddf401a8 (diff)
downloadPeerTube-dd24f1bb0a4b252e5342b251ba36853364da7b8e.tar.gz
PeerTube-dd24f1bb0a4b252e5342b251ba36853364da7b8e.tar.zst
PeerTube-dd24f1bb0a4b252e5342b251ba36853364da7b8e.zip
Add video filters to common video pages
Diffstat (limited to 'client')
-rw-r--r--client/src/app/+accounts/account-search/account-search.component.ts110
-rw-r--r--client/src/app/+accounts/account-video-channels/account-video-channels.component.html2
-rw-r--r--client/src/app/+accounts/account-videos/account-videos.component.html20
-rw-r--r--client/src/app/+accounts/account-videos/account-videos.component.ts93
-rw-r--r--client/src/app/+accounts/accounts-routing.module.ts10
-rw-r--r--client/src/app/+accounts/accounts.component.html4
-rw-r--r--client/src/app/+accounts/accounts.component.scss5
-rw-r--r--client/src/app/+accounts/accounts.component.ts22
-rw-r--r--client/src/app/+accounts/accounts.module.ts6
-rw-r--r--client/src/app/+admin/plugins/plugin-list-installed/plugin-list-installed.component.html2
-rw-r--r--client/src/app/+admin/plugins/plugin-search/plugin-search.component.html2
-rw-r--r--client/src/app/+my-library/my-history/my-history.component.html4
-rw-r--r--client/src/app/+my-library/my-history/my-history.component.ts6
-rw-r--r--client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html2
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html2
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html2
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.html4
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.ts6
-rw-r--r--client/src/app/+search/search-filters.component.scss1
-rw-r--r--client/src/app/+search/search.component.html2
-rw-r--r--client/src/app/+video-channels/video-channel-playlists/video-channel-playlists.component.html2
-rw-r--r--client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.html21
-rw-r--r--client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts101
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html8
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html2
-rw-r--r--client/src/app/+videos/video-list/index.ts4
-rw-r--r--client/src/app/+videos/video-list/overview/video-overview.component.html2
-rw-r--r--client/src/app/+videos/video-list/trending/index.ts2
-rw-r--r--client/src/app/+videos/video-list/trending/video-trending-header.component.html8
-rw-r--r--client/src/app/+videos/video-list/trending/video-trending-header.component.scss20
-rw-r--r--client/src/app/+videos/video-list/trending/video-trending-header.component.ts109
-rw-r--r--client/src/app/+videos/video-list/trending/video-trending.component.ts127
-rw-r--r--client/src/app/+videos/video-list/video-local.component.ts81
-rw-r--r--client/src/app/+videos/video-list/video-recently-added.component.ts73
-rw-r--r--client/src/app/+videos/video-list/video-user-subscriptions.component.html17
-rw-r--r--client/src/app/+videos/video-list/video-user-subscriptions.component.ts133
-rw-r--r--client/src/app/+videos/video-list/videos-list-common-page.component.html22
-rw-r--r--client/src/app/+videos/video-list/videos-list-common-page.component.ts219
-rw-r--r--client/src/app/+videos/videos-routing.module.ts54
-rw-r--r--client/src/app/+videos/videos.module.ts12
-rw-r--r--client/src/app/app-routing.module.ts1
-rw-r--r--client/src/app/app.component.ts80
-rw-r--r--client/src/app/core/core.module.ts14
-rw-r--r--client/src/app/core/routing/custom-reuse-strategy.ts6
-rw-r--r--client/src/app/core/routing/index.ts2
-rw-r--r--client/src/app/core/routing/peertube-router.service.ts78
-rw-r--r--client/src/app/core/routing/scroll.service.ts91
-rw-r--r--client/src/app/helpers/utils.ts226
-rw-r--r--client/src/app/helpers/utils/channel.ts34
-rw-r--r--client/src/app/helpers/utils/date.ts25
-rw-r--r--client/src/app/helpers/utils/html.ts18
-rw-r--r--client/src/app/helpers/utils/index.ts7
-rw-r--r--client/src/app/helpers/utils/object.ts47
-rw-r--r--client/src/app/helpers/utils/ui.ts33
-rw-r--r--client/src/app/helpers/utils/upload.ts37
-rw-r--r--client/src/app/helpers/utils/url.ts71
-rw-r--r--client/src/app/shared/shared-forms/advanced-input-filter.component.ts3
-rw-r--r--client/src/app/shared/shared-forms/select/index.ts3
-rw-r--r--client/src/app/shared/shared-forms/select/select-categories.component.html8
-rw-r--r--client/src/app/shared/shared-forms/select/select-categories.component.ts71
-rw-r--r--client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts115
-rw-r--r--client/src/app/shared/shared-forms/select/select-checkbox.component.html2
-rw-r--r--client/src/app/shared/shared-forms/select/select-checkbox.component.ts7
-rw-r--r--client/src/app/shared/shared-forms/select/select-languages.component.html9
-rw-r--r--client/src/app/shared/shared-forms/select/select-languages.component.ts74
-rw-r--r--client/src/app/shared/shared-forms/shared-form.module.ts9
-rw-r--r--client/src/app/shared/shared-icons/global-icon.component.ts1
-rw-r--r--client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts22
-rw-r--r--client/src/app/shared/shared-main/feeds/feed.component.scss3
-rw-r--r--client/src/app/shared/shared-main/misc/simple-search-input.component.html23
-rw-r--r--client/src/app/shared/shared-main/misc/simple-search-input.component.scss13
-rw-r--r--client/src/app/shared/shared-main/misc/simple-search-input.component.ts32
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.html2
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts110
-rw-r--r--client/src/app/shared/shared-search/advanced-search.model.ts20
-rw-r--r--client/src/app/shared/shared-user-settings/user-video-settings.component.html7
-rw-r--r--client/src/app/shared/shared-user-settings/user-video-settings.component.scss2
-rw-r--r--client/src/app/shared/shared-user-settings/user-video-settings.component.ts93
-rw-r--r--client/src/app/shared/shared-video-miniature/abstract-video-list.ts404
-rw-r--r--client/src/app/shared/shared-video-miniature/index.ts5
-rw-r--r--client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts14
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.scss1
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.html131
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.scss139
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.ts119
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters.model.ts240
-rw-r--r--client/src/app/shared/shared-video-miniature/video-list-header.component.html5
-rw-r--r--client/src/app/shared/shared-video-miniature/video-list-header.component.ts22
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.html (renamed from client/src/app/shared/shared-video-miniature/abstract-video-list.html)41
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.scss (renamed from client/src/app/shared/shared-video-miniature/abstract-video-list.scss)71
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.ts396
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-selection.component.html5
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-selection.component.ts106
-rw-r--r--client/src/assets/images/feather/chevrons-up.svg4
-rw-r--r--client/src/sass/bootstrap.scss1
-rw-r--r--client/src/sass/classes.scss4
-rw-r--r--client/src/sass/include/_mixins.scss77
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 @@
1import { forkJoin, Subscription } from 'rxjs'
2import { first, tap } from 'rxjs/operators'
3import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
6import { immutableAssign } from '@app/helpers'
7import { Account, AccountService, VideoService } from '@app/shared/shared-main'
8import { AbstractVideoList } from '@app/shared/shared-video-miniature'
9import { 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})
16export 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 @@
1import { forkJoin, Subscription } from 'rxjs' 1import { Subscription } from 'rxjs'
2import { first } from 'rxjs/operators' 2import { first } from 'rxjs/operators'
3import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' 3import { Component, OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router' 4import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
5import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
6import { immutableAssign } from '@app/helpers'
7import { Account, AccountService, VideoService } from '@app/shared/shared-main' 5import { Account, AccountService, VideoService } from '@app/shared/shared-main'
8import { AbstractVideoList } from '@app/shared/shared-video-miniature' 6import { VideoFilters } from '@app/shared/shared-video-miniature'
9import { VideoFilter } from '@shared/models' 7import { 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})
18export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { 13export 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 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { AccountSearchComponent } from './account-search/account-search.component'
4import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' 3import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
5import { AccountVideosComponent } from './account-videos/account-videos.component' 4import { AccountVideosComponent } from './account-videos/account-videos.component'
6import { AccountsComponent } from './accounts.component' 5import { 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 @@
1import { Subscription } from 'rxjs' 1import { Subscription } from 'rxjs'
2import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators' 2import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
3import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' 3import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
4import { ActivatedRoute } from '@angular/router' 4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core' 5import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
6import { 6import {
7 Account, 7 Account,
@@ -14,7 +14,6 @@ import {
14} from '@app/shared/shared-main' 14} from '@app/shared/shared-main'
15import { AccountReportComponent } from '@app/shared/shared-moderation' 15import { AccountReportComponent } from '@app/shared/shared-moderation'
16import { HttpStatusCode, User, UserRight } from '@shared/models' 16import { HttpStatusCode, User, UserRight } from '@shared/models'
17import { 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
23export class AccountsComponent implements OnInit, OnDestroy { 22export 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'
5import { SharedModerationModule } from '@app/shared/shared-moderation' 5import { SharedModerationModule } from '@app/shared/shared-moderation'
6import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' 6import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
7import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' 7import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
8import { AccountSearchComponent } from './account-search/account-search.component' 8import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
9import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' 9import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
10import { AccountVideosComponent } from './account-videos/account-videos.component' 10import { AccountVideosComponent } from './account-videos/account-videos.component'
11import { AccountsRoutingModule } from './accounts-routing.module' 11import { AccountsRoutingModule } from './accounts-routing.module'
12import { AccountsComponent } from './accounts.component' 12import { AccountsComponent } from './accounts.component'
13import { 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 @@
1import { forkJoin, Subscription } from 'rxjs' 1import { Subscription } from 'rxjs'
2import { first } from 'rxjs/operators' 2import { first } from 'rxjs/operators'
3import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' 3import { Component, OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router' 4import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
5import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
6import { immutableAssign } from '@app/helpers'
7import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' 5import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
8import { AbstractVideoList, MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' 6import { MiniatureDisplayOptions, VideoFilters } from '@app/shared/shared-video-miniature'
9import { VideoFilter } from '@shared/models' 7import { 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})
18export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { 13export 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 @@
1export * from './overview' 1export * from './overview'
2export * from './trending' 2export * from './videos-list-common-page.component'
3export * from './video-local.component'
4export * 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 @@
1export * from './video-trending-header.component'
2export * 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 @@
1import { Subscription } from 'rxjs'
2import { Component, HostBinding, Inject, OnDestroy, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, RedirectService } from '@app/core'
5import { ServerService } from '@app/core/server/server.service'
6import { GlobalIconName } from '@app/shared/shared-icons'
7import { VideoListHeaderComponent } from '@app/shared/shared-video-miniature'
8
9interface 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})
22export 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 @@
1import { Subscription } from 'rxjs'
2import { first, switchMap } from 'rxjs/operators'
3import { Component, ComponentFactoryResolver, Injector, OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute, Params, Router } from '@angular/router'
5import { AuthService, LocalStorageService, Notifier, RedirectService, ScreenService, ServerService, UserService } from '@app/core'
6import { HooksService } from '@app/core/plugins/hooks.service'
7import { immutableAssign } from '@app/helpers'
8import { VideoService } from '@app/shared/shared-main'
9import { AbstractVideoList } from '@app/shared/shared-video-miniature'
10import { VideoSortField } from '@shared/models'
11import { 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})
18export 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 @@
1import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
4import { HooksService } from '@app/core/plugins/hooks.service'
5import { immutableAssign } from '@app/helpers'
6import { VideoService } from '@app/shared/shared-main'
7import { AbstractVideoList } from '@app/shared/shared-video-miniature'
8import { 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})
15export 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 @@
1import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
4import { HooksService } from '@app/core/plugins/hooks.service'
5import { immutableAssign } from '@app/helpers'
6import { VideoService } from '@app/shared/shared-main'
7import { AbstractVideoList } from '@app/shared/shared-video-miniature'
8import { 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})
15export 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
2import { switchMap } from 'rxjs/operators' 2import { firstValueFrom } from 'rxjs'
3import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' 3import { switchMap, tap } from 'rxjs/operators'
4import { ActivatedRoute, Router } from '@angular/router' 4import { Component } from '@angular/core'
5import { AuthService, LocalStorageService, Notifier, ScopedTokensService, ScreenService, ServerService, UserService } from '@app/core' 5import { AuthService, ComponentPaginationLight, DisableForReuseHook, ScopedTokensService } from '@app/core'
6import { HooksService } from '@app/core/plugins/hooks.service' 6import { HooksService } from '@app/core/plugins/hooks.service'
7import { immutableAssign } from '@app/helpers'
8import { VideoService } from '@app/shared/shared-main' 7import { VideoService } from '@app/shared/shared-main'
9import { UserSubscriptionService } from '@app/shared/shared-user-subscription' 8import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
10import { AbstractVideoList } from '@app/shared/shared-video-miniature' 9import { VideoFilters } from '@app/shared/shared-video-miniature'
11import { FeedFormat, VideoSortField } from '@shared/models' 10import { VideoSortField } from '@shared/models'
12import { environment } from '../../../environments/environment'
13import { 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})
20export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy { 16export 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 @@
1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'
3import { ComponentPaginationLight, DisableForReuseHook, MetaService, RedirectService, ServerService } from '@app/core'
4import { HooksService } from '@app/core/plugins/hooks.service'
5import { VideoService } from '@app/shared/shared-main'
6import { VideoFilters, VideoFilterScope } from '@app/shared/shared-video-miniature/video-filters.model'
7import { ClientFilterHookName, VideoSortField } from '@shared/models'
8import { Subscription } from 'rxjs'
9
10export 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})
21export 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 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes, UrlSegment } from '@angular/router'
3import { LoginGuard } from '@app/core' 3import { LoginGuard } from '@app/core'
4import { VideoTrendingComponent } from './video-list' 4import { VideosListCommonPageComponent } from './video-list'
5import { VideoOverviewComponent } from './video-list/overview/video-overview.component' 5import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
6import { VideoLocalComponent } from './video-list/video-local.component'
7import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
8import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' 6import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
9import { VideosComponent } from './videos.component' 7import { 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'
5import { SharedMainModule } from '@app/shared/shared-main' 5import { SharedMainModule } from '@app/shared/shared-main'
6import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' 6import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
7import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' 7import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
8import { OverviewService, VideoTrendingComponent } from './video-list' 8import { OverviewService, VideosListCommonPageComponent } from './video-list'
9import { VideoOverviewComponent } from './video-list/overview/video-overview.component' 9import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
10import { VideoTrendingHeaderComponent } from './video-list/trending/video-trending-header.component'
11import { VideoLocalComponent } from './video-list/video-local.component'
12import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
13import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' 10import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
14import { VideosRoutingModule } from './videos-routing.module' 11import { VideosRoutingModule } from './videos-routing.module'
15import { VideosComponent } from './videos.component' 12import { 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 @@
1import { Hotkey, HotkeysService } from 'angular2-hotkeys' 1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
2import { filter, map, pairwise, switchMap } from 'rxjs/operators' 2import { filter, map, switchMap } from 'rxjs/operators'
3import { DOCUMENT, getLocaleDirection, PlatformLocation, ViewportScroller } from '@angular/common' 3import { DOCUMENT, getLocaleDirection, PlatformLocation } from '@angular/common'
4import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core' 4import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core'
5import { DomSanitizer, SafeHtml } from '@angular/platform-browser' 5import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
6import { Event, GuardsCheckStart, NavigationEnd, RouteConfigLoadEnd, RouteConfigLoadStart, Router, Scroll } from '@angular/router' 6import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router } from '@angular/router'
7import { AuthService, MarkdownService, RedirectService, ScreenService, ServerService, ThemeService, User } from '@app/core' 7import {
8 AuthService,
9 MarkdownService,
10 PeerTubeRouterService,
11 RedirectService,
12 ScreenService,
13 ScrollService,
14 ServerService,
15 ThemeService,
16 User
17} from '@app/core'
8import { HooksService } from '@app/core/plugins/hooks.service' 18import { HooksService } from '@app/core/plugins/hooks.service'
9import { PluginService } from '@app/core/plugins/plugin.service' 19import { PluginService } from '@app/core/plugins/plugin.service'
10import { CustomModalComponent } from '@app/modal/custom-modal.component' 20import { 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'
14import { Notifier } from './notification' 14import { Notifier } from './notification'
15import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer' 15import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer'
16import { RestExtractor, RestService } from './rest' 16import { RestExtractor, RestService } from './rest'
17import { HomepageRedirectComponent, LoginGuard, MetaGuard, MetaService, RedirectService, UnloggedGuard, UserRightGuard } from './routing' 17import {
18 HomepageRedirectComponent,
19 LoginGuard,
20 MetaGuard,
21 MetaService,
22 PeerTubeRouterService,
23 RedirectService,
24 ScrollService,
25 UnloggedGuard,
26 UserRightGuard
27} from './routing'
18import { CanDeactivateGuard } from './routing/can-deactivate-guard.service' 28import { CanDeactivateGuard } from './routing/can-deactivate-guard.service'
19import { ServerConfigResolver } from './routing/server-config-resolver.service' 29import { ServerConfigResolver } from './routing/server-config-resolver.service'
20import { ScopedTokensService } from './scoped-tokens' 30import { 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 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router' 2import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
3import { RouterSetting } from './'
4import { PeerTubeRouterService } from './peertube-router.service'
3 5
4@Injectable() 6@Injectable()
5export class CustomReuseStrategy implements RouteReuseStrategy { 7export 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'
5export * from './login-guard.service' 5export * from './login-guard.service'
6export * from './menu-guard.service' 6export * from './menu-guard.service'
7export * from './meta-guard.service' 7export * from './meta-guard.service'
8export * from './peertube-router.service'
8export * from './meta.service' 9export * from './meta.service'
9export * from './preload-selected-modules-list' 10export * from './preload-selected-modules-list'
10export * from './redirect.service' 11export * from './redirect.service'
12export * from './scroll.service'
11export * from './server-config-resolver.service' 13export * from './server-config-resolver.service'
12export * from './unlogged-guard.service' 14export * from './unlogged-guard.service'
13export * from './user-right-guard.service' 15export * 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 @@
1import { filter } from 'rxjs/operators'
2import { Injectable } from '@angular/core'
3import { ActivatedRoute, ActivatedRouteSnapshot, Event, NavigationEnd, Router, Scroll } from '@angular/router'
4import { ServerService } from '../server'
5
6export const enum RouterSetting {
7 NONE = 0,
8 REUSE_COMPONENT = 1 << 0,
9 DISABLE_SCROLL_RESTORE = 1 << 1
10}
11
12@Injectable()
13export 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 @@
1import * as debug from 'debug'
2import { pairwise } from 'rxjs'
3import { ViewportScroller } from '@angular/common'
4import { Injectable } from '@angular/core'
5import { RouterSetting } from '../'
6import { PeerTubeRouterService } from './peertube-router.service'
7
8const logger = debug('peertube:main:ScrollService')
9
10@Injectable()
11export 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 @@
1import { first, map } from 'rxjs/operators'
2import { SelectChannelItem } from 'src/types/select-options-item.model'
3import { DatePipe } from '@angular/common'
4import { HttpErrorResponse } from '@angular/common/http'
5import { Notifier } from '@app/core'
6import { HttpStatusCode } from '@shared/models'
7import { environment } from '../../environments/environment'
8import { AuthService } from '../core/auth'
9
10// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
11function 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
24function 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
51function 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
64function 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
74const datePipe = new DatePipe('en')
75function dateToHuman (date: string) {
76 return datePipe.transform(date, 'medium')
77}
78
79function 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
93function immutableAssign <A, B> (target: A, source: B) {
94 return Object.assign({}, target, source)
95}
96
97// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34
98function 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
123function objectLineFeedToHtml (obj: any, keyToNormalize: string) {
124 return immutableAssign(obj, {
125 [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize])
126 })
127}
128
129function lineFeedToHtml (text: string) {
130 if (!text) return text
131
132 return text.replace(/\r?\n|\r/g, '<br />')
133}
134
135function removeElementFromArray <T> (arr: T[], elem: T) {
136 const index = arr.indexOf(elem)
137 if (index !== -1) arr.splice(index, 1)
138}
139
140function 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
151function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') {
152 window.scrollTo({
153 left: 0,
154 top: 0,
155 behavior
156 })
157}
158
159function 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
169function 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
179function 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
209export {
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 @@
1import { first, map } from 'rxjs/operators'
2import { SelectChannelItem } from 'src/types/select-options-item.model'
3import { AuthService } from '../../core/auth'
4
5function 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
32export {
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 @@
1import { DatePipe } from '@angular/common'
2
3const datePipe = new DatePipe('en')
4function dateToHuman (date: string) {
5 return datePipe.transform(date, 'medium')
6}
7
8function 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
22export {
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 @@
1import { immutableAssign } from './object'
2
3function objectLineFeedToHtml (obj: any, keyToNormalize: string) {
4 return immutableAssign(obj, {
5 [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize])
6 })
7}
8
9function lineFeedToHtml (text: string) {
10 if (!text) return text
11
12 return text.replace(/\r?\n|\r/g, '<br />')
13}
14
15export {
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 @@
1export * from './channel'
2export * from './date'
3export * from './html'
4export * from './object'
5export * from './ui'
6export * from './upload'
7export * 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 @@
1function immutableAssign <A, B> (target: A, source: B) {
2 return Object.assign({}, target, source)
3}
4
5function removeElementFromArray <T> (arr: T[], elem: T) {
6 const index = arr.indexOf(elem)
7 if (index !== -1) arr.splice(index, 1)
8}
9
10function 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
21function 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
30function 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
41export {
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 @@
1function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') {
2 window.scrollTo({
3 left: 0,
4 top: 0,
5 behavior
6 })
7}
8
9function 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
19function 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
29export {
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 @@
1import { HttpErrorResponse } from '@angular/common/http'
2import { Notifier } from '@app/core'
3import { HttpStatusCode } from '@shared/models'
4
5function 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
35export {
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 @@
1import { environment } from '../../../environments/environment'
2
3// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
4function 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
17function 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
30function 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
41function 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
66export {
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})
19export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { 19export 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 @@
1export * from './select-categories.component'
1export * from './select-channel.component' 2export * from './select-channel.component'
3export * from './select-checkbox-all.component'
2export * from './select-checkbox.component' 4export * from './select-checkbox.component'
3export * from './select-custom-value.component' 5export * from './select-custom-value.component'
6export * from './select-languages.component'
4export * from './select-options.component' 7export * from './select-options.component'
5export * from './select-tags.component' 8export * 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
2import { Component, forwardRef, OnInit } from '@angular/core'
3import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
4import { ServerService } from '@app/core'
5import { SelectOptionsItem } from '../../../../types/select-options-item.model'
6import { 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})
20export 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 @@
1import { Component, forwardRef, Input } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core'
4import { SelectOptionsItem } from '../../../../types/select-options-item.model'
5import { 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})
30export 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'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { SelectOptionsItem } from '../../../../types/select-options-item.model' 3import { SelectOptionsItem } from '../../../../types/select-options-item.model'
4 4
5export type ItemSelectCheckboxValue = { id?: string | number, group?: string } | string 5export 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 @@
1import { Component, forwardRef, Input, OnInit } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { ServerService } from '@app/core'
4import { SelectOptionsItem } from '../../../../types/select-options-item.model'
5import { 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})
19export 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'
15import { PreviewUploadComponent } from './preview-upload.component' 15import { PreviewUploadComponent } from './preview-upload.component'
16import { ReactiveFileComponent } from './reactive-file.component' 16import { ReactiveFileComponent } from './reactive-file.component'
17import { 17import {
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 @@
1import { fromEvent, Observable, Subscription } from 'rxjs' 1import { fromEvent, Observable, Subscription } from 'rxjs'
2import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' 2import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
3import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' 3import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
4import { PeerTubeRouterService, RouterSetting } from '@app/core'
4 5
5@Directive({ 6@Directive({
6 selector: '[myInfiniteScroller]' 7 selector: '[myInfiniteScroller]'
7}) 8})
8export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { 9export 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
13my-global-icon { 14my-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
28input { 21input {
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 @@
1import { Subject } from 'rxjs'
2import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
3import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
4import { 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'
5import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' 5import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
6import { objectToFormData } from '@app/helpers' 6import { objectToFormData } from '@app/helpers'
7import { 7import {
8 BooleanBothQuery,
8 FeedFormat, 9 FeedFormat,
9 NSFWPolicyType, 10 NSFWPolicyType,
10 ResultList, 11 ResultList,
@@ -28,19 +29,21 @@ import { VideoDetails } from './video-details.model'
28import { VideoEdit } from './video-edit.model' 29import { VideoEdit } from './video-edit.model'
29import { Video } from './video.model' 30import { Video } from './video.model'
30 31
31export interface VideosProvider { 32export 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()
43export class VideoService implements VideosProvider { 46export 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 @@
1import { intoArray } from '@app/helpers'
1import { 2import {
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
22my-select-checkbox { 22my-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 @@
1import { pick } from 'lodash-es' 1import { pick } from 'lodash-es'
2import { forkJoin, Subject, Subscription } from 'rxjs' 2import { Subject, Subscription } from 'rxjs'
3import { first } from 'rxjs/operators' 3import { first } from 'rxjs/operators'
4import { Component, Input, OnDestroy, OnInit } from '@angular/core' 4import { Component, Input, OnDestroy, OnInit } from '@angular/core'
5import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' 5import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
6import { FormReactive, FormValidatorService, ItemSelectCheckboxValue } from '@app/shared/shared-forms' 6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
7import { UserUpdateMe } from '@shared/models' 7import { UserUpdateMe } from '@shared/models'
8import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' 8import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
9import { 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 @@
1import { fromEvent, Observable, ReplaySubject, Subject, Subscription } from 'rxjs'
2import { debounceTime, switchMap, tap } from 'rxjs/operators'
3import {
4 AfterContentInit,
5 ComponentFactoryResolver,
6 Directive,
7 Injector,
8 OnDestroy,
9 OnInit,
10 Type,
11 ViewChild,
12 ViewContainerRef
13} from '@angular/core'
14import { ActivatedRoute, Params, Router } from '@angular/router'
15import {
16 AuthService,
17 ComponentPaginationLight,
18 LocalStorageService,
19 Notifier,
20 ScreenService,
21 ServerService,
22 User,
23 UserService
24} from '@app/core'
25import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
26import { GlobalIconName } from '@app/shared/shared-icons'
27import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
28import { HTMLServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/models'
29import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
30import { Syndication, Video } from '../shared-main'
31import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component'
32import { MiniatureDisplayOptions } from './video-miniature.component'
33
34enum 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
46export 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 @@
1export * from './abstract-video-list'
2export * from './video-actions-dropdown.component' 1export * from './video-actions-dropdown.component'
3export * from './video-download.component' 2export * from './video-download.component'
3export * from './video-filters-header.component'
4export * from './video-filters.model'
4export * from './video-miniature.component' 5export * from './video-miniature.component'
6export * from './videos-list.component'
5export * from './videos-selection.component' 7export * from './videos-selection.component'
6export * from './video-list-header.component'
7export * from './shared-video-miniature.module' 8export * 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
2import { NgModule } from '@angular/core' 2import { NgModule } from '@angular/core'
3import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
3import { SharedFormModule } from '../shared-forms' 4import { SharedFormModule } from '../shared-forms'
4import { SharedGlobalIconModule } from '../shared-icons' 5import { SharedGlobalIconModule } from '../shared-icons'
5import { SharedMainModule } from '../shared-main/shared-main.module' 6import { SharedMainModule } from '../shared-main/shared-main.module'
6import { SharedModerationModule } from '../shared-moderation' 7import { SharedModerationModule } from '../shared-moderation'
7import { SharedVideoModule } from '../shared-video'
8import { SharedThumbnailModule } from '../shared-thumbnail' 8import { SharedThumbnailModule } from '../shared-thumbnail'
9import { SharedVideoModule } from '../shared-video'
9import { SharedVideoLiveModule } from '../shared-video-live' 10import { SharedVideoLiveModule } from '../shared-video-live'
10import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' 11import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module'
11import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' 12import { VideoActionsDropdownComponent } from './video-actions-dropdown.component'
12import { VideoDownloadComponent } from './video-download.component' 13import { VideoDownloadComponent } from './video-download.component'
14import { VideoFiltersHeaderComponent } from './video-filters-header.component'
13import { VideoMiniatureComponent } from './video-miniature.component' 15import { VideoMiniatureComponent } from './video-miniature.component'
16import { VideosListComponent } from './videos-list.component'
14import { VideosSelectionComponent } from './videos-selection.component' 17import { VideosSelectionComponent } from './videos-selection.component'
15import { VideoListHeaderComponent } from './video-list-header.component'
16import { 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
119my-select-languages,
120my-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 @@
1import * as debug from 'debug'
2import { Subscription } from 'rxjs'
3import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
4import { FormBuilder, FormGroup } from '@angular/forms'
5import { AuthService } from '@app/core'
6import { ServerService } from '@app/core/server/server.service'
7import { UserRight } from '@shared/models'
8import { NSFWPolicyType } from '@shared/models/videos'
9import { PeertubeModalService } from '../shared-main'
10import { VideoFilters } from './video-filters.model'
11
12const 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})
19export 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 @@
1import { intoArray, toBoolean } from '@app/helpers'
2import { AttributesOnly } from '@shared/core-utils'
3import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models'
4
5type VideoFiltersKeys = {
6 [ id in keyof AttributesOnly<VideoFilters> ]: any
7}
8
9export type VideoFilterScope = 'local' | 'federated'
10
11export 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 @@
1import { Component, Inject, ViewEncapsulation } from '@angular/core'
2
3export interface GenericHeaderData {
4 titlePage: string
5 titleTooltip?: string
6}
7
8export 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})
18export 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 @@
1import * as debug from 'debug'
2import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
3import { debounceTime, switchMap } from 'rxjs/operators'
4import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
5import { ActivatedRoute } from '@angular/router'
6import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core'
7import { GlobalIconName } from '@app/shared/shared-icons'
8import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
9import { ResultList, UserRight, VideoSortField } from '@shared/models'
10import { Syndication, Video } from '../shared-main'
11import { VideoFilters, VideoFilterScope } from './video-filters.model'
12import { MiniatureDisplayOptions } from './video-miniature.component'
13
14const logger = debug('peertube:videos:VideosListComponent')
15
16export type HeaderAction = {
17 iconName: GlobalIconName
18 label: string
19 justIcon?: boolean
20 routerLink?: string
21 href?: string
22 click?: (e: Event) => void
23}
24
25enum 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})
40export 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 @@
1import { Observable } from 'rxjs' 1import { Observable, Subject } from 'rxjs'
2import { 2import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core'
3 AfterContentInit, 3import { 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'
15import { ActivatedRoute, Router } from '@angular/router'
16import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
17import { ResultList, VideoSortField } from '@shared/models' 4import { ResultList, VideoSortField } from '@shared/models'
18import { PeerTubeTemplateDirective, Video } from '../shared-main' 5import { PeerTubeTemplateDirective, Video } from '../shared-main'
19import { AbstractVideoList } from './abstract-video-list'
20import { MiniatureDisplayOptions } from './video-miniature.component' 6import { MiniatureDisplayOptions } from './video-miniature.component'
21 7
22export type SelectionType = { [ id: number ]: boolean } 8export 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})
29export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { 15export 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