Merge branch 'feature/video-filters' into develop
This commit is contained in:
commit
fdec51e384
|
@ -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.')
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
<div class="no-results" i18n *ngIf="channelPagination.totalItems === 0">This account does not have channels.</div>
|
<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" *ngFor="let videoChannel of videoChannels">
|
||||||
|
|
||||||
<div class="channel-avatar-row">
|
<div class="channel-avatar-row">
|
||||||
|
|
|
@ -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>
|
|
@ -1,96 +1,69 @@
|
||||||
import { forkJoin, Subscription } from 'rxjs'
|
import { Subscription } from 'rxjs'
|
||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
|
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
|
||||||
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 { Account, AccountService, VideoService } from '@app/shared/shared-main'
|
||||||
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
|
import { VideoFilters } from '@app/shared/shared-video-miniature'
|
||||||
import { VideoFilter } from '@shared/models'
|
import { VideoSortField } from '@shared/models'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-account-videos',
|
selector: 'my-account-videos',
|
||||||
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
|
templateUrl: './account-videos.component.html'
|
||||||
styleUrls: [
|
|
||||||
'../../shared/shared-video-miniature/abstract-video-list.scss'
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class AccountVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
|
export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
|
||||||
// No value because we don't want a page title
|
getVideosObservableFunction = this.getVideosObservable.bind(this)
|
||||||
titlePage: string
|
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
|
||||||
loadOnInit = false
|
|
||||||
loadUserVideoPreferences = true
|
|
||||||
|
|
||||||
filter: VideoFilter = null
|
title = $localize`Videos`
|
||||||
|
defaultSort = '-publishedAt' as VideoSortField
|
||||||
|
|
||||||
|
account: Account
|
||||||
|
disabled = false
|
||||||
|
|
||||||
private account: Account
|
|
||||||
private accountSub: Subscription
|
private accountSub: Subscription
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
protected router: Router,
|
private screenService: ScreenService,
|
||||||
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 accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private videoService: VideoService,
|
private videoService: VideoService
|
||||||
protected cfr: ComponentFactoryResolver
|
|
||||||
) {
|
) {
|
||||||
super()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
super.ngOnInit()
|
|
||||||
|
|
||||||
this.enableAllFilterIfPossible()
|
|
||||||
|
|
||||||
// Parent get the account for us
|
// Parent get the account for us
|
||||||
this.accountSub = forkJoin([
|
this.accountService.accountLoaded.pipe(first())
|
||||||
this.accountService.accountLoaded.pipe(first()),
|
.subscribe(account => this.account = account)
|
||||||
this.onUserLoadedSubject.pipe(first())
|
|
||||||
]).subscribe(([ account ]) => {
|
|
||||||
this.account = account
|
|
||||||
|
|
||||||
this.reloadVideos()
|
|
||||||
this.generateSyndicationList()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy () {
|
ngOnDestroy () {
|
||||||
if (this.accountSub) this.accountSub.unsubscribe()
|
if (this.accountSub) this.accountSub.unsubscribe()
|
||||||
|
|
||||||
super.ngOnDestroy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideosObservable (page: number) {
|
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
||||||
const newPagination = immutableAssign(this.pagination, { currentPage: page })
|
|
||||||
const options = {
|
const options = {
|
||||||
|
...filters.toVideosAPIObject(),
|
||||||
|
|
||||||
|
videoPagination: pagination,
|
||||||
account: this.account,
|
account: this.account,
|
||||||
videoPagination: newPagination,
|
skipCount: true
|
||||||
sort: this.sort,
|
|
||||||
nsfwPolicy: this.nsfwPolicy,
|
|
||||||
videoFilter: this.filter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.videoService
|
return this.videoService.getAccountVideos(options)
|
||||||
.getAccountVideos(options)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleModerationDisplay () {
|
getSyndicationItems () {
|
||||||
this.filter = this.buildLocalFilter(this.filter, null)
|
return this.videoService.getAccountFeedUrls(this.account.id)
|
||||||
|
|
||||||
this.reloadVideos()
|
|
||||||
}
|
|
||||||
|
|
||||||
generateSyndicationList () {
|
|
||||||
this.syndicationItems = this.videoService.getAccountFeedUrls(this.account.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
displayAsRow () {
|
displayAsRow () {
|
||||||
return this.screenService.isInMobileView()
|
return this.screenService.isInMobileView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disableForReuse () {
|
||||||
|
this.disabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledForReuse () {
|
||||||
|
this.disabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { RouterModule, Routes } from '@angular/router'
|
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 { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
|
||||||
import { AccountVideosComponent } from './account-videos/account-videos.component'
|
import { AccountVideosComponent } from './account-videos/account-videos.component'
|
||||||
import { AccountsComponent } from './accounts.component'
|
import { AccountsComponent } from './accounts.component'
|
||||||
|
@ -41,14 +40,11 @@ const accountsRoutes: Routes = [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Old URL redirection
|
||||||
{
|
{
|
||||||
path: 'search',
|
path: 'search',
|
||||||
component: AccountSearchComponent,
|
redirectTo: 'videos'
|
||||||
data: {
|
|
||||||
meta: {
|
|
||||||
title: $localize`Search videos within account`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,7 +66,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="links">
|
<div class="links" [ngClass]="{ 'on-channel-page': isOnChannelPage() }">
|
||||||
<ng-template #linkTemplate let-item="item">
|
<ng-template #linkTemplate let-item="item">
|
||||||
<a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a>
|
<a [routerLink]="item.routerLink" routerLinkActive="active" class="title-page">{{ item.label }}</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -81,7 +81,7 @@
|
||||||
></my-simple-search-input>
|
></my-simple-search-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<router-outlet (activate)="onOutletLoaded($event)"></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="prependModerationActions">
|
<ng-container *ngIf="prependModerationActions">
|
||||||
|
|
|
@ -20,7 +20,10 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: $max-channels-width;
|
|
||||||
|
&.on-channel-page {
|
||||||
|
max-width: $max-channels-width;
|
||||||
|
}
|
||||||
|
|
||||||
simple-search-input {
|
simple-search-input {
|
||||||
@include margin-left(auto);
|
@include margin-left(auto);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Subscription } from 'rxjs'
|
import { Subscription } from 'rxjs'
|
||||||
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
|
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
|
||||||
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
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 { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
|
@ -14,7 +14,6 @@ import {
|
||||||
} from '@app/shared/shared-main'
|
} from '@app/shared/shared-main'
|
||||||
import { AccountReportComponent } from '@app/shared/shared-moderation'
|
import { AccountReportComponent } from '@app/shared/shared-moderation'
|
||||||
import { HttpStatusCode, User, UserRight } from '@shared/models'
|
import { HttpStatusCode, User, UserRight } from '@shared/models'
|
||||||
import { AccountSearchComponent } from './account-search/account-search.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
templateUrl: './accounts.component.html',
|
templateUrl: './accounts.component.html',
|
||||||
|
@ -23,8 +22,6 @@ import { AccountSearchComponent } from './account-search/account-search.componen
|
||||||
export class AccountsComponent implements OnInit, OnDestroy {
|
export class AccountsComponent implements OnInit, OnDestroy {
|
||||||
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent
|
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent
|
||||||
|
|
||||||
accountSearch: AccountSearchComponent
|
|
||||||
|
|
||||||
account: Account
|
account: Account
|
||||||
accountUser: User
|
accountUser: User
|
||||||
|
|
||||||
|
@ -45,6 +42,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private videoChannelService: VideoChannelService,
|
private videoChannelService: VideoChannelService,
|
||||||
|
@ -128,16 +126,10 @@ export class AccountsComponent implements OnInit, OnDestroy {
|
||||||
return $localize`${count} subscribers`
|
return $localize`${count} subscribers`
|
||||||
}
|
}
|
||||||
|
|
||||||
onOutletLoaded (component: Component) {
|
|
||||||
if (component instanceof AccountSearchComponent) {
|
|
||||||
this.accountSearch = component
|
|
||||||
} else {
|
|
||||||
this.accountSearch = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
searchChanged (search: string) {
|
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) {
|
onSearchInputDisplayChanged (displayed: boolean) {
|
||||||
|
@ -152,6 +144,10 @@ export class AccountsComponent implements OnInit, OnDestroy {
|
||||||
return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100
|
return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isOnChannelPage () {
|
||||||
|
return this.route.children[0].snapshot.url[0].path === 'video-channels'
|
||||||
|
}
|
||||||
|
|
||||||
private async onAccount (account: Account) {
|
private async onAccount (account: Account) {
|
||||||
this.accountFollowerTitle = $localize`${account.followersCount} direct account followers`
|
this.accountFollowerTitle = $localize`${account.followersCount} direct account followers`
|
||||||
|
|
||||||
|
|
|
@ -5,12 +5,11 @@ import { SharedMainModule } from '@app/shared/shared-main'
|
||||||
import { SharedModerationModule } from '@app/shared/shared-moderation'
|
import { SharedModerationModule } from '@app/shared/shared-moderation'
|
||||||
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
|
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
|
||||||
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
|
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 { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
|
||||||
import { AccountVideosComponent } from './account-videos/account-videos.component'
|
import { AccountVideosComponent } from './account-videos/account-videos.component'
|
||||||
import { AccountsRoutingModule } from './accounts-routing.module'
|
import { AccountsRoutingModule } from './accounts-routing.module'
|
||||||
import { AccountsComponent } from './accounts.component'
|
import { AccountsComponent } from './accounts.component'
|
||||||
import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -28,8 +27,7 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto
|
||||||
declarations: [
|
declarations: [
|
||||||
AccountsComponent,
|
AccountsComponent,
|
||||||
AccountVideosComponent,
|
AccountVideosComponent,
|
||||||
AccountVideoChannelsComponent,
|
AccountVideoChannelsComponent
|
||||||
AccountSearchComponent
|
|
||||||
],
|
],
|
||||||
|
|
||||||
exports: [
|
exports: [
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
{{ getNoResultMessage() }}
|
{{ getNoResultMessage() }}
|
||||||
</div>
|
</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 plugin" *ngFor="let plugin of plugins">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="first-row">
|
<div class="first-row">
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
No results.
|
No results.
|
||||||
</div>
|
</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 plugin" *ngFor="let plugin of plugins">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="first-row">
|
<div class="first-row">
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
<div class="top-buttons">
|
<div class="top-buttons">
|
||||||
<div class="search-wrapper">
|
<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>
|
||||||
|
|
||||||
<div class="history-switch">
|
<div class="history-switch">
|
||||||
|
@ -26,8 +26,8 @@
|
||||||
[titlePage]="titlePage"
|
[titlePage]="titlePage"
|
||||||
[getVideosObservableFunction]="getVideosObservableFunction"
|
[getVideosObservableFunction]="getVideosObservableFunction"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
[loadOnInit]="false"
|
|
||||||
i18n-noResultMessage noResultMessage="You don't have any video in your watch history yet."
|
i18n-noResultMessage noResultMessage="You don't have any video in your watch history yet."
|
||||||
[enableSelection]="false"
|
[enableSelection]="false"
|
||||||
|
[disabled]="disabled"
|
||||||
#videosSelection
|
#videosSelection
|
||||||
></my-videos-selection>
|
></my-videos-selection>
|
||||||
|
|
|
@ -50,6 +50,8 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
|
||||||
videos: Video[] = []
|
videos: Video[] = []
|
||||||
search: string
|
search: string
|
||||||
|
|
||||||
|
disabled = false
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
protected router: Router,
|
protected router: Router,
|
||||||
protected serverService: ServerService,
|
protected serverService: ServerService,
|
||||||
|
@ -74,11 +76,11 @@ export class MyHistoryComponent implements OnInit, DisableForReuseHook {
|
||||||
}
|
}
|
||||||
|
|
||||||
disableForReuse () {
|
disableForReuse () {
|
||||||
this.videosSelection.disableForReuse()
|
this.disabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledForReuse () {
|
enabledForReuse () {
|
||||||
this.videosSelection.enabledForReuse()
|
this.disabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadData () {
|
reloadData () {
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
|
|
||||||
<div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div>
|
<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">
|
<div *ngFor="let videoChannel of videoChannels" class="video-channel">
|
||||||
<my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
|
<my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()"
|
class="videos" myInfiniteScroller (nearOfBottom)="onNearOfBottom()"
|
||||||
cdkDropList (cdkDropListDropped)="drop($event)" [dataObservable]="onDataSubject.asObservable()"
|
cdkDropList (cdkDropListDropped)="drop($event)" [dataObservable]="onDataSubject.asObservable()"
|
||||||
>
|
>
|
||||||
<div class="video" *ngFor="let playlistElement of playlistElements; trackBy: trackByFn" cdkDrag [cdkDragStartDelay]="getDragStartDelay()">
|
<div class="video" *ngFor="let playlistElement of playlistElements; trackBy: trackByFn" cdkDrag [cdkDragStartDelay]="getDragStartDelay()">
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<div *ngFor="let playlist of videoPlaylists" class="video-playlist">
|
||||||
<my-video-playlist-miniature
|
<my-video-playlist-miniature
|
||||||
[playlist]="playlist" [toManage]="true" [displayChannel]="true"
|
[playlist]="playlist" [toManage]="true" [displayChannel]="true"
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="videos-header d-flex justify-content-between">
|
<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">
|
<div class="peertube-select-container peertube-select-button">
|
||||||
<select [(ngModel)]="sort" (ngModelChange)="onChangeSortColumn()" class="form-control">
|
<select [(ngModel)]="sort" (ngModelChange)="onChangeSortColumn()" class="form-control">
|
||||||
|
@ -41,7 +41,7 @@
|
||||||
[titlePage]="titlePage"
|
[titlePage]="titlePage"
|
||||||
[getVideosObservableFunction]="getVideosObservableFunction"
|
[getVideosObservableFunction]="getVideosObservableFunction"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
[loadOnInit]="false"
|
[disabled]="disabled"
|
||||||
#videosSelection
|
#videosSelection
|
||||||
>
|
>
|
||||||
<ng-template ptTemplate="globalButtons">
|
<ng-template ptTemplate="globalButtons">
|
||||||
|
|
|
@ -54,6 +54,8 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
disabled = false
|
||||||
|
|
||||||
private search: string
|
private search: string
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
|
@ -89,11 +91,11 @@ export class MyVideosComponent implements OnInit, DisableForReuseHook {
|
||||||
}
|
}
|
||||||
|
|
||||||
disableForReuse () {
|
disableForReuse () {
|
||||||
this.videosSelection.disableForReuse()
|
this.disabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledForReuse () {
|
enabledForReuse () {
|
||||||
this.videosSelection.enabledForReuse()
|
this.disabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideosObservable (page: number) {
|
getVideosObservable (page: number) {
|
||||||
|
|
|
@ -11,7 +11,6 @@ form {
|
||||||
}
|
}
|
||||||
|
|
||||||
.peertube-radio-container {
|
.peertube-radio-container {
|
||||||
@include peertube-radio-container;
|
|
||||||
@include margin-right(30px);
|
@include margin-right(30px);
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -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="results-header">
|
||||||
<div class="first-line">
|
<div class="first-line">
|
||||||
<div class="results-counter" *ngIf="pagination.totalItems">
|
<div class="results-counter" *ngIf="pagination.totalItems">
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
<div i18n class="no-results" *ngIf="pagination.totalItems === 0">This channel does not have playlists.</div>
|
<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">
|
<div *ngFor="let playlist of videoPlaylists" class="playlist-wrapper">
|
||||||
<my-video-playlist-miniature [playlist]="playlist" [toManage]="false" [displayAsRow]="displayAsRow()"></my-video-playlist-miniature>
|
<my-video-playlist-miniature [playlist]="playlist" [toManage]="false" [displayAsRow]="displayAsRow()"></my-video-playlist-miniature>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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>
|
|
@ -1,27 +1,21 @@
|
||||||
import { forkJoin, Subscription } from 'rxjs'
|
import { Subscription } from 'rxjs'
|
||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
|
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
|
||||||
import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
|
|
||||||
import { immutableAssign } from '@app/helpers'
|
|
||||||
import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
|
import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
|
||||||
import { AbstractVideoList, MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
|
import { MiniatureDisplayOptions, VideoFilters } from '@app/shared/shared-video-miniature'
|
||||||
import { VideoFilter } from '@shared/models'
|
import { VideoSortField } from '@shared/models/videos'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-video-channel-videos',
|
selector: 'my-video-channel-videos',
|
||||||
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
|
templateUrl: './video-channel-videos.component.html'
|
||||||
styleUrls: [
|
|
||||||
'../../shared/shared-video-miniature/abstract-video-list.scss'
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class VideoChannelVideosComponent extends AbstractVideoList implements OnInit, OnDestroy {
|
export class VideoChannelVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
|
||||||
// No value because we don't want a page title
|
getVideosObservableFunction = this.getVideosObservable.bind(this)
|
||||||
titlePage: string
|
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
|
||||||
loadOnInit = false
|
|
||||||
loadUserVideoPreferences = true
|
|
||||||
|
|
||||||
filter: VideoFilter = null
|
title = $localize`Videos`
|
||||||
|
defaultSort = '-publishedAt' as VideoSortField
|
||||||
|
|
||||||
displayOptions: MiniatureDisplayOptions = {
|
displayOptions: MiniatureDisplayOptions = {
|
||||||
date: true,
|
date: true,
|
||||||
|
@ -34,80 +28,55 @@ export class VideoChannelVideosComponent extends AbstractVideoList implements On
|
||||||
blacklistInfo: false
|
blacklistInfo: false
|
||||||
}
|
}
|
||||||
|
|
||||||
private videoChannel: VideoChannel
|
videoChannel: VideoChannel
|
||||||
|
disabled = false
|
||||||
|
|
||||||
private videoChannelSub: Subscription
|
private videoChannelSub: Subscription
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
protected router: Router,
|
private screenService: ScreenService,
|
||||||
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 videoChannelService: VideoChannelService,
|
private videoChannelService: VideoChannelService,
|
||||||
private videoService: VideoService
|
private videoService: VideoService
|
||||||
) {
|
) {
|
||||||
super()
|
|
||||||
|
|
||||||
this.titlePage = $localize`Published videos`
|
|
||||||
this.displayOptions = {
|
|
||||||
...this.displayOptions,
|
|
||||||
avatar: false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
super.ngOnInit()
|
|
||||||
|
|
||||||
this.enableAllFilterIfPossible()
|
|
||||||
|
|
||||||
// Parent get the video channel for us
|
// Parent get the video channel for us
|
||||||
this.videoChannelSub = forkJoin([
|
this.videoChannelService.videoChannelLoaded.pipe(first())
|
||||||
this.videoChannelService.videoChannelLoaded.pipe(first()),
|
.subscribe(videoChannel => {
|
||||||
this.onUserLoadedSubject.pipe(first())
|
this.videoChannel = videoChannel
|
||||||
]).subscribe(([ videoChannel ]) => {
|
})
|
||||||
this.videoChannel = videoChannel
|
|
||||||
|
|
||||||
this.reloadVideos()
|
|
||||||
this.generateSyndicationList()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy () {
|
ngOnDestroy () {
|
||||||
if (this.videoChannelSub) this.videoChannelSub.unsubscribe()
|
if (this.videoChannelSub) this.videoChannelSub.unsubscribe()
|
||||||
|
|
||||||
super.ngOnDestroy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideosObservable (page: number) {
|
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
||||||
const newPagination = immutableAssign(this.pagination, { currentPage: page })
|
const params = {
|
||||||
const options = {
|
...filters.toVideosAPIObject(),
|
||||||
|
|
||||||
|
videoPagination: pagination,
|
||||||
videoChannel: this.videoChannel,
|
videoChannel: this.videoChannel,
|
||||||
videoPagination: newPagination,
|
skipCount: true
|
||||||
sort: this.sort,
|
|
||||||
nsfwPolicy: this.nsfwPolicy,
|
|
||||||
videoFilter: this.filter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.videoService
|
return this.videoService.getVideoChannelVideos(params)
|
||||||
.getVideoChannelVideos(options)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
generateSyndicationList () {
|
getSyndicationItems () {
|
||||||
this.syndicationItems = this.videoService.getVideoChannelFeedUrls(this.videoChannel.id)
|
return this.videoService.getVideoChannelFeedUrls(this.videoChannel.id)
|
||||||
}
|
|
||||||
|
|
||||||
toggleModerationDisplay () {
|
|
||||||
this.filter = this.buildLocalFilter(this.filter, null)
|
|
||||||
|
|
||||||
this.reloadVideos()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
displayAsRow () {
|
displayAsRow () {
|
||||||
return this.screenService.isInMobileView()
|
return this.screenService.isInMobileView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disableForReuse () {
|
||||||
|
this.disabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledForReuse () {
|
||||||
|
this.disabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,13 +27,7 @@
|
||||||
|
|
||||||
<div *ngIf="totalNotDeletedComments === 0 && comments.length === 0" i18n>No comments.</div>
|
<div *ngIf="totalNotDeletedComments === 0 && comments.length === 0" i18n>No comments.</div>
|
||||||
|
|
||||||
<div
|
<div class="comment-threads" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
|
||||||
class="comment-threads"
|
|
||||||
myInfiniteScroller
|
|
||||||
[autoInit]="true"
|
|
||||||
(nearOfBottom)="onNearOfBottom()"
|
|
||||||
[dataObservable]="onDataSubject.asObservable()"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="anchor" #commentHighlightBlock id="highlighted-comment"></div>
|
<div class="anchor" #commentHighlightBlock id="highlighted-comment"></div>
|
||||||
<my-video-comment
|
<my-video-comment
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<div
|
<div
|
||||||
*ngIf="playlist && currentPlaylistPosition" class="playlist"
|
*ngIf="playlist && currentPlaylistPosition" class="playlist"
|
||||||
myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
|
myInfiniteScroller [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
|
||||||
>
|
>
|
||||||
<div class="playlist-info">
|
<div class="playlist-info">
|
||||||
<div class="playlist-display-name">
|
<div class="playlist-display-name">
|
||||||
|
|
|
@ -1,4 +1,2 @@
|
||||||
export * from './overview'
|
export * from './overview'
|
||||||
export * from './trending'
|
export * from './videos-list-common-page.component'
|
||||||
export * from './video-local.component'
|
|
||||||
export * from './video-recently-added.component'
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="no-results" i18n *ngIf="notResults">No results.</div>
|
<div class="no-results" i18n *ngIf="notResults">No results.</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
|
myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"
|
||||||
>
|
>
|
||||||
<ng-container *ngFor="let overview of overviews">
|
<ng-container *ngFor="let overview of overviews">
|
||||||
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './video-trending-header.component'
|
|
||||||
export * from './video-trending.component'
|
|
|
@ -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>
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
|
@ -1,94 +1,53 @@
|
||||||
|
|
||||||
import { switchMap } from 'rxjs/operators'
|
import { firstValueFrom } from 'rxjs'
|
||||||
import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'
|
import { switchMap, tap } from 'rxjs/operators'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { Component } from '@angular/core'
|
||||||
import { AuthService, LocalStorageService, Notifier, ScopedTokensService, ScreenService, ServerService, UserService } from '@app/core'
|
import { AuthService, ComponentPaginationLight, DisableForReuseHook, ScopedTokensService } from '@app/core'
|
||||||
import { HooksService } from '@app/core/plugins/hooks.service'
|
import { HooksService } from '@app/core/plugins/hooks.service'
|
||||||
import { immutableAssign } from '@app/helpers'
|
|
||||||
import { VideoService } from '@app/shared/shared-main'
|
import { VideoService } from '@app/shared/shared-main'
|
||||||
import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
|
import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
|
||||||
import { AbstractVideoList } from '@app/shared/shared-video-miniature'
|
import { VideoFilters } from '@app/shared/shared-video-miniature'
|
||||||
import { FeedFormat, VideoSortField } from '@shared/models'
|
import { VideoSortField } from '@shared/models'
|
||||||
import { environment } from '../../../environments/environment'
|
|
||||||
import { copyToClipboard } from '../../../root-helpers/utils'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-videos-user-subscriptions',
|
selector: 'my-videos-user-subscriptions',
|
||||||
styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
|
templateUrl: './video-user-subscriptions.component.html'
|
||||||
templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
|
|
||||||
})
|
})
|
||||||
export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy {
|
export class VideoUserSubscriptionsComponent implements DisableForReuseHook {
|
||||||
titlePage: string
|
getVideosObservableFunction = this.getVideosObservable.bind(this)
|
||||||
sort = '-publishedAt' as VideoSortField
|
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
|
||||||
groupByDate = true
|
|
||||||
|
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 (
|
constructor (
|
||||||
protected router: Router,
|
private authService: AuthService,
|
||||||
protected serverService: ServerService,
|
|
||||||
protected route: ActivatedRoute,
|
|
||||||
protected notifier: Notifier,
|
|
||||||
protected authService: AuthService,
|
|
||||||
protected userService: UserService,
|
|
||||||
protected screenService: ScreenService,
|
|
||||||
protected storageService: LocalStorageService,
|
|
||||||
private userSubscription: UserSubscriptionService,
|
private userSubscription: UserSubscriptionService,
|
||||||
protected cfr: ComponentFactoryResolver,
|
|
||||||
private hooks: HooksService,
|
private hooks: HooksService,
|
||||||
private videoService: VideoService,
|
private videoService: VideoService,
|
||||||
private scopedTokensService: ScopedTokensService
|
private scopedTokensService: ScopedTokensService
|
||||||
) {
|
) {
|
||||||
super()
|
|
||||||
|
|
||||||
this.titlePage = $localize`Videos from your subscriptions`
|
|
||||||
|
|
||||||
this.actions.push({
|
|
||||||
routerLink: '/my-library/subscriptions',
|
|
||||||
label: $localize`Subscriptions`,
|
|
||||||
iconName: 'cog'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
||||||
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 })
|
|
||||||
const params = {
|
const params = {
|
||||||
videoPagination: newPagination,
|
...filters.toVideosAPIObject(),
|
||||||
sort: this.sort,
|
|
||||||
|
videoPagination: pagination,
|
||||||
skipCount: true
|
skipCount: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,12 +60,32 @@ export class VideoUserSubscriptionsComponent extends AbstractVideoList implement
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
generateSyndicationList () {
|
getSyndicationItems () {
|
||||||
/* method disabled: the view provides its own */
|
return this.loadFeedToken()
|
||||||
throw new Error('Method not implemented.')
|
.then(() => {
|
||||||
|
const user = this.authService.getUser()
|
||||||
|
|
||||||
|
return this.videoService.getVideoSubscriptionFeedUrls(user.account.id, this.feedToken)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
activateCopiedMessage () {
|
disableForReuse () {
|
||||||
this.notifier.success($localize`Feed URL copied`)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,8 @@
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
import { RouterModule, Routes } from '@angular/router'
|
import { RouterModule, Routes, UrlSegment } from '@angular/router'
|
||||||
import { LoginGuard } from '@app/core'
|
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 { 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 { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
|
||||||
import { VideosComponent } from './videos.component'
|
import { VideosComponent } from './videos.component'
|
||||||
|
|
||||||
|
@ -22,32 +20,35 @@ const videosRoutes: Routes = [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: 'trending',
|
// Old URL redirection
|
||||||
component: VideoTrendingComponent,
|
|
||||||
data: {
|
|
||||||
meta: {
|
|
||||||
title: $localize`Trending videos`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'most-liked',
|
path: 'most-liked',
|
||||||
redirectTo: 'trending?alg=most-liked'
|
redirectTo: 'trending?sort=most-liked'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'recently-added',
|
matcher: (url: UrlSegment[]) => {
|
||||||
component: VideoRecentlyAddedComponent,
|
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: {
|
data: {
|
||||||
meta: {
|
|
||||||
title: $localize`Recently added videos`
|
|
||||||
},
|
|
||||||
reuse: {
|
reuse: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
key: 'recently-added-videos-list'
|
key: 'videos-list'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: 'subscriptions',
|
path: 'subscriptions',
|
||||||
canActivate: [ LoginGuard ],
|
canActivate: [ LoginGuard ],
|
||||||
|
@ -61,19 +62,6 @@ const videosRoutes: Routes = [
|
||||||
key: 'subscription-videos-list'
|
key: 'subscription-videos-list'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'local',
|
|
||||||
component: VideoLocalComponent,
|
|
||||||
data: {
|
|
||||||
meta: {
|
|
||||||
title: $localize`Local videos`
|
|
||||||
},
|
|
||||||
reuse: {
|
|
||||||
enabled: true,
|
|
||||||
key: 'local-videos-list'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,11 +5,8 @@ import { SharedGlobalIconModule } from '@app/shared/shared-icons'
|
||||||
import { SharedMainModule } from '@app/shared/shared-main'
|
import { SharedMainModule } from '@app/shared/shared-main'
|
||||||
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
|
import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
|
||||||
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
|
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 { 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 { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
|
||||||
import { VideosRoutingModule } from './videos-routing.module'
|
import { VideosRoutingModule } from './videos-routing.module'
|
||||||
import { VideosComponent } from './videos.component'
|
import { VideosComponent } from './videos.component'
|
||||||
|
@ -29,12 +26,9 @@ import { VideosComponent } from './videos.component'
|
||||||
declarations: [
|
declarations: [
|
||||||
VideosComponent,
|
VideosComponent,
|
||||||
|
|
||||||
VideoTrendingHeaderComponent,
|
|
||||||
VideoTrendingComponent,
|
|
||||||
VideoRecentlyAddedComponent,
|
|
||||||
VideoLocalComponent,
|
|
||||||
VideoUserSubscriptionsComponent,
|
VideoUserSubscriptionsComponent,
|
||||||
VideoOverviewComponent
|
VideoOverviewComponent,
|
||||||
|
VideosListCommonPageComponent
|
||||||
],
|
],
|
||||||
|
|
||||||
exports: [
|
exports: [
|
||||||
|
|
|
@ -177,6 +177,7 @@ routes.push({
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forRoot(routes, {
|
RouterModule.forRoot(routes, {
|
||||||
useHash: Boolean(history.pushState) === false,
|
useHash: Boolean(history.pushState) === false,
|
||||||
|
// Redefined in app component
|
||||||
scrollPositionRestoration: 'disabled',
|
scrollPositionRestoration: 'disabled',
|
||||||
preloadingStrategy: PreloadSelectedModulesList,
|
preloadingStrategy: PreloadSelectedModulesList,
|
||||||
anchorScrolling: 'disabled'
|
anchorScrolling: 'disabled'
|
||||||
|
|
|
@ -1,10 +1,20 @@
|
||||||
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
|
import { Hotkey, HotkeysService } from 'angular2-hotkeys'
|
||||||
import { filter, map, pairwise, switchMap } from 'rxjs/operators'
|
import { filter, map, switchMap } from 'rxjs/operators'
|
||||||
import { DOCUMENT, getLocaleDirection, PlatformLocation, ViewportScroller } from '@angular/common'
|
import { DOCUMENT, getLocaleDirection, PlatformLocation } from '@angular/common'
|
||||||
import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core'
|
import { AfterViewInit, Component, Inject, LOCALE_ID, OnInit, ViewChild } from '@angular/core'
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
|
||||||
import { Event, GuardsCheckStart, NavigationEnd, RouteConfigLoadEnd, RouteConfigLoadStart, Router, Scroll } from '@angular/router'
|
import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router } from '@angular/router'
|
||||||
import { AuthService, MarkdownService, RedirectService, ScreenService, ServerService, ThemeService, User } from '@app/core'
|
import {
|
||||||
|
AuthService,
|
||||||
|
MarkdownService,
|
||||||
|
PeerTubeRouterService,
|
||||||
|
RedirectService,
|
||||||
|
ScreenService,
|
||||||
|
ScrollService,
|
||||||
|
ServerService,
|
||||||
|
ThemeService,
|
||||||
|
User
|
||||||
|
} from '@app/core'
|
||||||
import { HooksService } from '@app/core/plugins/hooks.service'
|
import { HooksService } from '@app/core/plugins/hooks.service'
|
||||||
import { PluginService } from '@app/core/plugins/plugin.service'
|
import { PluginService } from '@app/core/plugins/plugin.service'
|
||||||
import { CustomModalComponent } from '@app/modal/custom-modal.component'
|
import { CustomModalComponent } from '@app/modal/custom-modal.component'
|
||||||
|
@ -39,10 +49,10 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||||
constructor (
|
constructor (
|
||||||
@Inject(DOCUMENT) private document: Document,
|
@Inject(DOCUMENT) private document: Document,
|
||||||
@Inject(LOCALE_ID) private localeId: string,
|
@Inject(LOCALE_ID) private localeId: string,
|
||||||
private viewportScroller: ViewportScroller,
|
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private serverService: ServerService,
|
private serverService: ServerService,
|
||||||
|
private peertubeRouter: PeerTubeRouterService,
|
||||||
private pluginService: PluginService,
|
private pluginService: PluginService,
|
||||||
private instanceService: InstanceService,
|
private instanceService: InstanceService,
|
||||||
private domSanitizer: DomSanitizer,
|
private domSanitizer: DomSanitizer,
|
||||||
|
@ -56,6 +66,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||||
private markdownService: MarkdownService,
|
private markdownService: MarkdownService,
|
||||||
private ngbConfig: NgbConfig,
|
private ngbConfig: NgbConfig,
|
||||||
private loadingBar: LoadingBarService,
|
private loadingBar: LoadingBarService,
|
||||||
|
private scrollService: ScrollService,
|
||||||
public menu: MenuService
|
public menu: MenuService
|
||||||
) {
|
) {
|
||||||
this.ngbConfig.animation = false
|
this.ngbConfig.animation = false
|
||||||
|
@ -85,6 +96,7 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.initRouteEvents()
|
this.initRouteEvents()
|
||||||
|
this.scrollService.enableScrollRestoration()
|
||||||
|
|
||||||
this.injectJS()
|
this.injectJS()
|
||||||
this.injectCSS()
|
this.injectCSS()
|
||||||
|
@ -132,66 +144,10 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
private initRouteEvents () {
|
private initRouteEvents () {
|
||||||
let resetScroll = true
|
|
||||||
const eventsObs = this.router.events
|
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
|
// Plugin hooks
|
||||||
navigationEndEvent.subscribe(e => {
|
this.peertubeRouter.getNavigationEndEvents().subscribe(e => {
|
||||||
this.hooks.runAction('action:router.navigation-end', 'common', { path: e.url })
|
this.hooks.runAction('action:router.navigation-end', 'common', { path: e.url })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,17 @@ import { throwIfAlreadyLoaded } from './module-import-guard'
|
||||||
import { Notifier } from './notification'
|
import { Notifier } from './notification'
|
||||||
import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer'
|
import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer'
|
||||||
import { RestExtractor, RestService } from './rest'
|
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 { CanDeactivateGuard } from './routing/can-deactivate-guard.service'
|
||||||
import { ServerConfigResolver } from './routing/server-config-resolver.service'
|
import { ServerConfigResolver } from './routing/server-config-resolver.service'
|
||||||
import { ScopedTokensService } from './scoped-tokens'
|
import { ScopedTokensService } from './scoped-tokens'
|
||||||
|
@ -80,6 +90,8 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra
|
||||||
PeerTubeSocket,
|
PeerTubeSocket,
|
||||||
ServerConfigResolver,
|
ServerConfigResolver,
|
||||||
CanDeactivateGuard,
|
CanDeactivateGuard,
|
||||||
|
PeerTubeRouterService,
|
||||||
|
ScrollService,
|
||||||
|
|
||||||
MetaService,
|
MetaService,
|
||||||
MetaGuard
|
MetaGuard
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
|
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
|
||||||
|
import { RouterSetting } from './'
|
||||||
|
import { PeerTubeRouterService } from './peertube-router.service'
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomReuseStrategy implements RouteReuseStrategy {
|
export class CustomReuseStrategy implements RouteReuseStrategy {
|
||||||
|
@ -78,6 +80,8 @@ export class CustomReuseStrategy implements RouteReuseStrategy {
|
||||||
}
|
}
|
||||||
|
|
||||||
private isReuseEnabled (route: ActivatedRouteSnapshot) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,11 @@ export * from './homepage-redirect.component'
|
||||||
export * from './login-guard.service'
|
export * from './login-guard.service'
|
||||||
export * from './menu-guard.service'
|
export * from './menu-guard.service'
|
||||||
export * from './meta-guard.service'
|
export * from './meta-guard.service'
|
||||||
|
export * from './peertube-router.service'
|
||||||
export * from './meta.service'
|
export * from './meta.service'
|
||||||
export * from './preload-selected-modules-list'
|
export * from './preload-selected-modules-list'
|
||||||
export * from './redirect.service'
|
export * from './redirect.service'
|
||||||
|
export * from './scroll.service'
|
||||||
export * from './server-config-resolver.service'
|
export * from './server-config-resolver.service'
|
||||||
export * from './unlogged-guard.service'
|
export * from './unlogged-guard.service'
|
||||||
export * from './user-right-guard.service'
|
export * from './user-right-guard.service'
|
||||||
|
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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 ])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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'
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ const logger = debug('peertube:AdvancedInputFilterComponent')
|
||||||
})
|
})
|
||||||
export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
|
export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
|
||||||
@Input() filters: AdvancedInputFilter[] = []
|
@Input() filters: AdvancedInputFilter[] = []
|
||||||
|
@Input() emitOnInit = true
|
||||||
|
|
||||||
@Output() search = new EventEmitter<string>()
|
@Output() search = new EventEmitter<string>()
|
||||||
|
|
||||||
|
@ -42,7 +43,7 @@ export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
|
||||||
this.viewInitialized = true
|
this.viewInitialized = true
|
||||||
|
|
||||||
// Init after view init to not send an event too early
|
// 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) {
|
onInputSearch (event: Event) {
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
export * from './select-categories.component'
|
||||||
export * from './select-channel.component'
|
export * from './select-channel.component'
|
||||||
|
export * from './select-checkbox-all.component'
|
||||||
export * from './select-checkbox.component'
|
export * from './select-checkbox.component'
|
||||||
export * from './select-custom-value.component'
|
export * from './select-custom-value.component'
|
||||||
|
export * from './select-languages.component'
|
||||||
export * from './select-options.component'
|
export * from './select-options.component'
|
||||||
export * from './select-tags.component'
|
export * from './select-tags.component'
|
||||||
|
|
|
@ -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>
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 + ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,8 +18,6 @@
|
||||||
|
|
||||||
groupBy="group"
|
groupBy="group"
|
||||||
[compareWith]="compareFn"
|
[compareWith]="compareFn"
|
||||||
|
|
||||||
[maxSelectedItems]="maxSelectedItems"
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<ng-template ng-optgroup-tmp let-item="item" let-item$="item$" let-index="index">
|
<ng-template ng-optgroup-tmp let-item="item" let-item$="item$" let-index="index">
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
|
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({
|
@Component({
|
||||||
selector: 'my-select-checkbox',
|
selector: 'my-select-checkbox',
|
||||||
|
@ -21,7 +21,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor {
|
||||||
@Input() selectedItems: ItemSelectCheckboxValue[] = []
|
@Input() selectedItems: ItemSelectCheckboxValue[] = []
|
||||||
@Input() selectableGroup: boolean
|
@Input() selectableGroup: boolean
|
||||||
@Input() selectableGroupAsModel: boolean
|
@Input() selectableGroupAsModel: boolean
|
||||||
@Input() maxSelectedItems: number
|
|
||||||
@Input() placeholder: string
|
@Input() placeholder: string
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
|
@ -46,8 +45,6 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor {
|
||||||
} else {
|
} else {
|
||||||
this.selectedItems = items
|
this.selectedItems = items
|
||||||
}
|
}
|
||||||
|
|
||||||
this.propagateChange(this.selectedItems)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registerOnChange (fn: (_: any) => void) {
|
registerOnChange (fn: (_: any) => void) {
|
||||||
|
@ -63,7 +60,7 @@ export class SelectCheckboxComponent implements OnInit, ControlValueAccessor {
|
||||||
}
|
}
|
||||||
|
|
||||||
compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) {
|
compareFn (item: SelectOptionsItem, selected: ItemSelectCheckboxValue) {
|
||||||
if (typeof selected === 'string') {
|
if (typeof selected === 'string' || typeof selected === 'number') {
|
||||||
return item.id === selected
|
return item.id === selected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,9 +15,12 @@ import { PeertubeCheckboxComponent } from './peertube-checkbox.component'
|
||||||
import { PreviewUploadComponent } from './preview-upload.component'
|
import { PreviewUploadComponent } from './preview-upload.component'
|
||||||
import { ReactiveFileComponent } from './reactive-file.component'
|
import { ReactiveFileComponent } from './reactive-file.component'
|
||||||
import {
|
import {
|
||||||
|
SelectCategoriesComponent,
|
||||||
SelectChannelComponent,
|
SelectChannelComponent,
|
||||||
|
SelectCheckboxAllComponent,
|
||||||
SelectCheckboxComponent,
|
SelectCheckboxComponent,
|
||||||
SelectCustomValueComponent,
|
SelectCustomValueComponent,
|
||||||
|
SelectLanguagesComponent,
|
||||||
SelectOptionsComponent,
|
SelectOptionsComponent,
|
||||||
SelectTagsComponent
|
SelectTagsComponent
|
||||||
} from './select'
|
} from './select'
|
||||||
|
@ -52,6 +55,9 @@ import { TimestampInputComponent } from './timestamp-input.component'
|
||||||
SelectTagsComponent,
|
SelectTagsComponent,
|
||||||
SelectCheckboxComponent,
|
SelectCheckboxComponent,
|
||||||
SelectCustomValueComponent,
|
SelectCustomValueComponent,
|
||||||
|
SelectLanguagesComponent,
|
||||||
|
SelectCategoriesComponent,
|
||||||
|
SelectCheckboxAllComponent,
|
||||||
|
|
||||||
DynamicFormFieldComponent,
|
DynamicFormFieldComponent,
|
||||||
|
|
||||||
|
@ -80,6 +86,9 @@ import { TimestampInputComponent } from './timestamp-input.component'
|
||||||
SelectTagsComponent,
|
SelectTagsComponent,
|
||||||
SelectCheckboxComponent,
|
SelectCheckboxComponent,
|
||||||
SelectCustomValueComponent,
|
SelectCustomValueComponent,
|
||||||
|
SelectLanguagesComponent,
|
||||||
|
SelectCategoriesComponent,
|
||||||
|
SelectCheckboxAllComponent,
|
||||||
|
|
||||||
DynamicFormFieldComponent,
|
DynamicFormFieldComponent,
|
||||||
|
|
||||||
|
|
|
@ -71,6 +71,7 @@ const icons = {
|
||||||
columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
|
columns: require('!!raw-loader?!../../../assets/images/feather/columns.svg').default,
|
||||||
live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
|
live: require('!!raw-loader?!../../../assets/images/feather/live.svg').default,
|
||||||
repeat: require('!!raw-loader?!../../../assets/images/feather/repeat.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,
|
'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default,
|
||||||
codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
|
codesandbox: require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default,
|
||||||
award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default
|
award: require('!!raw-loader?!../../../assets/images/feather/award.svg').default
|
||||||
|
|
|
@ -1,16 +1,19 @@
|
||||||
import { fromEvent, Observable, Subscription } from 'rxjs'
|
import { fromEvent, Observable, Subscription } from 'rxjs'
|
||||||
import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
|
import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
|
||||||
import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
|
import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
|
||||||
|
import { PeerTubeRouterService, RouterSetting } from '@app/core'
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: '[myInfiniteScroller]'
|
selector: '[myInfiniteScroller]'
|
||||||
})
|
})
|
||||||
export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked {
|
export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked {
|
||||||
@Input() percentLimit = 70
|
@Input() percentLimit = 70
|
||||||
@Input() autoInit = false
|
|
||||||
@Input() onItself = false
|
@Input() onItself = false
|
||||||
@Input() dataObservable: Observable<any[]>
|
@Input() dataObservable: Observable<any[]>
|
||||||
|
|
||||||
|
// Add angular state in query params to reuse the routed component
|
||||||
|
@Input() setAngularState: boolean
|
||||||
|
|
||||||
@Output() nearOfBottom = new EventEmitter<void>()
|
@Output() nearOfBottom = new EventEmitter<void>()
|
||||||
|
|
||||||
private decimalLimit = 0
|
private decimalLimit = 0
|
||||||
|
@ -20,7 +23,10 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
|
||||||
|
|
||||||
private checkScroll = false
|
private checkScroll = false
|
||||||
|
|
||||||
constructor (private el: ElementRef) {
|
constructor (
|
||||||
|
private peertubeRouter: PeerTubeRouterService,
|
||||||
|
private el: ElementRef
|
||||||
|
) {
|
||||||
this.decimalLimit = this.percentLimit / 100
|
this.decimalLimit = this.percentLimit / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,7 +42,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
if (this.autoInit === true) return this.initialize()
|
this.initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy () {
|
ngOnDestroy () {
|
||||||
|
@ -67,7 +73,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
|
||||||
filter(({ current }) => this.isScrollingDown(current)),
|
filter(({ current }) => this.isScrollingDown(current)),
|
||||||
filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
|
filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
|
||||||
)
|
)
|
||||||
.subscribe(() => this.nearOfBottom.emit())
|
.subscribe(() => {
|
||||||
|
if (this.setAngularState) this.setScrollRouteParams()
|
||||||
|
|
||||||
|
this.nearOfBottom.emit()
|
||||||
|
})
|
||||||
|
|
||||||
if (this.dataObservable) {
|
if (this.dataObservable) {
|
||||||
this.dataObservable
|
this.dataObservable
|
||||||
|
@ -96,4 +106,8 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh
|
||||||
this.lastCurrentBottom = current
|
this.lastCurrentBottom = current
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setScrollRouteParams () {
|
||||||
|
this.peertubeRouter.addRouteSetting(RouterSetting.REUSE_COMPONENT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,12 +7,11 @@
|
||||||
a {
|
a {
|
||||||
color: #000;
|
color: #000;
|
||||||
display: block;
|
display: block;
|
||||||
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
my-global-icon {
|
my-global-icon {
|
||||||
@include apply-svg-color(pvar(--mainForegroundColor));
|
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<input
|
<div class="input-group has-feedback has-clear">
|
||||||
#ref
|
<input
|
||||||
type="text"
|
#ref
|
||||||
[(ngModel)]="value"
|
type="text"
|
||||||
(keyup.enter)="searchChange()"
|
[(ngModel)]="value"
|
||||||
[hidden]="!inputShown"
|
(keyup.enter)="sendSearch()"
|
||||||
[name]="name"
|
[hidden]="!inputShown"
|
||||||
[placeholder]="placeholder"
|
[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 iconName="search" aria-label="Search" role="button" (click)="onIconClick()" [title]="iconTitle"></my-global-icon>
|
||||||
|
|
||||||
|
|
|
@ -11,20 +11,17 @@ my-global-icon {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
color: pvar(--mainColor);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: pvar(--mainHoverColor);
|
color: pvar(--mainHoverColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
&[iconName=search] {
|
|
||||||
color: pvar(--mainForegroundColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[iconName=cross] {
|
|
||||||
color: pvar(--mainForegroundColor);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
@include peertube-input-text(200px);
|
@include peertube-input-text(200px);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 0 5px 0 #a5a5a5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-simple-search-input',
|
selector: 'my-simple-search-input',
|
||||||
|
@ -22,23 +19,9 @@ export class SimpleSearchInputComponent implements OnInit {
|
||||||
value = ''
|
value = ''
|
||||||
inputShown: boolean
|
inputShown: boolean
|
||||||
|
|
||||||
private searchSubject = new Subject<string>()
|
private hasAlreadySentSearch = false
|
||||||
|
|
||||||
constructor (
|
|
||||||
private router: Router,
|
|
||||||
private route: ActivatedRoute
|
|
||||||
) {}
|
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
this.searchSubject
|
|
||||||
.pipe(
|
|
||||||
debounceTime(400),
|
|
||||||
distinctUntilChanged()
|
|
||||||
)
|
|
||||||
.subscribe(value => this.searchChanged.emit(value))
|
|
||||||
|
|
||||||
this.searchSubject.next(this.value)
|
|
||||||
|
|
||||||
if (this.isInputShown()) this.showInput(false)
|
if (this.isInputShown()) this.showInput(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,7 +37,7 @@ export class SimpleSearchInputComponent implements OnInit {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.searchChange()
|
this.sendSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
showInput (focus = true) {
|
showInput (focus = true) {
|
||||||
|
@ -80,9 +63,14 @@ export class SimpleSearchInputComponent implements OnInit {
|
||||||
this.hideInput()
|
this.hideInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
searchChange () {
|
sendSearch () {
|
||||||
this.router.navigate([ './search' ], { relativeTo: this.route })
|
this.hasAlreadySentSearch = true
|
||||||
|
this.searchChanged.emit(this.value)
|
||||||
|
}
|
||||||
|
|
||||||
this.searchSubject.next(this.value)
|
onResetFilter () {
|
||||||
|
this.value = ''
|
||||||
|
|
||||||
|
if (this.hasAlreadySentSearch) this.sendSearch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
|
<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)">
|
<div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
|
||||||
|
|
||||||
<ng-container [ngSwitch]="notification.type">
|
<ng-container [ngSwitch]="notification.type">
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { Injectable } from '@angular/core'
|
||||||
import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
|
import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
|
||||||
import { objectToFormData } from '@app/helpers'
|
import { objectToFormData } from '@app/helpers'
|
||||||
import {
|
import {
|
||||||
|
BooleanBothQuery,
|
||||||
FeedFormat,
|
FeedFormat,
|
||||||
NSFWPolicyType,
|
NSFWPolicyType,
|
||||||
ResultList,
|
ResultList,
|
||||||
|
@ -28,19 +29,21 @@ import { VideoDetails } from './video-details.model'
|
||||||
import { VideoEdit } from './video-edit.model'
|
import { VideoEdit } from './video-edit.model'
|
||||||
import { Video } from './video.model'
|
import { Video } from './video.model'
|
||||||
|
|
||||||
export interface VideosProvider {
|
export type CommonVideoParams = {
|
||||||
getVideos (parameters: {
|
videoPagination: ComponentPaginationLight
|
||||||
videoPagination: ComponentPaginationLight
|
sort: VideoSortField
|
||||||
sort: VideoSortField
|
filter?: VideoFilter
|
||||||
filter?: VideoFilter
|
categoryOneOf?: number[]
|
||||||
categoryOneOf?: number[]
|
languageOneOf?: string[]
|
||||||
languageOneOf?: string[]
|
isLive?: boolean
|
||||||
nsfwPolicy: NSFWPolicyType
|
skipCount?: boolean
|
||||||
}): Observable<ResultList<Video>>
|
// FIXME: remove?
|
||||||
|
nsfwPolicy?: NSFWPolicyType
|
||||||
|
nsfw?: BooleanBothQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideoService implements VideosProvider {
|
export class VideoService {
|
||||||
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
|
static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
|
||||||
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
|
static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
|
||||||
static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.'
|
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'>
|
account: Pick<Account, 'nameWithHost'>
|
||||||
videoPagination: ComponentPaginationLight
|
|
||||||
sort: VideoSortField
|
|
||||||
nsfwPolicy?: NSFWPolicyType
|
|
||||||
videoFilter?: VideoFilter
|
|
||||||
search?: string
|
search?: string
|
||||||
}): Observable<ResultList<Video>> {
|
}): Observable<ResultList<Video>> {
|
||||||
const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters
|
const { account, search } = parameters
|
||||||
|
|
||||||
const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
|
|
||||||
|
|
||||||
let params = new HttpParams()
|
let params = new HttpParams()
|
||||||
params = this.restService.addRestGetParams(params, pagination, sort)
|
params = this.buildCommonVideosParams({ params, ...parameters })
|
||||||
|
|
||||||
if (nsfwPolicy) {
|
if (search) params = params.set('search', search)
|
||||||
params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoFilter) {
|
|
||||||
params = params.set('filter', videoFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
params = params.set('search', search)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
.get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
|
.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'>
|
videoChannel: Pick<VideoChannel, 'nameWithHost'>
|
||||||
videoPagination: ComponentPaginationLight
|
|
||||||
sort: VideoSortField
|
|
||||||
nsfwPolicy?: NSFWPolicyType
|
|
||||||
videoFilter?: VideoFilter
|
|
||||||
}): Observable<ResultList<Video>> {
|
}): Observable<ResultList<Video>> {
|
||||||
const { videoChannel, videoPagination, sort, nsfwPolicy, videoFilter } = parameters
|
const { videoChannel } = parameters
|
||||||
|
|
||||||
const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
|
|
||||||
|
|
||||||
let params = new HttpParams()
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
.get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
|
.get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
|
||||||
|
@ -209,30 +182,9 @@ export class VideoService implements VideosProvider {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideos (parameters: {
|
getVideos (parameters: CommonVideoParams): Observable<ResultList<Video>> {
|
||||||
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)
|
|
||||||
|
|
||||||
let params = new HttpParams()
|
let params = new HttpParams()
|
||||||
params = this.restService.addRestGetParams(params, pagination, sort)
|
params = this.buildCommonVideosParams({ params, ...parameters })
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
return this.authHttp
|
return this.authHttp
|
||||||
.get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
|
.get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
|
||||||
|
@ -421,4 +373,22 @@ export class VideoService implements VideosProvider {
|
||||||
catchError(err => this.restExtractor.handleError(err))
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { intoArray } from '@app/helpers'
|
||||||
import {
|
import {
|
||||||
BooleanBothQuery,
|
BooleanBothQuery,
|
||||||
BooleanQuery,
|
BooleanQuery,
|
||||||
|
@ -74,8 +75,8 @@ export class AdvancedSearch {
|
||||||
this.categoryOneOf = options.categoryOneOf || undefined
|
this.categoryOneOf = options.categoryOneOf || undefined
|
||||||
this.licenceOneOf = options.licenceOneOf || undefined
|
this.licenceOneOf = options.licenceOneOf || undefined
|
||||||
this.languageOneOf = options.languageOneOf || undefined
|
this.languageOneOf = options.languageOneOf || undefined
|
||||||
this.tagsOneOf = this.intoArray(options.tagsOneOf)
|
this.tagsOneOf = intoArray(options.tagsOneOf)
|
||||||
this.tagsAllOf = this.intoArray(options.tagsAllOf)
|
this.tagsAllOf = intoArray(options.tagsAllOf)
|
||||||
this.durationMin = parseInt(options.durationMin, 10)
|
this.durationMin = parseInt(options.durationMin, 10)
|
||||||
this.durationMax = parseInt(options.durationMax, 10)
|
this.durationMax = parseInt(options.durationMax, 10)
|
||||||
|
|
||||||
|
@ -150,9 +151,9 @@ export class AdvancedSearch {
|
||||||
originallyPublishedStartDate: this.originallyPublishedStartDate,
|
originallyPublishedStartDate: this.originallyPublishedStartDate,
|
||||||
originallyPublishedEndDate: this.originallyPublishedEndDate,
|
originallyPublishedEndDate: this.originallyPublishedEndDate,
|
||||||
nsfw: this.nsfw,
|
nsfw: this.nsfw,
|
||||||
categoryOneOf: this.intoArray(this.categoryOneOf),
|
categoryOneOf: intoArray(this.categoryOneOf),
|
||||||
licenceOneOf: this.intoArray(this.licenceOneOf),
|
licenceOneOf: intoArray(this.licenceOneOf),
|
||||||
languageOneOf: this.intoArray(this.languageOneOf),
|
languageOneOf: intoArray(this.languageOneOf),
|
||||||
tagsOneOf: this.tagsOneOf,
|
tagsOneOf: this.tagsOneOf,
|
||||||
tagsAllOf: this.tagsAllOf,
|
tagsAllOf: this.tagsAllOf,
|
||||||
durationMin: this.durationMin,
|
durationMin: this.durationMin,
|
||||||
|
@ -198,13 +199,4 @@ export class AdvancedSearch {
|
||||||
|
|
||||||
return true
|
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 ]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,12 +30,7 @@
|
||||||
</my-help>
|
</my-help>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<my-select-checkbox
|
<my-select-languages formControlName="videoLanguages"></my-select-languages>
|
||||||
formControlName="videoLanguages" [availableItems]="languageItems"
|
|
||||||
[selectableGroup]="true" [selectableGroupAsModel]="true"
|
|
||||||
i18n-placeholder placeholder="Add a new language"
|
|
||||||
>
|
|
||||||
</my-select-checkbox >
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ input[type=submit] {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
my-select-checkbox {
|
my-select-languages {
|
||||||
@include responsive-width(340px);
|
@include responsive-width(340px);
|
||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { pick } from 'lodash-es'
|
import { pick } from 'lodash-es'
|
||||||
import { forkJoin, Subject, Subscription } from 'rxjs'
|
import { Subject, Subscription } from 'rxjs'
|
||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
|
||||||
import { AuthService, Notifier, ServerService, User, UserService } from '@app/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 { UserUpdateMe } from '@shared/models'
|
||||||
import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
|
import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
|
||||||
import { SelectOptionsItem } from '../../../types/select-options-item.model'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-user-video-settings',
|
selector: 'my-user-video-settings',
|
||||||
|
@ -19,12 +18,9 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
||||||
@Input() notifyOnUpdate = true
|
@Input() notifyOnUpdate = true
|
||||||
@Input() userInformationLoaded: Subject<any>
|
@Input() userInformationLoaded: Subject<any>
|
||||||
|
|
||||||
languageItems: SelectOptionsItem[] = []
|
|
||||||
defaultNSFWPolicy: NSFWPolicyType
|
defaultNSFWPolicy: NSFWPolicyType
|
||||||
formValuesWatcher: Subscription
|
formValuesWatcher: Subscription
|
||||||
|
|
||||||
private allLanguagesGroup: string
|
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
protected formValidatorService: FormValidatorService,
|
protected formValidatorService: FormValidatorService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
|
@ -36,8 +32,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
this.allLanguagesGroup = $localize`All languages`
|
|
||||||
|
|
||||||
this.buildForm({
|
this.buildForm({
|
||||||
nsfwPolicy: null,
|
nsfwPolicy: null,
|
||||||
webTorrentEnabled: null,
|
webTorrentEnabled: null,
|
||||||
|
@ -46,33 +40,23 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
||||||
videoLanguages: null
|
videoLanguages: null
|
||||||
})
|
})
|
||||||
|
|
||||||
forkJoin([
|
this.userInformationLoaded.pipe(first())
|
||||||
this.serverService.getVideoLanguages(),
|
.subscribe(
|
||||||
this.userInformationLoaded.pipe(first())
|
() => {
|
||||||
]).subscribe(([ languages ]) => {
|
const serverConfig = this.serverService.getHTMLConfig()
|
||||||
const group = this.allLanguagesGroup
|
this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
|
||||||
|
|
||||||
this.languageItems = [ { label: $localize`Unknown language`, id: '_unknown', group } ]
|
this.form.patchValue({
|
||||||
this.languageItems = this.languageItems
|
nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy,
|
||||||
.concat(languages.map(l => ({ label: l.label, id: l.id, group })))
|
webTorrentEnabled: this.user.webTorrentEnabled,
|
||||||
|
autoPlayVideo: this.user.autoPlayVideo === true,
|
||||||
|
autoPlayNextVideo: this.user.autoPlayNextVideo,
|
||||||
|
videoLanguages: this.user.videoLanguages
|
||||||
|
})
|
||||||
|
|
||||||
const videoLanguages: ItemSelectCheckboxValue[] = this.user.videoLanguages
|
if (this.reactiveUpdate) this.handleReactiveUpdate()
|
||||||
? this.user.videoLanguages.map(l => ({ id: l }))
|
}
|
||||||
: [ { group } ]
|
)
|
||||||
|
|
||||||
const serverConfig = this.serverService.getHTMLConfig()
|
|
||||||
this.defaultNSFWPolicy = serverConfig.instance.defaultNSFWPolicy
|
|
||||||
|
|
||||||
this.form.patchValue({
|
|
||||||
nsfwPolicy: this.user.nsfwPolicy || this.defaultNSFWPolicy,
|
|
||||||
webTorrentEnabled: this.user.webTorrentEnabled,
|
|
||||||
autoPlayVideo: this.user.autoPlayVideo === true,
|
|
||||||
autoPlayNextVideo: this.user.autoPlayNextVideo,
|
|
||||||
videoLanguages
|
|
||||||
})
|
|
||||||
|
|
||||||
if (this.reactiveUpdate) this.handleReactiveUpdate()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy () {
|
ngOnDestroy () {
|
||||||
|
@ -85,23 +69,15 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
||||||
const autoPlayVideo = this.form.value['autoPlayVideo']
|
const autoPlayVideo = this.form.value['autoPlayVideo']
|
||||||
const autoPlayNextVideo = this.form.value['autoPlayNextVideo']
|
const autoPlayNextVideo = this.form.value['autoPlayNextVideo']
|
||||||
|
|
||||||
let videoLanguagesForm = this.form.value['videoLanguages']
|
const videoLanguages = this.form.value['videoLanguages']
|
||||||
|
|
||||||
if (Array.isArray(videoLanguagesForm)) {
|
if (Array.isArray(videoLanguages)) {
|
||||||
if (videoLanguagesForm.length > 20) {
|
if (videoLanguages.length > 20) {
|
||||||
this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`)
|
this.notifier.error($localize`Too many languages are enabled. Please enable them all or stay below 20 enabled languages.`)
|
||||||
return
|
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 = {
|
let details: UserUpdateMe = {
|
||||||
nsfwPolicy,
|
nsfwPolicy,
|
||||||
webTorrentEnabled,
|
webTorrentEnabled,
|
||||||
|
@ -123,31 +99,6 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
|
||||||
return this.updateAnonymousProfile(details)
|
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 () {
|
private handleReactiveUpdate () {
|
||||||
let oldForm = { ...this.form.value }
|
let oldForm = { ...this.form.value }
|
||||||
|
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +1,8 @@
|
||||||
export * from './abstract-video-list'
|
|
||||||
export * from './video-actions-dropdown.component'
|
export * from './video-actions-dropdown.component'
|
||||||
export * from './video-download.component'
|
export * from './video-download.component'
|
||||||
|
export * from './video-filters-header.component'
|
||||||
|
export * from './video-filters.model'
|
||||||
export * from './video-miniature.component'
|
export * from './video-miniature.component'
|
||||||
|
export * from './videos-list.component'
|
||||||
export * from './videos-selection.component'
|
export * from './videos-selection.component'
|
||||||
export * from './video-list-header.component'
|
|
||||||
export * from './shared-video-miniature.module'
|
export * from './shared-video-miniature.module'
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
|
|
||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from '@angular/core'
|
||||||
|
import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
|
||||||
import { SharedFormModule } from '../shared-forms'
|
import { SharedFormModule } from '../shared-forms'
|
||||||
import { SharedGlobalIconModule } from '../shared-icons'
|
import { SharedGlobalIconModule } from '../shared-icons'
|
||||||
import { SharedMainModule } from '../shared-main/shared-main.module'
|
import { SharedMainModule } from '../shared-main/shared-main.module'
|
||||||
import { SharedModerationModule } from '../shared-moderation'
|
import { SharedModerationModule } from '../shared-moderation'
|
||||||
import { SharedVideoModule } from '../shared-video'
|
|
||||||
import { SharedThumbnailModule } from '../shared-thumbnail'
|
import { SharedThumbnailModule } from '../shared-thumbnail'
|
||||||
|
import { SharedVideoModule } from '../shared-video'
|
||||||
import { SharedVideoLiveModule } from '../shared-video-live'
|
import { SharedVideoLiveModule } from '../shared-video-live'
|
||||||
import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module'
|
import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module'
|
||||||
import { VideoActionsDropdownComponent } from './video-actions-dropdown.component'
|
import { VideoActionsDropdownComponent } from './video-actions-dropdown.component'
|
||||||
import { VideoDownloadComponent } from './video-download.component'
|
import { VideoDownloadComponent } from './video-download.component'
|
||||||
|
import { VideoFiltersHeaderComponent } from './video-filters-header.component'
|
||||||
import { VideoMiniatureComponent } from './video-miniature.component'
|
import { VideoMiniatureComponent } from './video-miniature.component'
|
||||||
|
import { VideosListComponent } from './videos-list.component'
|
||||||
import { VideosSelectionComponent } from './videos-selection.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({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -33,14 +34,17 @@ import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image
|
||||||
VideoDownloadComponent,
|
VideoDownloadComponent,
|
||||||
VideoMiniatureComponent,
|
VideoMiniatureComponent,
|
||||||
VideosSelectionComponent,
|
VideosSelectionComponent,
|
||||||
VideoListHeaderComponent
|
VideoFiltersHeaderComponent,
|
||||||
|
VideosListComponent
|
||||||
],
|
],
|
||||||
|
|
||||||
exports: [
|
exports: [
|
||||||
VideoActionsDropdownComponent,
|
VideoActionsDropdownComponent,
|
||||||
VideoDownloadComponent,
|
VideoDownloadComponent,
|
||||||
VideoMiniatureComponent,
|
VideoMiniatureComponent,
|
||||||
VideosSelectionComponent
|
VideosSelectionComponent,
|
||||||
|
VideoFiltersHeaderComponent,
|
||||||
|
VideosListComponent
|
||||||
],
|
],
|
||||||
|
|
||||||
providers: [ ]
|
providers: [ ]
|
||||||
|
|
|
@ -39,7 +39,6 @@
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|
||||||
.peertube-radio-container {
|
.peertube-radio-container {
|
||||||
@include peertube-radio-container;
|
|
||||||
@include margin-right(30px);
|
@include margin-right(30px);
|
||||||
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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')
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +0,0 @@
|
||||||
<h1 class="title-page title-page-single">
|
|
||||||
<div placement="bottom" [ngbTooltip]="data.titleTooltip" container="body">
|
|
||||||
{{ data.titlePage }}
|
|
||||||
</div>
|
|
||||||
</h1>
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +1,17 @@
|
||||||
<div class="margin-content">
|
<div class="margin-content">
|
||||||
<div class="videos-header">
|
<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">
|
<div class="action-block">
|
||||||
<my-feed *ngIf="syndicationItems" [syndicationItems]="syndicationItems"></my-feed>
|
<ng-container *ngFor="let action of headerActions">
|
||||||
|
|
||||||
<ng-container *ngFor="let action of actions">
|
|
||||||
<a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active">
|
<a *ngIf="action.routerLink" class="ml-2" [routerLink]="action.routerLink" routerLinkActive="active">
|
||||||
<ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
|
<ng-container *ngTemplateOutlet="actionContent; context:{ $implicit: action }"></ng-container>
|
||||||
</a>
|
</a>
|
||||||
|
@ -24,27 +30,18 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-container>
|
</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>
|
</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 class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div>
|
||||||
<div
|
<div
|
||||||
myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
|
myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" [setAngularState]="true"
|
||||||
class="videos" [ngClass]="{ 'display-as-row': displayAsRow() }"
|
class="videos" [ngClass]="{ 'display-as-row': displayAsRow }"
|
||||||
>
|
>
|
||||||
<ng-container *ngFor="let video of videos; trackBy: videoById;">
|
<ng-container *ngFor="let video of videos; trackBy: videoById;">
|
||||||
<h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
|
<h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
|
||||||
|
@ -53,7 +50,7 @@
|
||||||
|
|
||||||
<div class="video-wrapper">
|
<div class="video-wrapper">
|
||||||
<my-video-miniature
|
<my-video-miniature
|
||||||
[video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow()"
|
[video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow"
|
||||||
[displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
|
[displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
|
||||||
(videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
|
(videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
|
||||||
>
|
>
|
|
@ -3,44 +3,57 @@
|
||||||
@use '_mixins' as *;
|
@use '_mixins' as *;
|
||||||
@use '_miniature' as *;
|
@use '_miniature' as *;
|
||||||
|
|
||||||
$icon-size: 16px;
|
|
||||||
|
|
||||||
::ng-deep my-video-list-header {
|
|
||||||
display: flex;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.videos-header {
|
.videos-header {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
grid-template-columns: auto 1fr auto;
|
||||||
align-items: center;
|
margin-bottom: 30px;
|
||||||
|
|
||||||
my-feed {
|
.title,
|
||||||
display: inline-block;
|
.title-subscription {
|
||||||
width: calc(#{$icon-size} - 2px);
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.moderation-block {
|
.title {
|
||||||
@include margin-left(.4rem);
|
font-size: 18px;
|
||||||
|
color: pvar(--mainForegroundColor);
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: $font-semibold;
|
||||||
|
|
||||||
display: flex;
|
margin-top: 30px;
|
||||||
justify-content: flex-end;
|
margin-bottom: 0;
|
||||||
align-items: center;
|
}
|
||||||
|
|
||||||
my-global-icon {
|
.title-subscription {
|
||||||
position: relative;
|
grid-row: 2;
|
||||||
width: $icon-size;
|
font-size: 14px;
|
||||||
|
color: pvar(--greyForegroundColor);
|
||||||
|
|
||||||
|
&.no-title {
|
||||||
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-block {
|
||||||
|
grid-column: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
my-feed {
|
||||||
|
@include margin-left(5px);
|
||||||
|
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
color: pvar(--mainColor);
|
||||||
|
position: relative;
|
||||||
|
top: -2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-title {
|
.date-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: $font-semibold;
|
font-weight: $font-semibold;
|
||||||
margin-bottom: 20px;
|
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;
|
grid-column: 1 / -1;
|
||||||
|
|
||||||
&:not(:first-child) {
|
&:not(:first-child) {
|
||||||
|
@ -64,6 +77,18 @@ $icon-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: $mobile-view) {
|
@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 {
|
.videos-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,9 @@
|
||||||
<div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">{{ noResultMessage }}</div>
|
<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="video" *ngFor="let video of videos; let i = index; trackBy: videoById">
|
||||||
|
|
||||||
<div class="checkbox-container" *ngIf="enableSelection">
|
<div class="checkbox-container" *ngIf="enableSelection">
|
||||||
|
|
|
@ -1,22 +1,8 @@
|
||||||
import { Observable } from 'rxjs'
|
import { Observable, Subject } from 'rxjs'
|
||||||
import {
|
import { AfterContentInit, Component, ContentChildren, EventEmitter, Input, Output, QueryList, TemplateRef } from '@angular/core'
|
||||||
AfterContentInit,
|
import { ComponentPagination, Notifier, User } from '@app/core'
|
||||||
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 { ResultList, VideoSortField } from '@shared/models'
|
import { ResultList, VideoSortField } from '@shared/models'
|
||||||
import { PeerTubeTemplateDirective, Video } from '../shared-main'
|
import { PeerTubeTemplateDirective, Video } from '../shared-main'
|
||||||
import { AbstractVideoList } from './abstract-video-list'
|
|
||||||
import { MiniatureDisplayOptions } from './video-miniature.component'
|
import { MiniatureDisplayOptions } from './video-miniature.component'
|
||||||
|
|
||||||
export type SelectionType = { [ id: number ]: boolean }
|
export type SelectionType = { [ id: number ]: boolean }
|
||||||
|
@ -26,14 +12,18 @@ export type SelectionType = { [ id: number ]: boolean }
|
||||||
templateUrl: './videos-selection.component.html',
|
templateUrl: './videos-selection.component.html',
|
||||||
styleUrls: [ './videos-selection.component.scss' ]
|
styleUrls: [ './videos-selection.component.scss' ]
|
||||||
})
|
})
|
||||||
export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
|
export class VideosSelectionComponent implements AfterContentInit {
|
||||||
@Input() user: User
|
@Input() user: User
|
||||||
@Input() pagination: ComponentPagination
|
@Input() pagination: ComponentPagination
|
||||||
|
|
||||||
@Input() titlePage: string
|
@Input() titlePage: string
|
||||||
|
|
||||||
@Input() miniatureDisplayOptions: MiniatureDisplayOptions
|
@Input() miniatureDisplayOptions: MiniatureDisplayOptions
|
||||||
|
|
||||||
@Input() noResultMessage = $localize`No results.`
|
@Input() noResultMessage = $localize`No results.`
|
||||||
@Input() enableSelection = true
|
@Input() enableSelection = true
|
||||||
@Input() loadOnInit = true
|
|
||||||
|
@Input() disabled = false
|
||||||
|
|
||||||
@Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
|
@Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
|
||||||
|
|
||||||
|
@ -47,19 +37,18 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
|
||||||
rowButtonsTemplate: TemplateRef<any>
|
rowButtonsTemplate: TemplateRef<any>
|
||||||
globalButtonsTemplate: TemplateRef<any>
|
globalButtonsTemplate: TemplateRef<any>
|
||||||
|
|
||||||
|
videos: Video[] = []
|
||||||
|
sort: VideoSortField = '-publishedAt'
|
||||||
|
|
||||||
|
onDataSubject = new Subject<any[]>()
|
||||||
|
|
||||||
|
hasDoneFirstQuery = false
|
||||||
|
|
||||||
|
private lastQueryLength: number
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
protected router: Router,
|
private notifier: Notifier
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Input() get selection () {
|
@Input() get selection () {
|
||||||
return this._selection
|
return this._selection
|
||||||
|
@ -79,10 +68,6 @@ export class VideosSelectionComponent extends AbstractVideoList implements OnIni
|
||||||
this.videosModelChange.emit(this.videos)
|
this.videosModelChange.emit(this.videos)
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
|
||||||
super.ngOnInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterContentInit () {
|
ngAfterContentInit () {
|
||||||
{
|
{
|
||||||
const t = this.templates.find(t => t.name === 'rowButtons')
|
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')
|
const t = this.templates.find(t => t.name === 'globalButtons')
|
||||||
if (t) this.globalButtonsTemplate = t.template
|
if (t) this.globalButtonsTemplate = t.template
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy () {
|
this.loadMoreVideos()
|
||||||
super.ngOnDestroy()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getVideosObservable (page: number) {
|
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)
|
return Object.keys(this._selection).some(k => this._selection[k] === true)
|
||||||
}
|
}
|
||||||
|
|
||||||
generateSyndicationList () {
|
videoById (index: number, video: Video) {
|
||||||
throw new Error('Method not implemented.')
|
return video.id
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onMoreVideos () {
|
onNearOfBottom () {
|
||||||
this.videosModel = this.videos
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 |
|
@ -287,6 +287,7 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
|
||||||
|
|
||||||
&.show {
|
&.show {
|
||||||
max-height: 1500px;
|
max-height: 1500px;
|
||||||
|
overflow: inherit !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,3 +24,7 @@
|
||||||
.tertiary-button {
|
.tertiary-button {
|
||||||
@include tertiary-button;
|
@include tertiary-button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.peertube-radio-container {
|
||||||
|
@include peertube-radio-container;
|
||||||
|
}
|
||||||
|
|
|
@ -420,42 +420,55 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thanks: https://codepen.io/triss90/pen/XNEdRe/
|
// Thanks: https://codepen.io/manabox/pen/raQmpL
|
||||||
@mixin peertube-radio-container {
|
@mixin peertube-radio-container {
|
||||||
input[type=radio] {
|
[type=radio]:checked,
|
||||||
display: none;
|
[type=radio]:not(:checked) {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
}
|
||||||
|
|
||||||
+ label {
|
[type=radio]:checked + label,
|
||||||
font-weight: $font-regular;
|
[type=radio]:not(:checked) + label {
|
||||||
cursor: pointer;
|
position: relative;
|
||||||
|
padding-left: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 20px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
&::before {
|
[type=radio]:checked + label::before,
|
||||||
@include margin-right(10px);
|
[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;
|
||||||
|
}
|
||||||
|
|
||||||
position: relative;
|
[type=radio]:checked + label::after,
|
||||||
top: -2px;
|
[type=radio]:not(:checked) + label::after {
|
||||||
content: '';
|
content: '';
|
||||||
background: #fff;
|
width: 10px;
|
||||||
border-radius: 100%;
|
height: 10px;
|
||||||
border: 1px solid #000;
|
background: pvar(--mainColor);
|
||||||
display: inline-block;
|
position: absolute;
|
||||||
width: 15px;
|
top: 4px;
|
||||||
height: 15px;
|
left: 4px;
|
||||||
vertical-align: middle;
|
border-radius: 100%;
|
||||||
cursor: pointer;
|
transition: all 0.2s ease;
|
||||||
text-align: center;
|
}
|
||||||
}
|
[type=radio]:not(:checked) + label::after {
|
||||||
}
|
opacity: 0;
|
||||||
|
transform: scale(0);
|
||||||
&:checked + label::before {
|
}
|
||||||
background-color: #000;
|
[type=radio]:checked + label::after {
|
||||||
box-shadow: inset 0 0 0 4px #fff;
|
opacity: 1;
|
||||||
}
|
transform: scale(1);
|
||||||
|
|
||||||
&:focus + label::before {
|
|
||||||
outline: none;
|
|
||||||
border-color: #000;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue