Add video filters to common video pages

This commit is contained in:
Chocobozzz 2021-08-19 09:24:29 +02:00
parent 2e80d256cc
commit dd24f1bb0a
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
97 changed files with 2610 additions and 1896 deletions

View File

@ -1,110 +0,0 @@
import { forkJoin, Subscription } from 'rxjs'
import { first, tap } from 'rxjs/operators'
import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { immutableAssign } from '@app/helpers'
import { Account, AccountService, VideoService } from '@app/shared/shared-main'
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { VideoFilter } from '@shared/models'
@Component({
selector: 'my-account-search',
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ]
})
export class AccountSearchComponent extends AbstractVideoList implements OnInit, OnDestroy {
titlePage: string
loadOnInit = false
loadUserVideoPreferences = true
search = ''
filter: VideoFilter = null
private account: Account
private accountSub: Subscription
constructor (
protected router: Router,
protected serverService: ServerService,
protected route: ActivatedRoute,
protected authService: AuthService,
protected userService: UserService,
protected notifier: Notifier,
protected confirmService: ConfirmService,
protected screenService: ScreenService,
protected storageService: LocalStorageService,
protected cfr: ComponentFactoryResolver,
private accountService: AccountService,
private videoService: VideoService
) {
super()
}
ngOnInit () {
super.ngOnInit()
this.enableAllFilterIfPossible()
// Parent get the account for us
this.accountSub = forkJoin([
this.accountService.accountLoaded.pipe(first()),
this.onUserLoadedSubject.pipe(first())
]).subscribe(([ account ]) => {
this.account = account
this.reloadVideos()
})
}
ngOnDestroy () {
if (this.accountSub) this.accountSub.unsubscribe()
super.ngOnDestroy()
}
updateSearch (value: string) {
this.search = value
if (!this.search) {
this.router.navigate([ '../videos' ], { relativeTo: this.route })
return
}
this.videos = []
this.reloadVideos()
}
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
const options = {
account: this.account,
videoPagination: newPagination,
sort: this.sort,
nsfwPolicy: this.nsfwPolicy,
videoFilter: this.filter,
search: this.search
}
return this.videoService
.getAccountVideos(options)
.pipe(
tap(({ total }) => {
this.titlePage = this.search
? $localize`Published ${total} videos matching "${this.search}"`
: $localize`Published ${total} videos`
})
)
}
toggleModerationDisplay () {
this.filter = this.buildLocalFilter(this.filter, null)
this.reloadVideos()
}
generateSyndicationList () {
/* method disabled */
throw new Error('Method not implemented.')
}
}

View File

@ -4,7 +4,7 @@
<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">

View File

@ -0,0 +1,20 @@
<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>

View File

@ -1,96 +1,69 @@
import { forkJoin, Subscription } from 'rxjs'
import { Subscription } from 'rxjs'
import { first } from 'rxjs/operators'
import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { immutableAssign } from '@app/helpers'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
import { Account, AccountService, VideoService } from '@app/shared/shared-main'
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { VideoFilter } from '@shared/models'
import { VideoFilters } from '@app/shared/shared-video-miniature'
import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-account-videos',
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
styleUrls: [
'../../shared/shared-video-miniature/abstract-video-list.scss'
]
templateUrl: './account-videos.component.html'
})
export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
// No value because we don't want a page title
titlePage: string
loadOnInit = false
loadUserVideoPreferences = true
export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
getVideosObservableFunction = this.getVideosObservable.bind(this)
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
filter: VideoFilter = null
title = $localize`Videos`
defaultSort = '-publishedAt' as VideoSortField
account: Account
disabled = false
private account: Account
private accountSub: Subscription
constructor (
protected router: Router,
protected serverService: ServerService,
protected route: ActivatedRoute,
protected authService: AuthService,
protected userService: UserService,
protected notifier: Notifier,
protected confirmService: ConfirmService,
protected screenService: ScreenService,
protected storageService: LocalStorageService,
private screenService: ScreenService,
private accountService: AccountService,
private videoService: VideoService,
protected cfr: ComponentFactoryResolver
private videoService: VideoService
) {
super()
}
ngOnInit () {
super.ngOnInit()
this.enableAllFilterIfPossible()
// Parent get the account for us
this.accountSub = forkJoin([
this.accountService.accountLoaded.pipe(first()),
this.onUserLoadedSubject.pipe(first())
]).subscribe(([ account ]) => {
this.account = account
this.reloadVideos()
this.generateSyndicationList()
})
this.accountService.accountLoaded.pipe(first())
.subscribe(account => this.account = account)
}
ngOnDestroy () {
if (this.accountSub) this.accountSub.unsubscribe()
super.ngOnDestroy()
}
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
const options = {
...filters.toVideosAPIObject(),
videoPagination: pagination,
account: this.account,
videoPagination: newPagination,
sort: this.sort,
nsfwPolicy: this.nsfwPolicy,
videoFilter: this.filter
skipCount: true
}
return this.videoService
.getAccountVideos(options)
return this.videoService.getAccountVideos(options)
}
toggleModerationDisplay () {
this.filter = this.buildLocalFilter(this.filter, null)
this.reloadVideos()
}
generateSyndicationList () {
this.syndicationItems = this.videoService.getAccountFeedUrls(this.account.id)
getSyndicationItems () {
return this.videoService.getAccountFeedUrls(this.account.id)
}
displayAsRow () {
return this.screenService.isInMobileView()
}
disableForReuse () {
this.disabled = true
}
enabledForReuse () {
this.disabled = false
}
}

View File

@ -1,6 +1,5 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { AccountSearchComponent } from './account-search/account-search.component'
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
import { AccountVideosComponent } from './account-videos/account-videos.component'
import { AccountsComponent } from './accounts.component'
@ -41,14 +40,11 @@ const accountsRoutes: Routes = [
}
}
},
// Old URL redirection
{
path: 'search',
component: AccountSearchComponent,
data: {
meta: {
title: $localize`Search videos within account`
}
}
redirectTo: 'videos'
}
]
}

View File

@ -66,7 +66,7 @@
</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>
@ -81,7 +81,7 @@
></my-simple-search-input>
</div>
<router-outlet (activate)="onOutletLoaded($event)"></router-outlet>
<router-outlet></router-outlet>
</div>
<ng-container *ngIf="prependModerationActions">

View File

@ -20,7 +20,10 @@
display: flex;
justify-content: space-between;
align-items: center;
&.on-channel-page {
max-width: $max-channels-width;
}
simple-search-input {
@include margin-left(auto);

View File

@ -1,7 +1,7 @@
import { Subscription } from 'rxjs'
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
import {
Account,
@ -14,7 +14,6 @@ import {
} from '@app/shared/shared-main'
import { AccountReportComponent } from '@app/shared/shared-moderation'
import { HttpStatusCode, User, UserRight } from '@shared/models'
import { AccountSearchComponent } from './account-search/account-search.component'
@Component({
templateUrl: './accounts.component.html',
@ -23,8 +22,6 @@ import { AccountSearchComponent } from './account-search/account-search.componen
export class AccountsComponent implements OnInit, OnDestroy {
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent
accountSearch: AccountSearchComponent
account: Account
accountUser: User
@ -45,6 +42,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
constructor (
private route: ActivatedRoute,
private router: Router,
private userService: UserService,
private accountService: AccountService,
private videoChannelService: VideoChannelService,
@ -128,16 +126,10 @@ export class AccountsComponent implements OnInit, OnDestroy {
return $localize`${count} subscribers`
}
onOutletLoaded (component: Component) {
if (component instanceof AccountSearchComponent) {
this.accountSearch = component
} else {
this.accountSearch = undefined
}
}
searchChanged (search: string) {
if (this.accountSearch) this.accountSearch.updateSearch(search)
const queryParams = { search }
this.router.navigate([ './videos' ], { queryParams, relativeTo: this.route, queryParamsHandling: 'merge' })
}
onSearchInputDisplayChanged (displayed: boolean) {
@ -152,6 +144,10 @@ export class AccountsComponent implements OnInit, OnDestroy {
return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100
}
isOnChannelPage () {
return this.route.children[0].snapshot.url[0].path === 'video-channels'
}
private async onAccount (account: Account) {
this.accountFollowerTitle = $localize`${account.followersCount} direct account followers`

View File

@ -5,12 +5,11 @@ import { SharedMainModule } from '@app/shared/shared-main'
import { SharedModerationModule } from '@app/shared/shared-moderation'
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
import { AccountSearchComponent } from './account-search/account-search.component'
import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
import { AccountVideosComponent } from './account-videos/account-videos.component'
import { AccountsRoutingModule } from './accounts-routing.module'
import { AccountsComponent } from './accounts.component'
import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
@NgModule({
imports: [
@ -28,8 +27,7 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto
declarations: [
AccountsComponent,
AccountVideosComponent,
AccountVideoChannelsComponent,
AccountSearchComponent
AccountVideoChannelsComponent
],
exports: [

View File

@ -6,7 +6,7 @@
{{ 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">

View File

@ -29,7 +29,7 @@
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">

View File

@ -5,7 +5,7 @@
<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">
@ -26,8 +26,8 @@
[titlePage]="titlePage"
[getVideosObservableFunction]="getVideosObservableFunction"
[user]="user"
[loadOnInit]="false"
i18n-noResultMessage noResultMessage="You don't have any video in your watch history yet."
[enableSelection]="false"
[disabled]="disabled"
#videosSelection
></my-videos-selection>

View File

@ -50,6 +50,8 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
videos: Video[] = []
search: string
disabled = false
constructor (
protected router: Router,
protected serverService: ServerService,
@ -74,11 +76,11 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
}
disableForReuse () {
this.videosSelection.disableForReuse()
this.disabled = true
}
enabledForReuse () {
this.videosSelection.enabledForReuse()
this.disabled = false
}
reloadData () {

View File

@ -12,7 +12,7 @@
<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>

View File

@ -34,7 +34,7 @@
</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()">

View File

@ -12,7 +12,7 @@
</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"

View File

@ -19,7 +19,7 @@
</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">
@ -41,7 +41,7 @@
[titlePage]="titlePage"
[getVideosObservableFunction]="getVideosObservableFunction"
[user]="user"
[loadOnInit]="false"
[disabled]="disabled"
#videosSelection
>
<ng-template ptTemplate="globalButtons">

View File

@ -54,6 +54,8 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
}
]
disabled = false
private search: string
constructor (
@ -89,11 +91,11 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
}
disableForReuse () {
this.videosSelection.disableForReuse()
this.disabled = true
}
enabledForReuse () {
this.videosSelection.enabledForReuse()
this.disabled = false
}
getVideosObservable (page: number) {

View File

@ -11,7 +11,6 @@ form {
}
.peertube-radio-container {
@include peertube-radio-container;
@include margin-right(30px);
display: inline-block;

View File

@ -1,4 +1,4 @@
<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">

View File

@ -5,7 +5,7 @@
<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>

View File

@ -0,0 +1,21 @@
<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>

View File

@ -1,27 +1,21 @@
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,
@ -34,80 +28,55 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
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.videoChannelService.videoChannelLoaded.pipe(first())
.subscribe(videoChannel => {
this.videoChannel = videoChannel
this.reloadVideos()
this.generateSyndicationList()
})
}
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)
}
toggleModerationDisplay () {
this.filter = this.buildLocalFilter(this.filter, null)
this.reloadVideos()
getSyndicationItems () {
return this.videoService.getVideoChannelFeedUrls(this.videoChannel.id)
}
displayAsRow () {
return this.screenService.isInMobileView()
}
disableForReuse () {
this.disabled = true
}
enabledForReuse () {
this.disabled = false
}
}

View File

@ -27,13 +27,7 @@
<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

View File

@ -1,6 +1,6 @@
<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">

View File

@ -1,4 +1,2 @@
export * from './overview'
export * from './trending'
export * from './video-local.component'
export * from './video-recently-added.component'
export * from './videos-list-common-page.component'

View File

@ -4,7 +4,7 @@
<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">

View File

@ -1,2 +0,0 @@
export * from './video-trending-header.component'
export * from './video-trending.component'

View File

@ -1,8 +0,0 @@
<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>

View File

@ -1,20 +0,0 @@
@use '_mixins' as *;
.btn-group label {
border: 1px solid transparent;
border-radius: 9999px !important;
padding: 5px 16px;
opacity: .8;
&:not(:first-child) {
@include margin-left(.5rem);
}
my-global-icon {
@include margin-right(.1rem);
position: relative;
top: -2px;
height: 1rem;
}
}

View File

@ -1,109 +0,0 @@
import { Subscription } from 'rxjs'
import { Component, HostBinding, Inject, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, RedirectService } from '@app/core'
import { ServerService } from '@app/core/server/server.service'
import { GlobalIconName } from '@app/shared/shared-icons'
import { VideoListHeaderComponent } from '@app/shared/shared-video-miniature'
interface VideoTrendingHeaderItem {
label: string
iconName: GlobalIconName
value: string
tooltip?: string
hidden?: boolean
}
@Component({
selector: 'my-video-trending-title-page',
styleUrls: [ './video-trending-header.component.scss' ],
templateUrl: './video-trending-header.component.html'
})
export class VideoTrendingHeaderComponent extends VideoListHeaderComponent implements OnInit, OnDestroy {
@HostBinding('class') class = 'title-page title-page-single'
buttons: VideoTrendingHeaderItem[]
private algorithmChangeSub: Subscription
constructor (
@Inject('data') public data: any,
private route: ActivatedRoute,
private router: Router,
private auth: AuthService,
private serverService: ServerService,
private redirectService: RedirectService
) {
super(data)
this.buttons = [
{
label: $localize`:A variant of Trending videos based on the number of recent interactions, minus user history:Best`,
iconName: 'award',
value: 'best',
tooltip: $localize`Videos with the most interactions for recent videos, minus user history`,
hidden: true
},
{
label: $localize`:A variant of Trending videos based on the number of recent interactions:Hot`,
iconName: 'flame',
value: 'hot',
tooltip: $localize`Videos with the most interactions for recent videos`,
hidden: true
},
{
label: $localize`:Main variant of Trending videos based on number of recent views:Views`,
iconName: 'trending',
value: 'most-viewed',
tooltip: $localize`Videos with the most views during the last 24 hours`
},
{
label: $localize`:A variant of Trending videos based on the number of likes:Likes`,
iconName: 'like',
value: 'most-liked',
tooltip: $localize`Videos that have the most likes`
}
]
}
ngOnInit () {
const serverConfig = this.serverService.getHTMLConfig()
const algEnabled = serverConfig.trending.videos.algorithms.enabled
this.buttons = this.buttons.map(b => {
b.hidden = !algEnabled.includes(b.value)
// Best is adapted by the user history so
if (b.value === 'best' && !this.auth.isLoggedIn()) {
b.hidden = true
}
return b
})
this.algorithmChangeSub = this.route.queryParams.subscribe(
queryParams => {
this.data.model = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm()
}
)
}
ngOnDestroy () {
if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe()
}
setSort () {
const alg = this.data.model !== this.redirectService.getDefaultTrendingAlgorithm()
? this.data.model
: undefined
this.router.navigate(
[],
{
relativeTo: this.route,
queryParams: { alg },
queryParamsHandling: 'merge'
}
)
}
}

View File

@ -1,127 +0,0 @@
import { Subscription } from 'rxjs'
import { first, switchMap } from 'rxjs/operators'
import { Component, ComponentFactoryResolver, Injector, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Params, Router } from '@angular/router'
import { AuthService, LocalStorageService, Notifier, RedirectService, ScreenService, ServerService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { immutableAssign } from '@app/helpers'
import { VideoService } from '@app/shared/shared-main'
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { VideoSortField } from '@shared/models'
import { VideoTrendingHeaderComponent } from './video-trending-header.component'
@Component({
selector: 'my-videos-hot',
styleUrls: [ '../../../shared/shared-video-miniature/abstract-video-list.scss' ],
templateUrl: '../../../shared/shared-video-miniature/abstract-video-list.html'
})
export class VideoTrendingComponent extends AbstractVideoList implements OnInit, OnDestroy {
HeaderComponent = VideoTrendingHeaderComponent
titlePage: string
defaultSort: VideoSortField = '-trending'
loadUserVideoPreferences = true
private algorithmChangeSub: Subscription
constructor (
protected router: Router,
protected serverService: ServerService,
protected route: ActivatedRoute,
protected notifier: Notifier,
protected authService: AuthService,
protected userService: UserService,
protected screenService: ScreenService,
protected storageService: LocalStorageService,
protected cfr: ComponentFactoryResolver,
private videoService: VideoService,
private redirectService: RedirectService,
private hooks: HooksService
) {
super()
this.defaultSort = this.parseAlgorithm(this.redirectService.getDefaultTrendingAlgorithm())
this.headerComponentInjector = this.getInjector()
}
ngOnInit () {
super.ngOnInit()
this.generateSyndicationList()
// Subscribe to alg change after we loaded the data
// The initial alg load is handled by the parent class
this.algorithmChangeSub = this.onDataSubject
.pipe(
first(),
switchMap(() => this.route.queryParams)
).subscribe(queryParams => {
const oldSort = this.sort
this.loadPageRouteParams(queryParams)
if (oldSort !== this.sort) this.reloadVideos()
}
)
}
ngOnDestroy () {
super.ngOnDestroy()
if (this.algorithmChangeSub) this.algorithmChangeSub.unsubscribe()
}
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
const params = {
videoPagination: newPagination,
sort: this.sort,
categoryOneOf: this.categoryOneOf,
languageOneOf: this.languageOneOf,
nsfwPolicy: this.nsfwPolicy,
skipCount: true
}
return this.hooks.wrapObsFun(
this.videoService.getVideos.bind(this.videoService),
params,
'common',
'filter:api.trending-videos.videos.list.params',
'filter:api.trending-videos.videos.list.result'
)
}
generateSyndicationList () {
this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
}
getInjector () {
return Injector.create({
providers: [ {
provide: 'data',
useValue: {
model: this.defaultSort
}
} ]
})
}
protected loadPageRouteParams (queryParams: Params) {
const algorithm = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm()
this.sort = this.parseAlgorithm(algorithm)
}
private parseAlgorithm (algorithm: string): VideoSortField {
switch (algorithm) {
case 'most-viewed':
return '-trending'
case 'most-liked':
return '-likes'
default:
return '-' + algorithm as VideoSortField
}
}
}

View File

@ -1,81 +0,0 @@
import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { immutableAssign } from '@app/helpers'
import { VideoService } from '@app/shared/shared-main'
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { VideoFilter, VideoSortField } from '@shared/models'
@Component({
selector: 'my-videos-local',
styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
})
export class VideoLocalComponent extends AbstractVideoList implements OnInit, OnDestroy {
titlePage: string
sort = '-publishedAt' as VideoSortField
filter: VideoFilter = 'local'
loadUserVideoPreferences = true
constructor (
protected router: Router,
protected serverService: ServerService,
protected route: ActivatedRoute,
protected notifier: Notifier,
protected authService: AuthService,
protected userService: UserService,
protected screenService: ScreenService,
protected storageService: LocalStorageService,
protected cfr: ComponentFactoryResolver,
private videoService: VideoService,
private hooks: HooksService
) {
super()
this.titlePage = $localize`Local videos`
}
ngOnInit () {
super.ngOnInit()
this.enableAllFilterIfPossible()
this.generateSyndicationList()
}
ngOnDestroy () {
super.ngOnDestroy()
}
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
const params = {
videoPagination: newPagination,
sort: this.sort,
filter: this.filter,
categoryOneOf: this.categoryOneOf,
languageOneOf: this.languageOneOf,
nsfwPolicy: this.nsfwPolicy,
skipCount: true
}
return this.hooks.wrapObsFun(
this.videoService.getVideos.bind(this.videoService),
params,
'common',
'filter:api.local-videos.videos.list.params',
'filter:api.local-videos.videos.list.result'
)
}
generateSyndicationList () {
this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, this.filter, this.categoryOneOf)
}
toggleModerationDisplay () {
this.filter = this.buildLocalFilter(this.filter, 'local')
this.reloadVideos()
}
}

View File

@ -1,73 +0,0 @@
import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { immutableAssign } from '@app/helpers'
import { VideoService } from '@app/shared/shared-main'
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-videos-recently-added',
styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
})
export class VideoRecentlyAddedComponent extends AbstractVideoList implements OnInit, OnDestroy {
titlePage: string
sort: VideoSortField = '-publishedAt'
groupByDate = true
loadUserVideoPreferences = true
constructor (
protected route: ActivatedRoute,
protected serverService: ServerService,
protected router: Router,
protected notifier: Notifier,
protected authService: AuthService,
protected userService: UserService,
protected screenService: ScreenService,
protected storageService: LocalStorageService,
protected cfr: ComponentFactoryResolver,
private videoService: VideoService,
private hooks: HooksService
) {
super()
this.titlePage = $localize`Recently added`
}
ngOnInit () {
super.ngOnInit()
this.generateSyndicationList()
}
ngOnDestroy () {
super.ngOnDestroy()
}
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
const params = {
videoPagination: newPagination,
sort: this.sort,
categoryOneOf: this.categoryOneOf,
languageOneOf: this.languageOneOf,
nsfwPolicy: this.nsfwPolicy,
skipCount: true
}
return this.hooks.wrapObsFun(
this.videoService.getVideos.bind(this.videoService),
params,
'common',
'filter:api.recently-added-videos.videos.list.params',
'filter:api.recently-added-videos.videos.list.result'
)
}
generateSyndicationList () {
this.syndicationItems = this.videoService.getVideoFeedUrls(this.sort, undefined, this.categoryOneOf)
}
}

View File

@ -0,0 +1,17 @@
<my-videos-list
[getVideosObservableFunction]="getVideosObservableFunction"
[getSyndicationItemsFunction]="getSyndicationItemsFunction"
[title]="titlePage"
[defaultSort]="defaultSort"
[displayFilters]="false"
[displayModerationBlock]="false"
[loadUserVideoPreferences]="false"
[groupByDate]="true"
[disabled]="disabled"
>
</my-videos-list>

View File

@ -1,94 +1,53 @@
import { switchMap } from 'rxjs/operators'
import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, LocalStorageService, Notifier, ScopedTokensService, ScreenService, ServerService, UserService } from '@app/core'
import { firstValueFrom } from 'rxjs'
import { switchMap, tap } from 'rxjs/operators'
import { Component } from '@angular/core'
import { AuthService, ComponentPaginationLight, DisableForReuseHook, ScopedTokensService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { immutableAssign } from '@app/helpers'
import { VideoService } from '@app/shared/shared-main'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { FeedFormat, VideoSortField } from '@shared/models'
import { environment } from '../../../environments/environment'
import { copyToClipboard } from '../../../root-helpers/utils'
import { VideoFilters } from '@app/shared/shared-video-miniature'
import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-videos-user-subscriptions',
styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
templateUrl: './video-user-subscriptions.component.html'
})
export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy {
titlePage: string
sort = '-publishedAt' as VideoSortField
groupByDate = true
export class VideoUserSubscriptionsComponent implements DisableForReuseHook {
getVideosObservableFunction = this.getVideosObservable.bind(this)
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
defaultSort = '-publishedAt' as VideoSortField
actions = [
{
routerLink: '/my-library/subscriptions',
label: $localize`Subscriptions`,
iconName: 'cog'
}
]
titlePage = $localize`Videos from your subscriptions`
disabled = false
private feedToken: string
constructor (
protected router: Router,
protected serverService: ServerService,
protected route: ActivatedRoute,
protected notifier: Notifier,
protected authService: AuthService,
protected userService: UserService,
protected screenService: ScreenService,
protected storageService: LocalStorageService,
private authService: AuthService,
private userSubscription: UserSubscriptionService,
protected cfr: ComponentFactoryResolver,
private hooks: HooksService,
private videoService: VideoService,
private scopedTokensService: ScopedTokensService
) {
super()
this.titlePage = $localize`Videos from your subscriptions`
this.actions.push({
routerLink: '/my-library/subscriptions',
label: $localize`Subscriptions`,
iconName: 'cog'
})
}
ngOnInit () {
super.ngOnInit()
const user = this.authService.getUser()
let feedUrl = environment.originServerUrl
this.authService.userInformationLoaded
.pipe(switchMap(() => this.scopedTokensService.getScopedTokens()))
.subscribe({
next: tokens => {
const feeds = this.videoService.getVideoSubscriptionFeedUrls(user.account.id, tokens.feedToken)
feedUrl = feedUrl + feeds.find(f => f.format === FeedFormat.RSS).url
this.actions.unshift({
label: $localize`Copy feed URL`,
iconName: 'syndication',
justIcon: true,
href: feedUrl,
click: e => {
e.preventDefault()
copyToClipboard(feedUrl)
this.activateCopiedMessage()
}
})
},
error: err => {
this.notifier.error(err.message)
}
})
}
ngOnDestroy () {
super.ngOnDestroy()
}
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
const params = {
videoPagination: newPagination,
sort: this.sort,
...filters.toVideosAPIObject(),
videoPagination: pagination,
skipCount: true
}
@ -101,12 +60,32 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
)
}
generateSyndicationList () {
/* method disabled: the view provides its own */
throw new Error('Method not implemented.')
getSyndicationItems () {
return this.loadFeedToken()
.then(() => {
const user = this.authService.getUser()
return this.videoService.getVideoSubscriptionFeedUrls(user.account.id, this.feedToken)
})
}
activateCopiedMessage () {
this.notifier.success($localize`Feed URL copied`)
disableForReuse () {
this.disabled = true
}
enabledForReuse () {
this.disabled = false
}
private loadFeedToken () {
if (this.feedToken) return Promise.resolve(this.feedToken)
const obs = this.authService.userInformationLoaded
.pipe(
switchMap(() => this.scopedTokensService.getScopedTokens()),
tap(tokens => this.feedToken = tokens.feedToken)
)
return firstValueFrom(obs)
}
}

View File

@ -0,0 +1,22 @@
<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>

View File

@ -0,0 +1,219 @@
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router'
import { ComponentPaginationLight, DisableForReuseHook, MetaService, RedirectService, ServerService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { VideoService } from '@app/shared/shared-main'
import { VideoFilters, VideoFilterScope } from '@app/shared/shared-video-miniature/video-filters.model'
import { ClientFilterHookName, VideoSortField } from '@shared/models'
import { Subscription } from 'rxjs'
export type VideosListCommonPageRouteData = {
sort: VideoSortField
scope: VideoFilterScope
hookParams: ClientFilterHookName
hookResult: ClientFilterHookName
}
@Component({
templateUrl: './videos-list-common-page.component.html'
})
export class VideosListCommonPageComponent implements OnInit, OnDestroy, DisableForReuseHook {
getVideosObservableFunction = this.getVideosObservable.bind(this)
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
baseRouteBuilderFunction = this.baseRouteBuilder.bind(this)
title: string
titleTooltip: string
groupByDate: boolean
defaultSort: VideoSortField
defaultScope: VideoFilterScope
hookParams: ClientFilterHookName
hookResult: ClientFilterHookName
loadUserVideoPreferences = true
displayFilters = true
disabled = false
private trendingDays: number
private routeSub: Subscription
constructor (
private server: ServerService,
private route: ActivatedRoute,
private videoService: VideoService,
private hooks: HooksService,
private meta: MetaService,
private redirectService: RedirectService
) {
}
ngOnInit () {
this.trendingDays = this.server.getHTMLConfig().trending.videos.intervalDays
this.routeSub = this.route.params.subscribe(params => {
this.update(params['page'])
})
}
ngOnDestroy () {
if (this.routeSub) this.routeSub.unsubscribe()
}
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
const params = {
...filters.toVideosAPIObject(),
videoPagination: pagination,
skipCount: true
}
return this.hooks.wrapObsFun(
this.videoService.getVideos.bind(this.videoService),
params,
'common',
this.hookParams,
this.hookResult
)
}
getSyndicationItems (filters: VideoFilters) {
const result = filters.toVideosAPIObject()
return this.videoService.getVideoFeedUrls(result.sort, result.filter)
}
onFiltersChanged (filters: VideoFilters) {
this.buildTitle(filters.scope, filters.sort)
this.updateGroupByDate(filters.sort)
}
baseRouteBuilder (filters: VideoFilters) {
const sanitizedSort = this.getSanitizedSort(filters.sort)
let suffix: string
if (filters.scope === 'local') suffix = 'local'
else if (sanitizedSort === 'publishedAt') suffix = 'recently-added'
else suffix = 'trending'
return [ '/videos', suffix ]
}
disableForReuse () {
this.disabled = true
}
enabledForReuse () {
this.disabled = false
}
update (page: string) {
const data = this.getData(page)
this.hookParams = data.hookParams
this.hookResult = data.hookResult
this.defaultSort = data.sort
this.defaultScope = data.scope
this.buildTitle()
this.updateGroupByDate(this.defaultSort)
this.meta.setTitle(this.title)
}
private getData (page: string) {
if (page === 'trending') return this.generateTrendingData(this.route.snapshot)
if (page === 'local') return this.generateLocalData()
return this.generateRecentlyAddedData()
}
private generateRecentlyAddedData (): VideosListCommonPageRouteData {
return {
sort: '-publishedAt',
scope: 'federated',
hookParams: 'filter:api.recently-added-videos.videos.list.params',
hookResult: 'filter:api.recently-added-videos.videos.list.result'
}
}
private generateLocalData (): VideosListCommonPageRouteData {
return {
sort: '-publishedAt' as VideoSortField,
scope: 'local',
hookParams: 'filter:api.local-videos.videos.list.params',
hookResult: 'filter:api.local-videos.videos.list.result'
}
}
private generateTrendingData (route: ActivatedRouteSnapshot): VideosListCommonPageRouteData {
const sort = route.queryParams['sort'] ?? this.parseTrendingAlgorithm(this.redirectService.getDefaultTrendingAlgorithm())
return {
sort,
scope: 'federated',
hookParams: 'filter:api.trending-videos.videos.list.params',
hookResult: 'filter:api.trending-videos.videos.list.result'
}
}
private parseTrendingAlgorithm (algorithm: string): VideoSortField {
switch (algorithm) {
case 'most-viewed':
return '-trending'
case 'most-liked':
return '-likes'
default:
return '-' + algorithm as VideoSortField
}
}
private updateGroupByDate (sort: VideoSortField) {
this.groupByDate = sort === '-publishedAt' || sort === 'publishedAt'
}
private buildTitle (scope: VideoFilterScope = this.defaultScope, sort: VideoSortField = this.defaultSort) {
const sanitizedSort = this.getSanitizedSort(sort)
if (scope === 'local') {
this.title = $localize`Local videos`
this.titleTooltip = $localize`Only videos uploaded on this instance are displayed`
return
}
if (sanitizedSort === 'publishedAt') {
this.title = $localize`Recently added`
this.titleTooltip = undefined
return
}
if ([ 'best', 'hot', 'trending', 'likes' ].includes(sanitizedSort)) {
this.title = $localize`Trending`
if (sanitizedSort === 'best') this.titleTooltip = $localize`Videos with the most interactions for recent videos, minus user history`
if (sanitizedSort === 'hot') this.titleTooltip = $localize`Videos with the most interactions for recent videos`
if (sanitizedSort === 'likes') this.titleTooltip = $localize`Videos that have the most likes`
if (sanitizedSort === 'trending') {
if (this.trendingDays === 1) this.titleTooltip = $localize`Videos with the most views during the last 24 hours`
else this.titleTooltip = $localize`Videos with the most views during the last ${this.trendingDays} days`
}
return
}
}
private getSanitizedSort (sort: VideoSortField) {
return sort.replace(/^-/, '') as VideoSortField
}
}

View File

@ -1,10 +1,8 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { RouterModule, Routes, UrlSegment } from '@angular/router'
import { LoginGuard } from '@app/core'
import { VideoTrendingComponent } from './video-list'
import { VideosListCommonPageComponent } from './video-list'
import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
import { VideoLocalComponent } from './video-list/video-local.component'
import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
import { VideosComponent } from './videos.component'
@ -22,32 +20,35 @@ const videosRoutes: Routes = [
}
}
},
{
path: 'trending',
component: VideoTrendingComponent,
data: {
meta: {
title: $localize`Trending videos`
}
}
},
{
// Old URL redirection
path: 'most-liked',
redirectTo: 'trending?alg=most-liked'
redirectTo: 'trending?sort=most-liked'
},
{
path: 'recently-added',
component: VideoRecentlyAddedComponent,
data: {
meta: {
title: $localize`Recently added videos`
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: {
reuse: {
enabled: true,
key: 'recently-added-videos-list'
key: 'videos-list'
}
}
},
{
path: 'subscriptions',
canActivate: [ LoginGuard ],
@ -61,19 +62,6 @@ const videosRoutes: Routes = [
key: 'subscription-videos-list'
}
}
},
{
path: 'local',
component: VideoLocalComponent,
data: {
meta: {
title: $localize`Local videos`
},
reuse: {
enabled: true,
key: 'local-videos-list'
}
}
}
]
}

View File

@ -5,11 +5,8 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
import { OverviewService, VideoTrendingComponent } from './video-list'
import { OverviewService, VideosListCommonPageComponent } from './video-list'
import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
import { VideoTrendingHeaderComponent } from './video-list/trending/video-trending-header.component'
import { VideoLocalComponent } from './video-list/video-local.component'
import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
import { VideosRoutingModule } from './videos-routing.module'
import { VideosComponent } from './videos.component'
@ -29,12 +26,9 @@ import { VideosComponent } from './videos.component'
declarations: [
VideosComponent,
VideoTrendingHeaderComponent,
VideoTrendingComponent,
VideoRecentlyAddedComponent,
VideoLocalComponent,
VideoUserSubscriptionsComponent,
VideoOverviewComponent
VideoOverviewComponent,
VideosListCommonPageComponent
],
exports: [

View File

@ -177,6 +177,7 @@ routes.push({
imports: [
RouterModule.forRoot(routes, {
useHash: Boolean(history.pushState) === false,
// Redefined in app component
scrollPositionRestoration: 'disabled',
preloadingStrategy: PreloadSelectedModulesList,
anchorScrolling: 'disabled'

View File

@ -1,10 +1,20 @@
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { filter, map, pairwise, switchMap } from 'rxjs/operators'
import { DOCUMENT, getLocaleDirection, PlatformLocation, ViewportScroller } from '@angular/common'
import { filter, map, switchMap } from 'rxjs/operators'
import { DOCUMENT, getLocaleDirection, PlatformLocation } from '@angular/common'
import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { Event, GuardsCheckStart, NavigationEnd, RouteConfigLoadEnd, RouteConfigLoadStart, Router, Scroll } from '@angular/router'
import { AuthService, MarkdownService, RedirectService, ScreenService, ServerService, ThemeService, User } from '@app/core'
import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router } from '@angular/router'
import {
AuthService,
MarkdownService,
PeerTubeRouterService,
RedirectService,
ScreenService,
ScrollService,
ServerService,
ThemeService,
User
} from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
import { PluginService } from '@app/core/plugins/plugin.service'
import { CustomModalComponent } from '@app/modal/custom-modal.component'
@ -39,10 +49,10 @@ export class AppComponent implements OnInit, AfterViewInit {
constructor (
@Inject(DOCUMENT) private document: Document,
@Inject(LOCALE_ID) private localeId: string,
private viewportScroller: ViewportScroller,
private router: Router,
private authService: AuthService,
private serverService: ServerService,
private peertubeRouter: PeerTubeRouterService,
private pluginService: PluginService,
private instanceService: InstanceService,
private domSanitizer: DomSanitizer,
@ -56,6 +66,7 @@ export class AppComponent implements OnInit, AfterViewInit {
private markdownService: MarkdownService,
private ngbConfig: NgbConfig,
private loadingBar: LoadingBarService,
private scrollService: ScrollService,
public menu: MenuService
) {
this.ngbConfig.animation = false
@ -85,6 +96,7 @@ export class AppComponent implements OnInit, AfterViewInit {
}
this.initRouteEvents()
this.scrollService.enableScrollRestoration()
this.injectJS()
this.injectCSS()
@ -132,66 +144,10 @@ export class AppComponent implements OnInit, AfterViewInit {
}
private initRouteEvents () {
let resetScroll = true
const eventsObs = this.router.events
const scrollEvent = eventsObs.pipe(filter((e: Event): e is Scroll => e instanceof Scroll))
// Handle anchors/restore position
scrollEvent.subscribe(e => {
// scrollToAnchor first to preserve anchor position when using history navigation
if (e.anchor) {
setTimeout(() => {
this.viewportScroller.scrollToAnchor(e.anchor)
})
return
}
if (e.position) {
return this.viewportScroller.scrollToPosition(e.position)
}
if (resetScroll) {
return this.viewportScroller.scrollToPosition([ 0, 0 ])
}
})
const navigationEndEvent = eventsObs.pipe(filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd))
// When we add the a-state parameter, we don't want to alter the scroll
navigationEndEvent.pipe(pairwise())
.subscribe(([ e1, e2 ]) => {
try {
resetScroll = false
const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects)
const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects)
if (previousUrl.pathname !== nextUrl.pathname) {
resetScroll = true
return
}
const nextSearchParams = nextUrl.searchParams
nextSearchParams.delete('a-state')
const previousSearchParams = previousUrl.searchParams
nextSearchParams.sort()
previousSearchParams.sort()
if (nextSearchParams.toString() !== previousSearchParams.toString()) {
resetScroll = true
}
} catch (e) {
console.error('Cannot parse URL to check next scroll.', e)
resetScroll = true
}
})
// Plugin hooks
navigationEndEvent.subscribe(e => {
this.peertubeRouter.getNavigationEndEvents().subscribe(e => {
this.hooks.runAction('action:router.navigation-end', 'common', { path: e.url })
})

View File

@ -14,7 +14,17 @@ import { throwIfAlreadyLoaded } from './module-import-guard'
import { Notifier } from './notification'
import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer'
import { RestExtractor, RestService } from './rest'
import { HomepageRedirectComponent, LoginGuard, MetaGuard, MetaService, RedirectService, UnloggedGuard, UserRightGuard } from './routing'
import {
HomepageRedirectComponent,
LoginGuard,
MetaGuard,
MetaService,
PeerTubeRouterService,
RedirectService,
ScrollService,
UnloggedGuard,
UserRightGuard
} from './routing'
import { CanDeactivateGuard } from './routing/can-deactivate-guard.service'
import { ServerConfigResolver } from './routing/server-config-resolver.service'
import { ScopedTokensService } from './scoped-tokens'
@ -80,6 +90,8 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra
PeerTubeSocket,
ServerConfigResolver,
CanDeactivateGuard,
PeerTubeRouterService,
ScrollService,
MetaService,
MetaGuard

View File

@ -1,5 +1,7 @@
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
import { RouterSetting } from './'
import { PeerTubeRouterService } from './peertube-router.service'
@Injectable()
export class CustomReuseStrategy implements RouteReuseStrategy {
@ -78,6 +80,8 @@ export class CustomReuseStrategy implements RouteReuseStrategy {
}
private isReuseEnabled (route: ActivatedRouteSnapshot) {
return route.data.reuse?.enabled && route.queryParams['a-state']
// Cannot use peertube router here because of cyclic router dependency
return route.data.reuse?.enabled &&
!!(route.queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME] & RouterSetting.REUSE_COMPONENT)
}
}

View File

@ -5,9 +5,11 @@ export * from './homepage-redirect.component'
export * from './login-guard.service'
export * from './menu-guard.service'
export * from './meta-guard.service'
export * from './peertube-router.service'
export * from './meta.service'
export * from './preload-selected-modules-list'
export * from './redirect.service'
export * from './scroll.service'
export * from './server-config-resolver.service'
export * from './unlogged-guard.service'
export * from './user-right-guard.service'

View File

@ -0,0 +1,78 @@
import { filter } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ActivatedRoute, ActivatedRouteSnapshot, Event, NavigationEnd, Router, Scroll } from '@angular/router'
import { ServerService } from '../server'
export const enum RouterSetting {
NONE = 0,
REUSE_COMPONENT = 1 << 0,
DISABLE_SCROLL_RESTORE = 1 << 1
}
@Injectable()
export class PeerTubeRouterService {
static readonly ROUTE_SETTING_NAME = 's'
constructor (
private route: ActivatedRoute,
private router: Router,
private server: ServerService
) { }
addRouteSetting (toAdd: RouterSetting) {
if (this.hasRouteSetting(toAdd)) return
const current = this.getRouteSetting()
this.setRouteSetting(current | toAdd)
}
deleteRouteSetting (toDelete: RouterSetting) {
const current = this.getRouteSetting()
this.setRouteSetting(current & ~toDelete)
}
getRouteSetting (snapshot?: ActivatedRouteSnapshot) {
return (snapshot || this.route.snapshot).queryParams[PeerTubeRouterService.ROUTE_SETTING_NAME]
}
setRouteSetting (value: number) {
let path = window.location.pathname
if (!path || path === '/') path = this.server.getHTMLConfig().instance.defaultClientRoute
const queryParams = { [PeerTubeRouterService.ROUTE_SETTING_NAME]: value }
this.router.navigate([ path ], { queryParams, replaceUrl: true, queryParamsHandling: 'merge' })
}
hasRouteSetting (setting: RouterSetting, snapshot?: ActivatedRouteSnapshot) {
return !!(this.getRouteSetting(snapshot) & setting)
}
getNavigationEndEvents () {
return this.router.events.pipe(
filter((e: Event): e is NavigationEnd => e instanceof NavigationEnd)
)
}
getScrollEvents () {
return this.router.events.pipe(
filter((e: Event): e is Scroll => e instanceof Scroll)
)
}
silentNavigate (baseRoute: string[], queryParams: { [id: string]: string }) {
let routeSetting = this.getRouteSetting() ?? RouterSetting.NONE
routeSetting |= RouterSetting.DISABLE_SCROLL_RESTORE
queryParams = {
...queryParams,
[PeerTubeRouterService.ROUTE_SETTING_NAME]: routeSetting
}
return this.router.navigate(baseRoute, { queryParams })
}
}

View File

@ -0,0 +1,91 @@
import * as debug from 'debug'
import { pairwise } from 'rxjs'
import { ViewportScroller } from '@angular/common'
import { Injectable } from '@angular/core'
import { RouterSetting } from '../'
import { PeerTubeRouterService } from './peertube-router.service'
const logger = debug('peertube:main:ScrollService')
@Injectable()
export class ScrollService {
private resetScroll = true
constructor (
private viewportScroller: ViewportScroller,
private peertubeRouter: PeerTubeRouterService
) { }
enableScrollRestoration () {
// We'll manage scroll restoration ourselves
this.viewportScroller.setHistoryScrollRestoration('manual')
this.consumeScroll()
this.produceScroll()
}
private produceScroll () {
// When we add the a-state parameter, we don't want to alter the scroll
this.peertubeRouter.getNavigationEndEvents().pipe(pairwise())
.subscribe(([ e1, e2 ]) => {
try {
this.resetScroll = false
const previousUrl = new URL(window.location.origin + e1.urlAfterRedirects)
const nextUrl = new URL(window.location.origin + e2.urlAfterRedirects)
if (previousUrl.pathname !== nextUrl.pathname) {
this.resetScroll = true
return
}
if (this.peertubeRouter.hasRouteSetting(RouterSetting.DISABLE_SCROLL_RESTORE)) {
this.resetScroll = false
return
}
// Remove route settings from the comparison
const nextSearchParams = nextUrl.searchParams
nextSearchParams.delete(PeerTubeRouterService.ROUTE_SETTING_NAME)
const previousSearchParams = previousUrl.searchParams
nextSearchParams.sort()
previousSearchParams.sort()
if (nextSearchParams.toString() !== previousSearchParams.toString()) {
this.resetScroll = true
}
} catch (e) {
console.error('Cannot parse URL to check next scroll.', e)
this.resetScroll = true
}
})
}
private consumeScroll () {
// Handle anchors/restore position
this.peertubeRouter.getScrollEvents().subscribe(e => {
logger('Will schedule scroll after router event %o.', e)
// scrollToAnchor first to preserve anchor position when using history navigation
if (e.anchor) {
setTimeout(() => this.viewportScroller.scrollToAnchor(e.anchor))
return
}
if (e.position) {
setTimeout(() => this.viewportScroller.scrollToPosition(e.position))
return
}
if (this.resetScroll) {
return this.viewportScroller.scrollToPosition([ 0, 0 ])
}
})
}
}

View File

@ -1,226 +0,0 @@
import { first, map } from 'rxjs/operators'
import { SelectChannelItem } from 'src/types/select-options-item.model'
import { DatePipe } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http'
import { Notifier } from '@app/core'
import { HttpStatusCode } from '@shared/models'
import { environment } from '../../environments/environment'
import { AuthService } from '../core/auth'
// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
function getParameterByName (name: string, url: string) {
if (!url) url = window.location.href
name = name.replace(/[[\]]/g, '\\$&')
const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)')
const results = regex.exec(url)
if (!results) return null
if (!results[2]) return ''
return decodeURIComponent(results[2].replace(/\+/g, ' '))
}
function listUserChannels (authService: AuthService) {
return authService.userInformationLoaded
.pipe(
first(),
map(() => {
const user = authService.getUser()
if (!user) return undefined
const videoChannels = user.videoChannels
if (Array.isArray(videoChannels) === false) return undefined
return videoChannels
.sort((a, b) => {
if (a.updatedAt < b.updatedAt) return 1
if (a.updatedAt > b.updatedAt) return -1
return 0
})
.map(c => ({
id: c.id,
label: c.displayName,
support: c.support,
avatarPath: c.avatar?.path
}) as SelectChannelItem)
})
)
}
function getAbsoluteAPIUrl () {
let absoluteAPIUrl = environment.hmr === true
? 'http://localhost:9000'
: environment.apiUrl
if (!absoluteAPIUrl) {
// The API is on the same domain
absoluteAPIUrl = window.location.origin
}
return absoluteAPIUrl
}
function getAbsoluteEmbedUrl () {
let absoluteEmbedUrl = environment.originServerUrl
if (!absoluteEmbedUrl) {
// The Embed is on the same domain
absoluteEmbedUrl = window.location.origin
}
return absoluteEmbedUrl
}
const datePipe = new DatePipe('en')
function dateToHuman (date: string) {
return datePipe.transform(date, 'medium')
}
function durationToString (duration: number) {
const hours = Math.floor(duration / 3600)
const minutes = Math.floor((duration % 3600) / 60)
const seconds = duration % 60
const minutesPadding = minutes >= 10 ? '' : '0'
const secondsPadding = seconds >= 10 ? '' : '0'
const displayedHours = hours > 0 ? hours.toString() + ':' : ''
return (
displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString()
).replace(/^0/, '')
}
function immutableAssign <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
}

View File

@ -0,0 +1,34 @@
import { first, map } from 'rxjs/operators'
import { SelectChannelItem } from 'src/types/select-options-item.model'
import { AuthService } from '../../core/auth'
function listUserChannels (authService: AuthService) {
return authService.userInformationLoaded
.pipe(
first(),
map(() => {
const user = authService.getUser()
if (!user) return undefined
const videoChannels = user.videoChannels
if (Array.isArray(videoChannels) === false) return undefined
return videoChannels
.sort((a, b) => {
if (a.updatedAt < b.updatedAt) return 1
if (a.updatedAt > b.updatedAt) return -1
return 0
})
.map(c => ({
id: c.id,
label: c.displayName,
support: c.support,
avatarPath: c.avatar?.path
}) as SelectChannelItem)
})
)
}
export {
listUserChannels
}

View File

@ -0,0 +1,25 @@
import { DatePipe } from '@angular/common'
const datePipe = new DatePipe('en')
function dateToHuman (date: string) {
return datePipe.transform(date, 'medium')
}
function durationToString (duration: number) {
const hours = Math.floor(duration / 3600)
const minutes = Math.floor((duration % 3600) / 60)
const seconds = duration % 60
const minutesPadding = minutes >= 10 ? '' : '0'
const secondsPadding = seconds >= 10 ? '' : '0'
const displayedHours = hours > 0 ? hours.toString() + ':' : ''
return (
displayedHours + minutesPadding + minutes.toString() + ':' + secondsPadding + seconds.toString()
).replace(/^0/, '')
}
export {
durationToString,
dateToHuman
}

View File

@ -0,0 +1,18 @@
import { immutableAssign } from './object'
function objectLineFeedToHtml (obj: any, keyToNormalize: string) {
return immutableAssign(obj, {
[keyToNormalize]: lineFeedToHtml(obj[keyToNormalize])
})
}
function lineFeedToHtml (text: string) {
if (!text) return text
return text.replace(/\r?\n|\r/g, '<br />')
}
export {
objectLineFeedToHtml,
lineFeedToHtml
}

View File

@ -0,0 +1,7 @@
export * from './channel'
export * from './date'
export * from './html'
export * from './object'
export * from './ui'
export * from './upload'
export * from './url'

View File

@ -0,0 +1,47 @@
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
}

View File

@ -0,0 +1,33 @@
function scrollToTop (behavior: 'auto' | 'smooth' = 'auto') {
window.scrollTo({
left: 0,
top: 0,
behavior
})
}
function isInViewport (el: HTMLElement) {
const bounding = el.getBoundingClientRect()
return (
bounding.top >= 0 &&
bounding.left >= 0 &&
bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
bounding.right <= (window.innerWidth || document.documentElement.clientWidth)
)
}
function isXPercentInViewport (el: HTMLElement, percentVisible: number) {
const rect = el.getBoundingClientRect()
const windowHeight = (window.innerHeight || document.documentElement.clientHeight)
return !(
Math.floor(100 - (((rect.top >= 0 ? 0 : rect.top) / +-(rect.height / 1)) * 100)) < percentVisible ||
Math.floor(100 - ((rect.bottom - windowHeight) / rect.height) * 100) < percentVisible
)
}
export {
scrollToTop,
isInViewport,
isXPercentInViewport
}

View File

@ -0,0 +1,37 @@
import { HttpErrorResponse } from '@angular/common/http'
import { Notifier } from '@app/core'
import { HttpStatusCode } from '@shared/models'
function genericUploadErrorHandler (parameters: {
err: Pick<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
}

View File

@ -0,0 +1,71 @@
import { environment } from '../../../environments/environment'
// Thanks: https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
function getParameterByName (name: string, url: string) {
if (!url) url = window.location.href
name = name.replace(/[[\]]/g, '\\$&')
const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)')
const results = regex.exec(url)
if (!results) return null
if (!results[2]) return ''
return decodeURIComponent(results[2].replace(/\+/g, ' '))
}
function getAbsoluteAPIUrl () {
let absoluteAPIUrl = environment.hmr === true
? 'http://localhost:9000'
: environment.apiUrl
if (!absoluteAPIUrl) {
// The API is on the same domain
absoluteAPIUrl = window.location.origin
}
return absoluteAPIUrl
}
function getAbsoluteEmbedUrl () {
let absoluteEmbedUrl = environment.originServerUrl
if (!absoluteEmbedUrl) {
// The Embed is on the same domain
absoluteEmbedUrl = window.location.origin
}
return absoluteEmbedUrl
}
// Thanks: https://gist.github.com/ghinda/8442a57f22099bdb2e34
function objectToFormData (obj: any, form?: FormData, namespace?: string) {
const fd = form || new FormData()
let formKey
for (const key of Object.keys(obj)) {
if (namespace) formKey = `${namespace}[${key}]`
else formKey = key
if (obj[key] === undefined) continue
if (Array.isArray(obj[key]) && obj[key].length === 0) {
fd.append(key, null)
continue
}
if (obj[key] !== null && typeof obj[key] === 'object' && !(obj[key] instanceof File)) {
objectToFormData(obj[key], fd, formKey)
} else {
fd.append(formKey, obj[key])
}
}
return fd
}
export {
getParameterByName,
objectToFormData,
getAbsoluteAPIUrl,
getAbsoluteEmbedUrl
}

View File

@ -18,6 +18,7 @@ const logger = debug('peertube:AdvancedInputFilterComponent')
})
export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
@Input() filters: AdvancedInputFilter[] = []
@Input() emitOnInit = true
@Output() search = new EventEmitter<string>()
@ -42,7 +43,7 @@ export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
this.viewInitialized = true
// Init after view init to not send an event too early
if (this.emitSearchAfterViewInit) this.emitSearch()
if (this.emitOnInit && this.emitSearchAfterViewInit) this.emitSearch()
}
onInputSearch (event: Event) {

View File

@ -1,5 +1,8 @@
export * from './select-categories.component'
export * from './select-channel.component'
export * from './select-checkbox-all.component'
export * from './select-checkbox.component'
export * from './select-custom-value.component'
export * from './select-languages.component'
export * from './select-options.component'
export * from './select-tags.component'

View File

@ -0,0 +1,8 @@
<my-select-checkbox-all
[(ngModel)]="selectedCategories"
(ngModelChange)="onModelChange()"
[availableItems]="availableCategories"
i18n-placeholder placeholder="Add a new category"
[allGroupLabel]="allCategoriesGroup"
>
</my-select-checkbox-all>

View File

@ -0,0 +1,71 @@
import { Component, forwardRef, OnInit } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { ServerService } from '@app/core'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { ItemSelectCheckboxValue } from './select-checkbox.component'
@Component({
selector: 'my-select-categories',
styleUrls: [ './select-shared.component.scss' ],
templateUrl: './select-categories.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SelectCategoriesComponent),
multi: true
}
]
})
export class SelectCategoriesComponent implements ControlValueAccessor, OnInit {
selectedCategories: ItemSelectCheckboxValue[] = []
availableCategories: SelectOptionsItem[] = []
allCategoriesGroup = $localize`All categories`
// Fix a bug on ng-select when we update items after we selected items
private toWrite: any
private loaded = false
constructor (
private server: ServerService
) {
}
ngOnInit () {
this.server.getVideoCategories()
.subscribe(
categories => {
this.availableCategories = categories.map(c => ({ label: c.label, id: c.id + '', group: this.allCategoriesGroup }))
this.loaded = true
this.writeValue(this.toWrite)
}
)
}
propagateChange = (_: any) => { /* empty */ }
writeValue (categories: string[] | number[]) {
if (!this.loaded) {
this.toWrite = categories
return
}
this.selectedCategories = categories
? categories.map(c => c + '')
: categories as string[]
}
registerOnChange (fn: (_: any) => void) {
this.propagateChange = fn
}
registerOnTouched () {
// Unused
}
onModelChange () {
this.propagateChange(this.selectedCategories)
}
}

View File

@ -0,0 +1,115 @@
import { Component, forwardRef, Input } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { Notifier } from '@app/core'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { ItemSelectCheckboxValue } from './select-checkbox.component'
@Component({
selector: 'my-select-checkbox-all',
styleUrls: [ './select-shared.component.scss' ],
template: `
<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 + ''
})
}
}

View File

@ -18,8 +18,6 @@
groupBy="group"
[compareWith]="compareFn"
[maxSelectedItems]="maxSelectedItems"
>
<ng-template ng-optgroup-tmp let-item="item" let-item$="item$" let-index="index">

View File

@ -2,7 +2,7 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
export type ItemSelectCheckboxValue = { id?: string | number, group?: string } | string
export type ItemSelectCheckboxValue = { id?: string, group?: string } | string
@Component({
selector: 'my-select-checkbox',
@ -21,7 +21,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor {
@Input() selectedItems: ItemSelectCheckboxValue[] = []
@Input() selectableGroup: boolean
@Input() selectableGroupAsModel: boolean
@Input() maxSelectedItems: number
@Input() placeholder: string
ngOnInit () {
@ -46,8 +45,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor {
} else {
this.selectedItems = items
}
this.propagateChange(this.selectedItems)
}
registerOnChange (fn: (_: any) => void) {
@ -63,7 +60,7 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor {
}
compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) {
if (typeof selected === 'string') {
if (typeof selected === 'string' || typeof selected === 'number') {
return item.id === selected
}

View File

@ -0,0 +1,9 @@
<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>

View File

@ -0,0 +1,74 @@
import { Component, forwardRef, Input, OnInit } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { ServerService } from '@app/core'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { ItemSelectCheckboxValue } from './select-checkbox.component'
@Component({
selector: 'my-select-languages',
styleUrls: [ './select-shared.component.scss' ],
templateUrl: './select-languages.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => SelectLanguagesComponent),
multi: true
}
]
})
export class SelectLanguagesComponent implements ControlValueAccessor, OnInit {
@Input() maxLanguages: number
selectedLanguages: ItemSelectCheckboxValue[]
availableLanguages: SelectOptionsItem[] = []
allLanguagesGroup = $localize`All languages`
// Fix a bug on ng-select when we update items after we selected items
private toWrite: any
private loaded = false
constructor (
private server: ServerService
) {
}
ngOnInit () {
this.server.getVideoLanguages()
.subscribe(
languages => {
this.availableLanguages = [ { label: $localize`Unknown language`, id: '_unknown', group: this.allLanguagesGroup } ]
this.availableLanguages = this.availableLanguages
.concat(languages.map(l => ({ label: l.label, id: l.id, group: this.allLanguagesGroup })))
this.loaded = true
this.writeValue(this.toWrite)
}
)
}
propagateChange = (_: any) => { /* empty */ }
writeValue (languages: ItemSelectCheckboxValue[]) {
if (!this.loaded) {
this.toWrite = languages
return
}
this.selectedLanguages = languages
}
registerOnChange (fn: (_: any) => void) {
this.propagateChange = fn
}
registerOnTouched () {
// Unused
}
onModelChange () {
this.propagateChange(this.selectedLanguages)
}
}

View File

@ -15,9 +15,12 @@ import { PeertubeCheckboxComponent } from './peertube-checkbox.component'
import { PreviewUploadComponent } from './preview-upload.component'
import { ReactiveFileComponent } from './reactive-file.component'
import {
SelectCategoriesComponent,
SelectChannelComponent,
SelectCheckboxAllComponent,
SelectCheckboxComponent,
SelectCustomValueComponent,
SelectLanguagesComponent,
SelectOptionsComponent,
SelectTagsComponent
} from './select'
@ -52,6 +55,9 @@ import { TimestampInputComponent } from './timestamp-input.component'
SelectTagsComponent,
SelectCheckboxComponent,
SelectCustomValueComponent,
SelectLanguagesComponent,
SelectCategoriesComponent,
SelectCheckboxAllComponent,
DynamicFormFieldComponent,
@ -80,6 +86,9 @@ import { TimestampInputComponent } from './timestamp-input.component'
SelectTagsComponent,
SelectCheckboxComponent,
SelectCustomValueComponent,
SelectLanguagesComponent,
SelectCategoriesComponent,
SelectCheckboxAllComponent,
DynamicFormFieldComponent,

View File

@ -71,6 +71,7 @@ const icons = {
columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
repeat: require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default,
'chevrons-up': require('!!raw-loader?!../../../assets/images/feather/chevrons-up.svg').default,
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default

View File

@ -1,16 +1,19 @@
import { fromEvent, Observable, Subscription } from 'rxjs'
import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
import { PeerTubeRouterService, RouterSetting } from '@app/core'
@Directive({
selector: '[myInfiniteScroller]'
})
export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked {
@Input() percentLimit = 70
@Input() autoInit = false
@Input() onItself = false
@Input() dataObservable: Observable<any[]>
// Add angular state in query params to reuse the routed component
@Input() setAngularState: boolean
@Output() nearOfBottom = new EventEmitter<void>()
private decimalLimit = 0
@ -20,7 +23,10 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
private checkScroll = false
constructor (private el: ElementRef) {
constructor (
private peertubeRouter: PeerTubeRouterService,
private el: ElementRef
) {
this.decimalLimit = this.percentLimit / 100
}
@ -36,7 +42,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
}
ngOnInit () {
if (this.autoInit === true) return this.initialize()
this.initialize()
}
ngOnDestroy () {
@ -67,7 +73,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
filter(({ current }) => this.isScrollingDown(current)),
filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
)
.subscribe(() => this.nearOfBottom.emit())
.subscribe(() => {
if (this.setAngularState) this.setScrollRouteParams()
this.nearOfBottom.emit()
})
if (this.dataObservable) {
this.dataObservable
@ -96,4 +106,8 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
this.lastCurrentBottom = current
return result
}
private setScrollRouteParams () {
this.peertubeRouter.addRouteSetting(RouterSetting.REUSE_COMPONENT)
}
}

View File

@ -7,12 +7,11 @@
a {
color: #000;
display: block;
min-width: 100px;
}
}
my-global-icon {
@include apply-svg-color(pvar(--mainForegroundColor));
cursor: pointer;
width: 100%;
}

View File

@ -1,14 +1,19 @@
<div class="root">
<div class="input-group has-feedback has-clear">
<input
#ref
type="text"
[(ngModel)]="value"
(keyup.enter)="searchChange()"
(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>
<my-global-icon *ngIf="!alwaysShow && inputShown" i18n-title title="Close search" iconName="cross" (click)="hideInput()"></my-global-icon>

View File

@ -11,20 +11,17 @@ my-global-icon {
height: 28px;
width: 28px;
cursor: pointer;
color: pvar(--mainColor);
&:hover {
color: pvar(--mainHoverColor);
}
&[iconName=search] {
color: pvar(--mainForegroundColor);
}
&[iconName=cross] {
color: pvar(--mainForegroundColor);
}
}
input {
@include peertube-input-text(200px);
&:focus {
box-shadow: 0 0 5px 0 #a5a5a5;
}
}

View File

@ -1,7 +1,4 @@
import { Subject } from 'rxjs'
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
@Component({
selector: 'my-simple-search-input',
@ -22,23 +19,9 @@ export class SimpleSearchInputComponent implements OnInit {
value = ''
inputShown: boolean
private searchSubject = new Subject<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)
}
@ -54,7 +37,7 @@ export class SimpleSearchInputComponent implements OnInit {
return
}
this.searchChange()
this.sendSearch()
}
showInput (focus = true) {
@ -80,9 +63,14 @@ export class SimpleSearchInputComponent implements OnInit {
this.hideInput()
}
searchChange () {
this.router.navigate([ './search' ], { relativeTo: this.route })
sendSearch () {
this.hasAlreadySentSearch = true
this.searchChanged.emit(this.value)
}
this.searchSubject.next(this.value)
onResetFilter () {
this.value = ''
if (this.hasAlreadySentSearch) this.sendSearch()
}
}

View File

@ -1,6 +1,6 @@
<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">

View File

@ -5,6 +5,7 @@ import { Injectable } from '@angular/core'
import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
import { objectToFormData } from '@app/helpers'
import {
BooleanBothQuery,
FeedFormat,
NSFWPolicyType,
ResultList,
@ -28,19 +29,21 @@ import { VideoDetails } from './video-details.model'
import { VideoEdit } from './video-edit.model'
import { Video } from './video.model'
export interface VideosProvider {
getVideos (parameters: {
export type CommonVideoParams = {
videoPagination: ComponentPaginationLight
sort: VideoSortField
filter?: VideoFilter
categoryOneOf?: number[]
languageOneOf?: string[]
nsfwPolicy: NSFWPolicyType
}): Observable<ResultList<Video>>
isLive?: boolean
skipCount?: boolean
// FIXME: remove?
nsfwPolicy?: NSFWPolicyType
nsfw?: BooleanBothQuery
}
@Injectable()
export class VideoService implements VideosProvider {
export class VideoService {
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.'
@ -144,32 +147,16 @@ export class VideoService implements VideosProvider {
)
}
getAccountVideos (parameters: {
getAccountVideos (parameters: CommonVideoParams & {
account: Pick<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)
params = this.buildCommonVideosParams({ params, ...parameters })
if (nsfwPolicy) {
params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
}
if (videoFilter) {
params = params.set('filter', videoFilter)
}
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 })
@ -179,27 +166,13 @@ export class VideoService implements VideosProvider {
)
}
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 })
@ -209,30 +182,9 @@ export class VideoService implements VideosProvider {
)
}
getVideos (parameters: {
videoPagination: ComponentPaginationLight
sort: VideoSortField
filter?: VideoFilter
categoryOneOf?: number[]
languageOneOf?: string[]
isLive?: boolean
skipCount?: boolean
nsfwPolicy?: NSFWPolicyType
}): Observable<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 })
@ -421,4 +373,22 @@ export class VideoService implements VideosProvider {
catchError(err => this.restExtractor.handleError(err))
)
}
private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options
const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
let newParams = this.restService.addRestGetParams(params, pagination, sort)
if (filter) newParams = newParams.set('filter', filter)
if (skipCount) newParams = newParams.set('skipCount', skipCount + '')
if (isLive) newParams = newParams.set('isLive', isLive)
if (nsfw) newParams = newParams.set('nsfw', nsfw)
if (nsfwPolicy) newParams = newParams.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
if (languageOneOf) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf)
if (categoryOneOf) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf)
return newParams
}
}

View File

@ -1,3 +1,4 @@
import { intoArray } from '@app/helpers'
import {
BooleanBothQuery,
BooleanQuery,
@ -74,8 +75,8 @@ export class AdvancedSearch {
this.categoryOneOf = options.categoryOneOf || undefined
this.licenceOneOf = options.licenceOneOf || undefined
this.languageOneOf = options.languageOneOf || undefined
this.tagsOneOf = this.intoArray(options.tagsOneOf)
this.tagsAllOf = this.intoArray(options.tagsAllOf)
this.tagsOneOf = intoArray(options.tagsOneOf)
this.tagsAllOf = intoArray(options.tagsAllOf)
this.durationMin = parseInt(options.durationMin, 10)
this.durationMax = parseInt(options.durationMax, 10)
@ -150,9 +151,9 @@ export class AdvancedSearch {
originallyPublishedStartDate: this.originallyPublishedStartDate,
originallyPublishedEndDate: this.originallyPublishedEndDate,
nsfw: this.nsfw,
categoryOneOf: this.intoArray(this.categoryOneOf),
licenceOneOf: this.intoArray(this.licenceOneOf),
languageOneOf: this.intoArray(this.languageOneOf),
categoryOneOf: intoArray(this.categoryOneOf),
licenceOneOf: intoArray(this.licenceOneOf),
languageOneOf: intoArray(this.languageOneOf),
tagsOneOf: this.tagsOneOf,
tagsAllOf: this.tagsAllOf,
durationMin: this.durationMin,
@ -198,13 +199,4 @@ export class AdvancedSearch {
return true
}
private intoArray (value: any) {
if (!value) return undefined
if (Array.isArray(value)) return value
if (typeof value === 'string') return value.split(',')
return [ value ]
}
}

View File

@ -30,12 +30,7 @@
</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>

View File

@ -19,7 +19,7 @@ input[type=submit] {
margin-bottom: 30px;
}
my-select-checkbox {
my-select-languages {
@include responsive-width(340px);
display: block;

View File

@ -1,12 +1,11 @@
import { pick } from 'lodash-es'
import { forkJoin, Subject, Subscription } from 'rxjs'
import { Subject, Subscription } from 'rxjs'
import { first } from 'rxjs/operators'
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
import { FormReactive, FormValidatorService, ItemSelectCheckboxValue } from '@app/shared/shared-forms'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { UserUpdateMe } from '@shared/models'
import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
import { SelectOptionsItem } from '../../../types/select-options-item.model'
@Component({
selector: 'my-user-video-settings',
@ -19,12 +18,9 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
@Input() notifyOnUpdate = true
@Input() userInformationLoaded: Subject<any>
languageItems: SelectOptionsItem[] = []
defaultNSFWPolicy: NSFWPolicyType
formValuesWatcher: Subscription
private allLanguagesGroup: string
constructor (
protected formValidatorService: FormValidatorService,
private authService: AuthService,
@ -36,8 +32,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
}
ngOnInit () {
this.allLanguagesGroup = $localize`All languages`
this.buildForm({
nsfwPolicy: null,
webTorrentEnabled: null,
@ -46,20 +40,9 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
videoLanguages: null
})
forkJoin([
this.serverService.getVideoLanguages(),
this.userInformationLoaded.pipe(first())
]).subscribe(([ languages ]) => {
const group = this.allLanguagesGroup
this.languageItems = [ { label: $localize`Unknown language`, id: '_unknown', group } ]
this.languageItems = this.languageItems
.concat(languages.map(l => ({ label: l.label, id: l.id, group })))
const videoLanguages: ItemSelectCheckboxValue[] = this.user.videoLanguages
? this.user.videoLanguages.map(l => ({ id: l }))
: [ { group } ]
.subscribe(
() => {
const serverConfig = this.serverService.getHTMLConfig()
this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
@ -68,11 +51,12 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
webTorrentEnabled: this.user.webTorrentEnabled,
autoPlayVideo: this.user.autoPlayVideo === true,
autoPlayNextVideo: this.user.autoPlayNextVideo,
videoLanguages
videoLanguages: this.user.videoLanguages
})
if (this.reactiveUpdate) this.handleReactiveUpdate()
})
}
)
}
ngOnDestroy () {
@ -85,22 +69,14 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
const autoPlayVideo = this.form.value['autoPlayVideo']
const autoPlayNextVideo = this.form.value['autoPlayNextVideo']
let videoLanguagesForm = this.form.value['videoLanguages']
const videoLanguages = this.form.value['videoLanguages']
if (Array.isArray(videoLanguagesForm)) {
if (videoLanguagesForm.length > 20) {
if (Array.isArray(videoLanguages)) {
if (videoLanguages.length > 20) {
this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`)
return
}
// Automatically use "All languages" if the user did not select any language
if (videoLanguagesForm.length === 0) {
videoLanguagesForm = [ this.allLanguagesGroup ]
this.form.patchValue({ videoLanguages: [ { group: this.allLanguagesGroup } ] })
}
}
const videoLanguages = this.buildLanguagesFromForm(videoLanguagesForm)
let details: UserUpdateMe = {
nsfwPolicy,
@ -123,31 +99,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
return this.updateAnonymousProfile(details)
}
private buildLanguagesFromForm (videoLanguages: ItemSelectCheckboxValue[]) {
if (!Array.isArray(videoLanguages)) return undefined
// null means "All"
if (videoLanguages.length === this.languageItems.length) return null
if (videoLanguages.length === 1) {
const videoLanguage = videoLanguages[0]
if (typeof videoLanguage === 'string') {
if (videoLanguage === this.allLanguagesGroup) return null
} else {
if (videoLanguage.group === this.allLanguagesGroup) return null
}
}
return videoLanguages.map(l => {
if (typeof l === 'string') return l
if (l.group) return l.group
return l.id + ''
})
}
private handleReactiveUpdate () {
let oldForm = { ...this.form.value }

View File

@ -1,404 +0,0 @@
import { fromEvent, Observable, ReplaySubject, Subject, Subscription } from 'rxjs'
import { debounceTime, switchMap, tap } from 'rxjs/operators'
import {
AfterContentInit,
ComponentFactoryResolver,
Directive,
Injector,
OnDestroy,
OnInit,
Type,
ViewChild,
ViewContainerRef
} from '@angular/core'
import { ActivatedRoute, Params, Router } from '@angular/router'
import {
AuthService,
ComponentPaginationLight,
LocalStorageService,
Notifier,
ScreenService,
ServerService,
User,
UserService
} from '@app/core'
import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
import { GlobalIconName } from '@app/shared/shared-icons'
import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
import { HTMLServerConfig, UserRight, VideoFilter, VideoSortField } from '@shared/models'
import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
import { Syndication, Video } from '../shared-main'
import { GenericHeaderComponent, VideoListHeaderComponent } from './video-list-header.component'
import { MiniatureDisplayOptions } from './video-miniature.component'
enum GroupDate {
UNKNOWN = 0,
TODAY = 1,
YESTERDAY = 2,
THIS_WEEK = 3,
THIS_MONTH = 4,
LAST_MONTH = 5,
OLDER = 6
}
@Directive()
// eslint-disable-next-line @angular-eslint/directive-class-suffix
export abstract class AbstractVideoList implements OnInit, OnDestroy, AfterContentInit, DisableForReuseHook {
@ViewChild('videoListHeader', { static: true, read: ViewContainerRef }) videoListHeader: ViewContainerRef
HeaderComponent: Type<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()
}
}

View File

@ -1,7 +1,8 @@
export * from './abstract-video-list'
export * from './video-actions-dropdown.component'
export * from './video-download.component'
export * from './video-filters-header.component'
export * from './video-filters.model'
export * from './video-miniature.component'
export * from './videos-list.component'
export * from './videos-selection.component'
export * from './video-list-header.component'
export * from './shared-video-miniature.module'

View File

@ -1,19 +1,20 @@
import { NgModule } from '@angular/core'
import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
import { SharedFormModule } from '../shared-forms'
import { SharedGlobalIconModule } from '../shared-icons'
import { SharedMainModule } from '../shared-main/shared-main.module'
import { SharedModerationModule } from '../shared-moderation'
import { SharedVideoModule } from '../shared-video'
import { SharedThumbnailModule } from '../shared-thumbnail'
import { SharedVideoModule } from '../shared-video'
import { SharedVideoLiveModule } from '../shared-video-live'
import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module'
import { VideoActionsDropdownComponent } from './video-actions-dropdown.component'
import { VideoDownloadComponent } from './video-download.component'
import { VideoFiltersHeaderComponent } from './video-filters-header.component'
import { VideoMiniatureComponent } from './video-miniature.component'
import { VideosListComponent } from './videos-list.component'
import { VideosSelectionComponent } from './videos-selection.component'
import { VideoListHeaderComponent } from './video-list-header.component'
import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
@NgModule({
imports: [
@ -33,14 +34,17 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image
VideoDownloadComponent,
VideoMiniatureComponent,
VideosSelectionComponent,
VideoListHeaderComponent
VideoFiltersHeaderComponent,
VideosListComponent
],
exports: [
VideoActionsDropdownComponent,
VideoDownloadComponent,
VideoMiniatureComponent,
VideosSelectionComponent
VideosSelectionComponent,
VideoFiltersHeaderComponent,
VideosListComponent
],
providers: [ ]

View File

@ -39,7 +39,6 @@
margin-top: 20px;
.peertube-radio-container {
@include peertube-radio-container;
@include margin-right(30px);
display: inline-block;

View File

@ -0,0 +1,131 @@
<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>

View File

@ -0,0 +1,139 @@
@use '_variables' as *;
@use '_mixins' as *;
.root {
margin-bottom: 45px;
font-size: 15px;
}
.first-row {
display: flex;
justify-content: space-between;
}
.active-filters {
display: flex;
flex-wrap: wrap;
}
.filters {
display: flex;
flex-wrap: wrap;
margin-top: 25px;
border-bottom: 1px solid $separator-border-color;
input[type=radio] + label {
font-weight: $font-regular;
}
.form-group > label:first-child {
display: block;
&.with-description {
margin-bottom: 0;
}
&:not(.with-description) {
margin-bottom: 10px;
}
}
.form-group {
@include margin-right(30px);
}
}
.pastille {
@include margin-right(15px);
border-radius: 24px;
padding: 4px 15px;
font-size: 16px;
margin-bottom: 15px;
cursor: pointer;
}
.filters-toggle {
border: 2px solid pvar(--mainForegroundColor);
my-global-icon {
@include margin-left(5px);
}
&.active my-global-icon {
position: relative;
top: -1px;
}
&:not(.active) {
my-global-icon ::ng-deep svg {
transform: rotate(180deg);
}
}
}
// Than have an icon
.filters-toggle,
.active-filter.can-remove {
padding: 4px 11px 4px 15px;
}
.active-filter {
background-color: pvar(--channelBackgroundColor);
display: flex;
align-items: center;
&:not(.can-remove) {
cursor: default;
}
&.can-remove:hover {
opacity: 0.9;
}
my-global-icon {
@include margin-left(10px);
width: 16px;
color: pvar(--greyForegroundColor);
}
}
.sort {
min-width: 200px;
max-width: 300px;
height: min-content;
::ng-deep {
.ng-select-container {
height: 33px !important;
}
.ng-value strong {
@include margin-left(5px);
}
}
}
my-select-languages,
my-select-categories {
width: 300px;
display: inline-block;
}
.label-description {
font-size: 12px;
font-style: italic;
margin-bottom: 10px;
a {
color: pvar(--mainColor);
}
}
@media screen and (max-width: $small-view) {
.first-row {
flex-direction: column;
}
}

View File

@ -0,0 +1,119 @@
import * as debug from 'debug'
import { Subscription } from 'rxjs'
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
import { FormBuilder, FormGroup } from '@angular/forms'
import { AuthService } from '@app/core'
import { ServerService } from '@app/core/server/server.service'
import { UserRight } from '@shared/models'
import { NSFWPolicyType } from '@shared/models/videos'
import { PeertubeModalService } from '../shared-main'
import { VideoFilters } from './video-filters.model'
const logger = debug('peertube:videos:VideoFiltersHeaderComponent')
@Component({
selector: 'my-video-filters-header',
styleUrls: [ './video-filters-header.component.scss' ],
templateUrl: './video-filters-header.component.html'
})
export class VideoFiltersHeaderComponent implements OnInit, OnDestroy {
@Input() filters: VideoFilters
@Input() displayModerationBlock = false
@Input() defaultSort = '-publishedAt'
@Input() nsfwPolicy: NSFWPolicyType
@Output() filtersChanged = new EventEmitter()
areFiltersCollapsed = true
form: FormGroup
private routeSub: Subscription
constructor (
private auth: AuthService,
private serverService: ServerService,
private fb: FormBuilder,
private modalService: PeertubeModalService
) {
}
ngOnInit () {
this.form = this.fb.group({
sort: [ '' ],
nsfw: [ '' ],
languageOneOf: [ '' ],
categoryOneOf: [ '' ],
scope: [ '' ],
allVideos: [ '' ],
live: [ '' ]
})
this.patchForm(false)
this.filters.onChange(() => {
this.patchForm(false)
})
this.form.valueChanges.subscribe(values => {
logger('Loading values from form: %O', values)
this.filters.load(values)
this.filtersChanged.emit()
})
}
ngOnDestroy () {
if (this.routeSub) this.routeSub.unsubscribe()
}
canSeeAllVideos () {
if (!this.auth.isLoggedIn()) return false
if (!this.displayModerationBlock) return false
return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS)
}
isTrendingSortEnabled (sort: 'most-viewed' | 'hot' | 'best' | 'most-liked') {
const serverConfig = this.serverService.getHTMLConfig()
const enabled = serverConfig.trending.videos.algorithms.enabled.includes(sort)
// Best is adapted from the user
if (sort === 'best') return enabled && this.auth.isLoggedIn()
return enabled
}
resetFilter (key: string, canRemove: boolean) {
if (!canRemove) return
this.filters.reset(key)
this.patchForm(false)
this.filtersChanged.emit()
}
getFilterTitle (canRemove: boolean) {
if (canRemove) return $localize`Remove this filter`
return ''
}
onAccountSettingsClick (event: Event) {
if (this.auth.isLoggedIn()) return
event.preventDefault()
event.stopPropagation()
this.modalService.openQuickSettingsSubject.next()
}
private patchForm (emitEvent: boolean) {
const defaultValues = this.filters.toFormObject()
this.form.patchValue(defaultValues, { emitEvent })
logger('Patched form: %O', defaultValues)
}
}

View File

@ -0,0 +1,240 @@
import { intoArray, toBoolean } from '@app/helpers'
import { AttributesOnly } from '@shared/core-utils'
import { BooleanBothQuery, NSFWPolicyType, VideoFilter, VideoSortField } from '@shared/models'
type VideoFiltersKeys = {
[ id in keyof AttributesOnly<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')
}
}

View File

@ -1,5 +0,0 @@
<h1 class="title-page title-page-single">
<div placement="bottom" [ngbTooltip]="data.titleTooltip" container="body">
{{ data.titlePage }}
</div>
</h1>

View File

@ -1,22 +0,0 @@
import { Component, Inject, ViewEncapsulation } from '@angular/core'
export interface GenericHeaderData {
titlePage: string
titleTooltip?: string
}
export abstract class GenericHeaderComponent {
constructor (@Inject('data') public data: GenericHeaderData) {}
}
@Component({
selector: 'my-video-list-header',
// eslint-disable-next-line @angular-eslint/use-component-view-encapsulation
encapsulation: ViewEncapsulation.None,
templateUrl: './video-list-header.component.html'
})
export class VideoListHeaderComponent extends GenericHeaderComponent {
constructor (@Inject('data') public data: GenericHeaderData) {
super(data)
}
}

View File

@ -1,11 +1,17 @@
<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 *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>
<div class="action-block">
<my-feed *ngIf="syndicationItems" [syndicationItems]="syndicationItems"></my-feed>
<ng-container *ngFor="let action of actions">
<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>
@ -24,27 +30,18 @@
</ng-template>
</ng-container>
</div>
</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)">
@ -53,7 +50,7 @@
<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)"
>

View File

@ -3,34 +3,48 @@
@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;
.title,
.title-subscription {
grid-column: 1;
}
.title {
font-size: 18px;
color: pvar(--mainForegroundColor);
display: inline-block;
font-weight: $font-semibold;
margin-top: 30px;
margin-bottom: 0;
}
.title-subscription {
grid-row: 2;
font-size: 14px;
color: pvar(--greyForegroundColor);
&.no-title {
margin-top: 10px;
}
}
.action-block {
grid-column: 3;
}
my-feed {
@include margin-left(5px);
display: inline-block;
width: calc(#{$icon-size} - 2px);
}
.moderation-block {
@include margin-left(.4rem);
display: flex;
justify-content: flex-end;
align-items: center;
my-global-icon {
width: 16px;
color: pvar(--mainColor);
position: relative;
width: $icon-size;
}
top: -2px;
}
}
@ -38,9 +52,8 @@ $icon-size: 16px;
font-size: 16px;
font-weight: $font-semibold;
margin-bottom: 20px;
margin-top: -10px;
// make the element span a full grid row within .videos grid
// Make the element span a full grid row within .videos grid
grid-column: 1 / -1;
&:not(:first-child) {
@ -64,6 +77,18 @@ $icon-size: 16px;
}
@media screen and (max-width: $mobile-view) {
.videos-header,
my-video-filters-header {
@include margin-left(15px);
@include margin-right(15px);
display: inline-block;
}
.date-title {
text-align: center;
}
.videos-header {
flex-direction: column;
align-items: center;

View File

@ -0,0 +1,396 @@
import * as debug from 'debug'
import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
import { debounceTime, switchMap } from 'rxjs/operators'
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AuthService, ComponentPaginationLight, Notifier, PeerTubeRouterService, ScreenService, User, UserService } from '@app/core'
import { GlobalIconName } from '@app/shared/shared-icons'
import { isLastMonth, isLastWeek, isThisMonth, isToday, isYesterday } from '@shared/core-utils'
import { ResultList, UserRight, VideoSortField } from '@shared/models'
import { Syndication, Video } from '../shared-main'
import { VideoFilters, VideoFilterScope } from './video-filters.model'
import { MiniatureDisplayOptions } from './video-miniature.component'
const logger = debug('peertube:videos:VideosListComponent')
export type HeaderAction = {
iconName: GlobalIconName
label: string
justIcon?: boolean
routerLink?: string
href?: string
click?: (e: Event) => void
}
enum GroupDate {
UNKNOWN = 0,
TODAY = 1,
YESTERDAY = 2,
THIS_WEEK = 3,
THIS_MONTH = 4,
LAST_MONTH = 5,
OLDER = 6
}
@Component({
selector: 'my-videos-list',
templateUrl: './videos-list.component.html',
styleUrls: [ './videos-list.component.scss' ]
})
export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
@Input() getVideosObservableFunction: (pagination: ComponentPaginationLight, filters: VideoFilters) => Observable<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)
})
}
}

View File

@ -1,6 +1,9 @@
<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">

View File

@ -1,22 +1,8 @@
import { Observable } from 'rxjs'
import {
AfterContentInit,
Component,
ComponentFactoryResolver,
ContentChildren,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
TemplateRef
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
import { Observable, Subject } from 'rxjs'
import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core'
import { ComponentPagination, Notifier, User } from '@app/core'
import { ResultList, VideoSortField } from '@shared/models'
import { PeerTubeTemplateDirective, Video } from '../shared-main'
import { AbstractVideoList } from './abstract-video-list'
import { MiniatureDisplayOptions } from './video-miniature.component'
export type SelectionType = { [ id: number ]: boolean }
@ -26,14 +12,18 @@ export type SelectionType = { [ id: number ]: boolean }
templateUrl: './videos-selection.component.html',
styleUrls: [ './videos-selection.component.scss' ]
})
export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
export class VideosSelectionComponent implements AfterContentInit {
@Input() user: User
@Input() pagination: ComponentPagination
@Input() titlePage: string
@Input() miniatureDisplayOptions: MiniatureDisplayOptions
@Input() noResultMessage = $localize`No results.`
@Input() enableSelection = true
@Input() loadOnInit = true
@Input() disabled = false
@Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
@ -47,19 +37,18 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
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
@ -79,10 +68,6 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
this.videosModelChange.emit(this.videos)
}
ngOnInit () {
super.ngOnInit()
}
ngAfterContentInit () {
{
const t = this.templates.find(t => t.name === 'rowButtons')
@ -93,10 +78,8 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
const t = this.templates.find(t => t.name === 'globalButtons')
if (t) this.globalButtonsTemplate = t.template
}
}
ngOnDestroy () {
super.ngOnDestroy()
this.loadMoreVideos()
}
getVideosObservable (page: number) {
@ -111,11 +94,50 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
return Object.keys(this._selection).some(k => this._selection[k] === true)
}
generateSyndicationList () {
throw new Error('Method not implemented.')
videoById (index: number, video: Video) {
return video.id
}
protected onMoreVideos () {
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)
}
removeVideoFromArray (video: Video) {
this.videos = this.videos.filter(v => v.id !== video.id)
}
}

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 324 B

View File

@ -287,6 +287,7 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
&.show {
max-height: 1500px;
overflow: inherit !important;
}
}

View File

@ -24,3 +24,7 @@
.tertiary-button {
@include tertiary-button;
}
.peertube-radio-container {
@include peertube-radio-container;
}

View File

@ -420,42 +420,55 @@
}
}
// Thanks: https://codepen.io/triss90/pen/XNEdRe/
// Thanks: https://codepen.io/manabox/pen/raQmpL
@mixin peertube-radio-container {
input[type=radio] {
display: none;
+ label {
font-weight: $font-regular;
cursor: pointer;
&::before {
@include margin-right(10px);
[type=radio]:checked,
[type=radio]:not(:checked) {
position: absolute;
left: -9999px;
}
[type=radio]:checked + label,
[type=radio]:not(:checked) + label {
position: relative;
top: -2px;
content: '';
background: #fff;
border-radius: 100%;
border: 1px solid #000;
display: inline-block;
width: 15px;
height: 15px;
vertical-align: middle;
padding-left: 28px;
cursor: pointer;
text-align: center;
}
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);
}
}