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