From 5bcbcbe338ef5a1ed14f084311d013fbb25dabcf Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Fri, 22 Jan 2021 00:12:44 +0100 Subject: [PATCH] modularize abstract video list header and implement video hotness recommendation variant --- CREDITS.md | 1 + .../account-search.component.ts | 10 +-- .../account-videos.component.ts | 5 +- .../my-history/my-history.component.ts | 6 +- .../video-channel-videos.component.ts | 3 +- client/src/app/+videos/video-list/index.ts | 3 +- .../app/+videos/video-list/trending/index.ts | 4 + .../trending/video-hot.component.ts | 85 +++++++++++++++++++ .../video-most-liked.component.ts | 25 ++++-- .../video-trending-header.component.html | 6 ++ .../video-trending-header.component.scss | 17 ++++ .../video-trending-header.component.ts | 59 +++++++++++++ .../video-trending.component.ts | 30 +++++-- .../video-list/video-local.component.ts | 3 +- .../video-recently-added.component.ts | 3 +- .../video-user-subscriptions.component.ts | 6 +- .../src/app/+videos/videos-routing.module.ts | 18 +++- client/src/app/+videos/videos.module.ts | 9 +- client/src/app/menu/menu.component.html | 5 -- .../shared-icons/global-icon.component.ts | 1 + .../shared/shared-main/shared-main.module.ts | 5 +- .../abstract-video-list.html | 8 +- .../abstract-video-list.scss | 10 +-- .../abstract-video-list.ts | 50 ++++++++++- .../shared/shared-video-miniature/index.ts | 2 +- .../shared-video-miniature.module.ts | 4 +- .../video-list-header.component.ts | 20 +++++ .../videos-selection.component.ts | 4 +- client/src/assets/images/misc/flame.svg | 4 + server/initializers/constants.ts | 2 +- server/middlewares/sort.ts | 2 +- server/models/video/video-query-builder.ts | 42 ++++++++- server/models/video/video.ts | 2 + shared/models/videos/video-sort-field.type.ts | 5 +- 34 files changed, 396 insertions(+), 63 deletions(-) create mode 100644 client/src/app/+videos/video-list/trending/index.ts create mode 100644 client/src/app/+videos/video-list/trending/video-hot.component.ts rename client/src/app/+videos/video-list/{ => trending}/video-most-liked.component.ts (74%) create mode 100644 client/src/app/+videos/video-list/trending/video-trending-header.component.html create mode 100644 client/src/app/+videos/video-list/trending/video-trending-header.component.scss create mode 100644 client/src/app/+videos/video-list/trending/video-trending-header.component.ts rename client/src/app/+videos/video-list/{ => trending}/video-trending.component.ts (71%) create mode 100644 client/src/app/shared/shared-video-miniature/video-list-header.component.ts create mode 100644 client/src/assets/images/misc/flame.svg diff --git a/CREDITS.md b/CREDITS.md index 0948b4f35..6004e9bc0 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -459,6 +459,7 @@ * `language` by Aaron Jin (CC-BY) * `video-language` by Rigel Kent (CC-BY) * `peertube-x` by Solen DP (CC-BY) + * `flame` by Freepik (Flaticon License) # Contributors to our 2020 crowdfunding :heart: diff --git a/client/src/app/+accounts/account-search/account-search.component.ts b/client/src/app/+accounts/account-search/account-search.component.ts index 10c7a12d8..378aa78c4 100644 --- a/client/src/app/+accounts/account-search/account-search.component.ts +++ b/client/src/app/+accounts/account-search/account-search.component.ts @@ -1,6 +1,6 @@ import { Subscription } from 'rxjs' import { first, tap } from 'rxjs/operators' -import { Component, OnDestroy, OnInit } from '@angular/core' +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' @@ -11,9 +11,7 @@ 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' - ] + styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ] }) export class AccountSearchComponent extends AbstractVideoList implements OnInit, OnDestroy { titlePage: string @@ -35,6 +33,7 @@ export class AccountSearchComponent extends AbstractVideoList implements OnInit, protected confirmService: ConfirmService, protected screenService: ScreenService, protected storageService: LocalStorageService, + protected cfr: ComponentFactoryResolver, private accountService: AccountService, private videoService: VideoService ) { @@ -99,6 +98,7 @@ export class AccountSearchComponent extends AbstractVideoList implements OnInit, } generateSyndicationList () { - /* disable syndication */ + /* method disabled */ + throw new Error('Method not implemented.') } } 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 58d0719fd..da3903d2c 100644 --- a/client/src/app/+accounts/account-videos/account-videos.component.ts +++ b/client/src/app/+accounts/account-videos/account-videos.component.ts @@ -1,6 +1,6 @@ import { Subscription } from 'rxjs' import { first, tap } from 'rxjs/operators' -import { Component, OnDestroy, OnInit } from '@angular/core' +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' @@ -35,7 +35,8 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit, protected screenService: ScreenService, protected storageService: LocalStorageService, private accountService: AccountService, - private videoService: VideoService + private videoService: VideoService, + protected cfr: ComponentFactoryResolver ) { super() } 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 0c8e4b83f..1695bd7ad 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 @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit } from '@angular/core' +import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { AuthService, @@ -42,7 +42,8 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD protected screenService: ScreenService, protected storageService: LocalStorageService, private confirmService: ConfirmService, - private userHistoryService: UserHistoryService + private userHistoryService: UserHistoryService, + protected cfr: ComponentFactoryResolver ) { super() @@ -95,6 +96,7 @@ export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnD } generateSyndicationList () { + /* method disabled */ throw new Error('Method not implemented.') } diff --git a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts index 645696f48..a49fd0d5d 100644 --- a/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts +++ b/client/src/app/+video-channels/video-channel-videos/video-channel-videos.component.ts @@ -1,6 +1,6 @@ import { Subscription } from 'rxjs' import { first, tap } from 'rxjs/operators' -import { Component, OnDestroy, OnInit } from '@angular/core' +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' @@ -34,6 +34,7 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On protected confirmService: ConfirmService, protected screenService: ScreenService, protected storageService: LocalStorageService, + protected cfr: ComponentFactoryResolver, private videoChannelService: VideoChannelService, private videoService: VideoService ) { diff --git a/client/src/app/+videos/video-list/index.ts b/client/src/app/+videos/video-list/index.ts index af1bd58b7..dc27e29e2 100644 --- a/client/src/app/+videos/video-list/index.ts +++ b/client/src/app/+videos/video-list/index.ts @@ -1,5 +1,4 @@ export * from './overview' +export * from './trending' export * from './video-local.component' export * from './video-recently-added.component' -export * from './video-trending.component' -export * from './video-most-liked.component' diff --git a/client/src/app/+videos/video-list/trending/index.ts b/client/src/app/+videos/video-list/trending/index.ts new file mode 100644 index 000000000..8bae205a5 --- /dev/null +++ b/client/src/app/+videos/video-list/trending/index.ts @@ -0,0 +1,4 @@ +export * from './video-trending-header.component' +export * from './video-trending.component' +export * from './video-hot.component' +export * from './video-most-liked.component' diff --git a/client/src/app/+videos/video-list/trending/video-hot.component.ts b/client/src/app/+videos/video-list/trending/video-hot.component.ts new file mode 100644 index 000000000..1617eb21e --- /dev/null +++ b/client/src/app/+videos/video-list/trending/video-hot.component.ts @@ -0,0 +1,85 @@ +import { Component, ComponentFactoryResolver, Injector, 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' +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 VideoHotComponent extends AbstractVideoList implements OnInit, OnDestroy { + HeaderComponent = VideoTrendingHeaderComponent + titlePage: string + defaultSort: VideoSortField = '-hot' + + useUserVideoPreferences = 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.headerComponentInjector = this.getInjector() + } + + 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.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 + } + }] + }) + } +} diff --git a/client/src/app/+videos/video-list/video-most-liked.component.ts b/client/src/app/+videos/video-list/trending/video-most-liked.component.ts similarity index 74% rename from client/src/app/+videos/video-list/video-most-liked.component.ts rename to client/src/app/+videos/video-list/trending/video-most-liked.component.ts index 93408d76b..1781cc6aa 100644 --- a/client/src/app/+videos/video-list/video-most-liked.component.ts +++ b/client/src/app/+videos/video-list/trending/video-most-liked.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core' +import { Component, ComponentFactoryResolver, Injector, 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' @@ -6,13 +6,15 @@ 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-most-liked', - styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], - templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' + styleUrls: [ '../../../shared/shared-video-miniature/abstract-video-list.scss' ], + templateUrl: '../../../shared/shared-video-miniature/abstract-video-list.html' }) export class VideoMostLikedComponent extends AbstractVideoList implements OnInit { + HeaderComponent = VideoTrendingHeaderComponent titlePage: string defaultSort: VideoSortField = '-likes' @@ -27,19 +29,19 @@ export class VideoMostLikedComponent extends AbstractVideoList implements OnInit protected userService: UserService, protected screenService: ScreenService, protected storageService: LocalStorageService, + protected cfr: ComponentFactoryResolver, private videoService: VideoService, private hooks: HooksService ) { super() + + this.headerComponentInjector = this.getInjector() } ngOnInit () { super.ngOnInit() this.generateSyndicationList() - - this.titlePage = $localize`Most liked videos` - this.titleTooltip = $localize`Videos that have the most likes.` } getVideosObservable (page: number) { @@ -65,4 +67,15 @@ export class VideoMostLikedComponent extends AbstractVideoList implements OnInit generateSyndicationList () { this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) } + + getInjector () { + return Injector.create({ + providers: [{ + provide: 'data', + useValue: { + model: this.defaultSort + } + }] + }) + } } diff --git a/client/src/app/+videos/video-list/trending/video-trending-header.component.html b/client/src/app/+videos/video-list/trending/video-trending-header.component.html new file mode 100644 index 000000000..6319ee6d3 --- /dev/null +++ b/client/src/app/+videos/video-list/trending/video-trending-header.component.html @@ -0,0 +1,6 @@ +
+ +
\ No newline at end of file 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 new file mode 100644 index 000000000..923a1d67a --- /dev/null +++ b/client/src/app/+videos/video-list/trending/video-trending-header.component.scss @@ -0,0 +1,17 @@ +.btn-group label { + border: 1px solid transparent; + border-radius: 9999px !important; + padding: 5px 16px; + opacity: .8; + + &:not(:first-child) { + margin-left: .5rem; + } + + my-global-icon { + position: relative; + top: -2px; + height: 1rem; + margin-right: .1rem; + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..125f14e33 --- /dev/null +++ b/client/src/app/+videos/video-list/trending/video-trending-header.component.ts @@ -0,0 +1,59 @@ +import { Component, Inject } from '@angular/core' +import { Router } from '@angular/router' +import { VideoListHeaderComponent } from '@app/shared/shared-video-miniature' +import { GlobalIconName } from '@app/shared/shared-icons' +import { VideoSortField } from '@shared/models' + +interface VideoTrendingHeaderItem { + label: string + iconName: GlobalIconName + value: VideoSortField + path: string + tooltip?: string +} + +@Component({ + selector: 'video-trending-title-page', + host: { 'class': 'title-page title-page-single' }, + styleUrls: [ './video-trending-header.component.scss' ], + templateUrl: './video-trending-header.component.html' +}) +export class VideoTrendingHeaderComponent extends VideoListHeaderComponent { + buttons: VideoTrendingHeaderItem[] + + constructor ( + @Inject('data') public data: any, + private router: Router + ) { + super(data) + + this.buttons = [ + { + label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`, + iconName: 'flame', + value: '-hot', + path: 'hot', + tooltip: $localize`Videos totalizing the most interactions for recent videos`, + }, + { + label: $localize`:Main variant of Trending videos based on number of recent views:Views`, + iconName: 'trending', + value: '-trending', + path: 'trending', + tooltip: $localize`Videos totalizing 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: '-likes', + path: 'most-liked', + tooltip: $localize`Videos that have the most likes` + } + ] + } + + setSort () { + const path = this.buttons.find(b => b.value === this.data.model).path + this.router.navigate([ `/videos/${path}` ]) + } +} diff --git a/client/src/app/+videos/video-list/video-trending.component.ts b/client/src/app/+videos/video-list/trending/video-trending.component.ts similarity index 71% rename from client/src/app/+videos/video-list/video-trending.component.ts rename to client/src/app/+videos/video-list/trending/video-trending.component.ts index a188795d1..e77231586 100644 --- a/client/src/app/+videos/video-list/video-trending.component.ts +++ b/client/src/app/+videos/video-list/trending/video-trending.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit } from '@angular/core' +import { Component, ComponentFactoryResolver, Injector, 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' @@ -6,13 +6,15 @@ 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-trending', - styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ], - templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html' + 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' @@ -27,10 +29,13 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit, protected userService: UserService, protected screenService: ScreenService, protected storageService: LocalStorageService, + protected cfr: ComponentFactoryResolver, private videoService: VideoService, private hooks: HooksService ) { super() + + this.headerComponentInjector = this.getInjector() } ngOnInit () { @@ -43,13 +48,13 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit, const trendingDays = config.trending.videos.intervalDays if (trendingDays === 1) { - this.titlePage = $localize`Trending for the last 24 hours` this.titleTooltip = $localize`Trending videos are those totalizing the greatest number of views during the last 24 hours` - return + } else { + this.titleTooltip = $localize`Trending videos are those totalizing the greatest number of views during the last ${trendingDays} days` } - this.titlePage = $localize`Trending for the last ${trendingDays} days` - this.titleTooltip = $localize`Trending videos are those totalizing the greatest number of views during the last ${trendingDays} days` + this.headerComponentInjector = this.getInjector() + this.setHeader() }) } @@ -80,4 +85,15 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit, generateSyndicationList () { this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf) } + + getInjector () { + return Injector.create({ + providers: [{ + provide: 'data', + useValue: { + model: this.defaultSort + } + }] + }) + } } diff --git a/client/src/app/+videos/video-list/video-local.component.ts b/client/src/app/+videos/video-list/video-local.component.ts index 20dd61db9..af7eecff4 100644 --- a/client/src/app/+videos/video-list/video-local.component.ts +++ b/client/src/app/+videos/video-list/video-local.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit } from '@angular/core' +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' @@ -28,6 +28,7 @@ export class VideoLocalComponent extends AbstractVideoList implements OnInit, On protected userService: UserService, protected screenService: ScreenService, protected storageService: LocalStorageService, + protected cfr: ComponentFactoryResolver, private videoService: VideoService, private hooks: HooksService ) { diff --git a/client/src/app/+videos/video-list/video-recently-added.component.ts b/client/src/app/+videos/video-list/video-recently-added.component.ts index 34db6aabd..2f4908074 100644 --- a/client/src/app/+videos/video-list/video-recently-added.component.ts +++ b/client/src/app/+videos/video-list/video-recently-added.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit } from '@angular/core' +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' @@ -28,6 +28,7 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On protected userService: UserService, protected screenService: ScreenService, protected storageService: LocalStorageService, + protected cfr: ComponentFactoryResolver, private videoService: VideoService, private hooks: HooksService ) { 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 62d862ec9..e352a2b2c 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,6 +1,6 @@ import { switchMap } from 'rxjs/operators' -import { Component, OnDestroy, OnInit } from '@angular/core' +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 { HooksService } from '@app/core/plugins/hooks.service' @@ -33,6 +33,7 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement protected screenService: ScreenService, protected storageService: LocalStorageService, private userSubscription: UserSubscriptionService, + protected cfr: ComponentFactoryResolver, private hooks: HooksService, private videoService: VideoService, private scopedTokensService: ScopedTokensService @@ -102,7 +103,8 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement } generateSyndicationList () { - // not implemented yet + /* method disabled: the view provides its own */ + throw new Error('Method not implemented.') } activateCopiedMessage () { diff --git a/client/src/app/+videos/videos-routing.module.ts b/client/src/app/+videos/videos-routing.module.ts index f658182e0..b6850b436 100644 --- a/client/src/app/+videos/videos-routing.module.ts +++ b/client/src/app/+videos/videos-routing.module.ts @@ -3,10 +3,11 @@ import { RouterModule, Routes } from '@angular/router' import { LoginGuard } from '@app/core' import { MetaGuard } from '@ngx-meta/core' import { VideoOverviewComponent } from './video-list/overview/video-overview.component' +import { VideoHotComponent } from './video-list/trending/video-hot.component' +import { VideoMostLikedComponent } from './video-list/trending/video-most-liked.component' +import { VideoTrendingComponent } from './video-list/trending/video-trending.component' import { VideoLocalComponent } from './video-list/video-local.component' -import { VideoMostLikedComponent } from './video-list/video-most-liked.component' import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' -import { VideoTrendingComponent } from './video-list/video-trending.component' import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' import { VideosComponent } from './videos.component' @@ -38,6 +39,19 @@ const videosRoutes: Routes = [ } } }, + { + path: 'hot', + component: VideoHotComponent, + data: { + meta: { + title: $localize`Hot videos` + }, + reuse: { + enabled: true, + key: 'hot-videos-list' + } + } + }, { path: 'most-liked', component: VideoMostLikedComponent, diff --git a/client/src/app/+videos/videos.module.ts b/client/src/app/+videos/videos.module.ts index 1cf68bf83..4c88a0397 100644 --- a/client/src/app/+videos/videos.module.ts +++ b/client/src/app/+videos/videos.module.ts @@ -1,3 +1,4 @@ +import { CommonModule } from '@angular/common' import { NgModule } from '@angular/core' import { SharedFormModule } from '@app/shared/shared-forms' import { SharedGlobalIconModule } from '@app/shared/shared-icons' @@ -6,10 +7,12 @@ import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscripti import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' import { OverviewService } from './video-list' import { VideoOverviewComponent } from './video-list/overview/video-overview.component' +import { VideoTrendingHeaderComponent } from './video-list/trending/video-trending-header.component' +import { VideoHotComponent } from './video-list/trending/video-hot.component' +import { VideoTrendingComponent } from './video-list/trending/video-trending.component' +import { VideoMostLikedComponent } from './video-list/trending/video-most-liked.component' import { VideoLocalComponent } from './video-list/video-local.component' -import { VideoMostLikedComponent } from './video-list/video-most-liked.component' import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component' -import { VideoTrendingComponent } from './video-list/video-trending.component' import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component' import { VideosRoutingModule } from './videos-routing.module' import { VideosComponent } from './videos.component' @@ -28,7 +31,9 @@ import { VideosComponent } from './videos.component' declarations: [ VideosComponent, + VideoTrendingHeaderComponent, VideoTrendingComponent, + VideoHotComponent, VideoMostLikedComponent, VideoRecentlyAddedComponent, VideoLocalComponent, diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index f9e8ec2f4..9aa397edd 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html @@ -132,11 +132,6 @@ Trending - - - Most liked - - Recently added 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 0924b8119..def488df0 100644 --- a/client/src/app/shared/shared-icons/global-icon.component.ts +++ b/client/src/app/shared/shared-icons/global-icon.component.ts @@ -16,6 +16,7 @@ const icons = { 'playlist-add': require('!!raw-loader?!../../../assets/images/misc/playlist-add.svg').default, // material ui 'follower': require('!!raw-loader?!../../../assets/images/misc/account-arrow-left.svg').default, // material ui 'following': require('!!raw-loader?!../../../assets/images/misc/account-arrow-right.svg').default, // material ui + 'flame': require('!!raw-loader?!../../../assets/images/misc/flame.svg').default, // feather icons 'flag': require('!!raw-loader?!../../../assets/images/feather/flag.svg').default, diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index c69a4c8b2..9d550996d 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts @@ -11,7 +11,8 @@ import { NgbModalModule, NgbNavModule, NgbPopoverModule, - NgbTooltipModule + NgbTooltipModule, + NgbButtonsModule } from '@ng-bootstrap/ng-bootstrap' import { LoadingBarModule } from '@ngx-loading-bar/core' import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client' @@ -53,6 +54,7 @@ import { VideoChannelService } from './video-channel' NgbNavModule, NgbTooltipModule, NgbCollapseModule, + NgbButtonsModule, ClipboardModule, @@ -110,6 +112,7 @@ import { VideoChannelService } from './video-channel' NgbNavModule, NgbTooltipModule, NgbCollapseModule, + NgbButtonsModule, ClipboardModule, diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.html b/client/src/app/shared/shared-video-miniature/abstract-video-list.html index 368a7d70e..07f79cd6d 100644 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.html +++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.html @@ -1,13 +1,9 @@
-

-
- {{ titlePage }} -
-

+
- + diff --git a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss b/client/src/app/shared/shared-video-miniature/abstract-video-list.scss index 1c27c58c3..2eaf0dc70 100644 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss +++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.scss @@ -5,16 +5,16 @@ $iconSize: 16px; +::ng-deep .title-page.title-page-single { + display: flex; + flex-grow: 1; +} + .videos-header { display: flex; justify-content: space-between; align-items: center; - .title-page.title-page-single { - display: flex; - flex-grow: 1; - } - .action-block { ::ng-deep my-feed { my-global-icon { 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 index a5f22585d..3e84589cd 100644 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.ts +++ b/client/src/app/shared/shared-video-miniature/abstract-video-list.ts @@ -1,6 +1,16 @@ import { fromEvent, Observable, Subject, Subscription } from 'rxjs' import { debounceTime, switchMap, tap } from 'rxjs/operators' -import { Directive, OnDestroy, OnInit } from '@angular/core' +import { + AfterContentInit, + ComponentFactoryResolver, + Directive, + Injector, + OnDestroy, + OnInit, + Type, + ViewChild, + ViewContainerRef +} from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { AuthService, @@ -19,6 +29,7 @@ import { ServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/mo import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' import { Syndication, Video } from '../shared-main' import { MiniatureDisplayOptions, OwnerDisplayType } from './video-miniature.component' +import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component' enum GroupDate { UNKNOWN = 0, @@ -32,7 +43,12 @@ enum GroupDate { @Directive() // tslint:disable-next-line: directive-class-suffix -export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook { +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 @@ -92,6 +108,7 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor protected abstract screenService: ScreenService protected abstract storageService: LocalStorageService protected abstract router: Router + protected abstract cfr: ComponentFactoryResolver abstract titlePage: string private resizeSubscription: Subscription @@ -153,6 +170,13 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor 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.apply(this, [ this.HeaderComponent, this.headerComponentInjector ]) + } + } + disableForReuse () { this.disabled = true } @@ -268,7 +292,27 @@ export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableFor } toggleModerationDisplay () { - throw new Error('toggleModerationDisplay is not implemented') + 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) } // On videos hook for children that want to do something diff --git a/client/src/app/shared/shared-video-miniature/index.ts b/client/src/app/shared/shared-video-miniature/index.ts index 47ca6f51b..a8fd82bb9 100644 --- a/client/src/app/shared/shared-video-miniature/index.ts +++ b/client/src/app/shared/shared-video-miniature/index.ts @@ -3,5 +3,5 @@ export * from './video-actions-dropdown.component' export * from './video-download.component' export * from './video-miniature.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 3035bcfb3..7a7868853 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 @@ -12,6 +12,7 @@ import { VideoActionsDropdownComponent } from './video-actions-dropdown.componen import { VideoDownloadComponent } from './video-download.component' import { VideoMiniatureComponent } from './video-miniature.component' import { VideosSelectionComponent } from './videos-selection.component' +import { VideoListHeaderComponent } from './video-list-header.component' @NgModule({ imports: [ @@ -29,7 +30,8 @@ import { VideosSelectionComponent } from './videos-selection.component' VideoActionsDropdownComponent, VideoDownloadComponent, VideoMiniatureComponent, - VideosSelectionComponent + VideosSelectionComponent, + VideoListHeaderComponent ], exports: [ 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 new file mode 100644 index 000000000..a07248b96 --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/video-list-header.component.ts @@ -0,0 +1,20 @@ +import { Component, Inject } from '@angular/core' + +export abstract class GenericHeaderComponent { + constructor (@Inject('data') public data: any) {} +} + +@Component({ + selector: 'h1', + host: { 'class': 'title-page title-page-single' }, + template: ` +
+ {{ data.titlePage }} +
+ ` +}) +export class VideoListHeaderComponent extends GenericHeaderComponent { + constructor (@Inject('data') public data: any) { + super(data) + } +} 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 2b060b130..ef59975d4 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 @@ -2,6 +2,7 @@ import { Observable } from 'rxjs' import { AfterContentInit, Component, + ComponentFactoryResolver, ContentChildren, EventEmitter, Input, @@ -51,7 +52,8 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni protected userService: UserService, protected screenService: ScreenService, protected storageService: LocalStorageService, - protected serverService: ServerService + protected serverService: ServerService, + protected cfr: ComponentFactoryResolver ) { super() } diff --git a/client/src/assets/images/misc/flame.svg b/client/src/assets/images/misc/flame.svg new file mode 100644 index 000000000..be478bdd4 --- /dev/null +++ b/client/src/assets/images/misc/flame.svg @@ -0,0 +1,4 @@ + + + + diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 84a515857..89491708e 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -72,7 +72,7 @@ const SORTABLE_COLUMNS = { FOLLOWERS: [ 'createdAt', 'state', 'score' ], FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ], - VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending' ], + VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot' ], // Don't forget to update peertube-search-index with the same values VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], diff --git a/server/middlewares/sort.ts b/server/middlewares/sort.ts index 609046a46..0600ccd15 100644 --- a/server/middlewares/sort.ts +++ b/server/middlewares/sort.ts @@ -16,7 +16,7 @@ function setBlacklistSort (req: express.Request, res: express.Response, next: ex // Set model we want to sort onto if (req.query.sort === '-createdAt' || req.query.sort === 'createdAt' || req.query.sort === '-id' || req.query.sort === 'id') { - // If we want to sort onto the BlacklistedVideos relation, we won't specify it in the query parameter ... + // If we want to sort onto the BlacklistedVideos relation, we won't specify it in the query parameter... newSort.sortModel = undefined } else { newSort.sortModel = 'Video' diff --git a/server/models/video/video-query-builder.ts b/server/models/video/video-query-builder.ts index 9e5b6febb..65b72fe1c 100644 --- a/server/models/video/video-query-builder.ts +++ b/server/models/video/video-query-builder.ts @@ -32,6 +32,8 @@ export type BuildVideosQueryOptions = { videoPlaylistId?: number trendingDays?: number + hot?: boolean + user?: MUserAccountId historyOfUser?: MUserId @@ -239,14 +241,46 @@ function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) } } - // We don't exclude results in this if so if we do a count we don't need to add this complex clauses + // We don't exclude results in this so if we do a count we don't need to add this complex clause if (options.trendingDays && options.isCount !== true) { const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays) joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate') replacements.viewsGteDate = viewsGteDate - attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "videoViewsSum"') + attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"') + + group = 'GROUP BY "video"."id"' + } else if (options.hot && options.isCount !== true) { + /** + * "Hotness" is a measure based on absolute view/comment/like/dislike numbers, + * with fixed weights only applied to their log values. + * + * This algorithm gives little chance for an old video to have a good score, + * for which recent spikes in interactions could be a sign of "hotness" and + * justify a better score. However there are multiple ways to achieve that + * goal, which is left for later. Yes, this is a TODO :) + * + * note: weights and base score are in number of half-days. + * see https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L47-L58 + */ + const weights = { + like: 3, + dislike: 3, + view: 1 / 12, + comment: 6 + } + + joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"') + + attributes.push( + `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+) + `- LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-) + `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+) + `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id") - 1)) * ${weights.comment} ` + // comments (+) + '+ (SELECT EXTRACT(epoch FROM "video"."publishedAt") / 47000) ' + // base score (in number of half-days) + 'AS "score"' + ) group = 'GROUP BY "video"."id"' } @@ -372,8 +406,8 @@ function buildOrder (value: string) { if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()' - if (field.toLowerCase() === 'trending') { // Sort by aggregation - return `ORDER BY "videoViewsSum" ${direction}, "video"."views" ${direction}` + if ([ 'trending', 'hot' ].includes(field.toLowerCase())) { // Sort by aggregation + return `ORDER BY "score" ${direction}, "video"."views" ${direction}` } let firstSort: string diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 9b0aa809e..c56fbfbf2 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -1090,6 +1090,7 @@ export class VideoModel extends Model { const trendingDays = options.sort.endsWith('trending') ? CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS : undefined + const hot = options.sort.endsWith('hot') const serverActor = await getServerActor() @@ -1119,6 +1120,7 @@ export class VideoModel extends Model { user: options.user, historyOfUser: options.historyOfUser, trendingDays, + hot, search: options.search } diff --git a/shared/models/videos/video-sort-field.type.ts b/shared/models/videos/video-sort-field.type.ts index f2e70f5fa..97687f84b 100644 --- a/shared/models/videos/video-sort-field.type.ts +++ b/shared/models/videos/video-sort-field.type.ts @@ -5,4 +5,7 @@ export type VideoSortField = 'createdAt' | '-createdAt' | 'views' | '-views' | 'likes' | '-likes' | - 'trending' | '-trending' + + // trending sorts + 'trending' | '-trending' | + 'hot' | '-hot' -- 2.41.0