From dd24f1bb0a4b252e5342b251ba36853364da7b8e Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 19 Aug 2021 09:24:29 +0200 Subject: Add video filters to common video pages --- .../abstract-video-list.html | 64 ---- .../abstract-video-list.scss | 79 ---- .../shared-video-miniature/abstract-video-list.ts | 404 --------------------- .../src/app/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 ++++++ .../shared-video-miniature/video-filters.model.ts | 240 ++++++++++++ .../video-list-header.component.html | 5 - .../video-list-header.component.ts | 22 -- .../videos-list.component.html | 61 ++++ .../videos-list.component.scss | 104 ++++++ .../videos-list.component.ts | 396 ++++++++++++++++++++ .../videos-selection.component.html | 5 +- .../videos-selection.component.ts | 106 +++--- 17 files changed, 1270 insertions(+), 625 deletions(-) delete mode 100644 client/src/app/shared/shared-video-miniature/abstract-video-list.html delete mode 100644 client/src/app/shared/shared-video-miniature/abstract-video-list.scss 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 create mode 100644 client/src/app/shared/shared-video-miniature/videos-list.component.html create mode 100644 client/src/app/shared/shared-video-miniature/videos-list.component.scss create mode 100644 client/src/app/shared/shared-video-miniature/videos-list.component.ts (limited to 'client/src/app/shared/shared-video-miniature') 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 deleted file mode 100644 index 9ffeac5e8..000000000 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.html +++ /dev/null @@ -1,64 +0,0 @@ -
-
- - -
- - - - - - - - - - - - - - - - - - - - -
- -
-
- - - -
-
-
- -
No results.
-
- -

- {{ getCurrentGroupedDateLabel(video) }} -

- -
- - -
-
-
-
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 deleted file mode 100644 index 79e3c1bdf..000000000 --- a/client/src/app/shared/shared-video-miniature/abstract-video-list.scss +++ /dev/null @@ -1,79 +0,0 @@ -@use '_bootstrap-variables'; -@use '_variables' as *; -@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; - - my-feed { - display: inline-block; - width: calc(#{$icon-size} - 2px); - } - - .moderation-block { - @include margin-left(.4rem); - - display: flex; - justify-content: flex-end; - align-items: center; - - my-global-icon { - position: relative; - width: $icon-size; - } - } -} - -.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 - grid-column: 1 / -1; - - &:not(:first-child) { - margin-top: .5rem; - padding-top: 20px; - border-top: 1px solid $separator-border-color; - } -} - -.margin-content { - @include grid-videos-miniature-layout-with-margins; -} - -.display-as-row.videos { - @include margin-left(pvar(--horizontalMarginContent)); - @include margin-right(pvar(--horizontalMarginContent)); - - .video-wrapper { - margin-bottom: 15px; - } -} - -@media screen and (max-width: $mobile-view) { - .videos-header { - flex-direction: column; - align-items: center; - height: auto; - margin-bottom: 10px; - - .title-page { - @include margin-right(0); - - margin-bottom: 10px; - } - } -} 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/videos-list.component.html b/client/src/app/shared/shared-video-miniature/videos-list.component.html new file mode 100644 index 000000000..4ccb4092c --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.html @@ -0,0 +1,61 @@ +
+
+

+ {{ title }} +

+ +
+ Subscribe to RSS feed "{{ title }}" + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+
+ + + +
No results.
+
+ +

+ {{ getCurrentGroupedDateLabel(video) }} +

+ +
+ + +
+
+
+
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.scss b/client/src/app/shared/shared-video-miniature/videos-list.component.scss new file mode 100644 index 000000000..e82ef05ba --- /dev/null +++ b/client/src/app/shared/shared-video-miniature/videos-list.component.scss @@ -0,0 +1,104 @@ +@use '_bootstrap-variables'; +@use '_variables' as *; +@use '_mixins' as *; +@use '_miniature' as *; + +.videos-header { + display: grid; + grid-template-columns: auto 1fr auto; + margin-bottom: 30px; + + .title, + .title-subscription { + grid-column: 1; + } + + .title { + font-size: 18px; + color: pvar(--mainForegroundColor); + display: inline-block; + font-weight: $font-semibold; + + margin-top: 30px; + margin-bottom: 0; + } + + .title-subscription { + grid-row: 2; + font-size: 14px; + color: pvar(--greyForegroundColor); + + &.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; + + // Make the element span a full grid row within .videos grid + grid-column: 1 / -1; + + &:not(:first-child) { + margin-top: .5rem; + padding-top: 20px; + border-top: 1px solid $separator-border-color; + } +} + +.margin-content { + @include grid-videos-miniature-layout-with-margins; +} + +.display-as-row.videos { + @include margin-left(pvar(--horizontalMarginContent)); + @include margin-right(pvar(--horizontalMarginContent)); + + .video-wrapper { + margin-bottom: 15px; + } +} + +@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; + height: auto; + margin-bottom: 10px; + + .title-page { + @include margin-right(0); + + margin-bottom: 10px; + } + } +} 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) } } -- cgit v1.2.3