From dd24f1bb0a4b252e5342b251ba36853364da7b8e Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 19 Aug 2021 09:24:29 +0200 Subject: [PATCH] Add video filters to common video pages --- .../account-search.component.ts | 110 ----- .../account-video-channels.component.html | 2 +- .../account-videos.component.html | 20 + .../account-videos.component.ts | 93 ++-- .../app/+accounts/accounts-routing.module.ts | 10 +- .../src/app/+accounts/accounts.component.html | 4 +- .../src/app/+accounts/accounts.component.scss | 5 +- .../src/app/+accounts/accounts.component.ts | 22 +- client/src/app/+accounts/accounts.module.ts | 6 +- .../plugin-list-installed.component.html | 2 +- .../plugin-search.component.html | 2 +- .../my-history/my-history.component.html | 4 +- .../my-history/my-history.component.ts | 6 +- .../my-subscriptions.component.html | 2 +- .../my-video-playlist-elements.component.html | 2 +- .../my-video-playlists.component.html | 2 +- .../my-videos/my-videos.component.html | 4 +- .../my-videos/my-videos.component.ts | 6 +- .../app/+search/search-filters.component.scss | 1 - client/src/app/+search/search.component.html | 2 +- .../video-channel-playlists.component.html | 2 +- .../video-channel-videos.component.html | 21 + .../video-channel-videos.component.ts | 101 ++--- .../comment/video-comments.component.html | 8 +- .../video-watch-playlist.component.html | 2 +- client/src/app/+videos/video-list/index.ts | 4 +- .../overview/video-overview.component.html | 2 +- .../app/+videos/video-list/trending/index.ts | 2 - .../video-trending-header.component.html | 8 - .../video-trending-header.component.scss | 20 - .../video-trending-header.component.ts | 109 ----- .../trending/video-trending.component.ts | 127 ------ .../video-list/video-local.component.ts | 81 ---- .../video-recently-added.component.ts | 73 ---- .../video-user-subscriptions.component.html | 17 + .../video-user-subscriptions.component.ts | 133 +++--- .../videos-list-common-page.component.html | 22 + .../videos-list-common-page.component.ts | 219 ++++++++++ .../src/app/+videos/videos-routing.module.ts | 54 +-- client/src/app/+videos/videos.module.ts | 12 +- client/src/app/app-routing.module.ts | 1 + client/src/app/app.component.ts | 80 +--- client/src/app/core/core.module.ts | 14 +- .../app/core/routing/custom-reuse-strategy.ts | 6 +- client/src/app/core/routing/index.ts | 2 + .../core/routing/peertube-router.service.ts | 78 ++++ client/src/app/core/routing/scroll.service.ts | 91 ++++ client/src/app/helpers/utils.ts | 226 ---------- client/src/app/helpers/utils/channel.ts | 34 ++ client/src/app/helpers/utils/date.ts | 25 ++ client/src/app/helpers/utils/html.ts | 18 + client/src/app/helpers/utils/index.ts | 7 + client/src/app/helpers/utils/object.ts | 47 ++ client/src/app/helpers/utils/ui.ts | 33 ++ client/src/app/helpers/utils/upload.ts | 37 ++ client/src/app/helpers/utils/url.ts | 71 +++ .../advanced-input-filter.component.ts | 3 +- .../app/shared/shared-forms/select/index.ts | 3 + .../select/select-categories.component.html | 8 + .../select/select-categories.component.ts | 71 +++ .../select/select-checkbox-all.component.ts | 115 +++++ .../select/select-checkbox.component.html | 2 - .../select/select-checkbox.component.ts | 7 +- .../select/select-languages.component.html | 9 + .../select/select-languages.component.ts | 74 ++++ .../shared/shared-forms/shared-form.module.ts | 9 + .../shared-icons/global-icon.component.ts | 1 + .../angular/infinite-scroller.directive.ts | 22 +- .../shared-main/feeds/feed.component.scss | 3 +- .../misc/simple-search-input.component.html | 23 +- .../misc/simple-search-input.component.scss | 13 +- .../misc/simple-search-input.component.ts | 32 +- .../users/user-notifications.component.html | 2 +- .../shared/shared-main/video/video.service.ts | 110 ++--- .../shared-search/advanced-search.model.ts | 20 +- .../user-video-settings.component.html | 7 +- .../user-video-settings.component.scss | 2 +- .../user-video-settings.component.ts | 93 +--- .../abstract-video-list.ts | 404 ------------------ .../shared/shared-video-miniature/index.ts | 5 +- .../shared-video-miniature.module.ts | 14 +- .../video-download.component.scss | 1 - .../video-filters-header.component.html | 131 ++++++ .../video-filters-header.component.scss | 139 ++++++ .../video-filters-header.component.ts | 119 ++++++ .../video-filters.model.ts | 240 +++++++++++ .../video-list-header.component.html | 5 - .../video-list-header.component.ts | 22 - ...o-list.html => videos-list.component.html} | 41 +- ...o-list.scss => videos-list.component.scss} | 71 ++- .../videos-list.component.ts | 396 +++++++++++++++++ .../videos-selection.component.html | 5 +- .../videos-selection.component.ts | 106 +++-- .../src/assets/images/feather/chevrons-up.svg | 4 + client/src/sass/bootstrap.scss | 1 + client/src/sass/classes.scss | 4 + client/src/sass/include/_mixins.scss | 77 ++-- 97 files changed, 2610 insertions(+), 1896 deletions(-) delete mode 100644 client/src/app/+accounts/account-search/account-search.component.ts create mode 100644 client/src/app/+accounts/account-videos/account-videos.component.html create mode 100644 client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.html delete mode 100644 client/src/app/+videos/video-list/trending/index.ts delete mode 100644 client/src/app/+videos/video-list/trending/video-trending-header.component.html delete mode 100644 client/src/app/+videos/video-list/trending/video-trending-header.component.scss delete mode 100644 client/src/app/+videos/video-list/trending/video-trending-header.component.ts delete mode 100644 client/src/app/+videos/video-list/trending/video-trending.component.ts delete mode 100644 client/src/app/+videos/video-list/video-local.component.ts delete mode 100644 client/src/app/+videos/video-list/video-recently-added.component.ts create mode 100644 client/src/app/+videos/video-list/video-user-subscriptions.component.html create mode 100644 client/src/app/+videos/video-list/videos-list-common-page.component.html create mode 100644 client/src/app/+videos/video-list/videos-list-common-page.component.ts create mode 100644 client/src/app/core/routing/peertube-router.service.ts create mode 100644 client/src/app/core/routing/scroll.service.ts delete mode 100644 client/src/app/helpers/utils.ts create mode 100644 client/src/app/helpers/utils/channel.ts create mode 100644 client/src/app/helpers/utils/date.ts create mode 100644 client/src/app/helpers/utils/html.ts create mode 100644 client/src/app/helpers/utils/index.ts create mode 100644 client/src/app/helpers/utils/object.ts create mode 100644 client/src/app/helpers/utils/ui.ts create mode 100644 client/src/app/helpers/utils/upload.ts create mode 100644 client/src/app/helpers/utils/url.ts create mode 100644 client/src/app/shared/shared-forms/select/select-categories.component.html create mode 100644 client/src/app/shared/shared-forms/select/select-categories.component.ts create mode 100644 client/src/app/shared/shared-forms/select/select-checkbox-all.component.ts create mode 100644 client/src/app/shared/shared-forms/select/select-languages.component.html create mode 100644 client/src/app/shared/shared-forms/select/select-languages.component.ts delete mode 100644 client/src/app/shared/shared-video-miniature/abstract-video-list.ts create mode 100644 client/src/app/shared/shared-video-miniature/video-filters-header.component.html create mode 100644 client/src/app/shared/shared-video-miniature/video-filters-header.component.scss create mode 100644 client/src/app/shared/shared-video-miniature/video-filters-header.component.ts create mode 100644 client/src/app/shared/shared-video-miniature/video-filters.model.ts delete mode 100644 client/src/app/shared/shared-video-miniature/video-list-header.component.html delete mode 100644 client/src/app/shared/shared-video-miniature/video-list-header.component.ts rename client/src/app/shared/shared-video-miniature/{abstract-video-list.html => videos-list.component.html} (65%) rename client/src/app/shared/shared-video-miniature/{abstract-video-list.scss => videos-list.component.scss} (51%) create mode 100644 client/src/app/shared/shared-video-miniature/videos-list.component.ts create mode 100644 client/src/assets/images/feather/chevrons-up.svg 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 @@ -import { forkJoin, Subscription } from 'rxjs' -import { first, tap } from 'rxjs/operators' -import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' -import { immutableAssign } from '@app/helpers' -import { Account, AccountService, VideoService } from '@app/shared/shared-main' -import { AbstractVideoList } from '@app/shared/shared-video-miniature' -import { VideoFilter } from '@shared/models' - -@Component({ - selector: 'my-account-search', - templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html', - styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ] -}) -export class AccountSearchComponent extends AbstractVideoList implements OnInit, OnDestroy { - titlePage: string - loadOnInit = false - loadUserVideoPreferences = true - - search = '' - filter: VideoFilter = null - - private account: Account - private accountSub: Subscription - - constructor ( - protected router: Router, - protected serverService: ServerService, - protected route: ActivatedRoute, - protected authService: AuthService, - protected userService: UserService, - protected notifier: Notifier, - protected confirmService: ConfirmService, - protected screenService: ScreenService, - protected storageService: LocalStorageService, - protected cfr: ComponentFactoryResolver, - private accountService: AccountService, - private videoService: VideoService - ) { - super() - } - - ngOnInit () { - super.ngOnInit() - - this.enableAllFilterIfPossible() - - // Parent get the account for us - this.accountSub = forkJoin([ - this.accountService.accountLoaded.pipe(first()), - this.onUserLoadedSubject.pipe(first()) - ]).subscribe(([ account ]) => { - this.account = account - - this.reloadVideos() - }) - } - - ngOnDestroy () { - if (this.accountSub) this.accountSub.unsubscribe() - - super.ngOnDestroy() - } - - updateSearch (value: string) { - this.search = value - - if (!this.search) { - this.router.navigate([ '../videos' ], { relativeTo: this.route }) - return - } - - this.videos = [] - this.reloadVideos() - } - - getVideosObservable (page: number) { - const newPagination = immutableAssign(this.pagination, { currentPage: page }) - const options = { - account: this.account, - videoPagination: newPagination, - sort: this.sort, - nsfwPolicy: this.nsfwPolicy, - videoFilter: this.filter, - search: this.search - } - - return this.videoService - .getAccountVideos(options) - .pipe( - tap(({ total }) => { - this.titlePage = this.search - ? $localize`Published ${total} videos matching "${this.search}"` - : $localize`Published ${total} videos` - }) - ) - } - - toggleModerationDisplay () { - this.filter = this.buildLocalFilter(this.filter, null) - - this.reloadVideos() - } - - generateSyndicationList () { - /* method disabled */ - throw new Error('Method not implemented.') - } -} 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 @@
This account does not have channels.
-
+
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 @@ + + 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 @@ -import { forkJoin, Subscription } from 'rxjs' +import { Subscription } from 'rxjs' import { first } from 'rxjs/operators' -import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' -import { immutableAssign } from '@app/helpers' +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core' import { Account, AccountService, VideoService } from '@app/shared/shared-main' -import { AbstractVideoList } from '@app/shared/shared-video-miniature' -import { VideoFilter } from '@shared/models' +import { VideoFilters } from '@app/shared/shared-video-miniature' +import { VideoSortField } from '@shared/models' @Component({ selector: 'my-account-videos', - templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html', - styleUrls: [ - '../../shared/shared-video-miniature/abstract-video-list.scss' - ] + templateUrl: './account-videos.component.html' }) -export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy { - // No value because we don't want a page title - titlePage: string - loadOnInit = false - loadUserVideoPreferences = true +export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook { + getVideosObservableFunction = this.getVideosObservable.bind(this) + getSyndicationItemsFunction = this.getSyndicationItems.bind(this) - filter: VideoFilter = null + title = $localize`Videos` + defaultSort = '-publishedAt' as VideoSortField + + account: Account + disabled = false - private account: Account private accountSub: Subscription constructor ( - protected router: Router, - protected serverService: ServerService, - protected route: ActivatedRoute, - protected authService: AuthService, - protected userService: UserService, - protected notifier: Notifier, - protected confirmService: ConfirmService, - protected screenService: ScreenService, - protected storageService: LocalStorageService, + private screenService: ScreenService, private accountService: AccountService, - private videoService: VideoService, - protected cfr: ComponentFactoryResolver + private videoService: VideoService ) { - super() } ngOnInit () { - super.ngOnInit() - - this.enableAllFilterIfPossible() - // Parent get the account for us - this.accountSub = forkJoin([ - this.accountService.accountLoaded.pipe(first()), - this.onUserLoadedSubject.pipe(first()) - ]).subscribe(([ account ]) => { - this.account = account - - this.reloadVideos() - this.generateSyndicationList() - }) + this.accountService.accountLoaded.pipe(first()) + .subscribe(account => this.account = account) } ngOnDestroy () { if (this.accountSub) this.accountSub.unsubscribe() - - super.ngOnDestroy() } - getVideosObservable (page: number) { - const newPagination = immutableAssign(this.pagination, { currentPage: page }) + getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) { const options = { + ...filters.toVideosAPIObject(), + + videoPagination: pagination, account: this.account, - videoPagination: newPagination, - sort: this.sort, - nsfwPolicy: this.nsfwPolicy, - videoFilter: this.filter + skipCount: true } - return this.videoService - .getAccountVideos(options) + return this.videoService.getAccountVideos(options) } - toggleModerationDisplay () { - this.filter = this.buildLocalFilter(this.filter, null) + getSyndicationItems () { + return this.videoService.getAccountFeedUrls(this.account.id) + } - this.reloadVideos() + displayAsRow () { + return this.screenService.isInMobileView() } - generateSyndicationList () { - this.syndicationItems = this.videoService.getAccountFeedUrls(this.account.id) + disableForReuse () { + this.disabled = true } - displayAsRow () { - return this.screenService.isInMobileView() + enabledForReuse () { + this.disabled = false } } 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 @@ import { NgModule } from '@angular/core' import { RouterModule, Routes } from '@angular/router' -import { AccountSearchComponent } from './account-search/account-search.component' import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' import { AccountVideosComponent } from './account-videos/account-videos.component' import { AccountsComponent } from './accounts.component' @@ -41,14 +40,11 @@ const accountsRoutes: Routes = [ } } }, + + // Old URL redirection { path: 'search', - component: AccountSearchComponent, - data: { - meta: { - title: $localize`Search videos within account` - } - } + redirectTo: 'videos' } ] } 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 @@
- 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 @@ display: flex; justify-content: space-between; align-items: center; - max-width: $max-channels-width; + + &.on-channel-page { + max-width: $max-channels-width; + } simple-search-input { @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 @@ import { Subscription } from 'rxjs' import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators' import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core' -import { ActivatedRoute } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core' import { Account, @@ -14,7 +14,6 @@ import { } from '@app/shared/shared-main' import { AccountReportComponent } from '@app/shared/shared-moderation' import { HttpStatusCode, User, UserRight } from '@shared/models' -import { AccountSearchComponent } from './account-search/account-search.component' @Component({ templateUrl: './accounts.component.html', @@ -23,8 +22,6 @@ import { AccountSearchComponent } from './account-search/account-search.componen export class AccountsComponent implements OnInit, OnDestroy { @ViewChild('accountReportModal') accountReportModal: AccountReportComponent - accountSearch: AccountSearchComponent - account: Account accountUser: User @@ -45,6 +42,7 @@ export class AccountsComponent implements OnInit, OnDestroy { constructor ( private route: ActivatedRoute, + private router: Router, private userService: UserService, private accountService: AccountService, private videoChannelService: VideoChannelService, @@ -128,16 +126,10 @@ export class AccountsComponent implements OnInit, OnDestroy { return $localize`${count} subscribers` } - onOutletLoaded (component: Component) { - if (component instanceof AccountSearchComponent) { - this.accountSearch = component - } else { - this.accountSearch = undefined - } - } - searchChanged (search: string) { - if (this.accountSearch) this.accountSearch.updateSearch(search) + const queryParams = { search } + + this.router.navigate([ './videos' ], { queryParams, relativeTo: this.route, queryParamsHandling: 'merge' }) } onSearchInputDisplayChanged (displayed: boolean) { @@ -152,6 +144,10 @@ export class AccountsComponent implements OnInit, OnDestroy { return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100 } + isOnChannelPage () { + return this.route.children[0].snapshot.url[0].path === 'video-channels' + } + private async onAccount (account: Account) { this.accountFollowerTitle = $localize`${account.followersCount} direct account followers` 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' import { SharedModerationModule } from '@app/shared/shared-moderation' import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' -import { AccountSearchComponent } from './account-search/account-search.component' +import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component' import { AccountVideosComponent } from './account-videos/account-videos.component' import { AccountsRoutingModule } from './accounts-routing.module' import { AccountsComponent } from './accounts.component' -import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' @NgModule({ imports: [ @@ -28,8 +27,7 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto declarations: [ AccountsComponent, AccountVideosComponent, - AccountVideoChannelsComponent, - AccountSearchComponent + AccountVideoChannelsComponent ], 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 @@ {{ getNoResultMessage() }}
-
+
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 @@ No results.
-
+
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 @@
- +
@@ -26,8 +26,8 @@ [titlePage]="titlePage" [getVideosObservableFunction]="getVideosObservableFunction" [user]="user" - [loadOnInit]="false" i18n-noResultMessage noResultMessage="You don't have any video in your watch history yet." [enableSelection]="false" + [disabled]="disabled" #videosSelection > 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 { videos: Video[] = [] search: string + disabled = false + constructor ( protected router: Router, protected serverService: ServerService, @@ -74,11 +76,11 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook { } disableForReuse () { - this.videosSelection.disableForReuse() + this.disabled = true } enabledForReuse () { - this.videosSelection.enabledForReuse() + this.disabled = false } 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 @@
You don't have any subscription yet.
-
+
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 @@
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 @@
-
+
- +
{{ button.label }} - - -
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 @@ -@use '_mixins' as *; - -.btn-group label { - border: 1px solid transparent; - border-radius: 9999px !important; - padding: 5px 16px; - opacity: .8; - - &:not(:first-child) { - @include margin-left(.5rem); - } - - my-global-icon { - @include margin-right(.1rem); - - position: relative; - top: -2px; - height: 1rem; - } -} 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 @@ -import { Subscription } from 'rxjs' -import { Component, HostBinding, Inject, OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AuthService, RedirectService } from '@app/core' -import { ServerService } from '@app/core/server/server.service' -import { GlobalIconName } from '@app/shared/shared-icons' -import { VideoListHeaderComponent } from '@app/shared/shared-video-miniature' - -interface VideoTrendingHeaderItem { - label: string - iconName: GlobalIconName - value: string - tooltip?: string - hidden?: boolean -} - -@Component({ - selector: 'my-video-trending-title-page', - styleUrls: [ './video-trending-header.component.scss' ], - templateUrl: './video-trending-header.component.html' -}) -export class VideoTrendingHeaderComponent extends VideoListHeaderComponent implements OnInit, OnDestroy { - @HostBinding('class') class = 'title-page title-page-single' - - buttons: VideoTrendingHeaderItem[] - - private algorithmChangeSub: Subscription - - constructor ( - @Inject('data') public data: any, - private route: ActivatedRoute, - private router: Router, - private auth: AuthService, - private serverService: ServerService, - private redirectService: RedirectService - ) { - super(data) - - this.buttons = [ - { - label: $localize`:A variant of Trending videos based on the number of recent interactions, minus user history:Best`, - iconName: 'award', - value: 'best', - tooltip: $localize`Videos with the most interactions for recent videos, minus user history`, - hidden: true - }, - { - label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`, - iconName: 'flame', - value: 'hot', - tooltip: $localize`Videos with the most interactions for recent videos`, - hidden: true - }, - { - label: $localize`:Main variant of Trending videos based on number of recent views:Views`, - iconName: 'trending', - value: 'most-viewed', - tooltip: $localize`Videos with the most views during the last 24 hours` - }, - { - label: $localize`:A variant of Trending videos based on the number of likes:Likes`, - iconName: 'like', - value: 'most-liked', - tooltip: $localize`Videos that have the most likes` - } - ] - } - - ngOnInit () { - const serverConfig = this.serverService.getHTMLConfig() - const algEnabled = serverConfig.trending.videos.algorithms.enabled - - this.buttons = this.buttons.map(b => { - b.hidden = !algEnabled.includes(b.value) - - // Best is adapted by the user history so - if (b.value === 'best' && !this.auth.isLoggedIn()) { - b.hidden = true - } - - return b - }) - - this.algorithmChangeSub = this.route.queryParams.subscribe( - queryParams => { - this.data.model = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm() - } - ) - } - - ngOnDestroy () { - if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe() - } - - setSort () { - const alg = this.data.model !== this.redirectService.getDefaultTrendingAlgorithm() - ? this.data.model - : undefined - - this.router.navigate( - [], - { - relativeTo: this.route, - queryParams: { alg }, - queryParamsHandling: 'merge' - } - ) - } -} 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 @@ -import { Subscription } from 'rxjs' -import { first, switchMap } from 'rxjs/operators' -import { Component, ComponentFactoryResolver, Injector, OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute, Params, Router } from '@angular/router' -import { AuthService, LocalStorageService, Notifier, RedirectService, ScreenService, ServerService, UserService } from '@app/core' -import { HooksService } from '@app/core/plugins/hooks.service' -import { immutableAssign } from '@app/helpers' -import { VideoService } from '@app/shared/shared-main' -import { AbstractVideoList } from '@app/shared/shared-video-miniature' -import { VideoSortField } from '@shared/models' -import { VideoTrendingHeaderComponent } from './video-trending-header.component' - -@Component({ - selector: 'my-videos-hot', - styleUrls: [ '../../../shared/shared-video-miniature/abstract-video-list.scss' ], - templateUrl: '../../../shared/shared-video-miniature/abstract-video-list.html' -}) -export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy { - HeaderComponent = VideoTrendingHeaderComponent - titlePage: string - defaultSort: VideoSortField = '-trending' - - loadUserVideoPreferences = true - - private algorithmChangeSub: Subscription - - constructor ( - protected router: Router, - protected serverService: ServerService, - protected route: ActivatedRoute, - protected notifier: Notifier, - protected authService: AuthService, - protected userService: UserService, - protected screenService: ScreenService, - protected storageService: LocalStorageService, - protected cfr: ComponentFactoryResolver, - private videoService: VideoService, - private redirectService: RedirectService, - private hooks: HooksService - ) { - super() - - this.defaultSort = this.parseAlgorithm(this.redirectService.getDefaultTrendingAlgorithm()) - - this.headerComponentInjector = this.getInjector() - } - - ngOnInit () { - super.ngOnInit() - - this.generateSyndicationList() - - // Subscribe to alg change after we loaded the data - // The initial alg load is handled by the parent class - this.algorithmChangeSub = this.onDataSubject - .pipe( - first(), - switchMap(() => this.route.queryParams) - ).subscribe(queryParams => { - const oldSort = this.sort - - this.loadPageRouteParams(queryParams) - - if (oldSort !== this.sort) this.reloadVideos() - } - ) - } - - ngOnDestroy () { - super.ngOnDestroy() - if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe() - } - - getVideosObservable (page: number) { - const newPagination = immutableAssign(this.pagination, { currentPage: page }) - const params = { - videoPagination: newPagination, - sort: this.sort, - categoryOneOf: this.categoryOneOf, - languageOneOf: this.languageOneOf, - nsfwPolicy: this.nsfwPolicy, - skipCount: true - } - - return this.hooks.wrapObsFun( - this.videoService.getVideos.bind(this.videoService), - params, - 'common', - 'filter:api.trending-videos.videos.list.params', - 'filter:api.trending-videos.videos.list.result' - ) - } - - generateSyndicationList () { - this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) - } - - getInjector () { - return Injector.create({ - providers: [ { - provide: 'data', - useValue: { - model: this.defaultSort - } - } ] - }) - } - - protected loadPageRouteParams (queryParams: Params) { - const algorithm = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm() - - this.sort = this.parseAlgorithm(algorithm) - } - - private parseAlgorithm (algorithm: string): VideoSortField { - switch (algorithm) { - case 'most-viewed': - return '-trending' - - case 'most-liked': - return '-likes' - - default: - return '-' + algorithm as VideoSortField - } - } -} 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 @@ -import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' -import { HooksService } from '@app/core/plugins/hooks.service' -import { immutableAssign } from '@app/helpers' -import { VideoService } from '@app/shared/shared-main' -import { AbstractVideoList } from '@app/shared/shared-video-miniature' -import { VideoFilter, VideoSortField } from '@shared/models' - -@Component({ - selector: 'my-videos-local', - styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], - templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' -}) -export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy { - titlePage: string - sort = '-publishedAt' as VideoSortField - filter: VideoFilter = 'local' - - loadUserVideoPreferences = true - - constructor ( - protected router: Router, - protected serverService: ServerService, - protected route: ActivatedRoute, - protected notifier: Notifier, - protected authService: AuthService, - protected userService: UserService, - protected screenService: ScreenService, - protected storageService: LocalStorageService, - protected cfr: ComponentFactoryResolver, - private videoService: VideoService, - private hooks: HooksService - ) { - super() - - this.titlePage = $localize`Local videos` - } - - ngOnInit () { - super.ngOnInit() - - this.enableAllFilterIfPossible() - this.generateSyndicationList() - } - - ngOnDestroy () { - super.ngOnDestroy() - } - - getVideosObservable (page: number) { - const newPagination = immutableAssign(this.pagination, { currentPage: page }) - const params = { - videoPagination: newPagination, - sort: this.sort, - filter: this.filter, - categoryOneOf: this.categoryOneOf, - languageOneOf: this.languageOneOf, - nsfwPolicy: this.nsfwPolicy, - skipCount: true - } - - return this.hooks.wrapObsFun( - this.videoService.getVideos.bind(this.videoService), - params, - 'common', - 'filter:api.local-videos.videos.list.params', - 'filter:api.local-videos.videos.list.result' - ) - } - - generateSyndicationList () { - this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf) - } - - toggleModerationDisplay () { - this.filter = this.buildLocalFilter(this.filter, 'local') - - this.reloadVideos() - } -} 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 @@ -import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core' -import { HooksService } from '@app/core/plugins/hooks.service' -import { immutableAssign } from '@app/helpers' -import { VideoService } from '@app/shared/shared-main' -import { AbstractVideoList } from '@app/shared/shared-video-miniature' -import { VideoSortField } from '@shared/models' - -@Component({ - selector: 'my-videos-recently-added', - styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], - templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' -}) -export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy { - titlePage: string - sort: VideoSortField = '-publishedAt' - groupByDate = true - - loadUserVideoPreferences = true - - constructor ( - protected route: ActivatedRoute, - protected serverService: ServerService, - protected router: Router, - protected notifier: Notifier, - protected authService: AuthService, - protected userService: UserService, - protected screenService: ScreenService, - protected storageService: LocalStorageService, - protected cfr: ComponentFactoryResolver, - private videoService: VideoService, - private hooks: HooksService - ) { - super() - - this.titlePage = $localize`Recently added` - } - - ngOnInit () { - super.ngOnInit() - - this.generateSyndicationList() - } - - ngOnDestroy () { - super.ngOnDestroy() - } - - getVideosObservable (page: number) { - const newPagination = immutableAssign(this.pagination, { currentPage: page }) - const params = { - videoPagination: newPagination, - sort: this.sort, - categoryOneOf: this.categoryOneOf, - languageOneOf: this.languageOneOf, - nsfwPolicy: this.nsfwPolicy, - skipCount: true - } - - return this.hooks.wrapObsFun( - this.videoService.getVideos.bind(this.videoService), - params, - 'common', - 'filter:api.recently-added-videos.videos.list.params', - 'filter:api.recently-added-videos.videos.list.result' - ) - } - - generateSyndicationList () { - this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) - } -} 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 @@ + + 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 @@ -import { switchMap } from 'rxjs/operators' -import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AuthService, LocalStorageService, Notifier, ScopedTokensService, ScreenService, ServerService, UserService } from '@app/core' +import { firstValueFrom } from 'rxjs' +import { switchMap, tap } from 'rxjs/operators' +import { Component } from '@angular/core' +import { AuthService, ComponentPaginationLight, DisableForReuseHook, ScopedTokensService } from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' -import { immutableAssign } from '@app/helpers' import { VideoService } from '@app/shared/shared-main' import { UserSubscriptionService } from '@app/shared/shared-user-subscription' -import { AbstractVideoList } from '@app/shared/shared-video-miniature' -import { FeedFormat, VideoSortField } from '@shared/models' -import { environment } from '../../../environments/environment' -import { copyToClipboard } from '../../../root-helpers/utils' +import { VideoFilters } from '@app/shared/shared-video-miniature' +import { VideoSortField } from '@shared/models' @Component({ selector: 'my-videos-user-subscriptions', - styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], - templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' + templateUrl: './video-user-subscriptions.component.html' }) -export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy { - titlePage: string - sort = '-publishedAt' as VideoSortField - groupByDate = true +export class VideoUserSubscriptionsComponent implements DisableForReuseHook { + getVideosObservableFunction = this.getVideosObservable.bind(this) + getSyndicationItemsFunction = this.getSyndicationItems.bind(this) + + defaultSort = '-publishedAt' as VideoSortField + + actions = [ + { + routerLink: '/my-library/subscriptions', + label: $localize`Subscriptions`, + iconName: 'cog' + } + ] + + titlePage = $localize`Videos from your subscriptions` + + disabled = false + + private feedToken: string constructor ( - protected router: Router, - protected serverService: ServerService, - protected route: ActivatedRoute, - protected notifier: Notifier, - protected authService: AuthService, - protected userService: UserService, - protected screenService: ScreenService, - protected storageService: LocalStorageService, + private authService: AuthService, private userSubscription: UserSubscriptionService, - protected cfr: ComponentFactoryResolver, private hooks: HooksService, private videoService: VideoService, private scopedTokensService: ScopedTokensService ) { - super() - this.titlePage = $localize`Videos from your subscriptions` - - this.actions.push({ - routerLink: '/my-library/subscriptions', - label: $localize`Subscriptions`, - iconName: 'cog' - }) } - ngOnInit () { - super.ngOnInit() - - const user = this.authService.getUser() - let feedUrl = environment.originServerUrl - - this.authService.userInformationLoaded - .pipe(switchMap(() => this.scopedTokensService.getScopedTokens())) - .subscribe({ - next: tokens => { - const feeds = this.videoService.getVideoSubscriptionFeedUrls(user.account.id, tokens.feedToken) - feedUrl = feedUrl + feeds.find(f => f.format === FeedFormat.RSS).url - - this.actions.unshift({ - label: $localize`Copy feed URL`, - iconName: 'syndication', - justIcon: true, - href: feedUrl, - click: e => { - e.preventDefault() - copyToClipboard(feedUrl) - this.activateCopiedMessage() - } - }) - }, - - error: err => { - this.notifier.error(err.message) - } - }) - } - - ngOnDestroy () { - super.ngOnDestroy() - } - - getVideosObservable (page: number) { - const newPagination = immutableAssign(this.pagination, { currentPage: page }) + getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) { const params = { - videoPagination: newPagination, - sort: this.sort, + ...filters.toVideosAPIObject(), + + videoPagination: pagination, skipCount: true } @@ -101,12 +60,32 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement ) } - generateSyndicationList () { - /* method disabled: the view provides its own */ - throw new Error('Method not implemented.') + getSyndicationItems () { + return this.loadFeedToken() + .then(() => { + const user = this.authService.getUser() + + return this.videoService.getVideoSubscriptionFeedUrls(user.account.id, this.feedToken) + }) } - activateCopiedMessage () { - this.notifier.success($localize`Feed URL copied`) + disableForReuse () { + this.disabled = true + } + + enabledForReuse () { + this.disabled = false + } + + private loadFeedToken () { + if (this.feedToken) return Promise.resolve(this.feedToken) + + const obs = this.authService.userInformationLoaded + .pipe( + switchMap(() => this.scopedTokensService.getScopedTokens()), + tap(tokens => this.feedToken = tokens.feedToken) + ) + + return firstValueFrom(obs) } } 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 @@ + + 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 @@ +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router' +import { ComponentPaginationLight, DisableForReuseHook, MetaService, RedirectService, ServerService } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { VideoService } from '@app/shared/shared-main' +import { VideoFilters, VideoFilterScope } from '@app/shared/shared-video-miniature/video-filters.model' +import { ClientFilterHookName, VideoSortField } from '@shared/models' +import { Subscription } from 'rxjs' + +export type VideosListCommonPageRouteData = { + sort: VideoSortField + + scope: VideoFilterScope + hookParams: ClientFilterHookName + hookResult: ClientFilterHookName +} + +@Component({ + templateUrl: './videos-list-common-page.component.html' +}) +export class VideosListCommonPageComponent implements OnInit, OnDestroy, DisableForReuseHook { + getVideosObservableFunction = this.getVideosObservable.bind(this) + getSyndicationItemsFunction = this.getSyndicationItems.bind(this) + baseRouteBuilderFunction = this.baseRouteBuilder.bind(this) + + title: string + titleTooltip: string + + groupByDate: boolean + + defaultSort: VideoSortField + defaultScope: VideoFilterScope + + hookParams: ClientFilterHookName + hookResult: ClientFilterHookName + + loadUserVideoPreferences = true + + displayFilters = true + + disabled = false + + private trendingDays: number + private routeSub: Subscription + + constructor ( + private server: ServerService, + private route: ActivatedRoute, + private videoService: VideoService, + private hooks: HooksService, + private meta: MetaService, + private redirectService: RedirectService + ) { + } + + ngOnInit () { + this.trendingDays = this.server.getHTMLConfig().trending.videos.intervalDays + + this.routeSub = this.route.params.subscribe(params => { + this.update(params['page']) + }) + } + + ngOnDestroy () { + if (this.routeSub) this.routeSub.unsubscribe() + } + + getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) { + const params = { + ...filters.toVideosAPIObject(), + + videoPagination: pagination, + skipCount: true + } + + return this.hooks.wrapObsFun( + this.videoService.getVideos.bind(this.videoService), + params, + 'common', + this.hookParams, + this.hookResult + ) + } + + getSyndicationItems (filters: VideoFilters) { + const result = filters.toVideosAPIObject() + + return this.videoService.getVideoFeedUrls(result.sort, result.filter) + } + + onFiltersChanged (filters: VideoFilters) { + this.buildTitle(filters.scope, filters.sort) + this.updateGroupByDate(filters.sort) + } + + baseRouteBuilder (filters: VideoFilters) { + const sanitizedSort = this.getSanitizedSort(filters.sort) + + let suffix: string + + if (filters.scope === 'local') suffix = 'local' + else if (sanitizedSort === 'publishedAt') suffix = 'recently-added' + else suffix = 'trending' + + return [ '/videos', suffix ] + } + + disableForReuse () { + this.disabled = true + } + + enabledForReuse () { + this.disabled = false + } + + update (page: string) { + const data = this.getData(page) + + this.hookParams = data.hookParams + this.hookResult = data.hookResult + + this.defaultSort = data.sort + this.defaultScope = data.scope + + this.buildTitle() + this.updateGroupByDate(this.defaultSort) + + this.meta.setTitle(this.title) + } + + private getData (page: string) { + if (page === 'trending') return this.generateTrendingData(this.route.snapshot) + + if (page === 'local') return this.generateLocalData() + + return this.generateRecentlyAddedData() + } + + private generateRecentlyAddedData (): VideosListCommonPageRouteData { + return { + sort: '-publishedAt', + scope: 'federated', + hookParams: 'filter:api.recently-added-videos.videos.list.params', + hookResult: 'filter:api.recently-added-videos.videos.list.result' + } + } + + private generateLocalData (): VideosListCommonPageRouteData { + return { + sort: '-publishedAt' as VideoSortField, + scope: 'local', + hookParams: 'filter:api.local-videos.videos.list.params', + hookResult: 'filter:api.local-videos.videos.list.result' + } + } + + private generateTrendingData (route: ActivatedRouteSnapshot): VideosListCommonPageRouteData { + const sort = route.queryParams['sort'] ?? this.parseTrendingAlgorithm(this.redirectService.getDefaultTrendingAlgorithm()) + + return { + sort, + scope: 'federated', + hookParams: 'filter:api.trending-videos.videos.list.params', + hookResult: 'filter:api.trending-videos.videos.list.result' + } + } + + private parseTrendingAlgorithm (algorithm: string): VideoSortField { + switch (algorithm) { + case 'most-viewed': + return '-trending' + + case 'most-liked': + return '-likes' + + default: + return '-' + algorithm as VideoSortField + } + } + + private updateGroupByDate (sort: VideoSortField) { + this.groupByDate = sort === '-publishedAt' || sort === 'publishedAt' + } + + private buildTitle (scope: VideoFilterScope = this.defaultScope, sort: VideoSortField = this.defaultSort) { + const sanitizedSort = this.getSanitizedSort(sort) + + if (scope === 'local') { + this.title = $localize`Local videos` + this.titleTooltip = $localize`Only videos uploaded on this instance are displayed` + return + } + + if (sanitizedSort === 'publishedAt') { + this.title = $localize`Recently added` + this.titleTooltip = undefined + return + } + + if ([ 'best', 'hot', 'trending', 'likes' ].includes(sanitizedSort)) { + this.title = $localize`Trending` + + if (sanitizedSort === 'best') this.titleTooltip = $localize`Videos with the most interactions for recent videos, minus user history` + if (sanitizedSort === 'hot') this.titleTooltip = $localize`Videos with the most interactions for recent videos` + if (sanitizedSort === 'likes') this.titleTooltip = $localize`Videos that have the most likes` + + if (sanitizedSort === 'trending') { + if (this.trendingDays === 1) this.titleTooltip = $localize`Videos with the most views during the last 24 hours` + else this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days` + } + + return + } + } + + private getSanitizedSort (sort: VideoSortField) { + return sort.replace(/^-/, '') as VideoSortField + } +} 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 @@ import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' +import { RouterModule, Routes, UrlSegment } from '@angular/router' import { LoginGuard } from '@app/core' -import { VideoTrendingComponent } from './video-list' +import { VideosListCommonPageComponent } from './video-list' import { VideoOverviewComponent } from './video-list/overview/video-overview.component' -import { VideoLocalComponent } from './video-list/video-local.component' -import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' import { VideosComponent } from './videos.component' @@ -22,32 +20,35 @@ const videosRoutes: Routes = [ } } }, + { - path: 'trending', - component: VideoTrendingComponent, - data: { - meta: { - title: $localize`Trending videos` - } - } - }, - { + // Old URL redirection path: 'most-liked', - redirectTo: 'trending?alg=most-liked' + redirectTo: 'trending?sort=most-liked' }, { - path: 'recently-added', - component: VideoRecentlyAddedComponent, + matcher: (url: UrlSegment[]) => { + if (url.length === 1 && [ 'recently-added', 'trending', 'local' ].includes(url[0].path)) { + return { + consumed: url, + posParams: { + page: new UrlSegment(url[0].path, {}) + } + } + } + + return null + }, + + component: VideosListCommonPageComponent, data: { - meta: { - title: $localize`Recently added videos` - }, reuse: { enabled: true, - key: 'recently-added-videos-list' + key: 'videos-list' } } }, + { path: 'subscriptions', canActivate: [ LoginGuard ], @@ -61,19 +62,6 @@ const videosRoutes: Routes = [ key: 'subscription-videos-list' } } - }, - { - path: 'local', - component: VideoLocalComponent, - data: { - meta: { - title: $localize`Local videos` - }, - reuse: { - enabled: true, - key: 'local-videos-list' - } - } } ] } 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' import { SharedMainModule } from '@app/shared/shared-main' import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription' import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' -import { OverviewService, VideoTrendingComponent } from './video-list' +import { OverviewService, VideosListCommonPageComponent } from './video-list' import { VideoOverviewComponent } from './video-list/overview/video-overview.component' -import { VideoTrendingHeaderComponent } from './video-list/trending/video-trending-header.component' -import { VideoLocalComponent } from './video-list/video-local.component' -import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' import { VideosRoutingModule } from './videos-routing.module' import { VideosComponent } from './videos.component' @@ -29,12 +26,9 @@ import { VideosComponent } from './videos.component' declarations: [ VideosComponent, - VideoTrendingHeaderComponent, - VideoTrendingComponent, - VideoRecentlyAddedComponent, - VideoLocalComponent, VideoUserSubscriptionsComponent, - VideoOverviewComponent + VideoOverviewComponent, + VideosListCommonPageComponent ], 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({ imports: [ RouterModule.forRoot(routes, { useHash: Boolean(history.pushState) === false, + // Redefined in app component scrollPositionRestoration: 'disabled', preloadingStrategy: PreloadSelectedModulesList, 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 @@ import { Hotkey, HotkeysService } from 'angular2-hotkeys' -import { filter, map, pairwise, switchMap } from 'rxjs/operators' -import { DOCUMENT, getLocaleDirection, PlatformLocation, ViewportScroller } from '@angular/common' +import { filter, map, switchMap } from 'rxjs/operators' +import { DOCUMENT, getLocaleDirection, PlatformLocation } from '@angular/common' import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core' import { DomSanitizer, SafeHtml } from '@angular/platform-browser' -import { Event, GuardsCheckStart, NavigationEnd, RouteConfigLoadEnd, RouteConfigLoadStart, Router, Scroll } from '@angular/router' -import { AuthService, MarkdownService, RedirectService, ScreenService, ServerService, ThemeService, User } from '@app/core' +import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router } from '@angular/router' +import { + AuthService, + MarkdownService, + PeerTubeRouterService, + RedirectService, + ScreenService, + ScrollService, + ServerService, + ThemeService, + User +} from '@app/core' import { HooksService } from '@app/core/plugins/hooks.service' import { PluginService } from '@app/core/plugins/plugin.service' import { CustomModalComponent } from '@app/modal/custom-modal.component' @@ -39,10 +49,10 @@ export class AppComponent implements OnInit, AfterViewInit { constructor ( @Inject(DOCUMENT) private document: Document, @Inject(LOCALE_ID) private localeId: string, - private viewportScroller: ViewportScroller, private router: Router, private authService: AuthService, private serverService: ServerService, + private peertubeRouter: PeerTubeRouterService, private pluginService: PluginService, private instanceService: InstanceService, private domSanitizer: DomSanitizer, @@ -56,6 +66,7 @@ export class AppComponent implements OnInit, AfterViewInit { private markdownService: MarkdownService, private ngbConfig: NgbConfig, private loadingBar: LoadingBarService, + private scrollService: ScrollService, public menu: MenuService ) { this.ngbConfig.animation = false @@ -85,6 +96,7 @@ export class AppComponent implements OnInit, AfterViewInit { } this.initRouteEvents() + this.scrollService.enableScrollRestoration() this.injectJS() this.injectCSS() @@ -132,66 +144,10 @@ export class AppComponent implements OnInit, AfterViewInit { } private initRouteEvents () { - let resetScroll = true const eventsObs = this.router.events - const scrollEvent = eventsObs.pipe(filter((e: Event): e is Scroll => e instanceof Scroll)) - - // Handle anchors/restore position - scrollEvent.subscribe(e => { - // scrollToAnchor first to preserve anchor position when using history navigation - if (e.anchor) { - setTimeout(() => { - this.viewportScroller.scrollToAnchor(e.anchor) - }) - - return - } - - if (e.position) { - return this.viewportScroller.scrollToPosition(e.position) - } - - if (resetScroll) { - return this.viewportScroller.scrollToPosition([ 0, 0 ]) - } - }) - - const navigationEndEvent = eventsObs.pipe(filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd)) - - // When we add the a-state parameter, we don't want to alter the scroll - navigationEndEvent.pipe(pairwise()) - .subscribe(([ e1, e2 ]) => { - try { - resetScroll = false - - const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects) - const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects) - - if (previousUrl.pathname !== nextUrl.pathname) { - resetScroll = true - return - } - - const nextSearchParams = nextUrl.searchParams - nextSearchParams.delete('a-state') - - const previousSearchParams = previousUrl.searchParams - - nextSearchParams.sort() - previousSearchParams.sort() - - if (nextSearchParams.toString() !== previousSearchParams.toString()) { - resetScroll = true - } - } catch (e) { - console.error('Cannot parse URL to check next scroll.', e) - resetScroll = true - } - }) - // Plugin hooks - navigationEndEvent.subscribe(e => { + this.peertubeRouter.getNavigationEndEvents().subscribe(e => { this.hooks.runAction('action:router.navigation-end', 'common', { path: e.url }) }) 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' import { Notifier } from './notification' import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer' import { RestExtractor, RestService } from './rest' -import { HomepageRedirectComponent, LoginGuard, MetaGuard, MetaService, RedirectService, UnloggedGuard, UserRightGuard } from './routing' +import { + HomepageRedirectComponent, + LoginGuard, + MetaGuard, + MetaService, + PeerTubeRouterService, + RedirectService, + ScrollService, + UnloggedGuard, + UserRightGuard +} from './routing' import { CanDeactivateGuard } from './routing/can-deactivate-guard.service' import { ServerConfigResolver } from './routing/server-config-resolver.service' import { ScopedTokensService } from './scoped-tokens' @@ -80,6 +90,8 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra PeerTubeSocket, ServerConfigResolver, CanDeactivateGuard, + PeerTubeRouterService, + ScrollService, MetaService, 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 @@ import { Injectable } from '@angular/core' import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router' +import { RouterSetting } from './' +import { PeerTubeRouterService } from './peertube-router.service' @Injectable() export class CustomReuseStrategy implements RouteReuseStrategy { @@ -78,6 +80,8 @@ export class CustomReuseStrategy implements RouteReuseStrategy { } private isReuseEnabled (route: ActivatedRouteSnapshot) { - return route.data.reuse?.enabled && route.queryParams['a-state'] + // Cannot use peertube router here because of cyclic router dependency + return route.data.reuse?.enabled && + !!(route.queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME] & RouterSetting.REUSE_COMPONENT) } } 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' export * from './login-guard.service' export * from './menu-guard.service' export * from './meta-guard.service' +export * from './peertube-router.service' export * from './meta.service' export * from './preload-selected-modules-list' export * from './redirect.service' +export * from './scroll.service' export * from './server-config-resolver.service' export * from './unlogged-guard.service' export * from './user-right-guard.service' diff --git a/client/src/app/core/routing/peertube-router.service.ts b/client/src/app/core/routing/peertube-router.service.ts new file mode 100644 index 000000000..35716cc79 --- /dev/null +++ b/client/src/app/core/routing/peertube-router.service.ts @@ -0,0 +1,78 @@ +import { filter } from 'rxjs/operators' +import { Injectable } from '@angular/core' +import { ActivatedRoute, ActivatedRouteSnapshot, Event, NavigationEnd, Router, Scroll } from '@angular/router' +import { ServerService } from '../server' + +export const enum RouterSetting { + NONE = 0, + REUSE_COMPONENT = 1 << 0, + DISABLE_SCROLL_RESTORE = 1 << 1 +} + +@Injectable() +export class PeerTubeRouterService { + static readonly ROUTE_SETTING_NAME = 's' + + constructor ( + private route: ActivatedRoute, + private router: Router, + private server: ServerService + ) { } + + addRouteSetting (toAdd: RouterSetting) { + if (this.hasRouteSetting(toAdd)) return + + const current = this.getRouteSetting() + + this.setRouteSetting(current | toAdd) + } + + deleteRouteSetting (toDelete: RouterSetting) { + const current = this.getRouteSetting() + + this.setRouteSetting(current & ~toDelete) + } + + getRouteSetting (snapshot?: ActivatedRouteSnapshot) { + return (snapshot || this.route.snapshot).queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME] + } + + setRouteSetting (value: number) { + let path = window.location.pathname + if (!path || path === '/') path = this.server.getHTMLConfig().instance.defaultClientRoute + + const queryParams = { [PeerTubeRouterService.ROUTE_SETTING_NAME]: value } + + this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) + } + + hasRouteSetting (setting: RouterSetting, snapshot?: ActivatedRouteSnapshot) { + return !!(this.getRouteSetting(snapshot) & setting) + } + + getNavigationEndEvents () { + return this.router.events.pipe( + filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd) + ) + } + + getScrollEvents () { + return this.router.events.pipe( + filter((e: Event): e is Scroll => e instanceof Scroll) + ) + } + + silentNavigate (baseRoute: string[], queryParams: { [id: string]: string }) { + let routeSetting = this.getRouteSetting() ?? RouterSetting.NONE + routeSetting |= RouterSetting.DISABLE_SCROLL_RESTORE + + queryParams = { + ...queryParams, + + [PeerTubeRouterService.ROUTE_SETTING_NAME]: routeSetting + } + + return this.router.navigate(baseRoute, { queryParams }) + } + +} 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 @@ +import * as debug from 'debug' +import { pairwise } from 'rxjs' +import { ViewportScroller } from '@angular/common' +import { Injectable } from '@angular/core' +import { RouterSetting } from '../' +import { PeerTubeRouterService } from './peertube-router.service' + +const logger = debug('peertube:main:ScrollService') + +@Injectable() +export class ScrollService { + + private resetScroll = true + + constructor ( + private viewportScroller: ViewportScroller, + private peertubeRouter: PeerTubeRouterService + ) { } + + enableScrollRestoration () { + // We'll manage scroll restoration ourselves + this.viewportScroller.setHistoryScrollRestoration('manual') + + this.consumeScroll() + this.produceScroll() + } + + private produceScroll () { + // When we add the a-state parameter, we don't want to alter the scroll + this.peertubeRouter.getNavigationEndEvents().pipe(pairwise()) + .subscribe(([ e1, e2 ]) => { + try { + this.resetScroll = false + + const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects) + const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects) + + if (previousUrl.pathname !== nextUrl.pathname) { + this.resetScroll = true + return + } + + if (this.peertubeRouter.hasRouteSetting(RouterSetting.DISABLE_SCROLL_RESTORE)) { + this.resetScroll = false + return + } + + // Remove route settings from the comparison + const nextSearchParams = nextUrl.searchParams + nextSearchParams.delete(PeerTubeRouterService.ROUTE_SETTING_NAME) + + const previousSearchParams = previousUrl.searchParams + + nextSearchParams.sort() + previousSearchParams.sort() + + if (nextSearchParams.toString() !== previousSearchParams.toString()) { + this.resetScroll = true + } + } catch (e) { + console.error('Cannot parse URL to check next scroll.', e) + this.resetScroll = true + } + }) + } + + private consumeScroll () { + // Handle anchors/restore position + this.peertubeRouter.getScrollEvents().subscribe(e => { + logger('Will schedule scroll after router event %o.', e) + + // scrollToAnchor first to preserve anchor position when using history navigation + if (e.anchor) { + setTimeout(() => this.viewportScroller.scrollToAnchor(e.anchor)) + + return + } + + if (e.position) { + setTimeout(() => this.viewportScroller.scrollToPosition(e.position)) + + return + } + + if (this.resetScroll) { + return this.viewportScroller.scrollToPosition([ 0, 0 ]) + } + }) + } + +} 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 @@ -import { first, map } from 'rxjs/operators' -import { SelectChannelItem } from 'src/types/select-options-item.model' -import { DatePipe } from '@angular/common' -import { HttpErrorResponse } from '@angular/common/http' -import { Notifier } from '@app/core' -import { HttpStatusCode } from '@shared/models' -import { environment } from '../../environments/environment' -import { AuthService } from '../core/auth' - -// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript -function getParameterByName (name: string, url: string) { - if (!url) url = window.location.href - name = name.replace(/[[\]]/g, '\\$&') - - const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)') - const results = regex.exec(url) - - if (!results) return null - if (!results[2]) return '' - - return decodeURIComponent(results[2].replace(/\+/g, ' ')) -} - -function listUserChannels (authService: AuthService) { - return authService.userInformationLoaded - .pipe( - first(), - map(() => { - const user = authService.getUser() - if (!user) return undefined - - const videoChannels = user.videoChannels - if (Array.isArray(videoChannels) === false) return undefined - - return videoChannels - .sort((a, b) => { - if (a.updatedAt < b.updatedAt) return 1 - if (a.updatedAt > b.updatedAt) return -1 - return 0 - }) - .map(c => ({ - id: c.id, - label: c.displayName, - support: c.support, - avatarPath: c.avatar?.path - }) as SelectChannelItem) - }) - ) -} - -function getAbsoluteAPIUrl () { - let absoluteAPIUrl = environment.hmr === true - ? 'http://localhost:9000' - : environment.apiUrl - - if (!absoluteAPIUrl) { - // The API is on the same domain - absoluteAPIUrl = window.location.origin - } - - return absoluteAPIUrl -} - -function getAbsoluteEmbedUrl () { - let absoluteEmbedUrl = environment.originServerUrl - if (!absoluteEmbedUrl) { - // The Embed is on the same domain - absoluteEmbedUrl = window.location.origin - } - - return absoluteEmbedUrl -} - -const datePipe = new DatePipe('en') -function dateToHuman (date: string) { - return datePipe.transform(date, 'medium') -} - -function durationToString (duration: number) { - const hours = Math.floor(duration / 3600) - const minutes = Math.floor((duration % 3600) / 60) - const seconds = duration % 60 - - const minutesPadding = minutes >= 10 ? '' : '0' - const secondsPadding = seconds >= 10 ? '' : '0' - const displayedHours = hours > 0 ? hours.toString() + ':' : '' - - return ( - displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString() - ).replace(/^0/, '') -} - -function immutableAssign (target: A, source: B) { - return Object.assign({}, target, source) -} - -// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34 -function objectToFormData (obj: any, form?: FormData, namespace?: string) { - const fd = form || new FormData() - let formKey - - for (const key of Object.keys(obj)) { - if (namespace) formKey = `${namespace}[${key}]` - else formKey = key - - if (obj[key] === undefined) continue - - if (Array.isArray(obj[key]) && obj[key].length === 0) { - fd.append(key, null) - continue - } - - if (obj[key] !== null && typeof obj[key] === 'object' && !(obj[key] instanceof File)) { - objectToFormData(obj[key], fd, formKey) - } else { - fd.append(formKey, obj[key]) - } - } - - return fd -} - -function objectLineFeedToHtml (obj: any, keyToNormalize: string) { - return immutableAssign(obj, { - [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize]) - }) -} - -function lineFeedToHtml (text: string) { - if (!text) return text - - return text.replace(/\r?\n|\r/g, '
') -} - -function removeElementFromArray (arr: T[], elem: T) { - const index = arr.indexOf(elem) - if (index !== -1) arr.splice(index, 1) -} - -function sortBy (obj: any[], key1: string, key2?: string) { - return obj.sort((a, b) => { - const elem1 = key2 ? a[key1][key2] : a[key1] - const elem2 = key2 ? b[key1][key2] : b[key1] - - if (elem1 < elem2) return -1 - if (elem1 === elem2) return 0 - return 1 - }) -} - -function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') { - window.scrollTo({ - left: 0, - top: 0, - behavior - }) -} - -function isInViewport (el: HTMLElement) { - const bounding = el.getBoundingClientRect() - return ( - bounding.top >= 0 && - bounding.left >= 0 && - bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && - bounding.right <= (window.innerWidth || document.documentElement.clientWidth) - ) -} - -function isXPercentInViewport (el: HTMLElement, percentVisible: number) { - const rect = el.getBoundingClientRect() - const windowHeight = (window.innerHeight || document.documentElement.clientHeight) - - return !( - Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible || - Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible - ) -} - -function genericUploadErrorHandler (parameters: { - err: Pick - name: string - notifier: Notifier - sticky?: boolean -}) { - const { err, name, notifier, sticky } = { sticky: false, ...parameters } - const title = $localize`The upload failed` - let message = err.message - - if (err instanceof ErrorEvent) { // network error - message = $localize`The connection was interrupted` - notifier.error(message, title, null, sticky) - } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) { - message = $localize`The server encountered an error` - notifier.error(message, title, null, sticky) - } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) { - message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)` - notifier.error(message, title, null, sticky) - } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) { - const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G' - message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})` - notifier.error(message, title, null, sticky) - } else { - notifier.error(err.message, title) - } - - return message -} - -export { - sortBy, - durationToString, - lineFeedToHtml, - getParameterByName, - getAbsoluteAPIUrl, - dateToHuman, - immutableAssign, - objectToFormData, - getAbsoluteEmbedUrl, - objectLineFeedToHtml, - removeElementFromArray, - scrollToTop, - isInViewport, - isXPercentInViewport, - listUserChannels, - genericUploadErrorHandler -} 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 @@ +import { first, map } from 'rxjs/operators' +import { SelectChannelItem } from 'src/types/select-options-item.model' +import { AuthService } from '../../core/auth' + +function listUserChannels (authService: AuthService) { + return authService.userInformationLoaded + .pipe( + first(), + map(() => { + const user = authService.getUser() + if (!user) return undefined + + const videoChannels = user.videoChannels + if (Array.isArray(videoChannels) === false) return undefined + + return videoChannels + .sort((a, b) => { + if (a.updatedAt < b.updatedAt) return 1 + if (a.updatedAt > b.updatedAt) return -1 + return 0 + }) + .map(c => ({ + id: c.id, + label: c.displayName, + support: c.support, + avatarPath: c.avatar?.path + }) as SelectChannelItem) + }) + ) +} + +export { + listUserChannels +} 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 @@ +import { DatePipe } from '@angular/common' + +const datePipe = new DatePipe('en') +function dateToHuman (date: string) { + return datePipe.transform(date, 'medium') +} + +function durationToString (duration: number) { + const hours = Math.floor(duration / 3600) + const minutes = Math.floor((duration % 3600) / 60) + const seconds = duration % 60 + + const minutesPadding = minutes >= 10 ? '' : '0' + const secondsPadding = seconds >= 10 ? '' : '0' + const displayedHours = hours > 0 ? hours.toString() + ':' : '' + + return ( + displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString() + ).replace(/^0/, '') +} + +export { + durationToString, + dateToHuman +} 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 @@ +import { immutableAssign } from './object' + +function objectLineFeedToHtml (obj: any, keyToNormalize: string) { + return immutableAssign(obj, { + [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize]) + }) +} + +function lineFeedToHtml (text: string) { + if (!text) return text + + return text.replace(/\r?\n|\r/g, '
') +} + +export { + objectLineFeedToHtml, + lineFeedToHtml +} 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 @@ +export * from './channel' +export * from './date' +export * from './html' +export * from './object' +export * from './ui' +export * from './upload' +export * from './url' diff --git a/client/src/app/helpers/utils/object.ts b/client/src/app/helpers/utils/object.ts new file mode 100644 index 000000000..1ca4a23ac --- /dev/null +++ b/client/src/app/helpers/utils/object.ts @@ -0,0 +1,47 @@ +function immutableAssign (target: A, source: B) { + return Object.assign({}, target, source) +} + +function removeElementFromArray (arr: T[], elem: T) { + const index = arr.indexOf(elem) + if (index !== -1) arr.splice(index, 1) +} + +function sortBy (obj: any[], key1: string, key2?: string) { + return obj.sort((a, b) => { + const elem1 = key2 ? a[key1][key2] : a[key1] + const elem2 = key2 ? b[key1][key2] : b[key1] + + if (elem1 < elem2) return -1 + if (elem1 === elem2) return 0 + return 1 + }) +} + +function intoArray (value: any) { + if (!value) return undefined + if (Array.isArray(value)) return value + + if (typeof value === 'string') return value.split(',') + + return [ value ] +} + +function toBoolean (value: any) { + if (!value) return undefined + + if (typeof value === 'boolean') return value + + if (value === 'true') return true + if (value === 'false') return false + + return undefined +} + +export { + sortBy, + immutableAssign, + removeElementFromArray, + intoArray, + toBoolean +} 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 @@ +function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') { + window.scrollTo({ + left: 0, + top: 0, + behavior + }) +} + +function isInViewport (el: HTMLElement) { + const bounding = el.getBoundingClientRect() + return ( + bounding.top >= 0 && + bounding.left >= 0 && + bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + bounding.right <= (window.innerWidth || document.documentElement.clientWidth) + ) +} + +function isXPercentInViewport (el: HTMLElement, percentVisible: number) { + const rect = el.getBoundingClientRect() + const windowHeight = (window.innerHeight || document.documentElement.clientHeight) + + return !( + Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible || + Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible + ) +} + +export { + scrollToTop, + isInViewport, + isXPercentInViewport +} 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 @@ +import { HttpErrorResponse } from '@angular/common/http' +import { Notifier } from '@app/core' +import { HttpStatusCode } from '@shared/models' + +function genericUploadErrorHandler (parameters: { + err: Pick + name: string + notifier: Notifier + sticky?: boolean +}) { + const { err, name, notifier, sticky } = { sticky: false, ...parameters } + const title = $localize`The upload failed` + let message = err.message + + if (err instanceof ErrorEvent) { // network error + message = $localize`The connection was interrupted` + notifier.error(message, title, null, sticky) + } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) { + message = $localize`The server encountered an error` + notifier.error(message, title, null, sticky) + } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) { + message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)` + notifier.error(message, title, null, sticky) + } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) { + const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G' + message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})` + notifier.error(message, title, null, sticky) + } else { + notifier.error(err.message, title) + } + + return message +} + +export { + genericUploadErrorHandler +} 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 @@ +import { environment } from '../../../environments/environment' + +// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript +function getParameterByName (name: string, url: string) { + if (!url) url = window.location.href + name = name.replace(/[[\]]/g, '\\$&') + + const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)') + const results = regex.exec(url) + + if (!results) return null + if (!results[2]) return '' + + return decodeURIComponent(results[2].replace(/\+/g, ' ')) +} + +function getAbsoluteAPIUrl () { + let absoluteAPIUrl = environment.hmr === true + ? 'http://localhost:9000' + : environment.apiUrl + + if (!absoluteAPIUrl) { + // The API is on the same domain + absoluteAPIUrl = window.location.origin + } + + return absoluteAPIUrl +} + +function getAbsoluteEmbedUrl () { + let absoluteEmbedUrl = environment.originServerUrl + if (!absoluteEmbedUrl) { + // The Embed is on the same domain + absoluteEmbedUrl = window.location.origin + } + + return absoluteEmbedUrl +} + +// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34 +function objectToFormData (obj: any, form?: FormData, namespace?: string) { + const fd = form || new FormData() + let formKey + + for (const key of Object.keys(obj)) { + if (namespace) formKey = `${namespace}[${key}]` + else formKey = key + + if (obj[key] === undefined) continue + + if (Array.isArray(obj[key]) && obj[key].length === 0) { + fd.append(key, null) + continue + } + + if (obj[key] !== null && typeof obj[key] === 'object' && !(obj[key] instanceof File)) { + objectToFormData(obj[key], fd, formKey) + } else { + fd.append(formKey, obj[key]) + } + } + + return fd +} + +export { + getParameterByName, + objectToFormData, + getAbsoluteAPIUrl, + getAbsoluteEmbedUrl +} 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') }) export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { @Input() filters: AdvancedInputFilter[] = [] + @Input() emitOnInit = true @Output() search = new EventEmitter() @@ -42,7 +43,7 @@ export class AdvancedInputFilterComponent implements OnInit, AfterViewInit { this.viewInitialized = true // Init after view init to not send an event too early - if (this.emitSearchAfterViewInit) this.emitSearch() + if (this.emitOnInit && this.emitSearchAfterViewInit) this.emitSearch() } 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 @@ +export * from './select-categories.component' export * from './select-channel.component' +export * from './select-checkbox-all.component' export * from './select-checkbox.component' export * from './select-custom-value.component' +export * from './select-languages.component' export * from './select-options.component' export * from './select-tags.component' diff --git a/client/src/app/shared/shared-forms/select/select-categories.component.html b/client/src/app/shared/shared-forms/select/select-categories.component.html new file mode 100644 index 000000000..2ec2f1264 --- /dev/null +++ b/client/src/app/shared/shared-forms/select/select-categories.component.html @@ -0,0 +1,8 @@ + + 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 @@ + +import { Component, forwardRef, OnInit } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { ServerService } from '@app/core' +import { SelectOptionsItem } from '../../../../types/select-options-item.model' +import { ItemSelectCheckboxValue } from './select-checkbox.component' + +@Component({ + selector: 'my-select-categories', + styleUrls: [ './select-shared.component.scss' ], + templateUrl: './select-categories.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectCategoriesComponent), + multi: true + } + ] +}) +export class SelectCategoriesComponent implements ControlValueAccessor, OnInit { + selectedCategories: ItemSelectCheckboxValue[] = [] + availableCategories: SelectOptionsItem[] = [] + + allCategoriesGroup = $localize`All categories` + + // Fix a bug on ng-select when we update items after we selected items + private toWrite: any + private loaded = false + + constructor ( + private server: ServerService + ) { + + } + + ngOnInit () { + this.server.getVideoCategories() + .subscribe( + categories => { + this.availableCategories = categories.map(c => ({ label: c.label, id: c.id + '', group: this.allCategoriesGroup })) + this.loaded = true + this.writeValue(this.toWrite) + } + ) + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (categories: string[] | number[]) { + if (!this.loaded) { + this.toWrite = categories + return + } + + this.selectedCategories = categories + ? categories.map(c => c + '') + : categories as string[] + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.propagateChange(this.selectedCategories) + } +} 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 @@ +import { Component, forwardRef, Input } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { Notifier } from '@app/core' +import { SelectOptionsItem } from '../../../../types/select-options-item.model' +import { ItemSelectCheckboxValue } from './select-checkbox.component' + +@Component({ + selector: 'my-select-checkbox-all', + styleUrls: [ './select-shared.component.scss' ], + + template: ` + + `, + + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectCheckboxAllComponent), + multi: true + } + ] +}) +export class SelectCheckboxAllComponent implements ControlValueAccessor { + @Input() availableItems: SelectOptionsItem[] = [] + @Input() allGroupLabel: string + + @Input() placeholder: string + @Input() maxItems: number + + selectedItems: ItemSelectCheckboxValue[] + + constructor ( + private notifier: Notifier + ) { + + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (items: string[]) { + this.selectedItems = items + ? items.map(l => ({ id: l })) + : [ { group: this.allGroupLabel } ] + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + if (!this.isMaxConstraintValid()) return + + this.propagateChange(this.buildOutputItems()) + } + + onBlur () { + // Automatically use "All languages" if the user did not select any language + if (Array.isArray(this.selectedItems) && this.selectedItems.length === 0) { + this.selectedItems = [ { group: this.allGroupLabel } ] + } + } + + private isMaxConstraintValid () { + if (!this.maxItems) return true + + const outputItems = this.buildOutputItems() + if (!outputItems) return true + + if (outputItems.length >= this.maxItems) { + this.notifier.error($localize`You can't select more than ${this.maxItems} items`) + + return false + } + + return true + } + + private buildOutputItems () { + if (!Array.isArray(this.selectedItems)) return undefined + + // null means "All" + if (this.selectedItems.length === 0 || this.selectedItems.length === this.availableItems.length) { + return null + } + + if (this.selectedItems.length === 1) { + const item = this.selectedItems[0] + + const itemGroup = typeof item === 'string' || typeof item === 'number' + ? item + : item.group + + if (itemGroup === this.allGroupLabel) return null + } + + return this.selectedItems.map(l => { + if (typeof l === 'string' || typeof l === 'number') return l + + if (l.group) return l.group + + return l.id + '' + }) + } +} 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 @@ groupBy="group" [compareWith]="compareFn" - - [maxSelectedItems]="maxSelectedItems" > 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' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { SelectOptionsItem } from '../../../../types/select-options-item.model' -export type ItemSelectCheckboxValue = { id?: string | number, group?: string } | string +export type ItemSelectCheckboxValue = { id?: string, group?: string } | string @Component({ selector: 'my-select-checkbox', @@ -21,7 +21,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { @Input() selectedItems: ItemSelectCheckboxValue[] = [] @Input() selectableGroup: boolean @Input() selectableGroupAsModel: boolean - @Input() maxSelectedItems: number @Input() placeholder: string ngOnInit () { @@ -46,8 +45,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { } else { this.selectedItems = items } - - this.propagateChange(this.selectedItems) } registerOnChange (fn: (_: any) => void) { @@ -63,7 +60,7 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor { } compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) { - if (typeof selected === 'string') { + if (typeof selected === 'string' || typeof selected === 'number') { return item.id === selected } 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 @@ + + 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 @@ +import { Component, forwardRef, Input, OnInit } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { ServerService } from '@app/core' +import { SelectOptionsItem } from '../../../../types/select-options-item.model' +import { ItemSelectCheckboxValue } from './select-checkbox.component' + +@Component({ + selector: 'my-select-languages', + styleUrls: [ './select-shared.component.scss' ], + templateUrl: './select-languages.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectLanguagesComponent), + multi: true + } + ] +}) +export class SelectLanguagesComponent implements ControlValueAccessor, OnInit { + @Input() maxLanguages: number + + selectedLanguages: ItemSelectCheckboxValue[] + availableLanguages: SelectOptionsItem[] = [] + + allLanguagesGroup = $localize`All languages` + + // Fix a bug on ng-select when we update items after we selected items + private toWrite: any + private loaded = false + + constructor ( + private server: ServerService + ) { + + } + + ngOnInit () { + this.server.getVideoLanguages() + .subscribe( + languages => { + this.availableLanguages = [ { label: $localize`Unknown language`, id: '_unknown', group: this.allLanguagesGroup } ] + + this.availableLanguages = this.availableLanguages + .concat(languages.map(l => ({ label: l.label, id: l.id, group: this.allLanguagesGroup }))) + + this.loaded = true + this.writeValue(this.toWrite) + } + ) + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (languages: ItemSelectCheckboxValue[]) { + if (!this.loaded) { + this.toWrite = languages + return + } + + this.selectedLanguages = languages + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.propagateChange(this.selectedLanguages) + } +} 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' import { PreviewUploadComponent } from './preview-upload.component' import { ReactiveFileComponent } from './reactive-file.component' import { + SelectCategoriesComponent, SelectChannelComponent, + SelectCheckboxAllComponent, SelectCheckboxComponent, SelectCustomValueComponent, + SelectLanguagesComponent, SelectOptionsComponent, SelectTagsComponent } from './select' @@ -52,6 +55,9 @@ import { TimestampInputComponent } from './timestamp-input.component' SelectTagsComponent, SelectCheckboxComponent, SelectCustomValueComponent, + SelectLanguagesComponent, + SelectCategoriesComponent, + SelectCheckboxAllComponent, DynamicFormFieldComponent, @@ -80,6 +86,9 @@ import { TimestampInputComponent } from './timestamp-input.component' SelectTagsComponent, SelectCheckboxComponent, SelectCustomValueComponent, + SelectLanguagesComponent, + SelectCategoriesComponent, + SelectCheckboxAllComponent, DynamicFormFieldComponent, 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 = { columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default, live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default, repeat: require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, + 'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default, 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, 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 @@ import { fromEvent, Observable, Subscription } from 'rxjs' import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' +import { PeerTubeRouterService, RouterSetting } from '@app/core' @Directive({ selector: '[myInfiniteScroller]' }) export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { @Input() percentLimit = 70 - @Input() autoInit = false @Input() onItself = false @Input() dataObservable: Observable + // Add angular state in query params to reuse the routed component + @Input() setAngularState: boolean + @Output() nearOfBottom = new EventEmitter() private decimalLimit = 0 @@ -20,7 +23,10 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh private checkScroll = false - constructor (private el: ElementRef) { + constructor ( + private peertubeRouter: PeerTubeRouterService, + private el: ElementRef + ) { this.decimalLimit = this.percentLimit / 100 } @@ -36,7 +42,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh } ngOnInit () { - if (this.autoInit === true) return this.initialize() + this.initialize() } ngOnDestroy () { @@ -67,7 +73,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh filter(({ current }) => this.isScrollingDown(current)), filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit) ) - .subscribe(() => this.nearOfBottom.emit()) + .subscribe(() => { + if (this.setAngularState) this.setScrollRouteParams() + + this.nearOfBottom.emit() + }) if (this.dataObservable) { this.dataObservable @@ -96,4 +106,8 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh this.lastCurrentBottom = current return result } + + private setScrollRouteParams () { + this.peertubeRouter.addRouteSetting(RouterSetting.REUSE_COMPONENT) + } } 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 @@ a { color: #000; display: block; + min-width: 100px; } } my-global-icon { - @include apply-svg-color(pvar(--mainForegroundColor)); - cursor: pointer; width: 100%; } 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 @@
- +
+ + + + Clear filters +
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 { height: 28px; width: 28px; cursor: pointer; + color: pvar(--mainColor); &:hover { color: pvar(--mainHoverColor); } - - &[iconName=search] { - color: pvar(--mainForegroundColor); - } - - &[iconName=cross] { - color: pvar(--mainForegroundColor); - } } input { @include peertube-input-text(200px); + + &:focus { + box-shadow: 0 0 5px 0 #a5a5a5; + } } 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 @@ -import { Subject } from 'rxjs' -import { debounceTime, distinctUntilChanged } from 'rxjs/operators' import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' @Component({ selector: 'my-simple-search-input', @@ -22,23 +19,9 @@ export class SimpleSearchInputComponent implements OnInit { value = '' inputShown: boolean - private searchSubject = new Subject() - - constructor ( - private router: Router, - private route: ActivatedRoute - ) {} + private hasAlreadySentSearch = false ngOnInit () { - this.searchSubject - .pipe( - debounceTime(400), - distinctUntilChanged() - ) - .subscribe(value => this.searchChanged.emit(value)) - - this.searchSubject.next(this.value) - if (this.isInputShown()) this.showInput(false) } @@ -54,7 +37,7 @@ export class SimpleSearchInputComponent implements OnInit { return } - this.searchChange() + this.sendSearch() } showInput (focus = true) { @@ -80,9 +63,14 @@ export class SimpleSearchInputComponent implements OnInit { this.hideInput() } - searchChange () { - this.router.navigate([ './search' ], { relativeTo: this.route }) + sendSearch () { + this.hasAlreadySentSearch = true + this.searchChanged.emit(this.value) + } + + onResetFilter () { + this.value = '' - this.searchSubject.next(this.value) + if (this.hasAlreadySentSearch) this.sendSearch() } } 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 @@
You don't have notifications.
-
+
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' import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' import { objectToFormData } from '@app/helpers' import { + BooleanBothQuery, FeedFormat, NSFWPolicyType, ResultList, @@ -28,19 +29,21 @@ import { VideoDetails } from './video-details.model' import { VideoEdit } from './video-edit.model' import { Video } from './video.model' -export interface VideosProvider { - getVideos (parameters: { - videoPagination: ComponentPaginationLight - sort: VideoSortField - filter?: VideoFilter - categoryOneOf?: number[] - languageOneOf?: string[] - nsfwPolicy: NSFWPolicyType - }): Observable> +export type CommonVideoParams = { + videoPagination: ComponentPaginationLight + sort: VideoSortField + filter?: VideoFilter + categoryOneOf?: number[] + languageOneOf?: string[] + isLive?: boolean + skipCount?: boolean + // FIXME: remove? + nsfwPolicy?: NSFWPolicyType + nsfw?: BooleanBothQuery } @Injectable() -export class VideoService implements VideosProvider { +export class VideoService { static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.' @@ -144,32 +147,16 @@ export class VideoService implements VideosProvider { ) } - getAccountVideos (parameters: { + getAccountVideos (parameters: CommonVideoParams & { account: Pick - videoPagination: ComponentPaginationLight - sort: VideoSortField - nsfwPolicy?: NSFWPolicyType - videoFilter?: VideoFilter search?: string }): Observable> { - const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters - - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + const { account, search } = parameters let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (nsfwPolicy) { - params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) - } - - if (videoFilter) { - params = params.set('filter', videoFilter) - } + params = this.buildCommonVideosParams({ params, ...parameters }) - if (search) { - params = params.set('search', search) - } + if (search) params = params.set('search', search) return this.authHttp .get>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) @@ -179,27 +166,13 @@ export class VideoService implements VideosProvider { ) } - getVideoChannelVideos (parameters: { + getVideoChannelVideos (parameters: CommonVideoParams & { videoChannel: Pick - videoPagination: ComponentPaginationLight - sort: VideoSortField - nsfwPolicy?: NSFWPolicyType - videoFilter?: VideoFilter }): Observable> { - const { videoChannel, videoPagination, sort, nsfwPolicy, videoFilter } = parameters - - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + const { videoChannel } = parameters let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (nsfwPolicy) { - params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) - } - - if (videoFilter) { - params = params.set('filter', videoFilter) - } + params = this.buildCommonVideosParams({ params, ...parameters }) return this.authHttp .get>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params }) @@ -209,30 +182,9 @@ export class VideoService implements VideosProvider { ) } - getVideos (parameters: { - videoPagination: ComponentPaginationLight - sort: VideoSortField - filter?: VideoFilter - categoryOneOf?: number[] - languageOneOf?: string[] - isLive?: boolean - skipCount?: boolean - nsfwPolicy?: NSFWPolicyType - }): Observable> { - const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive } = parameters - - const pagination = this.restService.componentPaginationToRestPagination(videoPagination) - + getVideos (parameters: CommonVideoParams): Observable> { let params = new HttpParams() - params = this.restService.addRestGetParams(params, pagination, sort) - - if (filter) params = params.set('filter', filter) - if (skipCount) params = params.set('skipCount', skipCount + '') - - if (isLive) params = params.set('isLive', isLive) - if (nsfwPolicy) params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) - if (languageOneOf) this.restService.addArrayParams(params, 'languageOneOf', languageOneOf) - if (categoryOneOf) this.restService.addArrayParams(params, 'categoryOneOf', categoryOneOf) + params = this.buildCommonVideosParams({ params, ...parameters }) return this.authHttp .get>(VideoService.BASE_VIDEO_URL, { params }) @@ -421,4 +373,22 @@ export class VideoService implements VideosProvider { catchError(err => this.restExtractor.handleError(err)) ) } + + private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) { + const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options + + const pagination = this.restService.componentPaginationToRestPagination(videoPagination) + let newParams = this.restService.addRestGetParams(params, pagination, sort) + + if (filter) newParams = newParams.set('filter', filter) + if (skipCount) newParams = newParams.set('skipCount', skipCount + '') + + if (isLive) newParams = newParams.set('isLive', isLive) + if (nsfw) newParams = newParams.set('nsfw', nsfw) + if (nsfwPolicy) newParams = newParams.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) + if (languageOneOf) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf) + if (categoryOneOf) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf) + + return newParams + } } 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 @@ +import { intoArray } from '@app/helpers' import { BooleanBothQuery, BooleanQuery, @@ -74,8 +75,8 @@ export class AdvancedSearch { this.categoryOneOf = options.categoryOneOf || undefined this.licenceOneOf = options.licenceOneOf || undefined this.languageOneOf = options.languageOneOf || undefined - this.tagsOneOf = this.intoArray(options.tagsOneOf) - this.tagsAllOf = this.intoArray(options.tagsAllOf) + this.tagsOneOf = intoArray(options.tagsOneOf) + this.tagsAllOf = intoArray(options.tagsAllOf) this.durationMin = parseInt(options.durationMin, 10) this.durationMax = parseInt(options.durationMax, 10) @@ -150,9 +151,9 @@ export class AdvancedSearch { originallyPublishedStartDate: this.originallyPublishedStartDate, originallyPublishedEndDate: this.originallyPublishedEndDate, nsfw: this.nsfw, - categoryOneOf: this.intoArray(this.categoryOneOf), - licenceOneOf: this.intoArray(this.licenceOneOf), - languageOneOf: this.intoArray(this.languageOneOf), + categoryOneOf: intoArray(this.categoryOneOf), + licenceOneOf: intoArray(this.licenceOneOf), + languageOneOf: intoArray(this.languageOneOf), tagsOneOf: this.tagsOneOf, tagsAllOf: this.tagsAllOf, durationMin: this.durationMin, @@ -198,13 +199,4 @@ export class AdvancedSearch { return true } - - private intoArray (value: any) { - if (!value) return undefined - if (Array.isArray(value)) return value - - if (typeof value === 'string') return value.split(',') - - return [ value ] - } } 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 @@
- - +
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] { margin-bottom: 30px; } -my-select-checkbox { +my-select-languages { @include responsive-width(340px); 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 @@ import { pick } from 'lodash-es' -import { forkJoin, Subject, Subscription } from 'rxjs' +import { Subject, Subscription } from 'rxjs' import { first } from 'rxjs/operators' import { Component, Input, OnDestroy, OnInit } from '@angular/core' import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' -import { FormReactive, FormValidatorService, ItemSelectCheckboxValue } from '@app/shared/shared-forms' +import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' import { UserUpdateMe } from '@shared/models' import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' -import { SelectOptionsItem } from '../../../types/select-options-item.model' @Component({ selector: 'my-user-video-settings', @@ -19,12 +18,9 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, @Input() notifyOnUpdate = true @Input() userInformationLoaded: Subject - languageItems: SelectOptionsItem[] = [] defaultNSFWPolicy: NSFWPolicyType formValuesWatcher: Subscription - private allLanguagesGroup: string - constructor ( protected formValidatorService: FormValidatorService, private authService: AuthService, @@ -36,8 +32,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, } ngOnInit () { - this.allLanguagesGroup = $localize`All languages` - this.buildForm({ nsfwPolicy: null, webTorrentEnabled: null, @@ -46,33 +40,23 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, videoLanguages: null }) - forkJoin([ - this.serverService.getVideoLanguages(), - this.userInformationLoaded.pipe(first()) - ]).subscribe(([ languages ]) => { - const group = this.allLanguagesGroup - - this.languageItems = [ { label: $localize`Unknown language`, id: '_unknown', group } ] - this.languageItems = this.languageItems - .concat(languages.map(l => ({ label: l.label, id: l.id, group }))) - - const videoLanguages: ItemSelectCheckboxValue[] = this.user.videoLanguages - ? this.user.videoLanguages.map(l => ({ id: l })) - : [ { group } ] - - const serverConfig = this.serverService.getHTMLConfig() - this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy - - this.form.patchValue({ - nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy, - webTorrentEnabled: this.user.webTorrentEnabled, - autoPlayVideo: this.user.autoPlayVideo === true, - autoPlayNextVideo: this.user.autoPlayNextVideo, - videoLanguages - }) - - if (this.reactiveUpdate) this.handleReactiveUpdate() - }) + this.userInformationLoaded.pipe(first()) + .subscribe( + () => { + const serverConfig = this.serverService.getHTMLConfig() + this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy + + this.form.patchValue({ + nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy, + webTorrentEnabled: this.user.webTorrentEnabled, + autoPlayVideo: this.user.autoPlayVideo === true, + autoPlayNextVideo: this.user.autoPlayNextVideo, + videoLanguages: this.user.videoLanguages + }) + + if (this.reactiveUpdate) this.handleReactiveUpdate() + } + ) } ngOnDestroy () { @@ -85,23 +69,15 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, const autoPlayVideo = this.form.value['autoPlayVideo'] const autoPlayNextVideo = this.form.value['autoPlayNextVideo'] - let videoLanguagesForm = this.form.value['videoLanguages'] + const videoLanguages = this.form.value['videoLanguages'] - if (Array.isArray(videoLanguagesForm)) { - if (videoLanguagesForm.length > 20) { + if (Array.isArray(videoLanguages)) { + if (videoLanguages.length > 20) { this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`) return } - - // Automatically use "All languages" if the user did not select any language - if (videoLanguagesForm.length === 0) { - videoLanguagesForm = [ this.allLanguagesGroup ] - this.form.patchValue({ videoLanguages: [ { group: this.allLanguagesGroup } ] }) - } } - const videoLanguages = this.buildLanguagesFromForm(videoLanguagesForm) - let details: UserUpdateMe = { nsfwPolicy, webTorrentEnabled, @@ -123,31 +99,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit, return this.updateAnonymousProfile(details) } - private buildLanguagesFromForm (videoLanguages: ItemSelectCheckboxValue[]) { - if (!Array.isArray(videoLanguages)) return undefined - - // null means "All" - if (videoLanguages.length === this.languageItems.length) return null - - if (videoLanguages.length === 1) { - const videoLanguage = videoLanguages[0] - - if (typeof videoLanguage === 'string') { - if (videoLanguage === this.allLanguagesGroup) return null - } else { - if (videoLanguage.group === this.allLanguagesGroup) return null - } - } - - return videoLanguages.map(l => { - if (typeof l === 'string') return l - - if (l.group) return l.group - - return l.id + '' - }) - } - private handleReactiveUpdate () { let oldForm = { ...this.form.value } 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 @@ -import { fromEvent, Observable, ReplaySubject, Subject, Subscription } from 'rxjs' -import { debounceTime, switchMap, tap } from 'rxjs/operators' -import { - AfterContentInit, - ComponentFactoryResolver, - Directive, - Injector, - OnDestroy, - OnInit, - Type, - ViewChild, - ViewContainerRef -} from '@angular/core' -import { ActivatedRoute, Params, Router } from '@angular/router' -import { - AuthService, - ComponentPaginationLight, - LocalStorageService, - Notifier, - ScreenService, - ServerService, - User, - UserService -} from '@app/core' -import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook' -import { GlobalIconName } from '@app/shared/shared-icons' -import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils' -import { HTMLServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/models' -import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' -import { Syndication, Video } from '../shared-main' -import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component' -import { MiniatureDisplayOptions } from './video-miniature.component' - -enum GroupDate { - UNKNOWN = 0, - TODAY = 1, - YESTERDAY = 2, - THIS_WEEK = 3, - THIS_MONTH = 4, - LAST_MONTH = 5, - OLDER = 6 -} - -@Directive() -// eslint-disable-next-line @angular-eslint/directive-class-suffix -export abstract class AbstractVideoList implements OnInit, OnDestroy, AfterContentInit, DisableForReuseHook { - @ViewChild('videoListHeader', { static: true, read: ViewContainerRef }) videoListHeader: ViewContainerRef - - HeaderComponent: Type = VideoListHeaderComponent - headerComponentInjector: Injector - - pagination: ComponentPaginationLight = { - currentPage: 1, - itemsPerPage: 25 - } - sort: VideoSortField = '-publishedAt' - - categoryOneOf?: number[] - languageOneOf?: string[] - nsfwPolicy?: NSFWPolicyType - defaultSort: VideoSortField = '-publishedAt' - - syndicationItems: Syndication[] = [] - - loadOnInit = true - loadUserVideoPreferences = false - - displayModerationBlock = false - titleTooltip: string - displayVideoActions = true - groupByDate = false - - videos: Video[] = [] - hasDoneFirstQuery = false - disabled = false - - displayOptions: MiniatureDisplayOptions = { - date: true, - views: true, - by: true, - avatar: false, - privacyLabel: true, - privacyText: false, - state: false, - blacklistInfo: false - } - - actions: { - iconName: GlobalIconName - label: string - justIcon?: boolean - routerLink?: string - href?: string - click?: (e: Event) => void - }[] = [] - - onDataSubject = new Subject() - - userMiniature: User - - protected onUserLoadedSubject = new ReplaySubject(1) - - protected serverConfig: HTMLServerConfig - - protected abstract notifier: Notifier - protected abstract authService: AuthService - protected abstract userService: UserService - protected abstract route: ActivatedRoute - protected abstract serverService: ServerService - protected abstract screenService: ScreenService - protected abstract storageService: LocalStorageService - protected abstract router: Router - protected abstract cfr: ComponentFactoryResolver - abstract titlePage: string - - private resizeSubscription: Subscription - private angularState: number - - private groupedDateLabels: { [id in GroupDate]: string } - private groupedDates: { [id: number]: GroupDate } = {} - - private lastQueryLength: number - - abstract getVideosObservable (page: number): Observable<{ data: Video[] }> - - abstract generateSyndicationList (): void - - ngOnInit () { - this.serverConfig = this.serverService.getHTMLConfig() - - this.groupedDateLabels = { - [GroupDate.UNKNOWN]: null, - [GroupDate.TODAY]: $localize`Today`, - [GroupDate.YESTERDAY]: $localize`Yesterday`, - [GroupDate.THIS_WEEK]: $localize`This week`, - [GroupDate.THIS_MONTH]: $localize`This month`, - [GroupDate.LAST_MONTH]: $localize`Last month`, - [GroupDate.OLDER]: $localize`Older` - } - - // Subscribe to route changes - const routeParams = this.route.snapshot.queryParams - this.loadRouteParams(routeParams) - - this.resizeSubscription = fromEvent(window, 'resize') - .pipe(debounceTime(500)) - .subscribe(() => this.calcPageSizes()) - - this.calcPageSizes() - - const loadUserObservable = this.loadUserAndSettings() - loadUserObservable.subscribe(() => { - this.onUserLoadedSubject.next() - - if (this.loadOnInit === true) this.loadMoreVideos() - }) - - this.userService.listenAnonymousUpdate() - .pipe(switchMap(() => this.loadUserAndSettings())) - .subscribe(() => { - if (this.hasDoneFirstQuery) this.reloadVideos() - }) - - // Display avatar in mobile view - if (this.screenService.isInMobileView()) { - this.displayOptions.avatar = true - } - } - - ngOnDestroy () { - if (this.resizeSubscription) this.resizeSubscription.unsubscribe() - } - - ngAfterContentInit () { - if (this.videoListHeader) { - // some components don't use the header: they use their own template, like my-history.component.html - this.setHeader(this.HeaderComponent, this.headerComponentInjector) - } - } - - disableForReuse () { - this.disabled = true - } - - enabledForReuse () { - this.disabled = false - } - - videoById (index: number, video: Video) { - return video.id - } - - onNearOfBottom () { - if (this.disabled) return - - // No more results - if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return - - this.pagination.currentPage += 1 - - this.setScrollRouteParams() - - this.loadMoreVideos() - } - - loadMoreVideos (reset = false) { - this.getVideosObservable(this.pagination.currentPage) - .subscribe({ - next: ({ data }) => { - this.hasDoneFirstQuery = true - this.lastQueryLength = data.length - - if (reset) this.videos = [] - this.videos = this.videos.concat(data) - - if (this.groupByDate) this.buildGroupedDateLabels() - - this.onMoreVideos() - - this.onDataSubject.next(data) - }, - - error: err => { - const message = $localize`Cannot load more videos. Try again later.` - - console.error(message, { err }) - this.notifier.error(message) - } - }) - } - - reloadVideos () { - this.pagination.currentPage = 1 - this.loadMoreVideos(true) - } - - removeVideoFromArray (video: Video) { - this.videos = this.videos.filter(v => v.id !== video.id) - } - - buildGroupedDateLabels () { - let currentGroupedDate: GroupDate = GroupDate.UNKNOWN - - const periods = [ - { - value: GroupDate.TODAY, - validator: (d: Date) => isToday(d) - }, - { - value: GroupDate.YESTERDAY, - validator: (d: Date) => isYesterday(d) - }, - { - value: GroupDate.THIS_WEEK, - validator: (d: Date) => isLastWeek(d) - }, - { - value: GroupDate.THIS_MONTH, - validator: (d: Date) => isThisMonth(d) - }, - { - value: GroupDate.LAST_MONTH, - validator: (d: Date) => isLastMonth(d) - }, - { - value: GroupDate.OLDER, - validator: () => true - } - ] - - for (const video of this.videos) { - const publishedDate = video.publishedAt - - for (let i = 0; i < periods.length; i++) { - const period = periods[i] - - if (currentGroupedDate <= period.value && period.validator(publishedDate)) { - - if (currentGroupedDate !== period.value) { - currentGroupedDate = period.value - this.groupedDates[video.id] = currentGroupedDate - } - - break - } - } - } - } - - getCurrentGroupedDateLabel (video: Video) { - if (this.groupByDate === false) return undefined - - return this.groupedDateLabels[this.groupedDates[video.id]] - } - - toggleModerationDisplay () { - throw new Error('toggleModerationDisplay ' + $localize`function is not implemented`) - } - - setHeader ( - t: Type = this.HeaderComponent, - i: Injector = this.headerComponentInjector - ) { - const injector = i || Injector.create({ - providers: [ { - provide: 'data', - useValue: { - titlePage: this.titlePage, - titleTooltip: this.titleTooltip - } - } ] - }) - const viewContainerRef = this.videoListHeader - viewContainerRef.clear() - - const componentFactory = this.cfr.resolveComponentFactory(t) - viewContainerRef.createComponent(componentFactory, 0, injector) - } - - // Can be redefined by child - displayAsRow () { - return false - } - - // On videos hook for children that want to do something - protected onMoreVideos () { /* empty */ } - - protected load () { /* empty */ } - - // Hook if the page has custom route params - protected loadPageRouteParams (_queryParams: Params) { /* empty */ } - - protected loadRouteParams (queryParams: Params) { - this.sort = queryParams['sort'] as VideoSortField || this.defaultSort - this.categoryOneOf = queryParams['categoryOneOf'] - this.angularState = queryParams['a-state'] - - this.loadPageRouteParams(queryParams) - } - - protected buildLocalFilter (existing: VideoFilter, base: VideoFilter) { - if (base === 'local') { - return existing === 'local' - ? 'all-local' as 'all-local' - : 'local' as 'local' - } - - return existing === 'all' - ? null - : 'all' - } - - protected enableAllFilterIfPossible () { - if (!this.authService.isLoggedIn()) return - - this.authService.userInformationLoaded - .subscribe(() => { - const user = this.authService.getUser() - this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) - }) - } - - private calcPageSizes () { - if (this.screenService.isInMobileView()) { - this.pagination.itemsPerPage = 5 - } - } - - private setScrollRouteParams () { - // Already set - if (this.angularState) return - - this.angularState = 42 - - const queryParams = { - 'a-state': this.angularState, - categoryOneOf: this.categoryOneOf - } - - let path = this.getUrlWithoutParams() - if (!path || path === '/') path = this.serverConfig.instance.defaultClientRoute - - this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' }) - } - - private loadUserAndSettings () { - return this.userService.getAnonymousOrLoggedUser() - .pipe(tap(user => { - this.userMiniature = user - - if (!this.loadUserVideoPreferences) return - - this.languageOneOf = user.videoLanguages - this.nsfwPolicy = user.nsfwPolicy - })) - } - - private getUrlWithoutParams () { - const urlTree = this.router.parseUrl(this.router.url) - urlTree.queryParams = {} - - return urlTree.toString() - } -} 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 @@ -export * from './abstract-video-list' export * from './video-actions-dropdown.component' export * from './video-download.component' +export * from './video-filters-header.component' +export * from './video-filters.model' export * from './video-miniature.component' +export * from './videos-list.component' export * from './videos-selection.component' -export * from './video-list-header.component' export * from './shared-video-miniature.module' diff --git a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts index 03be6d2ff..632213922 100644 --- a/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts +++ b/client/src/app/shared/shared-video-miniature/shared-video-miniature.module.ts @@ -1,19 +1,20 @@ import { NgModule } from '@angular/core' +import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' import { SharedFormModule } from '../shared-forms' import { SharedGlobalIconModule } from '../shared-icons' import { SharedMainModule } from '../shared-main/shared-main.module' import { SharedModerationModule } from '../shared-moderation' -import { SharedVideoModule } from '../shared-video' import { SharedThumbnailModule } from '../shared-thumbnail' +import { SharedVideoModule } from '../shared-video' import { SharedVideoLiveModule } from '../shared-video-live' import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module' import { VideoActionsDropdownComponent } from './video-actions-dropdown.component' import { VideoDownloadComponent } from './video-download.component' +import { VideoFiltersHeaderComponent } from './video-filters-header.component' import { VideoMiniatureComponent } from './video-miniature.component' +import { VideosListComponent } from './videos-list.component' import { VideosSelectionComponent } from './videos-selection.component' -import { VideoListHeaderComponent } from './video-list-header.component' -import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' @NgModule({ imports: [ @@ -33,14 +34,17 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image VideoDownloadComponent, VideoMiniatureComponent, VideosSelectionComponent, - VideoListHeaderComponent + VideoFiltersHeaderComponent, + VideosListComponent ], exports: [ VideoActionsDropdownComponent, VideoDownloadComponent, VideoMiniatureComponent, - VideosSelectionComponent + VideosSelectionComponent, + VideoFiltersHeaderComponent, + VideosListComponent ], 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 @@ margin-top: 20px; .peertube-radio-container { - @include peertube-radio-container; @include margin-right(30px); 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 @@ + + + + + +
+ +
+
+
+ More filters + Less filters + + +
+ +
+ + {{ activeFilter.label }} + + : {{ activeFilter.value }} + + + +
+
+ + + Sort by "Recently Added" + + Sort by "Views" + Sort by "Hot" + Sort by "Best" + Sort by "Likes" + + +
+ +
+
+
+ + + + +
+ +
+ + + +
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + +
+ +
+ + + +
+
+
+ +
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 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.root { + margin-bottom: 45px; + font-size: 15px; +} + +.first-row { + display: flex; + justify-content: space-between; +} + +.active-filters { + display: flex; + flex-wrap: wrap; +} + +.filters { + display: flex; + flex-wrap: wrap; + margin-top: 25px; + + border-bottom: 1px solid $separator-border-color; + + input[type=radio] + label { + font-weight: $font-regular; + } + + .form-group > label:first-child { + display: block; + + &.with-description { + margin-bottom: 0; + } + + &:not(.with-description) { + margin-bottom: 10px; + } + } + + .form-group { + @include margin-right(30px); + } +} + +.pastille { + @include margin-right(15px); + + border-radius: 24px; + padding: 4px 15px; + font-size: 16px; + margin-bottom: 15px; + cursor: pointer; +} + +.filters-toggle { + border: 2px solid pvar(--mainForegroundColor); + + my-global-icon { + @include margin-left(5px); + } + + &.active my-global-icon { + position: relative; + top: -1px; + } + + &:not(.active) { + my-global-icon ::ng-deep svg { + transform: rotate(180deg); + } + } +} + +// Than have an icon +.filters-toggle, +.active-filter.can-remove { + padding: 4px 11px 4px 15px; +} + +.active-filter { + background-color: pvar(--channelBackgroundColor); + display: flex; + align-items: center; + + &:not(.can-remove) { + cursor: default; + } + + &.can-remove:hover { + opacity: 0.9; + } + + my-global-icon { + @include margin-left(10px); + + width: 16px; + color: pvar(--greyForegroundColor); + } +} + +.sort { + min-width: 200px; + max-width: 300px; + height: min-content; + + ::ng-deep { + .ng-select-container { + height: 33px !important; + } + + .ng-value strong { + @include margin-left(5px); + } + } +} + +my-select-languages, +my-select-categories { + width: 300px; + display: inline-block; +} + +.label-description { + font-size: 12px; + font-style: italic; + margin-bottom: 10px; + + a { + color: pvar(--mainColor); + } +} + +@media screen and (max-width: $small-view) { + .first-row { + flex-direction: column; + } +} 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 @@ +import * as debug from 'debug' +import { Subscription } from 'rxjs' +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' +import { FormBuilder, FormGroup } from '@angular/forms' +import { AuthService } from '@app/core' +import { ServerService } from '@app/core/server/server.service' +import { UserRight } from '@shared/models' +import { NSFWPolicyType } from '@shared/models/videos' +import { PeertubeModalService } from '../shared-main' +import { VideoFilters } from './video-filters.model' + +const logger = debug('peertube:videos:VideoFiltersHeaderComponent') + +@Component({ + selector: 'my-video-filters-header', + styleUrls: [ './video-filters-header.component.scss' ], + templateUrl: './video-filters-header.component.html' +}) +export class VideoFiltersHeaderComponent implements OnInit, OnDestroy { + @Input() filters: VideoFilters + + @Input() displayModerationBlock = false + + @Input() defaultSort = '-publishedAt' + @Input() nsfwPolicy: NSFWPolicyType + + @Output() filtersChanged = new EventEmitter() + + areFiltersCollapsed = true + + form: FormGroup + + private routeSub: Subscription + + constructor ( + private auth: AuthService, + private serverService: ServerService, + private fb: FormBuilder, + private modalService: PeertubeModalService + ) { + } + + ngOnInit () { + this.form = this.fb.group({ + sort: [ '' ], + nsfw: [ '' ], + languageOneOf: [ '' ], + categoryOneOf: [ '' ], + scope: [ '' ], + allVideos: [ '' ], + live: [ '' ] + }) + + this.patchForm(false) + + this.filters.onChange(() => { + this.patchForm(false) + }) + + this.form.valueChanges.subscribe(values => { + logger('Loading values from form: %O', values) + + this.filters.load(values) + this.filtersChanged.emit() + }) + } + + ngOnDestroy () { + if (this.routeSub) this.routeSub.unsubscribe() + } + + canSeeAllVideos () { + if (!this.auth.isLoggedIn()) return false + if (!this.displayModerationBlock) return false + + return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS) + } + + isTrendingSortEnabled (sort: 'most-viewed' | 'hot' | 'best' | 'most-liked') { + const serverConfig = this.serverService.getHTMLConfig() + + const enabled = serverConfig.trending.videos.algorithms.enabled.includes(sort) + + // Best is adapted from the user + if (sort === 'best') return enabled && this.auth.isLoggedIn() + + return enabled + } + + resetFilter (key: string, canRemove: boolean) { + if (!canRemove) return + + this.filters.reset(key) + this.patchForm(false) + this.filtersChanged.emit() + } + + getFilterTitle (canRemove: boolean) { + if (canRemove) return $localize`Remove this filter` + + return '' + } + + onAccountSettingsClick (event: Event) { + if (this.auth.isLoggedIn()) return + + event.preventDefault() + event.stopPropagation() + + this.modalService.openQuickSettingsSubject.next() + } + + private patchForm (emitEvent: boolean) { + const defaultValues = this.filters.toFormObject() + this.form.patchValue(defaultValues, { emitEvent }) + + logger('Patched form: %O', defaultValues) + } +} 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 @@ +import { intoArray, toBoolean } from '@app/helpers' +import { AttributesOnly } from '@shared/core-utils' +import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models' + +type VideoFiltersKeys = { + [ id in keyof AttributesOnly ]: any +} + +export type VideoFilterScope = 'local' | 'federated' + +export class VideoFilters { + sort: VideoSortField + nsfw: BooleanBothQuery + + languageOneOf: string[] + categoryOneOf: number[] + + scope: VideoFilterScope + allVideos: boolean + + live: BooleanBothQuery + + search: string + + private defaultValues = new Map([ + [ 'sort', '-publishedAt' ], + [ 'nsfw', 'false' ], + [ 'languageOneOf', undefined ], + [ 'categoryOneOf', undefined ], + [ 'scope', 'federated' ], + [ 'allVideos', false ], + [ 'live', 'both' ] + ]) + + private activeFilters: { key: string, canRemove: boolean, label: string, value?: string }[] = [] + private defaultNSFWPolicy: NSFWPolicyType + + private onChangeCallbacks: Array<() => void> = [] + private oldFormObjectString: string + + constructor (defaultSort: string, defaultScope: VideoFilterScope) { + this.setDefaultSort(defaultSort) + this.setDefaultScope(defaultScope) + + this.reset() + } + + onChange (cb: () => void) { + this.onChangeCallbacks.push(cb) + } + + triggerChange () { + // Don't run on change if the values did not change + const currentFormObjectString = JSON.stringify(this.toFormObject()) + if (this.oldFormObjectString && currentFormObjectString === this.oldFormObjectString) return + + this.oldFormObjectString = currentFormObjectString + + for (const cb of this.onChangeCallbacks) { + cb() + } + } + + setDefaultScope (scope: VideoFilterScope) { + this.defaultValues.set('scope', scope) + } + + setDefaultSort (sort: string) { + this.defaultValues.set('sort', sort) + } + + setNSFWPolicy (nsfwPolicy: NSFWPolicyType) { + this.updateDefaultNSFW(nsfwPolicy) + } + + reset (specificKey?: string) { + for (const [ key, value ] of this.defaultValues) { + if (specificKey && specificKey !== key) continue + + // FIXME: typings + this[key as any] = value + } + + this.buildActiveFilters() + } + + load (obj: Partial>) { + if (obj.sort !== undefined) this.sort = obj.sort + + if (obj.nsfw !== undefined) this.nsfw = obj.nsfw + + if (obj.languageOneOf !== undefined) this.languageOneOf = intoArray(obj.languageOneOf) + if (obj.categoryOneOf !== undefined) this.categoryOneOf = intoArray(obj.categoryOneOf) + + if (obj.scope !== undefined) this.scope = obj.scope + if (obj.allVideos !== undefined) this.allVideos = toBoolean(obj.allVideos) + + if (obj.live !== undefined) this.live = obj.live + + if (obj.search !== undefined) this.search = obj.search + + this.buildActiveFilters() + } + + buildActiveFilters () { + this.activeFilters = [] + + this.activeFilters.push({ + key: 'nsfw', + canRemove: false, + label: $localize`Sensitive content`, + value: this.getNSFWValue() + }) + + this.activeFilters.push({ + key: 'scope', + canRemove: false, + label: $localize`Scope`, + value: this.scope === 'federated' + ? $localize`Federated` + : $localize`Local` + }) + + if (this.languageOneOf && this.languageOneOf.length !== 0) { + this.activeFilters.push({ + key: 'languageOneOf', + canRemove: true, + label: $localize`Languages`, + value: this.languageOneOf.map(l => l.toUpperCase()).join(', ') + }) + } + + if (this.categoryOneOf && this.categoryOneOf.length !== 0) { + this.activeFilters.push({ + key: 'categoryOneOf', + canRemove: true, + label: $localize`Categories`, + value: this.categoryOneOf.join(', ') + }) + } + + if (this.allVideos) { + this.activeFilters.push({ + key: 'allVideos', + canRemove: true, + label: $localize`All videos` + }) + } + + if (this.live === 'true') { + this.activeFilters.push({ + key: 'live', + canRemove: true, + label: $localize`Live videos` + }) + } else if (this.live === 'false') { + this.activeFilters.push({ + key: 'live', + canRemove: true, + label: $localize`VOD videos` + }) + } + } + + getActiveFilters () { + return this.activeFilters + } + + toFormObject (): VideoFiltersKeys { + const result: Partial = {} + + for (const [ key ] of this.defaultValues) { + result[key] = this[key] + } + + return result as VideoFiltersKeys + } + + toUrlObject () { + const result: { [ id: string ]: any } = {} + + for (const [ key, defaultValue ] of this.defaultValues) { + if (this[key] !== defaultValue) { + result[key] = this[key] + } + } + + return result + } + + toVideosAPIObject () { + let filter: VideoFilter + + if (this.scope === 'local' && this.allVideos) { + filter = 'all-local' + } else if (this.scope === 'federated' && this.allVideos) { + filter = 'all' + } else if (this.scope === 'local') { + filter = 'local' + } + + let isLive: boolean + if (this.live === 'true') isLive = true + else if (this.live === 'false') isLive = false + + return { + sort: this.sort, + nsfw: this.nsfw, + languageOneOf: this.languageOneOf, + categoryOneOf: this.categoryOneOf, + search: this.search, + filter, + isLive + } + } + + getNSFWDisplayLabel () { + if (this.defaultNSFWPolicy === 'blur') return $localize`Blurred` + + return $localize`Displayed` + } + + private getNSFWValue () { + if (this.nsfw === 'false') return $localize`hidden` + if (this.defaultNSFWPolicy === 'blur') return $localize`blurred` + + return $localize`displayed` + } + + private updateDefaultNSFW (nsfwPolicy: NSFWPolicyType) { + const nsfw = nsfwPolicy === 'do_not_list' + ? 'false' + : 'both' + + this.defaultValues.set('nsfw', nsfw) + this.defaultNSFWPolicy = nsfwPolicy + + this.reset('nsfw') + } +} 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 @@ -

-
- {{ data.titlePage }} -
-

\ 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 @@ -import { Component, Inject, ViewEncapsulation } from '@angular/core' - -export interface GenericHeaderData { - titlePage: string - titleTooltip?: string -} - -export abstract class GenericHeaderComponent { - constructor (@Inject('data') public data: GenericHeaderData) {} -} - -@Component({ - selector: 'my-video-list-header', - // eslint-disable-next-line @angular-eslint/use-component-view-encapsulation - encapsulation: ViewEncapsulation.None, - templateUrl: './video-list-header.component.html' -}) -export class VideoListHeaderComponent extends GenericHeaderComponent { - constructor (@Inject('data') public data: GenericHeaderData) { - super(data) - } -} 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 similarity index 65% rename from client/src/app/shared/shared-video-miniature/abstract-video-list.html rename to 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 @@
- +

+ {{ title }} +

-
- +
+ Subscribe to RSS feed "{{ title }}" + + +
- +
+ @@ -24,27 +30,18 @@
- -
-
- - - -
-
+ +
No results.

@@ -53,7 +50,7 @@
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 similarity index 51% rename from client/src/app/shared/shared-video-miniature/abstract-video-list.scss rename to 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 @@ @use '_mixins' as *; @use '_miniature' as *; -$icon-size: 16px; - -::ng-deep my-video-list-header { - display: flex; - flex-grow: 1; -} - .videos-header { - display: flex; - justify-content: space-between; - align-items: center; + display: grid; + grid-template-columns: auto 1fr auto; + margin-bottom: 30px; - my-feed { - display: inline-block; - width: calc(#{$icon-size} - 2px); + .title, + .title-subscription { + grid-column: 1; } - .moderation-block { - @include margin-left(.4rem); + .title { + font-size: 18px; + color: pvar(--mainForegroundColor); + display: inline-block; + font-weight: $font-semibold; - display: flex; - justify-content: flex-end; - align-items: center; + margin-top: 30px; + margin-bottom: 0; + } + + .title-subscription { + grid-row: 2; + font-size: 14px; + color: pvar(--greyForegroundColor); - my-global-icon { - position: relative; - width: $icon-size; + &.no-title { + margin-top: 10px; } } + + .action-block { + grid-column: 3; + } + + my-feed { + @include margin-left(5px); + + display: inline-block; + width: 16px; + color: pvar(--mainColor); + position: relative; + top: -2px; + } } .date-title { font-size: 16px; font-weight: $font-semibold; margin-bottom: 20px; - margin-top: -10px; - // make the element span a full grid row within .videos grid + // Make the element span a full grid row within .videos grid grid-column: 1 / -1; &:not(:first-child) { @@ -64,6 +77,18 @@ $icon-size: 16px; } @media screen and (max-width: $mobile-view) { + .videos-header, + my-video-filters-header { + @include margin-left(15px); + @include margin-right(15px); + + display: inline-block; + } + + .date-title { + text-align: center; + } + .videos-header { flex-direction: column; 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 @@ +import * as debug from 'debug' +import { fromEvent, Observable, Subject, Subscription } from 'rxjs' +import { debounceTime, switchMap } from 'rxjs/operators' +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core' +import { GlobalIconName } from '@app/shared/shared-icons' +import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils' +import { ResultList, UserRight, VideoSortField } from '@shared/models' +import { Syndication, Video } from '../shared-main' +import { VideoFilters, VideoFilterScope } from './video-filters.model' +import { MiniatureDisplayOptions } from './video-miniature.component' + +const logger = debug('peertube:videos:VideosListComponent') + +export type HeaderAction = { + iconName: GlobalIconName + label: string + justIcon?: boolean + routerLink?: string + href?: string + click?: (e: Event) => void +} + +enum GroupDate { + UNKNOWN = 0, + TODAY = 1, + YESTERDAY = 2, + THIS_WEEK = 3, + THIS_MONTH = 4, + LAST_MONTH = 5, + OLDER = 6 +} + +@Component({ + selector: 'my-videos-list', + templateUrl: './videos-list.component.html', + styleUrls: [ './videos-list.component.scss' ] +}) +export class VideosListComponent implements OnInit, OnChanges, OnDestroy { + @Input() getVideosObservableFunction: (pagination: ComponentPaginationLight, filters: VideoFilters) => Observable> + @Input() getSyndicationItemsFunction: (filters: VideoFilters) => Promise | Syndication[] + @Input() baseRouteBuilderFunction: (filters: VideoFilters) => string[] + + @Input() title: string + @Input() titleTooltip: string + @Input() displayTitle = true + + @Input() defaultSort: VideoSortField + @Input() defaultScope: VideoFilterScope = 'federated' + @Input() displayFilters = false + @Input() displayModerationBlock = false + + @Input() loadUserVideoPreferences = false + + @Input() displayAsRow = false + @Input() displayVideoActions = true + @Input() groupByDate = false + + @Input() headerActions: HeaderAction[] = [] + + @Input() displayOptions: MiniatureDisplayOptions = { + date: true, + views: true, + by: true, + avatar: false, + privacyLabel: true, + privacyText: false, + state: false, + blacklistInfo: false + } + + @Input() disabled = false + + @Output() filtersChanged = new EventEmitter() + + videos: Video[] = [] + filters: VideoFilters + syndicationItems: Syndication[] + + onDataSubject = new Subject() + hasDoneFirstQuery = false + + userMiniature: User + + private routeSub: Subscription + private userSub: Subscription + private resizeSub: Subscription + + private pagination: ComponentPaginationLight = { + currentPage: 1, + itemsPerPage: 25 + } + + private groupedDateLabels: { [id in GroupDate]: string } + private groupedDates: { [id: number]: GroupDate } = {} + + private lastQueryLength: number + + constructor ( + private notifier: Notifier, + private authService: AuthService, + private userService: UserService, + private route: ActivatedRoute, + private screenService: ScreenService, + private peertubeRouter: PeerTubeRouterService + ) { + + } + + ngOnInit () { + this.filters = new VideoFilters(this.defaultSort, this.defaultScope) + this.filters.load({ ...this.route.snapshot.queryParams, scope: this.defaultScope }) + + this.groupedDateLabels = { + [GroupDate.UNKNOWN]: null, + [GroupDate.TODAY]: $localize`Today`, + [GroupDate.YESTERDAY]: $localize`Yesterday`, + [GroupDate.THIS_WEEK]: $localize`This week`, + [GroupDate.THIS_MONTH]: $localize`This month`, + [GroupDate.LAST_MONTH]: $localize`Last month`, + [GroupDate.OLDER]: $localize`Older` + } + + this.resizeSub = fromEvent(window, 'resize') + .pipe(debounceTime(500)) + .subscribe(() => this.calcPageSizes()) + + this.calcPageSizes() + + this.userService.getAnonymousOrLoggedUser() + .subscribe(user => { + this.userMiniature = user + + if (this.loadUserVideoPreferences) { + this.loadUserSettings(user) + } + + this.scheduleOnFiltersChanged(false) + + this.subscribeToAnonymousUpdate() + this.subscribeToSearchChange() + }) + + // Display avatar in mobile view + if (this.screenService.isInMobileView()) { + this.displayOptions.avatar = true + } + } + + ngOnDestroy () { + if (this.resizeSub) this.resizeSub.unsubscribe() + if (this.routeSub) this.routeSub.unsubscribe() + if (this.userSub) this.userSub.unsubscribe() + } + + ngOnChanges (changes: SimpleChanges) { + if (!this.filters) return + + let updated = false + + if (changes['defaultScope']) { + updated = true + this.filters.setDefaultScope(this.defaultScope) + } + + if (changes['defaultSort']) { + updated = true + this.filters.setDefaultSort(this.defaultSort) + } + + if (!updated) return + + const customizedByUser = this.hasBeenCustomizedByUser() + + if (!customizedByUser) { + if (this.loadUserVideoPreferences) { + this.loadUserSettings(this.userMiniature) + } + + this.filters.reset('scope') + this.filters.reset('sort') + } + + this.scheduleOnFiltersChanged(customizedByUser) + } + + videoById (_index: number, video: Video) { + return video.id + } + + onNearOfBottom () { + if (this.disabled) return + + // No more results + if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return + + this.pagination.currentPage += 1 + + this.loadMoreVideos() + } + + loadMoreVideos (reset = false) { + this.getVideosObservableFunction(this.pagination, this.filters) + .subscribe({ + next: ({ data }) => { + this.hasDoneFirstQuery = true + this.lastQueryLength = data.length + + if (reset) this.videos = [] + this.videos = this.videos.concat(data) + + if (this.groupByDate) this.buildGroupedDateLabels() + + this.onDataSubject.next(data) + }, + + error: err => { + const message = $localize`Cannot load more videos. Try again later.` + + console.error(message, { err }) + this.notifier.error(message) + } + }) + } + + reloadVideos () { + this.pagination.currentPage = 1 + this.loadMoreVideos(true) + } + + removeVideoFromArray (video: Video) { + this.videos = this.videos.filter(v => v.id !== video.id) + } + + buildGroupedDateLabels () { + let currentGroupedDate: GroupDate = GroupDate.UNKNOWN + + const periods = [ + { + value: GroupDate.TODAY, + validator: (d: Date) => isToday(d) + }, + { + value: GroupDate.YESTERDAY, + validator: (d: Date) => isYesterday(d) + }, + { + value: GroupDate.THIS_WEEK, + validator: (d: Date) => isLastWeek(d) + }, + { + value: GroupDate.THIS_MONTH, + validator: (d: Date) => isThisMonth(d) + }, + { + value: GroupDate.LAST_MONTH, + validator: (d: Date) => isLastMonth(d) + }, + { + value: GroupDate.OLDER, + validator: () => true + } + ] + + for (const video of this.videos) { + const publishedDate = video.publishedAt + + for (let i = 0; i < periods.length; i++) { + const period = periods[i] + + if (currentGroupedDate <= period.value && period.validator(publishedDate)) { + + if (currentGroupedDate !== period.value) { + currentGroupedDate = period.value + this.groupedDates[video.id] = currentGroupedDate + } + + break + } + } + } + } + + getCurrentGroupedDateLabel (video: Video) { + if (this.groupByDate === false) return undefined + + return this.groupedDateLabels[this.groupedDates[video.id]] + } + + scheduleOnFiltersChanged (customizedByUser: boolean) { + // We'll reload videos, but avoid weird UI effect + this.videos = [] + + setTimeout(() => this.onFiltersChanged(customizedByUser)) + } + + onFiltersChanged (customizedByUser: boolean) { + logger('Running on filters changed') + + this.updateUrl(customizedByUser) + + this.filters.triggerChange() + + this.reloadSyndicationItems() + this.reloadVideos() + } + + protected enableAllFilterIfPossible () { + if (!this.authService.isLoggedIn()) return + + this.authService.userInformationLoaded + .subscribe(() => { + const user = this.authService.getUser() + this.displayModerationBlock = user.hasRight(UserRight.SEE_ALL_VIDEOS) + }) + } + + private calcPageSizes () { + if (this.screenService.isInMobileView()) { + this.pagination.itemsPerPage = 5 + } + } + + private loadUserSettings (user: User) { + this.filters.setNSFWPolicy(user.nsfwPolicy) + + // Don't reset language filter if we don't want to refresh the component + if (!this.hasBeenCustomizedByUser()) { + this.filters.load({ languageOneOf: user.videoLanguages }) + } + } + + private reloadSyndicationItems () { + Promise.resolve(this.getSyndicationItemsFunction(this.filters)) + .then(items => { + if (!items || items.length === 0) this.syndicationItems = undefined + else this.syndicationItems = items + }) + .catch(err => console.error('Cannot get syndication items.', err)) + } + + private updateUrl (customizedByUser: boolean) { + const baseQuery = this.filters.toUrlObject() + + // Set or reset customized by user query param + const queryParams = customizedByUser || this.hasBeenCustomizedByUser() + ? { ...baseQuery, c: customizedByUser } + : baseQuery + + logger('Will inject %O in URL query', queryParams) + + const baseRoute = this.baseRouteBuilderFunction + ? this.baseRouteBuilderFunction(this.filters) + : [] + + const pathname = window.location.pathname + + const baseRouteChanged = baseRoute.length !== 0 && + pathname !== '/' && // Exclude special '/' case, we'll be redirected without component change + baseRoute.length !== 0 && pathname !== baseRoute.join('/') + + if (baseRouteChanged || Object.keys(baseQuery).length !== 0 || customizedByUser) { + this.peertubeRouter.silentNavigate(baseRoute, queryParams) + } + + this.filtersChanged.emit(this.filters) + } + + private hasBeenCustomizedByUser () { + return this.route.snapshot.queryParams['c'] === 'true' + } + + private subscribeToAnonymousUpdate () { + this.userSub = this.userService.listenAnonymousUpdate() + .pipe(switchMap(() => this.userService.getAnonymousOrLoggedUser())) + .subscribe(user => { + if (this.loadUserVideoPreferences) { + this.loadUserSettings(user) + } + + if (this.hasDoneFirstQuery) { + this.reloadVideos() + } + }) + } + + private subscribeToSearchChange () { + this.routeSub = this.route.queryParams.subscribe(param => { + if (!param['search']) return + + this.filters.load({ search: param['search'] }) + this.onFiltersChanged(true) + }) + } +} 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 @@
{{ noResultMessage }}
-
+
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 @@ -import { Observable } from 'rxjs' -import { - AfterContentInit, - Component, - ComponentFactoryResolver, - ContentChildren, - EventEmitter, - Input, - OnDestroy, - OnInit, - Output, - QueryList, - TemplateRef -} from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core' +import { Observable, Subject } from 'rxjs' +import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core' +import { ComponentPagination, Notifier, User } from '@app/core' import { ResultList, VideoSortField } from '@shared/models' import { PeerTubeTemplateDirective, Video } from '../shared-main' -import { AbstractVideoList } from './abstract-video-list' import { MiniatureDisplayOptions } from './video-miniature.component' export type SelectionType = { [ id: number ]: boolean } @@ -26,14 +12,18 @@ export type SelectionType = { [ id: number ]: boolean } templateUrl: './videos-selection.component.html', styleUrls: [ './videos-selection.component.scss' ] }) -export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit { +export class VideosSelectionComponent implements AfterContentInit { @Input() user: User @Input() pagination: ComponentPagination + @Input() titlePage: string + @Input() miniatureDisplayOptions: MiniatureDisplayOptions + @Input() noResultMessage = $localize`No results.` @Input() enableSelection = true - @Input() loadOnInit = true + + @Input() disabled = false @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable> @@ -47,19 +37,18 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni rowButtonsTemplate: TemplateRef globalButtonsTemplate: TemplateRef + videos: Video[] = [] + sort: VideoSortField = '-publishedAt' + + onDataSubject = new Subject() + + hasDoneFirstQuery = false + + private lastQueryLength: number + constructor ( - protected router: Router, - protected route: ActivatedRoute, - protected notifier: Notifier, - protected authService: AuthService, - protected userService: UserService, - protected screenService: ScreenService, - protected storageService: LocalStorageService, - protected serverService: ServerService, - protected cfr: ComponentFactoryResolver - ) { - super() - } + private notifier: Notifier + ) { } @Input() get selection () { return this._selection @@ -79,10 +68,6 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni this.videosModelChange.emit(this.videos) } - ngOnInit () { - super.ngOnInit() - } - ngAfterContentInit () { { const t = this.templates.find(t => t.name === 'rowButtons') @@ -93,10 +78,8 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni const t = this.templates.find(t => t.name === 'globalButtons') if (t) this.globalButtonsTemplate = t.template } - } - ngOnDestroy () { - super.ngOnDestroy() + this.loadMoreVideos() } getVideosObservable (page: number) { @@ -111,11 +94,50 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni return Object.keys(this._selection).some(k => this._selection[k] === true) } - generateSyndicationList () { - throw new Error('Method not implemented.') + videoById (index: number, video: Video) { + return video.id + } + + onNearOfBottom () { + if (this.disabled) return + + // No more results + if (this.lastQueryLength !== undefined && this.lastQueryLength < this.pagination.itemsPerPage) return + + this.pagination.currentPage += 1 + + this.loadMoreVideos() + } + + loadMoreVideos (reset = false) { + this.getVideosObservable(this.pagination.currentPage) + .subscribe({ + next: ({ data }) => { + this.hasDoneFirstQuery = true + this.lastQueryLength = data.length + + if (reset) this.videos = [] + this.videos = this.videos.concat(data) + this.videosModel = this.videos + + this.onDataSubject.next(data) + }, + + error: err => { + const message = $localize`Cannot load more videos. Try again later.` + + console.error(message, { err }) + this.notifier.error(message) + } + }) + } + + reloadVideos () { + this.pagination.currentPage = 1 + this.loadMoreVideos(true) } - protected onMoreVideos () { - this.videosModel = this.videos + removeVideoFromArray (video: Video) { + this.videos = this.videos.filter(v => v.id !== video.id) } } 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 @@ + + + + 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/'; &.show { max-height: 1500px; + overflow: inherit !important; } } 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 @@ .tertiary-button { @include tertiary-button; } + +.peertube-radio-container { + @include peertube-radio-container; +} 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 @@ } } -// Thanks: https://codepen.io/triss90/pen/XNEdRe/ +// Thanks: https://codepen.io/manabox/pen/raQmpL @mixin peertube-radio-container { - input[type=radio] { - display: none; - - + label { - font-weight: $font-regular; - cursor: pointer; + [type=radio]:checked, + [type=radio]:not(:checked) { + position: absolute; + left: -9999px; + } - &::before { - @include margin-right(10px); - - position: relative; - top: -2px; - content: ''; - background: #fff; - border-radius: 100%; - border: 1px solid #000; - display: inline-block; - width: 15px; - height: 15px; - vertical-align: middle; - cursor: pointer; - text-align: center; - } - } + [type=radio]:checked + label, + [type=radio]:not(:checked) + label { + position: relative; + padding-left: 28px; + cursor: pointer; + line-height: 20px; + display: inline-block; + } - &:checked + label::before { - background-color: #000; - box-shadow: inset 0 0 0 4px #fff; - } + [type=radio]:checked + label::before, + [type=radio]:not(:checked) + label::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 18px; + height: 18px; + border: 1px solid #C6C6C6; + border-radius: 100%; + background: #fff; + } - &:focus + label::before { - outline: none; - border-color: #000; - } + [type=radio]:checked + label::after, + [type=radio]:not(:checked) + label::after { + content: ''; + width: 10px; + height: 10px; + background: pvar(--mainColor); + position: absolute; + top: 4px; + left: 4px; + border-radius: 100%; + transition: all 0.2s ease; + } + [type=radio]:not(:checked) + label::after { + opacity: 0; + transform: scale(0); + } + [type=radio]:checked + label::after { + opacity: 1; + transform: scale(1); } } -- 2.41.0