+++ /dev/null
-import { forkJoin, Subscription } from 'rxjs'
-import { first, tap } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { immutableAssign } from '@app/helpers'
-import { Account, AccountService, VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { VideoFilter } from '@shared/models'
-
-@Component({
- selector: 'my-account-search',
- templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
- styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ]
-})
-export class AccountSearchComponent extends AbstractVideoList implements OnInit, OnDestroy {
- titlePage: string
- loadOnInit = false
- loadUserVideoPreferences = true
-
- search = ''
- filter: VideoFilter = null
-
- private account: Account
- private accountSub: Subscription
-
- constructor (
- protected router: Router,
- protected serverService: ServerService,
- protected route: ActivatedRoute,
- protected authService: AuthService,
- protected userService: UserService,
- protected notifier: Notifier,
- protected confirmService: ConfirmService,
- protected screenService: ScreenService,
- protected storageService: LocalStorageService,
- protected cfr: ComponentFactoryResolver,
- private accountService: AccountService,
- private videoService: VideoService
- ) {
- super()
- }
-
- ngOnInit () {
- super.ngOnInit()
-
- this.enableAllFilterIfPossible()
-
- // Parent get the account for us
- this.accountSub = forkJoin([
- this.accountService.accountLoaded.pipe(first()),
- this.onUserLoadedSubject.pipe(first())
- ]).subscribe(([ account ]) => {
- this.account = account
-
- this.reloadVideos()
- })
- }
-
- ngOnDestroy () {
- if (this.accountSub) this.accountSub.unsubscribe()
-
- super.ngOnDestroy()
- }
-
- updateSearch (value: string) {
- this.search = value
-
- if (!this.search) {
- this.router.navigate([ '../videos' ], { relativeTo: this.route })
- return
- }
-
- this.videos = []
- this.reloadVideos()
- }
-
- getVideosObservable (page: number) {
- const newPagination = immutableAssign(this.pagination, { currentPage: page })
- const options = {
- account: this.account,
- videoPagination: newPagination,
- sort: this.sort,
- nsfwPolicy: this.nsfwPolicy,
- videoFilter: this.filter,
- search: this.search
- }
-
- return this.videoService
- .getAccountVideos(options)
- .pipe(
- tap(({ total }) => {
- this.titlePage = this.search
- ? $localize`Published ${total} videos matching "${this.search}"`
- : $localize`Published ${total} videos`
- })
- )
- }
-
- toggleModerationDisplay () {
- this.filter = this.buildLocalFilter(this.filter, null)
-
- this.reloadVideos()
- }
-
- generateSyndicationList () {
- /* method disabled */
- throw new Error('Method not implemented.')
- }
-}
<div class="no-results" i18n *ngIf="channelPagination.totalItems === 0">This account does not have channels.</div>
- <div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onChannelDataSubject.asObservable()">
+ <div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onChannelDataSubject.asObservable()">
<div class="channel" *ngFor="let videoChannel of videoChannels">
<div class="channel-avatar-row">
--- /dev/null
+<my-videos-list
+ *ngIf="account"
+
+ [title]="title"
+ [displayTitle]="false"
+
+ [getVideosObservableFunction]="getVideosObservableFunction"
+ [getSyndicationItemsFunction]="getSyndicationItemsFunction"
+
+ [defaultSort]="defaultSort"
+
+ [displayFilters]="true"
+ [displayModerationBlock]="true"
+ [displayAsRow]="displayAsRow()"
+
+ [loadUserVideoPreferences]="true"
+
+ [disabled]="disabled"
+>
+</my-videos-list>
-import { forkJoin, Subscription } from 'rxjs'
+import { Subscription } from 'rxjs'
import { first } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { immutableAssign } from '@app/helpers'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
import { Account, AccountService, VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { VideoFilter } from '@shared/models'
+import { VideoFilters } from '@app/shared/shared-video-miniature'
+import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-account-videos',
- templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
- styleUrls: [
- '../../shared/shared-video-miniature/abstract-video-list.scss'
- ]
+ templateUrl: './account-videos.component.html'
})
-export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
- // No value because we don't want a page title
- titlePage: string
- loadOnInit = false
- loadUserVideoPreferences = true
+export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
+ getVideosObservableFunction = this.getVideosObservable.bind(this)
+ getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
- filter: VideoFilter = null
+ title = $localize`Videos`
+ defaultSort = '-publishedAt' as VideoSortField
+
+ account: Account
+ disabled = false
- private account: Account
private accountSub: Subscription
constructor (
- protected router: Router,
- protected serverService: ServerService,
- protected route: ActivatedRoute,
- protected authService: AuthService,
- protected userService: UserService,
- protected notifier: Notifier,
- protected confirmService: ConfirmService,
- protected screenService: ScreenService,
- protected storageService: LocalStorageService,
+ private screenService: ScreenService,
private accountService: AccountService,
- private videoService: VideoService,
- protected cfr: ComponentFactoryResolver
+ private videoService: VideoService
) {
- super()
}
ngOnInit () {
- super.ngOnInit()
-
- this.enableAllFilterIfPossible()
-
// Parent get the account for us
- this.accountSub = forkJoin([
- this.accountService.accountLoaded.pipe(first()),
- this.onUserLoadedSubject.pipe(first())
- ]).subscribe(([ account ]) => {
- this.account = account
-
- this.reloadVideos()
- this.generateSyndicationList()
- })
+ this.accountService.accountLoaded.pipe(first())
+ .subscribe(account => this.account = account)
}
ngOnDestroy () {
if (this.accountSub) this.accountSub.unsubscribe()
-
- super.ngOnDestroy()
}
- getVideosObservable (page: number) {
- const newPagination = immutableAssign(this.pagination, { currentPage: page })
+ getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
const options = {
+ ...filters.toVideosAPIObject(),
+
+ videoPagination: pagination,
account: this.account,
- videoPagination: newPagination,
- sort: this.sort,
- nsfwPolicy: this.nsfwPolicy,
- videoFilter: this.filter
+ skipCount: true
}
- return this.videoService
- .getAccountVideos(options)
+ return this.videoService.getAccountVideos(options)
}
- toggleModerationDisplay () {
- this.filter = this.buildLocalFilter(this.filter, null)
+ getSyndicationItems () {
+ return this.videoService.getAccountFeedUrls(this.account.id)
+ }
- this.reloadVideos()
+ displayAsRow () {
+ return this.screenService.isInMobileView()
}
- generateSyndicationList () {
- this.syndicationItems = this.videoService.getAccountFeedUrls(this.account.id)
+ disableForReuse () {
+ this.disabled = true
}
- displayAsRow () {
- return this.screenService.isInMobileView()
+ enabledForReuse () {
+ this.disabled = false
}
}
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
-import { AccountSearchComponent } from './account-search/account-search.component'
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
import { AccountVideosComponent } from './account-videos/account-videos.component'
import { AccountsComponent } from './accounts.component'
}
}
},
+
+ // Old URL redirection
{
path: 'search',
- component: AccountSearchComponent,
- data: {
- meta: {
- title: $localize`Search videos within account`
- }
- }
+ redirectTo: 'videos'
}
]
}
</div>
</div>
- <div class="links">
+ <div class="links" [ngClass]="{ 'on-channel-page': isOnChannelPage() }">
<ng-template #linkTemplate let-item="item">
<a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a>
</ng-template>
></my-simple-search-input>
</div>
- <router-outlet (activate)="onOutletLoaded($event)"></router-outlet>
+ <router-outlet></router-outlet>
</div>
<ng-container *ngIf="prependModerationActions">
display: flex;
justify-content: space-between;
align-items: center;
- max-width: $max-channels-width;
+
+ &.on-channel-page {
+ max-width: $max-channels-width;
+ }
simple-search-input {
@include margin-left(auto);
import { Subscription } from 'rxjs'
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
-import { ActivatedRoute } from '@angular/router'
+import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
import {
Account,
} from '@app/shared/shared-main'
import { AccountReportComponent } from '@app/shared/shared-moderation'
import { HttpStatusCode, User, UserRight } from '@shared/models'
-import { AccountSearchComponent } from './account-search/account-search.component'
@Component({
templateUrl: './accounts.component.html',
export class AccountsComponent implements OnInit, OnDestroy {
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent
- accountSearch: AccountSearchComponent
-
account: Account
accountUser: User
constructor (
private route: ActivatedRoute,
+ private router: Router,
private userService: UserService,
private accountService: AccountService,
private videoChannelService: VideoChannelService,
return $localize`${count} subscribers`
}
- onOutletLoaded (component: Component) {
- if (component instanceof AccountSearchComponent) {
- this.accountSearch = component
- } else {
- this.accountSearch = undefined
- }
- }
-
searchChanged (search: string) {
- if (this.accountSearch) this.accountSearch.updateSearch(search)
+ const queryParams = { search }
+
+ this.router.navigate([ './videos' ], { queryParams, relativeTo: this.route, queryParamsHandling: 'merge' })
}
onSearchInputDisplayChanged (displayed: boolean) {
return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100
}
+ isOnChannelPage () {
+ return this.route.children[0].snapshot.url[0].path === 'video-channels'
+ }
+
private async onAccount (account: Account) {
this.accountFollowerTitle = $localize`${account.followersCount} direct account followers`
import { SharedModerationModule } from '@app/shared/shared-moderation'
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
-import { AccountSearchComponent } from './account-search/account-search.component'
+import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
import { AccountVideosComponent } from './account-videos/account-videos.component'
import { AccountsRoutingModule } from './accounts-routing.module'
import { AccountsComponent } from './accounts.component'
-import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
@NgModule({
imports: [
declarations: [
AccountsComponent,
AccountVideosComponent,
- AccountVideoChannelsComponent,
- AccountSearchComponent
+ AccountVideoChannelsComponent
],
exports: [
{{ getNoResultMessage() }}
</div>
-<div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()">
+<div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div class="card plugin" *ngFor="let plugin of plugins">
<div class="card-body">
<div class="first-row">
No results.
</div>
-<div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()">
+<div class="plugins" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div class="card plugin" *ngFor="let plugin of plugins">
<div class="card-body">
<div class="first-row">
<div class="top-buttons">
<div class="search-wrapper">
- <my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
+ <my-advanced-input-filter [emitOnInit]="false" (search)="onSearch($event)"></my-advanced-input-filter>
</div>
<div class="history-switch">
[titlePage]="titlePage"
[getVideosObservableFunction]="getVideosObservableFunction"
[user]="user"
- [loadOnInit]="false"
i18n-noResultMessage noResultMessage="You don't have any video in your watch history yet."
[enableSelection]="false"
+ [disabled]="disabled"
#videosSelection
></my-videos-selection>
videos: Video[] = []
search: string
+ disabled = false
+
constructor (
protected router: Router,
protected serverService: ServerService,
}
disableForReuse () {
- this.videosSelection.disableForReuse()
+ this.disabled = true
}
enabledForReuse () {
- this.videosSelection.enabledForReuse()
+ this.disabled = false
}
reloadData () {
<div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div>
-<div class="video-channels" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
+<div class="video-channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let videoChannel of videoChannels" class="video-channel">
<my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
</div>
<div
- class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()"
+ class="videos" myInfiniteScroller (nearOfBottom)="onNearOfBottom()"
cdkDropList (cdkDropListDropped)="drop($event)" [dataObservable]="onDataSubject.asObservable()"
>
<div class="video" *ngFor="let playlistElement of playlistElements; trackBy: trackByFn" cdkDrag [cdkDragStartDelay]="getDragStartDelay()">
</a>
</div>
-<div class="video-playlists" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
+<div class="video-playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let playlist of videoPlaylists" class="video-playlist">
<my-video-playlist-miniature
[playlist]="playlist" [toManage]="true" [displayChannel]="true"
</h1>
<div class="videos-header d-flex justify-content-between">
- <my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
+ <my-advanced-input-filter [emitOnInit]="false" [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
<div class="peertube-select-container peertube-select-button">
<select [(ngModel)]="sort" (ngModelChange)="onChangeSortColumn()" class="form-control">
[titlePage]="titlePage"
[getVideosObservableFunction]="getVideosObservableFunction"
[user]="user"
- [loadOnInit]="false"
+ [disabled]="disabled"
#videosSelection
>
<ng-template ptTemplate="globalButtons">
}
]
+ disabled = false
+
private search: string
constructor (
}
disableForReuse () {
- this.videosSelection.disableForReuse()
+ this.disabled = true
}
enabledForReuse () {
- this.videosSelection.enabledForReuse()
+ this.disabled = false
}
getVideosObservable (page: number) {
}
.peertube-radio-container {
- @include peertube-radio-container;
@include margin-right(30px);
display: inline-block;
-<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" class="search-result">
+<div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" class="search-result">
<div class="results-header">
<div class="first-line">
<div class="results-counter" *ngIf="pagination.totalItems">
<div i18n class="no-results" *ngIf="pagination.totalItems === 0">This channel does not have playlists.</div>
- <div class="playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()">
+ <div class="playlists" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let playlist of videoPlaylists" class="playlist-wrapper">
<my-video-playlist-miniature [playlist]="playlist" [toManage]="false" [displayAsRow]="displayAsRow()"></my-video-playlist-miniature>
</div>
--- /dev/null
+<my-videos-list
+ *ngIf="videoChannel"
+
+ [title]="title"
+ [displayTitle]="false"
+
+ [getVideosObservableFunction]="getVideosObservableFunction"
+ [getSyndicationItemsFunction]="getSyndicationItemsFunction"
+
+ [defaultSort]="defaultSort"
+
+ [displayFilters]="true"
+ [displayModerationBlock]="true"
+ [displayOptions]="displayOptions"
+ [displayAsRow]="displayAsRow()"
+
+ [loadUserVideoPreferences]="true"
+
+ [disabled]="disabled"
+>
+</my-videos-list>
-import { forkJoin, Subscription } from 'rxjs'
+import { Subscription } from 'rxjs'
import { first } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { immutableAssign } from '@app/helpers'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList, MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
-import { VideoFilter } from '@shared/models'
+import { MiniatureDisplayOptions, VideoFilters } from '@app/shared/shared-video-miniature'
+import { VideoSortField } from '@shared/models/videos'
@Component({
selector: 'my-video-channel-videos',
- templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
- styleUrls: [
- '../../shared/shared-video-miniature/abstract-video-list.scss'
- ]
+ templateUrl: './video-channel-videos.component.html'
})
-export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
- // No value because we don't want a page title
- titlePage: string
- loadOnInit = false
- loadUserVideoPreferences = true
+export class VideoChannelVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
+ getVideosObservableFunction = this.getVideosObservable.bind(this)
+ getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
- filter: VideoFilter = null
+ title = $localize`Videos`
+ defaultSort = '-publishedAt' as VideoSortField
displayOptions: MiniatureDisplayOptions = {
date: true,
blacklistInfo: false
}
- private videoChannel: VideoChannel
+ videoChannel: VideoChannel
+ disabled = false
+
private videoChannelSub: Subscription
constructor (
- protected router: Router,
- protected serverService: ServerService,
- protected route: ActivatedRoute,
- protected authService: AuthService,
- protected userService: UserService,
- protected notifier: Notifier,
- protected confirmService: ConfirmService,
- protected screenService: ScreenService,
- protected storageService: LocalStorageService,
- protected cfr: ComponentFactoryResolver,
+ private screenService: ScreenService,
private videoChannelService: VideoChannelService,
private videoService: VideoService
) {
- super()
-
- this.titlePage = $localize`Published videos`
- this.displayOptions = {
- ...this.displayOptions,
- avatar: false
- }
}
ngOnInit () {
- super.ngOnInit()
-
- this.enableAllFilterIfPossible()
-
// Parent get the video channel for us
- this.videoChannelSub = forkJoin([
- this.videoChannelService.videoChannelLoaded.pipe(first()),
- this.onUserLoadedSubject.pipe(first())
- ]).subscribe(([ videoChannel ]) => {
- this.videoChannel = videoChannel
-
- this.reloadVideos()
- this.generateSyndicationList()
- })
+ this.videoChannelService.videoChannelLoaded.pipe(first())
+ .subscribe(videoChannel => {
+ this.videoChannel = videoChannel
+ })
}
ngOnDestroy () {
if (this.videoChannelSub) this.videoChannelSub.unsubscribe()
-
- super.ngOnDestroy()
}
- getVideosObservable (page: number) {
- const newPagination = immutableAssign(this.pagination, { currentPage: page })
- const options = {
+ getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
+ const params = {
+ ...filters.toVideosAPIObject(),
+
+ videoPagination: pagination,
videoChannel: this.videoChannel,
- videoPagination: newPagination,
- sort: this.sort,
- nsfwPolicy: this.nsfwPolicy,
- videoFilter: this.filter
+ skipCount: true
}
- return this.videoService
- .getVideoChannelVideos(options)
+ return this.videoService.getVideoChannelVideos(params)
}
- generateSyndicationList () {
- this.syndicationItems = this.videoService.getVideoChannelFeedUrls(this.videoChannel.id)
+ getSyndicationItems () {
+ return this.videoService.getVideoChannelFeedUrls(this.videoChannel.id)
}
- toggleModerationDisplay () {
- this.filter = this.buildLocalFilter(this.filter, null)
+ displayAsRow () {
+ return this.screenService.isInMobileView()
+ }
- this.reloadVideos()
+ disableForReuse () {
+ this.disabled = true
}
- displayAsRow () {
- return this.screenService.isInMobileView()
+ enabledForReuse () {
+ this.disabled = false
}
}
<div *ngIf="totalNotDeletedComments === 0 && comments.length === 0" i18n>No comments.</div>
- <div
- class="comment-threads"
- myInfiniteScroller
- [autoInit]="true"
- (nearOfBottom)="onNearOfBottom()"
- [dataObservable]="onDataSubject.asObservable()"
- >
+ <div class="comment-threads" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div>
<div class="anchor" #commentHighlightBlock id="highlighted-comment"></div>
<my-video-comment
<div
*ngIf="playlist && currentPlaylistPosition" class="playlist"
- myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
+ myInfiniteScroller [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
>
<div class="playlist-info">
<div class="playlist-display-name">
export * from './overview'
-export * from './trending'
-export * from './video-local.component'
-export * from './video-recently-added.component'
+export * from './videos-list-common-page.component'
<div class="no-results" i18n *ngIf="notResults">No results.</div>
<div
- myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
+ myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"
>
<ng-container *ngFor="let overview of overviews">
+++ /dev/null
-export * from './video-trending-header.component'
-export * from './video-trending.component'
+++ /dev/null
-<div class="btn-group btn-group-toggle" ngbRadioGroup name="radioBasic" [(ngModel)]="data.model" (ngModelChange)="setSort()">
- <ng-container *ngFor="let button of buttons">
- <label *ngIf="!button.hidden" ngbButtonLabel class="btn-light" placement="bottom right-bottom left-bottom" [ngbTooltip]="button.tooltip" container="body">
- <my-global-icon [iconName]="button.iconName"></my-global-icon>
- <input ngbButton type="radio" [value]="button.value"> {{ button.label }}
- </label>
- </ng-container>
-</div>
+++ /dev/null
-@use '_mixins' as *;
-
-.btn-group label {
- border: 1px solid transparent;
- border-radius: 9999px !important;
- padding: 5px 16px;
- opacity: .8;
-
- &:not(:first-child) {
- @include margin-left(.5rem);
- }
-
- my-global-icon {
- @include margin-right(.1rem);
-
- position: relative;
- top: -2px;
- height: 1rem;
- }
-}
+++ /dev/null
-import { Subscription } from 'rxjs'
-import { Component, HostBinding, Inject, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, RedirectService } from '@app/core'
-import { ServerService } from '@app/core/server/server.service'
-import { GlobalIconName } from '@app/shared/shared-icons'
-import { VideoListHeaderComponent } from '@app/shared/shared-video-miniature'
-
-interface VideoTrendingHeaderItem {
- label: string
- iconName: GlobalIconName
- value: string
- tooltip?: string
- hidden?: boolean
-}
-
-@Component({
- selector: 'my-video-trending-title-page',
- styleUrls: [ './video-trending-header.component.scss' ],
- templateUrl: './video-trending-header.component.html'
-})
-export class VideoTrendingHeaderComponent extends VideoListHeaderComponent implements OnInit, OnDestroy {
- @HostBinding('class') class = 'title-page title-page-single'
-
- buttons: VideoTrendingHeaderItem[]
-
- private algorithmChangeSub: Subscription
-
- constructor (
- @Inject('data') public data: any,
- private route: ActivatedRoute,
- private router: Router,
- private auth: AuthService,
- private serverService: ServerService,
- private redirectService: RedirectService
- ) {
- super(data)
-
- this.buttons = [
- {
- label: $localize`:A variant of Trending videos based on the number of recent interactions, minus user history:Best`,
- iconName: 'award',
- value: 'best',
- tooltip: $localize`Videos with the most interactions for recent videos, minus user history`,
- hidden: true
- },
- {
- label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`,
- iconName: 'flame',
- value: 'hot',
- tooltip: $localize`Videos with the most interactions for recent videos`,
- hidden: true
- },
- {
- label: $localize`:Main variant of Trending videos based on number of recent views:Views`,
- iconName: 'trending',
- value: 'most-viewed',
- tooltip: $localize`Videos with the most views during the last 24 hours`
- },
- {
- label: $localize`:A variant of Trending videos based on the number of likes:Likes`,
- iconName: 'like',
- value: 'most-liked',
- tooltip: $localize`Videos that have the most likes`
- }
- ]
- }
-
- ngOnInit () {
- const serverConfig = this.serverService.getHTMLConfig()
- const algEnabled = serverConfig.trending.videos.algorithms.enabled
-
- this.buttons = this.buttons.map(b => {
- b.hidden = !algEnabled.includes(b.value)
-
- // Best is adapted by the user history so
- if (b.value === 'best' && !this.auth.isLoggedIn()) {
- b.hidden = true
- }
-
- return b
- })
-
- this.algorithmChangeSub = this.route.queryParams.subscribe(
- queryParams => {
- this.data.model = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm()
- }
- )
- }
-
- ngOnDestroy () {
- if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe()
- }
-
- setSort () {
- const alg = this.data.model !== this.redirectService.getDefaultTrendingAlgorithm()
- ? this.data.model
- : undefined
-
- this.router.navigate(
- [],
- {
- relativeTo: this.route,
- queryParams: { alg },
- queryParamsHandling: 'merge'
- }
- )
- }
-}
+++ /dev/null
-import { Subscription } from 'rxjs'
-import { first, switchMap } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, Injector, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Params, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, RedirectService, ScreenService, ServerService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { immutableAssign } from '@app/helpers'
-import { VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { VideoSortField } from '@shared/models'
-import { VideoTrendingHeaderComponent } from './video-trending-header.component'
-
-@Component({
- selector: 'my-videos-hot',
- styleUrls: [ '../../../shared/shared-video-miniature/abstract-video-list.scss' ],
- templateUrl: '../../../shared/shared-video-miniature/abstract-video-list.html'
-})
-export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy {
- HeaderComponent = VideoTrendingHeaderComponent
- titlePage: string
- defaultSort: VideoSortField = '-trending'
-
- loadUserVideoPreferences = true
-
- private algorithmChangeSub: Subscription
-
- constructor (
- protected router: Router,
- protected serverService: ServerService,
- protected route: ActivatedRoute,
- protected notifier: Notifier,
- protected authService: AuthService,
- protected userService: UserService,
- protected screenService: ScreenService,
- protected storageService: LocalStorageService,
- protected cfr: ComponentFactoryResolver,
- private videoService: VideoService,
- private redirectService: RedirectService,
- private hooks: HooksService
- ) {
- super()
-
- this.defaultSort = this.parseAlgorithm(this.redirectService.getDefaultTrendingAlgorithm())
-
- this.headerComponentInjector = this.getInjector()
- }
-
- ngOnInit () {
- super.ngOnInit()
-
- this.generateSyndicationList()
-
- // Subscribe to alg change after we loaded the data
- // The initial alg load is handled by the parent class
- this.algorithmChangeSub = this.onDataSubject
- .pipe(
- first(),
- switchMap(() => this.route.queryParams)
- ).subscribe(queryParams => {
- const oldSort = this.sort
-
- this.loadPageRouteParams(queryParams)
-
- if (oldSort !== this.sort) this.reloadVideos()
- }
- )
- }
-
- ngOnDestroy () {
- super.ngOnDestroy()
- if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe()
- }
-
- getVideosObservable (page: number) {
- const newPagination = immutableAssign(this.pagination, { currentPage: page })
- const params = {
- videoPagination: newPagination,
- sort: this.sort,
- categoryOneOf: this.categoryOneOf,
- languageOneOf: this.languageOneOf,
- nsfwPolicy: this.nsfwPolicy,
- skipCount: true
- }
-
- return this.hooks.wrapObsFun(
- this.videoService.getVideos.bind(this.videoService),
- params,
- 'common',
- 'filter:api.trending-videos.videos.list.params',
- 'filter:api.trending-videos.videos.list.result'
- )
- }
-
- generateSyndicationList () {
- this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
- }
-
- getInjector () {
- return Injector.create({
- providers: [ {
- provide: 'data',
- useValue: {
- model: this.defaultSort
- }
- } ]
- })
- }
-
- protected loadPageRouteParams (queryParams: Params) {
- const algorithm = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm()
-
- this.sort = this.parseAlgorithm(algorithm)
- }
-
- private parseAlgorithm (algorithm: string): VideoSortField {
- switch (algorithm) {
- case 'most-viewed':
- return '-trending'
-
- case 'most-liked':
- return '-likes'
-
- default:
- return '-' + algorithm as VideoSortField
- }
- }
-}
+++ /dev/null
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { immutableAssign } from '@app/helpers'
-import { VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { VideoFilter, VideoSortField } from '@shared/models'
-
-@Component({
- selector: 'my-videos-local',
- styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
- templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
-})
-export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy {
- titlePage: string
- sort = '-publishedAt' as VideoSortField
- filter: VideoFilter = 'local'
-
- loadUserVideoPreferences = true
-
- constructor (
- protected router: Router,
- protected serverService: ServerService,
- protected route: ActivatedRoute,
- protected notifier: Notifier,
- protected authService: AuthService,
- protected userService: UserService,
- protected screenService: ScreenService,
- protected storageService: LocalStorageService,
- protected cfr: ComponentFactoryResolver,
- private videoService: VideoService,
- private hooks: HooksService
- ) {
- super()
-
- this.titlePage = $localize`Local videos`
- }
-
- ngOnInit () {
- super.ngOnInit()
-
- this.enableAllFilterIfPossible()
- this.generateSyndicationList()
- }
-
- ngOnDestroy () {
- super.ngOnDestroy()
- }
-
- getVideosObservable (page: number) {
- const newPagination = immutableAssign(this.pagination, { currentPage: page })
- const params = {
- videoPagination: newPagination,
- sort: this.sort,
- filter: this.filter,
- categoryOneOf: this.categoryOneOf,
- languageOneOf: this.languageOneOf,
- nsfwPolicy: this.nsfwPolicy,
- skipCount: true
- }
-
- return this.hooks.wrapObsFun(
- this.videoService.getVideos.bind(this.videoService),
- params,
- 'common',
- 'filter:api.local-videos.videos.list.params',
- 'filter:api.local-videos.videos.list.result'
- )
- }
-
- generateSyndicationList () {
- this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf)
- }
-
- toggleModerationDisplay () {
- this.filter = this.buildLocalFilter(this.filter, 'local')
-
- this.reloadVideos()
- }
-}
+++ /dev/null
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { immutableAssign } from '@app/helpers'
-import { VideoService } from '@app/shared/shared-main'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { VideoSortField } from '@shared/models'
-
-@Component({
- selector: 'my-videos-recently-added',
- styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
- templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
-})
-export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy {
- titlePage: string
- sort: VideoSortField = '-publishedAt'
- groupByDate = true
-
- loadUserVideoPreferences = true
-
- constructor (
- protected route: ActivatedRoute,
- protected serverService: ServerService,
- protected router: Router,
- protected notifier: Notifier,
- protected authService: AuthService,
- protected userService: UserService,
- protected screenService: ScreenService,
- protected storageService: LocalStorageService,
- protected cfr: ComponentFactoryResolver,
- private videoService: VideoService,
- private hooks: HooksService
- ) {
- super()
-
- this.titlePage = $localize`Recently added`
- }
-
- ngOnInit () {
- super.ngOnInit()
-
- this.generateSyndicationList()
- }
-
- ngOnDestroy () {
- super.ngOnDestroy()
- }
-
- getVideosObservable (page: number) {
- const newPagination = immutableAssign(this.pagination, { currentPage: page })
- const params = {
- videoPagination: newPagination,
- sort: this.sort,
- categoryOneOf: this.categoryOneOf,
- languageOneOf: this.languageOneOf,
- nsfwPolicy: this.nsfwPolicy,
- skipCount: true
- }
-
- return this.hooks.wrapObsFun(
- this.videoService.getVideos.bind(this.videoService),
- params,
- 'common',
- 'filter:api.recently-added-videos.videos.list.params',
- 'filter:api.recently-added-videos.videos.list.result'
- )
- }
-
- generateSyndicationList () {
- this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
- }
-}
--- /dev/null
+<my-videos-list
+ [getVideosObservableFunction]="getVideosObservableFunction"
+ [getSyndicationItemsFunction]="getSyndicationItemsFunction"
+
+ [title]="titlePage"
+
+ [defaultSort]="defaultSort"
+
+ [displayFilters]="false"
+ [displayModerationBlock]="false"
+
+ [loadUserVideoPreferences]="false"
+ [groupByDate]="true"
+
+ [disabled]="disabled"
+>
+</my-videos-list>
-import { switchMap } from 'rxjs/operators'
-import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, LocalStorageService, Notifier, ScopedTokensService, ScreenService, ServerService, UserService } from '@app/core'
+import { firstValueFrom } from 'rxjs'
+import { switchMap, tap } from 'rxjs/operators'
+import { Component } from '@angular/core'
+import { AuthService, ComponentPaginationLight, DisableForReuseHook, ScopedTokensService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
-import { immutableAssign } from '@app/helpers'
import { VideoService } from '@app/shared/shared-main'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
-import { AbstractVideoList } from '@app/shared/shared-video-miniature'
-import { FeedFormat, VideoSortField } from '@shared/models'
-import { environment } from '../../../environments/environment'
-import { copyToClipboard } from '../../../root-helpers/utils'
+import { VideoFilters } from '@app/shared/shared-video-miniature'
+import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-videos-user-subscriptions',
- styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
- templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
+ templateUrl: './video-user-subscriptions.component.html'
})
-export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy {
- titlePage: string
- sort = '-publishedAt' as VideoSortField
- groupByDate = true
+export class VideoUserSubscriptionsComponent implements DisableForReuseHook {
+ getVideosObservableFunction = this.getVideosObservable.bind(this)
+ getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
+
+ defaultSort = '-publishedAt' as VideoSortField
+
+ actions = [
+ {
+ routerLink: '/my-library/subscriptions',
+ label: $localize`Subscriptions`,
+ iconName: 'cog'
+ }
+ ]
+
+ titlePage = $localize`Videos from your subscriptions`
+
+ disabled = false
+
+ private feedToken: string
constructor (
- protected router: Router,
- protected serverService: ServerService,
- protected route: ActivatedRoute,
- protected notifier: Notifier,
- protected authService: AuthService,
- protected userService: UserService,
- protected screenService: ScreenService,
- protected storageService: LocalStorageService,
+ private authService: AuthService,
private userSubscription: UserSubscriptionService,
- protected cfr: ComponentFactoryResolver,
private hooks: HooksService,
private videoService: VideoService,
private scopedTokensService: ScopedTokensService
) {
- super()
- this.titlePage = $localize`Videos from your subscriptions`
-
- this.actions.push({
- routerLink: '/my-library/subscriptions',
- label: $localize`Subscriptions`,
- iconName: 'cog'
- })
}
- ngOnInit () {
- super.ngOnInit()
-
- const user = this.authService.getUser()
- let feedUrl = environment.originServerUrl
-
- this.authService.userInformationLoaded
- .pipe(switchMap(() => this.scopedTokensService.getScopedTokens()))
- .subscribe({
- next: tokens => {
- const feeds = this.videoService.getVideoSubscriptionFeedUrls(user.account.id, tokens.feedToken)
- feedUrl = feedUrl + feeds.find(f => f.format === FeedFormat.RSS).url
-
- this.actions.unshift({
- label: $localize`Copy feed URL`,
- iconName: 'syndication',
- justIcon: true,
- href: feedUrl,
- click: e => {
- e.preventDefault()
- copyToClipboard(feedUrl)
- this.activateCopiedMessage()
- }
- })
- },
-
- error: err => {
- this.notifier.error(err.message)
- }
- })
- }
-
- ngOnDestroy () {
- super.ngOnDestroy()
- }
-
- getVideosObservable (page: number) {
- const newPagination = immutableAssign(this.pagination, { currentPage: page })
+ getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
const params = {
- videoPagination: newPagination,
- sort: this.sort,
+ ...filters.toVideosAPIObject(),
+
+ videoPagination: pagination,
skipCount: true
}
)
}
- generateSyndicationList () {
- /* method disabled: the view provides its own */
- throw new Error('Method not implemented.')
+ getSyndicationItems () {
+ return this.loadFeedToken()
+ .then(() => {
+ const user = this.authService.getUser()
+
+ return this.videoService.getVideoSubscriptionFeedUrls(user.account.id, this.feedToken)
+ })
}
- activateCopiedMessage () {
- this.notifier.success($localize`Feed URL copied`)
+ disableForReuse () {
+ this.disabled = true
+ }
+
+ enabledForReuse () {
+ this.disabled = false
+ }
+
+ private loadFeedToken () {
+ if (this.feedToken) return Promise.resolve(this.feedToken)
+
+ const obs = this.authService.userInformationLoaded
+ .pipe(
+ switchMap(() => this.scopedTokensService.getScopedTokens()),
+ tap(tokens => this.feedToken = tokens.feedToken)
+ )
+
+ return firstValueFrom(obs)
}
}
--- /dev/null
+<my-videos-list
+ [getVideosObservableFunction]="getVideosObservableFunction"
+ [getSyndicationItemsFunction]="getSyndicationItemsFunction"
+ [baseRouteBuilderFunction]="baseRouteBuilderFunction"
+
+ [title]="title"
+ [titleTooltip]="titleTooltip"
+
+ [defaultSort]="defaultSort"
+ [defaultScope]="defaultScope"
+
+ [displayFilters]="true"
+ [displayModerationBlock]="true"
+
+ [loadUserVideoPreferences]="true"
+ [groupByDate]="groupByDate"
+
+ [disabled]="disabled"
+
+ (filtersChanged)="onFiltersChanged($event)"
+>
+</my-videos-list>
--- /dev/null
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'
+import { ComponentPaginationLight, DisableForReuseHook, MetaService, RedirectService, ServerService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { VideoService } from '@app/shared/shared-main'
+import { VideoFilters, VideoFilterScope } from '@app/shared/shared-video-miniature/video-filters.model'
+import { ClientFilterHookName, VideoSortField } from '@shared/models'
+import { Subscription } from 'rxjs'
+
+export type VideosListCommonPageRouteData = {
+ sort: VideoSortField
+
+ scope: VideoFilterScope
+ hookParams: ClientFilterHookName
+ hookResult: ClientFilterHookName
+}
+
+@Component({
+ templateUrl: './videos-list-common-page.component.html'
+})
+export class VideosListCommonPageComponent implements OnInit, OnDestroy, DisableForReuseHook {
+ getVideosObservableFunction = this.getVideosObservable.bind(this)
+ getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
+ baseRouteBuilderFunction = this.baseRouteBuilder.bind(this)
+
+ title: string
+ titleTooltip: string
+
+ groupByDate: boolean
+
+ defaultSort: VideoSortField
+ defaultScope: VideoFilterScope
+
+ hookParams: ClientFilterHookName
+ hookResult: ClientFilterHookName
+
+ loadUserVideoPreferences = true
+
+ displayFilters = true
+
+ disabled = false
+
+ private trendingDays: number
+ private routeSub: Subscription
+
+ constructor (
+ private server: ServerService,
+ private route: ActivatedRoute,
+ private videoService: VideoService,
+ private hooks: HooksService,
+ private meta: MetaService,
+ private redirectService: RedirectService
+ ) {
+ }
+
+ ngOnInit () {
+ this.trendingDays = this.server.getHTMLConfig().trending.videos.intervalDays
+
+ this.routeSub = this.route.params.subscribe(params => {
+ this.update(params['page'])
+ })
+ }
+
+ ngOnDestroy () {
+ if (this.routeSub) this.routeSub.unsubscribe()
+ }
+
+ getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
+ const params = {
+ ...filters.toVideosAPIObject(),
+
+ videoPagination: pagination,
+ skipCount: true
+ }
+
+ return this.hooks.wrapObsFun(
+ this.videoService.getVideos.bind(this.videoService),
+ params,
+ 'common',
+ this.hookParams,
+ this.hookResult
+ )
+ }
+
+ getSyndicationItems (filters: VideoFilters) {
+ const result = filters.toVideosAPIObject()
+
+ return this.videoService.getVideoFeedUrls(result.sort, result.filter)
+ }
+
+ onFiltersChanged (filters: VideoFilters) {
+ this.buildTitle(filters.scope, filters.sort)
+ this.updateGroupByDate(filters.sort)
+ }
+
+ baseRouteBuilder (filters: VideoFilters) {
+ const sanitizedSort = this.getSanitizedSort(filters.sort)
+
+ let suffix: string
+
+ if (filters.scope === 'local') suffix = 'local'
+ else if (sanitizedSort === 'publishedAt') suffix = 'recently-added'
+ else suffix = 'trending'
+
+ return [ '/videos', suffix ]
+ }
+
+ disableForReuse () {
+ this.disabled = true
+ }
+
+ enabledForReuse () {
+ this.disabled = false
+ }
+
+ update (page: string) {
+ const data = this.getData(page)
+
+ this.hookParams = data.hookParams
+ this.hookResult = data.hookResult
+
+ this.defaultSort = data.sort
+ this.defaultScope = data.scope
+
+ this.buildTitle()
+ this.updateGroupByDate(this.defaultSort)
+
+ this.meta.setTitle(this.title)
+ }
+
+ private getData (page: string) {
+ if (page === 'trending') return this.generateTrendingData(this.route.snapshot)
+
+ if (page === 'local') return this.generateLocalData()
+
+ return this.generateRecentlyAddedData()
+ }
+
+ private generateRecentlyAddedData (): VideosListCommonPageRouteData {
+ return {
+ sort: '-publishedAt',
+ scope: 'federated',
+ hookParams: 'filter:api.recently-added-videos.videos.list.params',
+ hookResult: 'filter:api.recently-added-videos.videos.list.result'
+ }
+ }
+
+ private generateLocalData (): VideosListCommonPageRouteData {
+ return {
+ sort: '-publishedAt' as VideoSortField,
+ scope: 'local',
+ hookParams: 'filter:api.local-videos.videos.list.params',
+ hookResult: 'filter:api.local-videos.videos.list.result'
+ }
+ }
+
+ private generateTrendingData (route: ActivatedRouteSnapshot): VideosListCommonPageRouteData {
+ const sort = route.queryParams['sort'] ?? this.parseTrendingAlgorithm(this.redirectService.getDefaultTrendingAlgorithm())
+
+ return {
+ sort,
+ scope: 'federated',
+ hookParams: 'filter:api.trending-videos.videos.list.params',
+ hookResult: 'filter:api.trending-videos.videos.list.result'
+ }
+ }
+
+ private parseTrendingAlgorithm (algorithm: string): VideoSortField {
+ switch (algorithm) {
+ case 'most-viewed':
+ return '-trending'
+
+ case 'most-liked':
+ return '-likes'
+
+ default:
+ return '-' + algorithm as VideoSortField
+ }
+ }
+
+ private updateGroupByDate (sort: VideoSortField) {
+ this.groupByDate = sort === '-publishedAt' || sort === 'publishedAt'
+ }
+
+ private buildTitle (scope: VideoFilterScope = this.defaultScope, sort: VideoSortField = this.defaultSort) {
+ const sanitizedSort = this.getSanitizedSort(sort)
+
+ if (scope === 'local') {
+ this.title = $localize`Local videos`
+ this.titleTooltip = $localize`Only videos uploaded on this instance are displayed`
+ return
+ }
+
+ if (sanitizedSort === 'publishedAt') {
+ this.title = $localize`Recently added`
+ this.titleTooltip = undefined
+ return
+ }
+
+ if ([ 'best', 'hot', 'trending', 'likes' ].includes(sanitizedSort)) {
+ this.title = $localize`Trending`
+
+ if (sanitizedSort === 'best') this.titleTooltip = $localize`Videos with the most interactions for recent videos, minus user history`
+ if (sanitizedSort === 'hot') this.titleTooltip = $localize`Videos with the most interactions for recent videos`
+ if (sanitizedSort === 'likes') this.titleTooltip = $localize`Videos that have the most likes`
+
+ if (sanitizedSort === 'trending') {
+ if (this.trendingDays === 1) this.titleTooltip = $localize`Videos with the most views during the last 24 hours`
+ else this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days`
+ }
+
+ return
+ }
+ }
+
+ private getSanitizedSort (sort: VideoSortField) {
+ return sort.replace(/^-/, '') as VideoSortField
+ }
+}
import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
+import { RouterModule, Routes, UrlSegment } from '@angular/router'
import { LoginGuard } from '@app/core'
-import { VideoTrendingComponent } from './video-list'
+import { VideosListCommonPageComponent } from './video-list'
import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
-import { VideoLocalComponent } from './video-list/video-local.component'
-import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
import { VideosComponent } from './videos.component'
}
}
},
+
{
- path: 'trending',
- component: VideoTrendingComponent,
- data: {
- meta: {
- title: $localize`Trending videos`
- }
- }
- },
- {
+ // Old URL redirection
path: 'most-liked',
- redirectTo: 'trending?alg=most-liked'
+ redirectTo: 'trending?sort=most-liked'
},
{
- path: 'recently-added',
- component: VideoRecentlyAddedComponent,
+ matcher: (url: UrlSegment[]) => {
+ if (url.length === 1 && [ 'recently-added', 'trending', 'local' ].includes(url[0].path)) {
+ return {
+ consumed: url,
+ posParams: {
+ page: new UrlSegment(url[0].path, {})
+ }
+ }
+ }
+
+ return null
+ },
+
+ component: VideosListCommonPageComponent,
data: {
- meta: {
- title: $localize`Recently added videos`
- },
reuse: {
enabled: true,
- key: 'recently-added-videos-list'
+ key: 'videos-list'
}
}
},
+
{
path: 'subscriptions',
canActivate: [ LoginGuard ],
key: 'subscription-videos-list'
}
}
- },
- {
- path: 'local',
- component: VideoLocalComponent,
- data: {
- meta: {
- title: $localize`Local videos`
- },
- reuse: {
- enabled: true,
- key: 'local-videos-list'
- }
- }
}
]
}
import { SharedMainModule } from '@app/shared/shared-main'
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
-import { OverviewService, VideoTrendingComponent } from './video-list'
+import { OverviewService, VideosListCommonPageComponent } from './video-list'
import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
-import { VideoTrendingHeaderComponent } from './video-list/trending/video-trending-header.component'
-import { VideoLocalComponent } from './video-list/video-local.component'
-import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
import { VideosRoutingModule } from './videos-routing.module'
import { VideosComponent } from './videos.component'
declarations: [
VideosComponent,
- VideoTrendingHeaderComponent,
- VideoTrendingComponent,
- VideoRecentlyAddedComponent,
- VideoLocalComponent,
VideoUserSubscriptionsComponent,
- VideoOverviewComponent
+ VideoOverviewComponent,
+ VideosListCommonPageComponent
],
exports: [
imports: [
RouterModule.forRoot(routes, {
useHash: Boolean(history.pushState) === false,
+ // Redefined in app component
scrollPositionRestoration: 'disabled',
preloadingStrategy: PreloadSelectedModulesList,
anchorScrolling: 'disabled'
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { filter, map, pairwise, switchMap } from 'rxjs/operators'
-import { DOCUMENT, getLocaleDirection, PlatformLocation, ViewportScroller } from '@angular/common'
+import { filter, map, switchMap } from 'rxjs/operators'
+import { DOCUMENT, getLocaleDirection, PlatformLocation } from '@angular/common'
import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
-import { Event, GuardsCheckStart, NavigationEnd, RouteConfigLoadEnd, RouteConfigLoadStart, Router, Scroll } from '@angular/router'
-import { AuthService, MarkdownService, RedirectService, ScreenService, ServerService, ThemeService, User } from '@app/core'
+import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router } from '@angular/router'
+import {
+ AuthService,
+ MarkdownService,
+ PeerTubeRouterService,
+ RedirectService,
+ ScreenService,
+ ScrollService,
+ ServerService,
+ ThemeService,
+ User
+} from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { PluginService } from '@app/core/plugins/plugin.service'
import { CustomModalComponent } from '@app/modal/custom-modal.component'
constructor (
@Inject(DOCUMENT) private document: Document,
@Inject(LOCALE_ID) private localeId: string,
- private viewportScroller: ViewportScroller,
private router: Router,
private authService: AuthService,
private serverService: ServerService,
+ private peertubeRouter: PeerTubeRouterService,
private pluginService: PluginService,
private instanceService: InstanceService,
private domSanitizer: DomSanitizer,
private markdownService: MarkdownService,
private ngbConfig: NgbConfig,
private loadingBar: LoadingBarService,
+ private scrollService: ScrollService,
public menu: MenuService
) {
this.ngbConfig.animation = false
}
this.initRouteEvents()
+ this.scrollService.enableScrollRestoration()
this.injectJS()
this.injectCSS()
}
private initRouteEvents () {
- let resetScroll = true
const eventsObs = this.router.events
- const scrollEvent = eventsObs.pipe(filter((e: Event): e is Scroll => e instanceof Scroll))
-
- // Handle anchors/restore position
- scrollEvent.subscribe(e => {
- // scrollToAnchor first to preserve anchor position when using history navigation
- if (e.anchor) {
- setTimeout(() => {
- this.viewportScroller.scrollToAnchor(e.anchor)
- })
-
- return
- }
-
- if (e.position) {
- return this.viewportScroller.scrollToPosition(e.position)
- }
-
- if (resetScroll) {
- return this.viewportScroller.scrollToPosition([ 0, 0 ])
- }
- })
-
- const navigationEndEvent = eventsObs.pipe(filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd))
-
- // When we add the a-state parameter, we don't want to alter the scroll
- navigationEndEvent.pipe(pairwise())
- .subscribe(([ e1, e2 ]) => {
- try {
- resetScroll = false
-
- const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects)
- const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects)
-
- if (previousUrl.pathname !== nextUrl.pathname) {
- resetScroll = true
- return
- }
-
- const nextSearchParams = nextUrl.searchParams
- nextSearchParams.delete('a-state')
-
- const previousSearchParams = previousUrl.searchParams
-
- nextSearchParams.sort()
- previousSearchParams.sort()
-
- if (nextSearchParams.toString() !== previousSearchParams.toString()) {
- resetScroll = true
- }
- } catch (e) {
- console.error('Cannot parse URL to check next scroll.', e)
- resetScroll = true
- }
- })
-
// Plugin hooks
- navigationEndEvent.subscribe(e => {
+ this.peertubeRouter.getNavigationEndEvents().subscribe(e => {
this.hooks.runAction('action:router.navigation-end', 'common', { path: e.url })
})
import { Notifier } from './notification'
import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer'
import { RestExtractor, RestService } from './rest'
-import { HomepageRedirectComponent, LoginGuard, MetaGuard, MetaService, RedirectService, UnloggedGuard, UserRightGuard } from './routing'
+import {
+ HomepageRedirectComponent,
+ LoginGuard,
+ MetaGuard,
+ MetaService,
+ PeerTubeRouterService,
+ RedirectService,
+ ScrollService,
+ UnloggedGuard,
+ UserRightGuard
+} from './routing'
import { CanDeactivateGuard } from './routing/can-deactivate-guard.service'
import { ServerConfigResolver } from './routing/server-config-resolver.service'
import { ScopedTokensService } from './scoped-tokens'
PeerTubeSocket,
ServerConfigResolver,
CanDeactivateGuard,
+ PeerTubeRouterService,
+ ScrollService,
MetaService,
MetaGuard
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
+import { RouterSetting } from './'
+import { PeerTubeRouterService } from './peertube-router.service'
@Injectable()
export class CustomReuseStrategy implements RouteReuseStrategy {
}
private isReuseEnabled (route: ActivatedRouteSnapshot) {
- return route.data.reuse?.enabled && route.queryParams['a-state']
+ // Cannot use peertube router here because of cyclic router dependency
+ return route.data.reuse?.enabled &&
+ !!(route.queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME] & RouterSetting.REUSE_COMPONENT)
}
}
export * from './login-guard.service'
export * from './menu-guard.service'
export * from './meta-guard.service'
+export * from './peertube-router.service'
export * from './meta.service'
export * from './preload-selected-modules-list'
export * from './redirect.service'
+export * from './scroll.service'
export * from './server-config-resolver.service'
export * from './unlogged-guard.service'
export * from './user-right-guard.service'
--- /dev/null
+import { filter } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ActivatedRoute, ActivatedRouteSnapshot, Event, NavigationEnd, Router, Scroll } from '@angular/router'
+import { ServerService } from '../server'
+
+export const enum RouterSetting {
+ NONE = 0,
+ REUSE_COMPONENT = 1 << 0,
+ DISABLE_SCROLL_RESTORE = 1 << 1
+}
+
+@Injectable()
+export class PeerTubeRouterService {
+ static readonly ROUTE_SETTING_NAME = 's'
+
+ constructor (
+ private route: ActivatedRoute,
+ private router: Router,
+ private server: ServerService
+ ) { }
+
+ addRouteSetting (toAdd: RouterSetting) {
+ if (this.hasRouteSetting(toAdd)) return
+
+ const current = this.getRouteSetting()
+
+ this.setRouteSetting(current | toAdd)
+ }
+
+ deleteRouteSetting (toDelete: RouterSetting) {
+ const current = this.getRouteSetting()
+
+ this.setRouteSetting(current & ~toDelete)
+ }
+
+ getRouteSetting (snapshot?: ActivatedRouteSnapshot) {
+ return (snapshot || this.route.snapshot).queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME]
+ }
+
+ setRouteSetting (value: number) {
+ let path = window.location.pathname
+ if (!path || path === '/') path = this.server.getHTMLConfig().instance.defaultClientRoute
+
+ const queryParams = { [PeerTubeRouterService.ROUTE_SETTING_NAME]: value }
+
+ this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
+ }
+
+ hasRouteSetting (setting: RouterSetting, snapshot?: ActivatedRouteSnapshot) {
+ return !!(this.getRouteSetting(snapshot) & setting)
+ }
+
+ getNavigationEndEvents () {
+ return this.router.events.pipe(
+ filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd)
+ )
+ }
+
+ getScrollEvents () {
+ return this.router.events.pipe(
+ filter((e: Event): e is Scroll => e instanceof Scroll)
+ )
+ }
+
+ silentNavigate (baseRoute: string[], queryParams: { [id: string]: string }) {
+ let routeSetting = this.getRouteSetting() ?? RouterSetting.NONE
+ routeSetting |= RouterSetting.DISABLE_SCROLL_RESTORE
+
+ queryParams = {
+ ...queryParams,
+
+ [PeerTubeRouterService.ROUTE_SETTING_NAME]: routeSetting
+ }
+
+ return this.router.navigate(baseRoute, { queryParams })
+ }
+
+}
--- /dev/null
+import * as debug from 'debug'
+import { pairwise } from 'rxjs'
+import { ViewportScroller } from '@angular/common'
+import { Injectable } from '@angular/core'
+import { RouterSetting } from '../'
+import { PeerTubeRouterService } from './peertube-router.service'
+
+const logger = debug('peertube:main:ScrollService')
+
+@Injectable()
+export class ScrollService {
+
+ private resetScroll = true
+
+ constructor (
+ private viewportScroller: ViewportScroller,
+ private peertubeRouter: PeerTubeRouterService
+ ) { }
+
+ enableScrollRestoration () {
+ // We'll manage scroll restoration ourselves
+ this.viewportScroller.setHistoryScrollRestoration('manual')
+
+ this.consumeScroll()
+ this.produceScroll()
+ }
+
+ private produceScroll () {
+ // When we add the a-state parameter, we don't want to alter the scroll
+ this.peertubeRouter.getNavigationEndEvents().pipe(pairwise())
+ .subscribe(([ e1, e2 ]) => {
+ try {
+ this.resetScroll = false
+
+ const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects)
+ const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects)
+
+ if (previousUrl.pathname !== nextUrl.pathname) {
+ this.resetScroll = true
+ return
+ }
+
+ if (this.peertubeRouter.hasRouteSetting(RouterSetting.DISABLE_SCROLL_RESTORE)) {
+ this.resetScroll = false
+ return
+ }
+
+ // Remove route settings from the comparison
+ const nextSearchParams = nextUrl.searchParams
+ nextSearchParams.delete(PeerTubeRouterService.ROUTE_SETTING_NAME)
+
+ const previousSearchParams = previousUrl.searchParams
+
+ nextSearchParams.sort()
+ previousSearchParams.sort()
+
+ if (nextSearchParams.toString() !== previousSearchParams.toString()) {
+ this.resetScroll = true
+ }
+ } catch (e) {
+ console.error('Cannot parse URL to check next scroll.', e)
+ this.resetScroll = true
+ }
+ })
+ }
+
+ private consumeScroll () {
+ // Handle anchors/restore position
+ this.peertubeRouter.getScrollEvents().subscribe(e => {
+ logger('Will schedule scroll after router event %o.', e)
+
+ // scrollToAnchor first to preserve anchor position when using history navigation
+ if (e.anchor) {
+ setTimeout(() => this.viewportScroller.scrollToAnchor(e.anchor))
+
+ return
+ }
+
+ if (e.position) {
+ setTimeout(() => this.viewportScroller.scrollToPosition(e.position))
+
+ return
+ }
+
+ if (this.resetScroll) {
+ return this.viewportScroller.scrollToPosition([ 0, 0 ])
+ }
+ })
+ }
+
+}
+++ /dev/null
-import { first, map } from 'rxjs/operators'
-import { SelectChannelItem } from 'src/types/select-options-item.model'
-import { DatePipe } from '@angular/common'
-import { HttpErrorResponse } from '@angular/common/http'
-import { Notifier } from '@app/core'
-import { HttpStatusCode } from '@shared/models'
-import { environment } from '../../environments/environment'
-import { AuthService } from '../core/auth'
-
-// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
-function getParameterByName (name: string, url: string) {
- if (!url) url = window.location.href
- name = name.replace(/[[\]]/g, '\\$&')
-
- const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)')
- const results = regex.exec(url)
-
- if (!results) return null
- if (!results[2]) return ''
-
- return decodeURIComponent(results[2].replace(/\+/g, ' '))
-}
-
-function listUserChannels (authService: AuthService) {
- return authService.userInformationLoaded
- .pipe(
- first(),
- map(() => {
- const user = authService.getUser()
- if (!user) return undefined
-
- const videoChannels = user.videoChannels
- if (Array.isArray(videoChannels) === false) return undefined
-
- return videoChannels
- .sort((a, b) => {
- if (a.updatedAt < b.updatedAt) return 1
- if (a.updatedAt > b.updatedAt) return -1
- return 0
- })
- .map(c => ({
- id: c.id,
- label: c.displayName,
- support: c.support,
- avatarPath: c.avatar?.path
- }) as SelectChannelItem)
- })
- )
-}
-
-function getAbsoluteAPIUrl () {
- let absoluteAPIUrl = environment.hmr === true
- ? 'http://localhost:9000'
- : environment.apiUrl
-
- if (!absoluteAPIUrl) {
- // The API is on the same domain
- absoluteAPIUrl = window.location.origin
- }
-
- return absoluteAPIUrl
-}
-
-function getAbsoluteEmbedUrl () {
- let absoluteEmbedUrl = environment.originServerUrl
- if (!absoluteEmbedUrl) {
- // The Embed is on the same domain
- absoluteEmbedUrl = window.location.origin
- }
-
- return absoluteEmbedUrl
-}
-
-const datePipe = new DatePipe('en')
-function dateToHuman (date: string) {
- return datePipe.transform(date, 'medium')
-}
-
-function durationToString (duration: number) {
- const hours = Math.floor(duration / 3600)
- const minutes = Math.floor((duration % 3600) / 60)
- const seconds = duration % 60
-
- const minutesPadding = minutes >= 10 ? '' : '0'
- const secondsPadding = seconds >= 10 ? '' : '0'
- const displayedHours = hours > 0 ? hours.toString() + ':' : ''
-
- return (
- displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString()
- ).replace(/^0/, '')
-}
-
-function immutableAssign <A, B> (target: A, source: B) {
- return Object.assign({}, target, source)
-}
-
-// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34
-function objectToFormData (obj: any, form?: FormData, namespace?: string) {
- const fd = form || new FormData()
- let formKey
-
- for (const key of Object.keys(obj)) {
- if (namespace) formKey = `${namespace}[${key}]`
- else formKey = key
-
- if (obj[key] === undefined) continue
-
- if (Array.isArray(obj[key]) && obj[key].length === 0) {
- fd.append(key, null)
- continue
- }
-
- if (obj[key] !== null && typeof obj[key] === 'object' && !(obj[key] instanceof File)) {
- objectToFormData(obj[key], fd, formKey)
- } else {
- fd.append(formKey, obj[key])
- }
- }
-
- return fd
-}
-
-function objectLineFeedToHtml (obj: any, keyToNormalize: string) {
- return immutableAssign(obj, {
- [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize])
- })
-}
-
-function lineFeedToHtml (text: string) {
- if (!text) return text
-
- return text.replace(/\r?\n|\r/g, '<br />')
-}
-
-function removeElementFromArray <T> (arr: T[], elem: T) {
- const index = arr.indexOf(elem)
- if (index !== -1) arr.splice(index, 1)
-}
-
-function sortBy (obj: any[], key1: string, key2?: string) {
- return obj.sort((a, b) => {
- const elem1 = key2 ? a[key1][key2] : a[key1]
- const elem2 = key2 ? b[key1][key2] : b[key1]
-
- if (elem1 < elem2) return -1
- if (elem1 === elem2) return 0
- return 1
- })
-}
-
-function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') {
- window.scrollTo({
- left: 0,
- top: 0,
- behavior
- })
-}
-
-function isInViewport (el: HTMLElement) {
- const bounding = el.getBoundingClientRect()
- return (
- bounding.top >= 0 &&
- bounding.left >= 0 &&
- bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
- bounding.right <= (window.innerWidth || document.documentElement.clientWidth)
- )
-}
-
-function isXPercentInViewport (el: HTMLElement, percentVisible: number) {
- const rect = el.getBoundingClientRect()
- const windowHeight = (window.innerHeight || document.documentElement.clientHeight)
-
- return !(
- Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible ||
- Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible
- )
-}
-
-function genericUploadErrorHandler (parameters: {
- err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
- name: string
- notifier: Notifier
- sticky?: boolean
-}) {
- const { err, name, notifier, sticky } = { sticky: false, ...parameters }
- const title = $localize`The upload failed`
- let message = err.message
-
- if (err instanceof ErrorEvent) { // network error
- message = $localize`The connection was interrupted`
- notifier.error(message, title, null, sticky)
- } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
- message = $localize`The server encountered an error`
- notifier.error(message, title, null, sticky)
- } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
- message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)`
- notifier.error(message, title, null, sticky)
- } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
- const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G'
- message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})`
- notifier.error(message, title, null, sticky)
- } else {
- notifier.error(err.message, title)
- }
-
- return message
-}
-
-export {
- sortBy,
- durationToString,
- lineFeedToHtml,
- getParameterByName,
- getAbsoluteAPIUrl,
- dateToHuman,
- immutableAssign,
- objectToFormData,
- getAbsoluteEmbedUrl,
- objectLineFeedToHtml,
- removeElementFromArray,
- scrollToTop,
- isInViewport,
- isXPercentInViewport,
- listUserChannels,
- genericUploadErrorHandler
-}
--- /dev/null
+import { first, map } from 'rxjs/operators'
+import { SelectChannelItem } from 'src/types/select-options-item.model'
+import { AuthService } from '../../core/auth'
+
+function listUserChannels (authService: AuthService) {
+ return authService.userInformationLoaded
+ .pipe(
+ first(),
+ map(() => {
+ const user = authService.getUser()
+ if (!user) return undefined
+
+ const videoChannels = user.videoChannels
+ if (Array.isArray(videoChannels) === false) return undefined
+
+ return videoChannels
+ .sort((a, b) => {
+ if (a.updatedAt < b.updatedAt) return 1
+ if (a.updatedAt > b.updatedAt) return -1
+ return 0
+ })
+ .map(c => ({
+ id: c.id,
+ label: c.displayName,
+ support: c.support,
+ avatarPath: c.avatar?.path
+ }) as SelectChannelItem)
+ })
+ )
+}
+
+export {
+ listUserChannels
+}
--- /dev/null
+import { DatePipe } from '@angular/common'
+
+const datePipe = new DatePipe('en')
+function dateToHuman (date: string) {
+ return datePipe.transform(date, 'medium')
+}
+
+function durationToString (duration: number) {
+ const hours = Math.floor(duration / 3600)
+ const minutes = Math.floor((duration % 3600) / 60)
+ const seconds = duration % 60
+
+ const minutesPadding = minutes >= 10 ? '' : '0'
+ const secondsPadding = seconds >= 10 ? '' : '0'
+ const displayedHours = hours > 0 ? hours.toString() + ':' : ''
+
+ return (
+ displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString()
+ ).replace(/^0/, '')
+}
+
+export {
+ durationToString,
+ dateToHuman
+}
--- /dev/null
+import { immutableAssign } from './object'
+
+function objectLineFeedToHtml (obj: any, keyToNormalize: string) {
+ return immutableAssign(obj, {
+ [keyToNormalize]: lineFeedToHtml(obj[keyToNormalize])
+ })
+}
+
+function lineFeedToHtml (text: string) {
+ if (!text) return text
+
+ return text.replace(/\r?\n|\r/g, '<br />')
+}
+
+export {
+ objectLineFeedToHtml,
+ lineFeedToHtml
+}
--- /dev/null
+export * from './channel'
+export * from './date'
+export * from './html'
+export * from './object'
+export * from './ui'
+export * from './upload'
+export * from './url'
--- /dev/null
+function immutableAssign <A, B> (target: A, source: B) {
+ return Object.assign({}, target, source)
+}
+
+function removeElementFromArray <T> (arr: T[], elem: T) {
+ const index = arr.indexOf(elem)
+ if (index !== -1) arr.splice(index, 1)
+}
+
+function sortBy (obj: any[], key1: string, key2?: string) {
+ return obj.sort((a, b) => {
+ const elem1 = key2 ? a[key1][key2] : a[key1]
+ const elem2 = key2 ? b[key1][key2] : b[key1]
+
+ if (elem1 < elem2) return -1
+ if (elem1 === elem2) return 0
+ return 1
+ })
+}
+
+function intoArray (value: any) {
+ if (!value) return undefined
+ if (Array.isArray(value)) return value
+
+ if (typeof value === 'string') return value.split(',')
+
+ return [ value ]
+}
+
+function toBoolean (value: any) {
+ if (!value) return undefined
+
+ if (typeof value === 'boolean') return value
+
+ if (value === 'true') return true
+ if (value === 'false') return false
+
+ return undefined
+}
+
+export {
+ sortBy,
+ immutableAssign,
+ removeElementFromArray,
+ intoArray,
+ toBoolean
+}
--- /dev/null
+function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') {
+ window.scrollTo({
+ left: 0,
+ top: 0,
+ behavior
+ })
+}
+
+function isInViewport (el: HTMLElement) {
+ const bounding = el.getBoundingClientRect()
+ return (
+ bounding.top >= 0 &&
+ bounding.left >= 0 &&
+ bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
+ bounding.right <= (window.innerWidth || document.documentElement.clientWidth)
+ )
+}
+
+function isXPercentInViewport (el: HTMLElement, percentVisible: number) {
+ const rect = el.getBoundingClientRect()
+ const windowHeight = (window.innerHeight || document.documentElement.clientHeight)
+
+ return !(
+ Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible ||
+ Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible
+ )
+}
+
+export {
+ scrollToTop,
+ isInViewport,
+ isXPercentInViewport
+}
--- /dev/null
+import { HttpErrorResponse } from '@angular/common/http'
+import { Notifier } from '@app/core'
+import { HttpStatusCode } from '@shared/models'
+
+function genericUploadErrorHandler (parameters: {
+ err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
+ name: string
+ notifier: Notifier
+ sticky?: boolean
+}) {
+ const { err, name, notifier, sticky } = { sticky: false, ...parameters }
+ const title = $localize`The upload failed`
+ let message = err.message
+
+ if (err instanceof ErrorEvent) { // network error
+ message = $localize`The connection was interrupted`
+ notifier.error(message, title, null, sticky)
+ } else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
+ message = $localize`The server encountered an error`
+ notifier.error(message, title, null, sticky)
+ } else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
+ message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)`
+ notifier.error(message, title, null, sticky)
+ } else if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413) {
+ const maxFileSize = err.headers?.get('X-File-Maximum-Size') || '8G'
+ message = $localize`Your ${name} file was too large (max. size: ${maxFileSize})`
+ notifier.error(message, title, null, sticky)
+ } else {
+ notifier.error(err.message, title)
+ }
+
+ return message
+}
+
+export {
+ genericUploadErrorHandler
+}
--- /dev/null
+import { environment } from '../../../environments/environment'
+
+// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
+function getParameterByName (name: string, url: string) {
+ if (!url) url = window.location.href
+ name = name.replace(/[[\]]/g, '\\$&')
+
+ const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)')
+ const results = regex.exec(url)
+
+ if (!results) return null
+ if (!results[2]) return ''
+
+ return decodeURIComponent(results[2].replace(/\+/g, ' '))
+}
+
+function getAbsoluteAPIUrl () {
+ let absoluteAPIUrl = environment.hmr === true
+ ? 'http://localhost:9000'
+ : environment.apiUrl
+
+ if (!absoluteAPIUrl) {
+ // The API is on the same domain
+ absoluteAPIUrl = window.location.origin
+ }
+
+ return absoluteAPIUrl
+}
+
+function getAbsoluteEmbedUrl () {
+ let absoluteEmbedUrl = environment.originServerUrl
+ if (!absoluteEmbedUrl) {
+ // The Embed is on the same domain
+ absoluteEmbedUrl = window.location.origin
+ }
+
+ return absoluteEmbedUrl
+}
+
+// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34
+function objectToFormData (obj: any, form?: FormData, namespace?: string) {
+ const fd = form || new FormData()
+ let formKey
+
+ for (const key of Object.keys(obj)) {
+ if (namespace) formKey = `${namespace}[${key}]`
+ else formKey = key
+
+ if (obj[key] === undefined) continue
+
+ if (Array.isArray(obj[key]) && obj[key].length === 0) {
+ fd.append(key, null)
+ continue
+ }
+
+ if (obj[key] !== null && typeof obj[key] === 'object' && !(obj[key] instanceof File)) {
+ objectToFormData(obj[key], fd, formKey)
+ } else {
+ fd.append(formKey, obj[key])
+ }
+ }
+
+ return fd
+}
+
+export {
+ getParameterByName,
+ objectToFormData,
+ getAbsoluteAPIUrl,
+ getAbsoluteEmbedUrl
+}
})
export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
@Input() filters: AdvancedInputFilter[] = []
+ @Input() emitOnInit = true
@Output() search = new EventEmitter<string>()
this.viewInitialized = true
// Init after view init to not send an event too early
- if (this.emitSearchAfterViewInit) this.emitSearch()
+ if (this.emitOnInit && this.emitSearchAfterViewInit) this.emitSearch()
}
onInputSearch (event: Event) {
+export * from './select-categories.component'
export * from './select-channel.component'
+export * from './select-checkbox-all.component'
export * from './select-checkbox.component'
export * from './select-custom-value.component'
+export * from './select-languages.component'
export * from './select-options.component'
export * from './select-tags.component'
--- /dev/null
+<my-select-checkbox-all
+ [(ngModel)]="selectedCategories"
+ (ngModelChange)="onModelChange()"
+ [availableItems]="availableCategories"
+ i18n-placeholder placeholder="Add a new category"
+ [allGroupLabel]="allCategoriesGroup"
+>
+</my-select-checkbox-all>
--- /dev/null
+
+import { Component, forwardRef, OnInit } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { ServerService } from '@app/core'
+import { SelectOptionsItem } from '../../../../types/select-options-item.model'
+import { ItemSelectCheckboxValue } from './select-checkbox.component'
+
+@Component({
+ selector: 'my-select-categories',
+ styleUrls: [ './select-shared.component.scss' ],
+ templateUrl: './select-categories.component.html',
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => SelectCategoriesComponent),
+ multi: true
+ }
+ ]
+})
+export class SelectCategoriesComponent implements ControlValueAccessor, OnInit {
+ selectedCategories: ItemSelectCheckboxValue[] = []
+ availableCategories: SelectOptionsItem[] = []
+
+ allCategoriesGroup = $localize`All categories`
+
+ // Fix a bug on ng-select when we update items after we selected items
+ private toWrite: any
+ private loaded = false
+
+ constructor (
+ private server: ServerService
+ ) {
+
+ }
+
+ ngOnInit () {
+ this.server.getVideoCategories()
+ .subscribe(
+ categories => {
+ this.availableCategories = categories.map(c => ({ label: c.label, id: c.id + '', group: this.allCategoriesGroup }))
+ this.loaded = true
+ this.writeValue(this.toWrite)
+ }
+ )
+ }
+
+ propagateChange = (_: any) => { /* empty */ }
+
+ writeValue (categories: string[] | number[]) {
+ if (!this.loaded) {
+ this.toWrite = categories
+ return
+ }
+
+ this.selectedCategories = categories
+ ? categories.map(c => c + '')
+ : categories as string[]
+ }
+
+ registerOnChange (fn: (_: any) => void) {
+ this.propagateChange = fn
+ }
+
+ registerOnTouched () {
+ // Unused
+ }
+
+ onModelChange () {
+ this.propagateChange(this.selectedCategories)
+ }
+}
--- /dev/null
+import { Component, forwardRef, Input } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { Notifier } from '@app/core'
+import { SelectOptionsItem } from '../../../../types/select-options-item.model'
+import { ItemSelectCheckboxValue } from './select-checkbox.component'
+
+@Component({
+ selector: 'my-select-checkbox-all',
+ styleUrls: [ './select-shared.component.scss' ],
+
+ template: `
+ <my-select-checkbox
+ [(ngModel)]="selectedItems"
+ (ngModelChange)="onModelChange()"
+ [availableItems]="availableItems"
+ [selectableGroup]="true" [selectableGroupAsModel]="true"
+ [placeholder]="placeholder"
+ (focusout)="onBlur()"
+ >
+ </my-select-checkbox>`,
+
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => SelectCheckboxAllComponent),
+ multi: true
+ }
+ ]
+})
+export class SelectCheckboxAllComponent implements ControlValueAccessor {
+ @Input() availableItems: SelectOptionsItem[] = []
+ @Input() allGroupLabel: string
+
+ @Input() placeholder: string
+ @Input() maxItems: number
+
+ selectedItems: ItemSelectCheckboxValue[]
+
+ constructor (
+ private notifier: Notifier
+ ) {
+
+ }
+
+ propagateChange = (_: any) => { /* empty */ }
+
+ writeValue (items: string[]) {
+ this.selectedItems = items
+ ? items.map(l => ({ id: l }))
+ : [ { group: this.allGroupLabel } ]
+ }
+
+ registerOnChange (fn: (_: any) => void) {
+ this.propagateChange = fn
+ }
+
+ registerOnTouched () {
+ // Unused
+ }
+
+ onModelChange () {
+ if (!this.isMaxConstraintValid()) return
+
+ this.propagateChange(this.buildOutputItems())
+ }
+
+ onBlur () {
+ // Automatically use "All languages" if the user did not select any language
+ if (Array.isArray(this.selectedItems) && this.selectedItems.length === 0) {
+ this.selectedItems = [ { group: this.allGroupLabel } ]
+ }
+ }
+
+ private isMaxConstraintValid () {
+ if (!this.maxItems) return true
+
+ const outputItems = this.buildOutputItems()
+ if (!outputItems) return true
+
+ if (outputItems.length >= this.maxItems) {
+ this.notifier.error($localize`You can't select more than ${this.maxItems} items`)
+
+ return false
+ }
+
+ return true
+ }
+
+ private buildOutputItems () {
+ if (!Array.isArray(this.selectedItems)) return undefined
+
+ // null means "All"
+ if (this.selectedItems.length === 0 || this.selectedItems.length === this.availableItems.length) {
+ return null
+ }
+
+ if (this.selectedItems.length === 1) {
+ const item = this.selectedItems[0]
+
+ const itemGroup = typeof item === 'string' || typeof item === 'number'
+ ? item
+ : item.group
+
+ if (itemGroup === this.allGroupLabel) return null
+ }
+
+ return this.selectedItems.map(l => {
+ if (typeof l === 'string' || typeof l === 'number') return l
+
+ if (l.group) return l.group
+
+ return l.id + ''
+ })
+ }
+}
groupBy="group"
[compareWith]="compareFn"
-
- [maxSelectedItems]="maxSelectedItems"
>
<ng-template ng-optgroup-tmp let-item="item" let-item$="item$" let-index="index">
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
-export type ItemSelectCheckboxValue = { id?: string | number, group?: string } | string
+export type ItemSelectCheckboxValue = { id?: string, group?: string } | string
@Component({
selector: 'my-select-checkbox',
@Input() selectedItems: ItemSelectCheckboxValue[] = []
@Input() selectableGroup: boolean
@Input() selectableGroupAsModel: boolean
- @Input() maxSelectedItems: number
@Input() placeholder: string
ngOnInit () {
} else {
this.selectedItems = items
}
-
- this.propagateChange(this.selectedItems)
}
registerOnChange (fn: (_: any) => void) {
}
compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) {
- if (typeof selected === 'string') {
+ if (typeof selected === 'string' || typeof selected === 'number') {
return item.id === selected
}
--- /dev/null
+<my-select-checkbox-all
+ [(ngModel)]="selectedLanguages"
+ (ngModelChange)="onModelChange()"
+ [availableItems]="availableLanguages"
+ [maxItems]="maxLanguages"
+ i18n-placeholder placeholder="Add a new language"
+ [allGroupLabel]="allLanguagesGroup"
+>
+</my-select-checkbox-all>
--- /dev/null
+import { Component, forwardRef, Input, OnInit } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { ServerService } from '@app/core'
+import { SelectOptionsItem } from '../../../../types/select-options-item.model'
+import { ItemSelectCheckboxValue } from './select-checkbox.component'
+
+@Component({
+ selector: 'my-select-languages',
+ styleUrls: [ './select-shared.component.scss' ],
+ templateUrl: './select-languages.component.html',
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => SelectLanguagesComponent),
+ multi: true
+ }
+ ]
+})
+export class SelectLanguagesComponent implements ControlValueAccessor, OnInit {
+ @Input() maxLanguages: number
+
+ selectedLanguages: ItemSelectCheckboxValue[]
+ availableLanguages: SelectOptionsItem[] = []
+
+ allLanguagesGroup = $localize`All languages`
+
+ // Fix a bug on ng-select when we update items after we selected items
+ private toWrite: any
+ private loaded = false
+
+ constructor (
+ private server: ServerService
+ ) {
+
+ }
+
+ ngOnInit () {
+ this.server.getVideoLanguages()
+ .subscribe(
+ languages => {
+ this.availableLanguages = [ { label: $localize`Unknown language`, id: '_unknown', group: this.allLanguagesGroup } ]
+
+ this.availableLanguages = this.availableLanguages
+ .concat(languages.map(l => ({ label: l.label, id: l.id, group: this.allLanguagesGroup })))
+
+ this.loaded = true
+ this.writeValue(this.toWrite)
+ }
+ )
+ }
+
+ propagateChange = (_: any) => { /* empty */ }
+
+ writeValue (languages: ItemSelectCheckboxValue[]) {
+ if (!this.loaded) {
+ this.toWrite = languages
+ return
+ }
+
+ this.selectedLanguages = languages
+ }
+
+ registerOnChange (fn: (_: any) => void) {
+ this.propagateChange = fn
+ }
+
+ registerOnTouched () {
+ // Unused
+ }
+
+ onModelChange () {
+ this.propagateChange(this.selectedLanguages)
+ }
+}
import { PreviewUploadComponent } from './preview-upload.component'
import { ReactiveFileComponent } from './reactive-file.component'
import {
+ SelectCategoriesComponent,
SelectChannelComponent,
+ SelectCheckboxAllComponent,
SelectCheckboxComponent,
SelectCustomValueComponent,
+ SelectLanguagesComponent,
SelectOptionsComponent,
SelectTagsComponent
} from './select'
SelectTagsComponent,
SelectCheckboxComponent,
SelectCustomValueComponent,
+ SelectLanguagesComponent,
+ SelectCategoriesComponent,
+ SelectCheckboxAllComponent,
DynamicFormFieldComponent,
SelectTagsComponent,
SelectCheckboxComponent,
SelectCustomValueComponent,
+ SelectLanguagesComponent,
+ SelectCategoriesComponent,
+ SelectCheckboxAllComponent,
DynamicFormFieldComponent,
columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
repeat: require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
+ 'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default,
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default
import { fromEvent, Observable, Subscription } from 'rxjs'
import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
+import { PeerTubeRouterService, RouterSetting } from '@app/core'
@Directive({
selector: '[myInfiniteScroller]'
})
export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked {
@Input() percentLimit = 70
- @Input() autoInit = false
@Input() onItself = false
@Input() dataObservable: Observable<any[]>
+ // Add angular state in query params to reuse the routed component
+ @Input() setAngularState: boolean
+
@Output() nearOfBottom = new EventEmitter<void>()
private decimalLimit = 0
private checkScroll = false
- constructor (private el: ElementRef) {
+ constructor (
+ private peertubeRouter: PeerTubeRouterService,
+ private el: ElementRef
+ ) {
this.decimalLimit = this.percentLimit / 100
}
}
ngOnInit () {
- if (this.autoInit === true) return this.initialize()
+ this.initialize()
}
ngOnDestroy () {
filter(({ current }) => this.isScrollingDown(current)),
filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
)
- .subscribe(() => this.nearOfBottom.emit())
+ .subscribe(() => {
+ if (this.setAngularState) this.setScrollRouteParams()
+
+ this.nearOfBottom.emit()
+ })
if (this.dataObservable) {
this.dataObservable
this.lastCurrentBottom = current
return result
}
+
+ private setScrollRouteParams () {
+ this.peertubeRouter.addRouteSetting(RouterSetting.REUSE_COMPONENT)
+ }
}
a {
color: #000;
display: block;
+ min-width: 100px;
}
}
my-global-icon {
- @include apply-svg-color(pvar(--mainForegroundColor));
-
cursor: pointer;
width: 100%;
}
<div class="root">
- <input
- #ref
- type="text"
- [(ngModel)]="value"
- (keyup.enter)="searchChange()"
- [hidden]="!inputShown"
- [name]="name"
- [placeholder]="placeholder"
- >
+ <div class="input-group has-feedback has-clear">
+ <input
+ #ref
+ type="text"
+ [(ngModel)]="value"
+ (keyup.enter)="sendSearch()"
+ [hidden]="!inputShown"
+ [name]="name"
+ [placeholder]="placeholder"
+ >
+
+ <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="onResetFilter()"></a>
+ <span class="sr-only" i18n>Clear filters</span>
+ </div>
<my-global-icon iconName="search" aria-label="Search" role="button" (click)="onIconClick()" [title]="iconTitle"></my-global-icon>
height: 28px;
width: 28px;
cursor: pointer;
+ color: pvar(--mainColor);
&:hover {
color: pvar(--mainHoverColor);
}
-
- &[iconName=search] {
- color: pvar(--mainForegroundColor);
- }
-
- &[iconName=cross] {
- color: pvar(--mainForegroundColor);
- }
}
input {
@include peertube-input-text(200px);
+
+ &:focus {
+ box-shadow: 0 0 5px 0 #a5a5a5;
+ }
}
-import { Subject } from 'rxjs'
-import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
@Component({
selector: 'my-simple-search-input',
value = ''
inputShown: boolean
- private searchSubject = new Subject<string>()
-
- constructor (
- private router: Router,
- private route: ActivatedRoute
- ) {}
+ private hasAlreadySentSearch = false
ngOnInit () {
- this.searchSubject
- .pipe(
- debounceTime(400),
- distinctUntilChanged()
- )
- .subscribe(value => this.searchChanged.emit(value))
-
- this.searchSubject.next(this.value)
-
if (this.isInputShown()) this.showInput(false)
}
return
}
- this.searchChange()
+ this.sendSearch()
}
showInput (focus = true) {
this.hideInput()
}
- searchChange () {
- this.router.navigate([ './search' ], { relativeTo: this.route })
+ sendSearch () {
+ this.hasAlreadySentSearch = true
+ this.searchChanged.emit(this.value)
+ }
+
+ onResetFilter () {
+ this.value = ''
- this.searchSubject.next(this.value)
+ if (this.hasAlreadySentSearch) this.sendSearch()
}
}
<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
-<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
+<div class="notifications" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
<ng-container [ngSwitch]="notification.type">
import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
import { objectToFormData } from '@app/helpers'
import {
+ BooleanBothQuery,
FeedFormat,
NSFWPolicyType,
ResultList,
import { VideoEdit } from './video-edit.model'
import { Video } from './video.model'
-export interface VideosProvider {
- getVideos (parameters: {
- videoPagination: ComponentPaginationLight
- sort: VideoSortField
- filter?: VideoFilter
- categoryOneOf?: number[]
- languageOneOf?: string[]
- nsfwPolicy: NSFWPolicyType
- }): Observable<ResultList<Video>>
+export type CommonVideoParams = {
+ videoPagination: ComponentPaginationLight
+ sort: VideoSortField
+ filter?: VideoFilter
+ categoryOneOf?: number[]
+ languageOneOf?: string[]
+ isLive?: boolean
+ skipCount?: boolean
+ // FIXME: remove?
+ nsfwPolicy?: NSFWPolicyType
+ nsfw?: BooleanBothQuery
}
@Injectable()
-export class VideoService implements VideosProvider {
+export class VideoService {
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.'
)
}
- getAccountVideos (parameters: {
+ getAccountVideos (parameters: CommonVideoParams & {
account: Pick<Account, 'nameWithHost'>
- videoPagination: ComponentPaginationLight
- sort: VideoSortField
- nsfwPolicy?: NSFWPolicyType
- videoFilter?: VideoFilter
search?: string
}): Observable<ResultList<Video>> {
- const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters
-
- const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+ const { account, search } = parameters
let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (nsfwPolicy) {
- params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
- }
-
- if (videoFilter) {
- params = params.set('filter', videoFilter)
- }
+ params = this.buildCommonVideosParams({ params, ...parameters })
- if (search) {
- params = params.set('search', search)
- }
+ if (search) params = params.set('search', search)
return this.authHttp
.get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
)
}
- getVideoChannelVideos (parameters: {
+ getVideoChannelVideos (parameters: CommonVideoParams & {
videoChannel: Pick<VideoChannel, 'nameWithHost'>
- videoPagination: ComponentPaginationLight
- sort: VideoSortField
- nsfwPolicy?: NSFWPolicyType
- videoFilter?: VideoFilter
}): Observable<ResultList<Video>> {
- const { videoChannel, videoPagination, sort, nsfwPolicy, videoFilter } = parameters
-
- const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+ const { videoChannel } = parameters
let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (nsfwPolicy) {
- params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
- }
-
- if (videoFilter) {
- params = params.set('filter', videoFilter)
- }
+ params = this.buildCommonVideosParams({ params, ...parameters })
return this.authHttp
.get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
)
}
- getVideos (parameters: {
- videoPagination: ComponentPaginationLight
- sort: VideoSortField
- filter?: VideoFilter
- categoryOneOf?: number[]
- languageOneOf?: string[]
- isLive?: boolean
- skipCount?: boolean
- nsfwPolicy?: NSFWPolicyType
- }): Observable<ResultList<Video>> {
- const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive } = parameters
-
- const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
+ getVideos (parameters: CommonVideoParams): Observable<ResultList<Video>> {
let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (filter) params = params.set('filter', filter)
- if (skipCount) params = params.set('skipCount', skipCount + '')
-
- if (isLive) params = params.set('isLive', isLive)
- if (nsfwPolicy) params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
- if (languageOneOf) this.restService.addArrayParams(params, 'languageOneOf', languageOneOf)
- if (categoryOneOf) this.restService.addArrayParams(params, 'categoryOneOf', categoryOneOf)
+ params = this.buildCommonVideosParams({ params, ...parameters })
return this.authHttp
.get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
catchError(err => this.restExtractor.handleError(err))
)
}
+
+ private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
+ const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options
+
+ const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+ let newParams = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (filter) newParams = newParams.set('filter', filter)
+ if (skipCount) newParams = newParams.set('skipCount', skipCount + '')
+
+ if (isLive) newParams = newParams.set('isLive', isLive)
+ if (nsfw) newParams = newParams.set('nsfw', nsfw)
+ if (nsfwPolicy) newParams = newParams.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
+ if (languageOneOf) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf)
+ if (categoryOneOf) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf)
+
+ return newParams
+ }
}
+import { intoArray } from '@app/helpers'
import {
BooleanBothQuery,
BooleanQuery,
this.categoryOneOf = options.categoryOneOf || undefined
this.licenceOneOf = options.licenceOneOf || undefined
this.languageOneOf = options.languageOneOf || undefined
- this.tagsOneOf = this.intoArray(options.tagsOneOf)
- this.tagsAllOf = this.intoArray(options.tagsAllOf)
+ this.tagsOneOf = intoArray(options.tagsOneOf)
+ this.tagsAllOf = intoArray(options.tagsAllOf)
this.durationMin = parseInt(options.durationMin, 10)
this.durationMax = parseInt(options.durationMax, 10)
originallyPublishedStartDate: this.originallyPublishedStartDate,
originallyPublishedEndDate: this.originallyPublishedEndDate,
nsfw: this.nsfw,
- categoryOneOf: this.intoArray(this.categoryOneOf),
- licenceOneOf: this.intoArray(this.licenceOneOf),
- languageOneOf: this.intoArray(this.languageOneOf),
+ categoryOneOf: intoArray(this.categoryOneOf),
+ licenceOneOf: intoArray(this.licenceOneOf),
+ languageOneOf: intoArray(this.languageOneOf),
tagsOneOf: this.tagsOneOf,
tagsAllOf: this.tagsAllOf,
durationMin: this.durationMin,
return true
}
-
- private intoArray (value: any) {
- if (!value) return undefined
- if (Array.isArray(value)) return value
-
- if (typeof value === 'string') return value.split(',')
-
- return [ value ]
- }
}
</my-help>
<div>
- <my-select-checkbox
- formControlName="videoLanguages" [availableItems]="languageItems"
- [selectableGroup]="true" [selectableGroupAsModel]="true"
- i18n-placeholder placeholder="Add a new language"
- >
- </my-select-checkbox >
+ <my-select-languages formControlName="videoLanguages"></my-select-languages>
</div>
</div>
margin-bottom: 30px;
}
-my-select-checkbox {
+my-select-languages {
@include responsive-width(340px);
display: block;
import { pick } from 'lodash-es'
-import { forkJoin, Subject, Subscription } from 'rxjs'
+import { Subject, Subscription } from 'rxjs'
import { first } from 'rxjs/operators'
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
-import { FormReactive, FormValidatorService, ItemSelectCheckboxValue } from '@app/shared/shared-forms'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { UserUpdateMe } from '@shared/models'
import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { SelectOptionsItem } from '../../../types/select-options-item.model'
@Component({
selector: 'my-user-video-settings',
@Input() notifyOnUpdate = true
@Input() userInformationLoaded: Subject<any>
- languageItems: SelectOptionsItem[] = []
defaultNSFWPolicy: NSFWPolicyType
formValuesWatcher: Subscription
- private allLanguagesGroup: string
-
constructor (
protected formValidatorService: FormValidatorService,
private authService: AuthService,
}
ngOnInit () {
- this.allLanguagesGroup = $localize`All languages`
-
this.buildForm({
nsfwPolicy: null,
webTorrentEnabled: null,
videoLanguages: null
})
- forkJoin([
- this.serverService.getVideoLanguages(),
- this.userInformationLoaded.pipe(first())
- ]).subscribe(([ languages ]) => {
- const group = this.allLanguagesGroup
-
- this.languageItems = [ { label: $localize`Unknown language`, id: '_unknown', group } ]
- this.languageItems = this.languageItems
- .concat(languages.map(l => ({ label: l.label, id: l.id, group })))
-
- const videoLanguages: ItemSelectCheckboxValue[] = this.user.videoLanguages
- ? this.user.videoLanguages.map(l => ({ id: l }))
- : [ { group } ]
-
- const serverConfig = this.serverService.getHTMLConfig()
- this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
-
- this.form.patchValue({
- nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy,
- webTorrentEnabled: this.user.webTorrentEnabled,
- autoPlayVideo: this.user.autoPlayVideo === true,
- autoPlayNextVideo: this.user.autoPlayNextVideo,
- videoLanguages
- })
-
- if (this.reactiveUpdate) this.handleReactiveUpdate()
- })
+ this.userInformationLoaded.pipe(first())
+ .subscribe(
+ () => {
+ const serverConfig = this.serverService.getHTMLConfig()
+ this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
+
+ this.form.patchValue({
+ nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy,
+ webTorrentEnabled: this.user.webTorrentEnabled,
+ autoPlayVideo: this.user.autoPlayVideo === true,
+ autoPlayNextVideo: this.user.autoPlayNextVideo,
+ videoLanguages: this.user.videoLanguages
+ })
+
+ if (this.reactiveUpdate) this.handleReactiveUpdate()
+ }
+ )
}
ngOnDestroy () {
const autoPlayVideo = this.form.value['autoPlayVideo']
const autoPlayNextVideo = this.form.value['autoPlayNextVideo']
- let videoLanguagesForm = this.form.value['videoLanguages']
+ const videoLanguages = this.form.value['videoLanguages']
- if (Array.isArray(videoLanguagesForm)) {
- if (videoLanguagesForm.length > 20) {
+ if (Array.isArray(videoLanguages)) {
+ if (videoLanguages.length > 20) {
this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`)
return
}
-
- // Automatically use "All languages" if the user did not select any language
- if (videoLanguagesForm.length === 0) {
- videoLanguagesForm = [ this.allLanguagesGroup ]
- this.form.patchValue({ videoLanguages: [ { group: this.allLanguagesGroup } ] })
- }
}
- const videoLanguages = this.buildLanguagesFromForm(videoLanguagesForm)
-
let details: UserUpdateMe = {
nsfwPolicy,
webTorrentEnabled,
return this.updateAnonymousProfile(details)
}
- private buildLanguagesFromForm (videoLanguages: ItemSelectCheckboxValue[]) {
- if (!Array.isArray(videoLanguages)) return undefined
-
- // null means "All"
- if (videoLanguages.length === this.languageItems.length) return null
-
- if (videoLanguages.length === 1) {
- const videoLanguage = videoLanguages[0]
-
- if (typeof videoLanguage === 'string') {
- if (videoLanguage === this.allLanguagesGroup) return null
- } else {
- if (videoLanguage.group === this.allLanguagesGroup) return null
- }
- }
-
- return videoLanguages.map(l => {
- if (typeof l === 'string') return l
-
- if (l.group) return l.group
-
- return l.id + ''
- })
- }
-
private handleReactiveUpdate () {
let oldForm = { ...this.form.value }
+++ /dev/null
-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<GenericHeaderComponent> = 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<any[]>()
-
- userMiniature: User
-
- protected onUserLoadedSubject = new ReplaySubject<void>(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<any> = 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()
- }
-}
-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'
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: [
VideoDownloadComponent,
VideoMiniatureComponent,
VideosSelectionComponent,
- VideoListHeaderComponent
+ VideoFiltersHeaderComponent,
+ VideosListComponent
],
exports: [
VideoActionsDropdownComponent,
VideoDownloadComponent,
VideoMiniatureComponent,
- VideosSelectionComponent
+ VideosSelectionComponent,
+ VideoFiltersHeaderComponent,
+ VideosListComponent
],
providers: [ ]
margin-top: 20px;
.peertube-radio-container {
- @include peertube-radio-container;
@include margin-right(30px);
display: inline-block;
--- /dev/null
+<ng-template #updateSettings let-fragment>
+ <div class="label-description text-muted" i18n>
+ Update
+ <a routerLink="/my-account/settings" [fragment]="fragment">
+ <span (click)="onAccountSettingsClick($event)">your settings</span>
+ </a
+ ></div>
+</ng-template>
+
+
+<div class="root" [formGroup]="form">
+
+ <div class="first-row">
+ <div class="active-filters">
+ <div
+ class="pastille filters-toggle" (click)="areFiltersCollapsed = !areFiltersCollapsed" role="button"
+ [attr.aria-expanded]="!areFiltersCollapsed" aria-controls="collapseBasic"
+ [ngClass]="{ active: !areFiltersCollapsed }"
+ >
+ <ng-container i18n *ngIf="areFiltersCollapsed">More filters</ng-container>
+ <ng-container i18n *ngIf="!areFiltersCollapsed">Less filters</ng-container>
+
+ <my-global-icon iconName="chevrons-up"></my-global-icon>
+ </div>
+
+ <div
+ *ngFor="let activeFilter of filters.getActiveFilters()" (click)="resetFilter(activeFilter.key, activeFilter.canRemove)"
+ class="active-filter pastille" [ngClass]="{ 'can-remove': activeFilter.canRemove }" [title]="getFilterTitle(activeFilter.canRemove)"
+ >
+ <span>
+ {{ activeFilter.label }}
+
+ <ng-container *ngIf="activeFilter.value">: {{ activeFilter.value }}</ng-container>
+ </span>
+
+ <my-global-icon *ngIf="activeFilter.canRemove" iconName="cross"></my-global-icon>
+ </div>
+ </div>
+
+ <ng-select
+ class="sort"
+ formControlName="sort"
+ [clearable]="false"
+ [searchable]="false"
+ >
+ <ng-option i18n value="-publishedAt">Sort by <strong>"Recently Added"</strong></ng-option>
+
+ <ng-option i18n *ngIf="isTrendingSortEnabled('most-viewed')" value="-trending">Sort by <strong>"Views"</strong></ng-option>
+ <ng-option i18n *ngIf="isTrendingSortEnabled('hot')" value="-hot">Sort by <strong>"Hot"</strong></ng-option>
+ <ng-option i18n *ngIf="isTrendingSortEnabled('best')" value="-best">Sort by <strong>"Best"</strong></ng-option>
+ <ng-option i18n *ngIf="isTrendingSortEnabled('most-liked')" value="-likes">Sort by <strong>"Likes"</strong></ng-option>
+ </ng-select>
+
+ </div>
+
+ <div class="collapse-transition" [ngbCollapse]="areFiltersCollapsed">
+ <div class="filters">
+ <div class="form-group">
+ <label class="with-description" for="languageOneOf" i18n>Languages:</label>
+ <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-languages-subtitles' }"></ng-template>
+
+ <my-select-languages [maxLanguages]="20" formControlName="languageOneOf"></my-select-languages>
+ </div>
+
+ <div class="form-group">
+ <label class="with-description" for="nsfw" i18n>Sensitive content:</label>
+ <ng-template *ngTemplateOutlet="updateSettings; context: { $implicit: 'video-sensitive-content-policy' }"></ng-template>
+
+ <div class="peertube-radio-container">
+ <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwBoth" i18n-value value="both" />
+ <label for="nsfwBoth">{{ filters.getNSFWDisplayLabel() }}</label>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input formControlName="nsfw" type="radio" name="nsfw" id="nsfwFalse" i18n-value value="false" />
+ <label for="nsfwFalse" i18n>Hide</label>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="scope" i18n>Scope:</label>
+
+ <div class="peertube-radio-container">
+ <input formControlName="scope" type="radio" name="scope" id="scopeLocal" i18n-value value="local" />
+ <label for="scopeLocal" i18n>Local videos (this instance)</label>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input formControlName="scope" type="radio" name="scope" id="scopeFederated" i18n-value value="federated" />
+ <label for="scopeFederated" i18n>Federated videos (this instance + followed instances)</label>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="type" i18n>Type:</label>
+
+ <div class="peertube-radio-container">
+ <input formControlName="live" type="radio" name="live" id="liveBoth" i18n-value value="both" />
+ <label for="liveBoth" i18n>VOD & Live videos</label>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input formControlName="live" type="radio" name="live" id="liveTrue" i18n-value value="true" />
+ <label for="liveTrue" i18n>Live videos</label>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input formControlName="live" type="radio" name="live" id="liveFalse" i18n-value value="false" />
+ <label for="liveFalse" i18n>VOD videos</label>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="categoryOneOf" i18n>Categories:</label>
+
+ <my-select-categories formControlName="categoryOneOf"></my-select-categories>
+ </div>
+
+ <div class="form-group" *ngIf="canSeeAllVideos()">
+ <label for="allVideos" i18n>Moderation:</label>
+
+ <my-peertube-checkbox
+ formControlName="allVideos"
+ inputName="allVideos"
+ i18n-labelText labelText="Display all videos (private, unlisted or not yet published)"
+ ></my-peertube-checkbox>
+ </div>
+ </div>
+ </div>
+
+</div>
--- /dev/null
+@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;
+ }
+}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+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<VideoFilters> ]: 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<keyof VideoFilters, any>([
+ [ '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<AttributesOnly<VideoFilters>>) {
+ 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<VideoFiltersKeys> = {}
+
+ 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')
+ }
+}
+++ /dev/null
-<h1 class="title-page title-page-single">
- <div placement="bottom" [ngbTooltip]="data.titleTooltip" container="body">
- {{ data.titlePage }}
- </div>
-</h1>
\ No newline at end of file
+++ /dev/null
-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)
- }
-}
<div class="margin-content">
<div class="videos-header">
- <ng-template #videoListHeader></ng-template>
+ <h1 *ngIf="displayTitle" class="title" placement="bottom" [ngbTooltip]="titleTooltip" container="body">
+ {{ title }}
+ </h1>
- <div class="action-block">
- <my-feed *ngIf="syndicationItems" [syndicationItems]="syndicationItems"></my-feed>
+ <div *ngIf="syndicationItems" [ngClass]="{ 'no-title': !displayTitle }" class="title-subscription">
+ <ng-container i18n>Subscribe to RSS feed "{{ title }}"</ng-container>
+
+ <my-feed [syndicationItems]="syndicationItems"></my-feed>
+ </div>
- <ng-container *ngFor="let action of actions">
+ <div class="action-block">
+ <ng-container *ngFor="let action of headerActions">
<a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active">
<ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
</a>
</ng-template>
</ng-container>
</div>
-
- <div class="moderation-block" *ngIf="displayModerationBlock">
- <div class="c-hand" ngbDropdown placement="bottom-right auto">
- <my-global-icon iconName="cog" ngbDropdownToggle></my-global-icon>
-
- <div role="menu" class="dropdown-menu" ngbDropdownMenu>
- <div class="dropdown-item">
- <my-peertube-checkbox
- (change)="toggleModerationDisplay()"
- inputName="display-unlisted-private" i18n-labelText labelText="Display all videos (private, unlisted or not yet published)"
- ></my-peertube-checkbox>
- </div>
- </div>
- </div>
- </div>
</div>
+ <my-video-filters-header
+ *ngIf="displayFilters" [displayModerationBlock]="displayModerationBlock"
+ [defaultSort]="defaultSort" [filters]="filters"
+ (filtersChanged)="onFiltersChanged(true)"
+ ></my-video-filters-header>
+
<div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div>
<div
- myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
- class="videos" [ngClass]="{ 'display-as-row': displayAsRow() }"
+ myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true"
+ class="videos" [ngClass]="{ 'display-as-row': displayAsRow }"
>
<ng-container *ngFor="let video of videos; trackBy: videoById;">
<h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
<div class="video-wrapper">
<my-video-miniature
- [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow()"
+ [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow"
[displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
(videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
>
@use '_mixins' as *;
@use '_miniature' as *;
-$icon-size: 16px;
-
-::ng-deep my-video-list-header {
- display: flex;
- flex-grow: 1;
-}
-
.videos-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ margin-bottom: 30px;
- my-feed {
- display: inline-block;
- width: calc(#{$icon-size} - 2px);
+ .title,
+ .title-subscription {
+ grid-column: 1;
}
- .moderation-block {
- @include margin-left(.4rem);
+ .title {
+ font-size: 18px;
+ color: pvar(--mainForegroundColor);
+ display: inline-block;
+ font-weight: $font-semibold;
- display: flex;
- justify-content: flex-end;
- align-items: center;
+ margin-top: 30px;
+ margin-bottom: 0;
+ }
+
+ .title-subscription {
+ grid-row: 2;
+ font-size: 14px;
+ color: pvar(--greyForegroundColor);
- my-global-icon {
- position: relative;
- width: $icon-size;
+ &.no-title {
+ margin-top: 10px;
}
}
+
+ .action-block {
+ grid-column: 3;
+ }
+
+ my-feed {
+ @include margin-left(5px);
+
+ display: inline-block;
+ width: 16px;
+ color: pvar(--mainColor);
+ position: relative;
+ top: -2px;
+ }
}
.date-title {
font-size: 16px;
font-weight: $font-semibold;
margin-bottom: 20px;
- margin-top: -10px;
- // make the element span a full grid row within .videos grid
+ // Make the element span a full grid row within .videos grid
grid-column: 1 / -1;
&:not(:first-child) {
}
@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;
--- /dev/null
+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<ResultList<Video>>
+ @Input() getSyndicationItemsFunction: (filters: VideoFilters) => Promise<Syndication[]> | 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<VideoFilters>()
+
+ videos: Video[] = []
+ filters: VideoFilters
+ syndicationItems: Syndication[]
+
+ onDataSubject = new Subject<any[]>()
+ 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)
+ })
+ }
+}
<div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">{{ noResultMessage }}</div>
-<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos">
+<div
+ class="videos"
+ myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true"
+>
<div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById">
<div class="checkbox-container" *ngIf="enableSelection">
-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 }
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<ResultList<Video>>
rowButtonsTemplate: TemplateRef<any>
globalButtonsTemplate: TemplateRef<any>
+ videos: Video[] = []
+ sort: VideoSortField = '-publishedAt'
+
+ onDataSubject = new Subject<any[]>()
+
+ 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
this.videosModelChange.emit(this.videos)
}
- ngOnInit () {
- super.ngOnInit()
- }
-
ngAfterContentInit () {
{
const t = this.templates.find(t => t.name === 'rowButtons')
const t = this.templates.find(t => t.name === 'globalButtons')
if (t) this.globalButtonsTemplate = t.template
}
- }
- ngOnDestroy () {
- super.ngOnDestroy()
+ this.loadMoreVideos()
}
getVideosObservable (page: number) {
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)
}
}
--- /dev/null
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevrons-up">
+ <polyline points="17 11 12 6 7 11"></polyline>
+ <polyline points="17 18 12 13 7 18"></polyline>
+</svg>
&.show {
max-height: 1500px;
+ overflow: inherit !important;
}
}
.tertiary-button {
@include tertiary-button;
}
+
+.peertube-radio-container {
+ @include peertube-radio-container;
+}
}
}
-// Thanks: https://codepen.io/triss90/pen/XNEdRe/
+// Thanks: https://codepen.io/manabox/pen/raQmpL
@mixin peertube-radio-container {
- input[type=radio] {
- display: none;
-
- + label {
- font-weight: $font-regular;
- cursor: pointer;
+ [type=radio]:checked,
+ [type=radio]:not(:checked) {
+ position: absolute;
+ left: -9999px;
+ }
- &::before {
- @include margin-right(10px);
-
- position: relative;
- top: -2px;
- content: '';
- background: #fff;
- border-radius: 100%;
- border: 1px solid #000;
- display: inline-block;
- width: 15px;
- height: 15px;
- vertical-align: middle;
- cursor: pointer;
- text-align: center;
- }
- }
+ [type=radio]:checked + label,
+ [type=radio]:not(:checked) + label {
+ position: relative;
+ padding-left: 28px;
+ cursor: pointer;
+ line-height: 20px;
+ display: inline-block;
+ }
- &:checked + label::before {
- background-color: #000;
- box-shadow: inset 0 0 0 4px #fff;
- }
+ [type=radio]:checked + label::before,
+ [type=radio]:not(:checked) + label::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 18px;
+ height: 18px;
+ border: 1px solid #C6C6C6;
+ border-radius: 100%;
+ background: #fff;
+ }
- &:focus + label::before {
- outline: none;
- border-color: #000;
- }
+ [type=radio]:checked + label::after,
+ [type=radio]:not(:checked) + label::after {
+ content: '';
+ width: 10px;
+ height: 10px;
+ background: pvar(--mainColor);
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ border-radius: 100%;
+ transition: all 0.2s ease;
+ }
+ [type=radio]:not(:checked) + label::after {
+ opacity: 0;
+ transform: scale(0);
+ }
+ [type=radio]:checked + label::after {
+ opacity: 1;
+ transform: scale(1);
}
}