-import { Component, OnInit } from '@angular/core'
-import { FollowService } from '@app/shared/instance/follow.service'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { Notifier } from '@app/core'
-import { RestService } from '@app/shared'
import { SortMeta } from 'primeng/api'
import { Subject } from 'rxjs'
+import { Component, OnInit } from '@angular/core'
+import { ComponentPagination, hasMoreItems, Notifier, RestService } from '@app/core'
+import { InstanceFollowService } from '@app/shared/shared-instance'
@Component({
selector: 'my-about-follows',
constructor (
private restService: RestService,
private notifier: Notifier,
- private followService: FollowService
+ private followService: InstanceFollowService
) { }
ngOnInit () {
-import { Component, OnInit, ViewChild, AfterViewChecked } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
+import { ViewportScroller } from '@angular/common'
+import { AfterViewChecked, Component, OnInit, ViewChild } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { ServerService } from '@app/core'
+import { InstanceService } from '@app/shared/shared-instance'
import { ServerConfig } from '@shared/models'
-import { ActivatedRoute } from '@angular/router'
import { ResolverData } from './about-instance.resolver'
-import { ViewportScroller } from '@angular/common'
@Component({
selector: 'my-about-instance',
+import { forkJoin } from 'rxjs'
+import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
-import { map, switchMap } from 'rxjs/operators'
-import { forkJoin } from 'rxjs'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { InstanceService } from '@app/shared/shared-instance'
import { About } from '@shared/models/server'
export type ResolverData = { about: About, languages: string[], categories: string[] }
@Injectable()
export class AboutInstanceResolver implements Resolve<any> {
- constructor (
- private instanceService: InstanceService
- ) {}
+
+ constructor (private instanceService: InstanceService) {}
resolve (route: ActivatedRouteSnapshot) {
return this.instanceService.getAbout()
import { Component, OnInit, ViewChild } from '@angular/core'
import { Notifier, ServerService } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
+import { FormReactive, FormValidatorService, InstanceValidatorsService } from '@app/shared/shared-forms'
+import { InstanceService } from '@app/shared/shared-instance'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { FormReactive, InstanceValidatorsService } from '@app/shared'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { I18n } from '@ngx-translate/i18n-polyfill'
import { ServerConfig } from '@shared/models'
@Component({
import { Component, OnInit } from '@angular/core'
-import { MarkdownService } from '@app/shared/renderer'
+import { MarkdownService } from '@app/core'
@Component({
selector: 'my-about-peertube-contributors',
import { NgModule } from '@angular/core'
-import { AboutRoutingModule } from './about-routing.module'
-import { AboutComponent } from './about.component'
-import { SharedModule } from '../shared'
+import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
-import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
+import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
import { ContactAdminModalComponent } from '@app/+about/about-instance/contact-admin-modal.component'
-import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
import { AboutPeertubeContributorsComponent } from '@app/+about/about-peertube/about-peertube-contributors.component'
-import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
+import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedInstanceModule } from '@app/shared/shared-instance'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { AboutRoutingModule } from './about-routing.module'
+import { AboutComponent } from './about.component'
@NgModule({
imports: [
AboutRoutingModule,
- SharedModule
+
+ SharedMainModule,
+ SharedFormModule,
+ SharedInstanceModule,
+ SharedGlobalIconModule
],
declarations: [
+import { Subscription } from 'rxjs'
import { Component, OnDestroy, OnInit } from '@angular/core'
-import { Account } from '@app/shared/account/account.model'
-import { AccountService } from '@app/shared/account/account.service'
+import { MarkdownService } from '@app/core'
+import { Account, AccountService } from '@app/shared/shared-main'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subscription } from 'rxjs'
-import { MarkdownService } from '@app/shared/renderer'
@Component({
selector: 'my-account-about',
import { from, Subject, Subscription } from 'rxjs'
import { concatMap, map, switchMap, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
-import { User, UserService } from '@app/shared'
-import { Account } from '@app/shared/account/account.model'
-import { AccountService } from '@app/shared/account/account.service'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoSortField } from '@app/shared/video/sort-field.type'
-import { Video } from '@app/shared/video/video.model'
-import { VideoService } from '@app/shared/video/video.service'
+import { ComponentPagination, hasMoreItems, ScreenService, User, UserService } from '@app/core'
+import { Account, AccountService, Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-account-video-channels',
+import { Subscription } from 'rxjs'
+import { first, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoService } from '../../shared/video/video.service'
-import { Account } from '@app/shared/account/account.model'
-import { AccountService } from '@app/shared/account/account.service'
-import { first, tap } from 'rxjs/operators'
+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 { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subscription } from 'rxjs'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
@Component({
selector: 'my-account-videos',
- templateUrl: '../../shared/video/abstract-video-list.html',
+ templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
styleUrls: [
- '../../shared/video/abstract-video-list.scss',
+ '../../shared/shared-video-miniature/abstract-video-list.scss',
'./account-videos.component.scss'
]
})
+import { Subscription } from 'rxjs'
+import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
-import { AccountService } from '@app/shared/account/account.service'
-import { Account } from '@app/shared/account/account.model'
-import { RestExtractor, UserService } from '@app/shared'
-import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
-import { Subscription } from 'rxjs'
-import { AuthService, Notifier, RedirectService } from '@app/core'
-import { User, UserRight } from '../../../../shared'
+import { AuthService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
+import { Account, AccountService, ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { User, UserRight } from '@shared/models'
@Component({
templateUrl: './accounts.component.html',
import { NgModule } from '@angular/core'
-import { SharedModule } from '../shared'
-import { AccountsRoutingModule } from './accounts-routing.module'
-import { AccountsComponent } from './accounts.component'
-import { AccountVideosComponent } from './account-videos/account-videos.component'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+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 { AccountAboutComponent } from './account-about/account-about.component'
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'
@NgModule({
imports: [
AccountsRoutingModule,
- SharedModule
+
+ SharedMainModule,
+ SharedFormModule,
+ SharedUserSubscriptionModule,
+ SharedModerationModule,
+ SharedVideoMiniatureModule,
+ SharedGlobalIconModule
],
declarations: [
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { ConfigRoutes } from '@app/+admin/config'
-
+import { ModerationRoutes } from '@app/+admin/moderation/moderation.routes'
+import { PluginsRoutes } from '@app/+admin/plugins/plugins.routes'
+import { SystemRoutes } from '@app/+admin/system'
import { MetaGuard } from '@ngx-meta/core'
-
import { AdminComponent } from './admin.component'
import { FollowsRoutes } from './follows'
import { UsersRoutes } from './users'
-import { ModerationRoutes } from '@app/+admin/moderation/moderation.routes'
-import { SystemRoutes } from '@app/+admin/system'
-import { PluginsRoutes } from '@app/+admin/plugins/plugins.routes'
const adminRoutes: Routes = [
{
import { Component, OnInit } from '@angular/core'
-import { UserRight } from '../../../../shared'
-import { AuthService } from '../core/auth/auth.service'
-import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
+import { AuthService } from '@app/core'
+import { ListOverflowItem } from '@app/shared/shared-main'
import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserRight } from '@shared/models'
@Component({
templateUrl: './admin.component.html'
-import { NgModule } from '@angular/core'
-import { ConfigComponent, EditCustomConfigComponent } from '@app/+admin/config'
-import { ConfigService } from '@app/+admin/config/shared/config.service'
+import { ChartModule } from 'primeng/chart'
+import { SelectButtonModule } from 'primeng/selectbutton'
import { TableModule } from 'primeng/table'
-import { SharedModule } from '../shared'
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedModerationModule } from '@app/shared/shared-moderation'
import { AdminRoutingModule } from './admin-routing.module'
import { AdminComponent } from './admin.component'
+import { ConfigComponent, EditCustomConfigComponent } from './config'
+import { ConfigService } from './config/shared/config.service'
import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponent } from './follows'
import { FollowingListComponent } from './follows/following-list/following-list.component'
-import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users'
-import {
- ModerationCommentModalComponent,
- VideoAbuseListComponent,
- VideoBlockListComponent
-} from './moderation'
-import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
-import { RedundancyCheckboxComponent } from '@app/+admin/follows/shared/redundancy-checkbox.component'
-import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
-import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
-import { JobService, LogsComponent, LogsService, SystemComponent } from '@app/+admin/system'
-import { DebugComponent, DebugService } from '@app/+admin/system/debug'
-import { PluginsComponent } from '@app/+admin/plugins/plugins.component'
-import { PluginListInstalledComponent } from '@app/+admin/plugins/plugin-list-installed/plugin-list-installed.component'
-import { PluginSearchComponent } from '@app/+admin/plugins/plugin-search/plugin-search.component'
-import { PluginShowInstalledComponent } from '@app/+admin/plugins/plugin-show-installed/plugin-show-installed.component'
-import { SelectButtonModule } from 'primeng/selectbutton'
-import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
-import { VideoRedundancyInformationComponent } from '@app/+admin/follows/video-redundancies-list/video-redundancy-information.component'
-import { ChartModule } from 'primeng/chart'
+import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
+import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
+import { ModerationCommentModalComponent, VideoAbuseListComponent, VideoBlockListComponent } from './moderation'
+import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
+import { ModerationComponent } from './moderation/moderation.component'
import { VideoAbuseDetailsComponent } from './moderation/video-abuse-list/video-abuse-details.component'
+import { PluginListInstalledComponent } from './plugins/plugin-list-installed/plugin-list-installed.component'
+import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component'
+import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component'
+import { PluginsComponent } from './plugins/plugins.component'
+import { PluginApiService } from './plugins/shared/plugin-api.service'
+import { JobService, LogsComponent, LogsService, SystemComponent } from './system'
+import { DebugComponent, DebugService } from './system/debug'
+import { JobsComponent } from './system/jobs/jobs.component'
+import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersComponent, UserUpdateComponent } from './users'
@NgModule({
imports: [
AdminRoutingModule,
- SharedModule,
+ SharedMainModule,
+ SharedFormModule,
+ SharedModerationModule,
+ SharedGlobalIconModule,
TableModule,
SelectButtonModule,
import { Routes } from '@angular/router'
import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config'
import { UserRightGuard } from '@app/core'
-import { UserRight } from '../../../../../shared/models/users'
+import { UserRight } from '@shared/models'
import { ConfigComponent } from './config.component'
export const ConfigRoutes: Routes = [
-import { AfterViewChecked, Component, OnInit, ViewChild } from '@angular/core'
-import { ConfigService } from '@app/+admin/config/shared/config.service'
-import { ServerService } from '@app/core/server/server.service'
-import { CustomConfigValidatorsService, FormReactive, UserValidatorsService } from '@app/shared'
-import { Notifier } from '@app/core'
-import { CustomConfig } from '../../../../../../shared/models/server/custom-config.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { SelectItem } from 'primeng/api'
import { forkJoin } from 'rxjs'
-import { ServerConfig } from '@shared/models'
import { ViewportScroller } from '@angular/common'
+import { AfterViewChecked, Component, OnInit, ViewChild } from '@angular/core'
+import { ConfigService } from '@app/+admin/config/shared/config.service'
+import { Notifier } from '@app/core'
+import { ServerService } from '@app/core/server/server.service'
+import { CustomConfigValidatorsService, FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
import { NgbNav } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { CustomConfig, ServerConfig } from '@shared/models'
@Component({
selector: 'my-edit-custom-config',
+++ /dev/null
-<ng-template #modal>
- <div class="modal-header">
- <h4 i18n class="modal-title">{{ action }}</h4>
-
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
- </div>
-
- <div class="modal-body">
- <form novalidate [formGroup]="form" (ngSubmit)="submit()">
- <div class="form-group">
- <label i18n for="hosts">1 host (without "http://") per line</label>
-
- <textarea
- [placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts"
- class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus
- ></textarea>
-
- <div *ngIf="formErrors.domains" class="form-error">
- {{ formErrors.domains }}
-
- <div *ngIf="form.controls['domains'].errors.validDomains">
- {{ form.controls['domains'].errors.validDomains.value }}
- </div>
- </div>
- </div>
-
- <ng-content select="warning"></ng-content>
-
- <div class="form-group inputs">
- <input
- type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
- (click)="hide()" (key.enter)="hide()"
- >
-
- <input
- type="submit" [value]="action" class="action-button-submit"
- [disabled]="!form.valid"
- >
- </div>
- </form>
- </div>
-
-</ng-template>
+++ /dev/null
-textarea {
- height: 200px;
-}
+++ /dev/null
-import { Component, OnInit, ViewChild, Input, Output, EventEmitter } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { FormReactive } from '@app/shared/forms'
-import { BatchDomainsValidatorsService } from './batch-domains-validators.service'
-
-@Component({
- selector: 'my-batch-domains-modal',
- templateUrl: './batch-domains-modal.component.html',
- styleUrls: [ './batch-domains-modal.component.scss' ]
-})
-export class BatchDomainsModalComponent extends FormReactive implements OnInit {
- @ViewChild('modal', { static: true }) modal: NgbModal
- @Input() placeholder = 'example.com'
- @Input() action: string
- @Output() domains = new EventEmitter<string[]>()
-
- private openedModal: NgbModalRef
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private modalService: NgbModal,
- private batchDomainsValidatorsService: BatchDomainsValidatorsService,
- private i18n: I18n
- ) {
- super()
- }
-
- ngOnInit () {
- if (!this.action) this.action = this.i18n('Process domains')
-
- this.buildForm({
- domains: this.batchDomainsValidatorsService.DOMAINS
- })
- }
-
- openModal () {
- this.openedModal = this.modalService.open(this.modal, { centered: true })
- }
-
- hide () {
- this.openedModal.close()
- }
-
- submit () {
- this.domains.emit(
- this.batchDomainsValidatorsService.getNotEmptyHosts(this.form.controls['domains'].value)
- )
- this.form.reset()
- this.hide()
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators, ValidatorFn } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator, validateHost } from '@app/shared/forms/form-validators'
-
-@Injectable()
-export class BatchDomainsValidatorsService {
- readonly DOMAINS: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.DOMAINS = {
- VALIDATORS: [ Validators.required, this.validDomains, this.isHostsUnique ],
- MESSAGES: {
- 'required': this.i18n('Domain is required.'),
- 'validDomains': this.i18n('Domains entered are invalid.'),
- 'uniqueDomains': this.i18n('Domains entered contain duplicates.')
- }
- }
- }
-
- getNotEmptyHosts (hosts: string) {
- return hosts
- .split('\n')
- .filter((host: string) => host && host.length !== 0) // Eject empty hosts
- }
-
- private validDomains: ValidatorFn = (control) => {
- if (!control.value) return null
-
- const newHostsErrors = []
- const hosts = this.getNotEmptyHosts(control.value)
-
- for (const host of hosts) {
- if (validateHost(host) === false) {
- newHostsErrors.push(this.i18n('{{host}} is not valid', { host }))
- }
- }
-
- /* Is not valid. */
- if (newHostsErrors.length !== 0) {
- return {
- 'validDomains': {
- reason: 'invalid',
- value: newHostsErrors.join('. ') + '.'
- }
- }
- }
-
- /* Is valid. */
- return null
- }
-
- private isHostsUnique: ValidatorFn = (control) => {
- if (!control.value) return null
-
- const hosts = this.getNotEmptyHosts(control.value)
-
- if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
- return null
- } else {
- return {
- 'uniqueDomains': {
- reason: 'invalid'
- }
- }
- }
- }
-}
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { CustomConfig } from '../../../../../../shared/models/server/custom-config.model'
-import { environment } from '../../../../environments/environment'
-import { RestExtractor } from '../../../shared'
+import { RestExtractor } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
+import { CustomConfig } from '@shared/models'
+import { environment } from '../../../../environments/environment'
@Injectable()
export class ConfigService {
-import { Component, OnInit } from '@angular/core'
-import { ConfirmService, Notifier } from '@app/core'
import { SortMeta } from 'primeng/api'
-import { ActorFollow } from '../../../../../../shared/models/actors/follow.model'
-import { RestPagination, RestTable } from '../../../shared'
-import { FollowService } from '@app/shared/instance/follow.service'
+import { Component, OnInit } from '@angular/core'
+import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
+import { InstanceFollowService } from '@app/shared/shared-instance'
import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ActorFollow } from '@shared/models'
@Component({
selector: 'my-followers-list',
private confirmService: ConfirmService,
private notifier: Notifier,
private i18n: I18n,
- private followService: FollowService
+ private followService: InstanceFollowService
) {
super()
}
-import { Component, OnInit, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
import { SortMeta } from 'primeng/api'
-import { ActorFollow } from '../../../../../../shared/models/actors/follow.model'
-import { ConfirmService } from '../../../core/confirm/confirm.service'
-import { RestPagination, RestTable } from '../../../shared'
-import { FollowService } from '@app/shared/instance/follow.service'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
+import { InstanceFollowService } from '@app/shared/shared-instance'
+import { BatchDomainsModalComponent } from '@app/shared/shared-moderation'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component'
+import { ActorFollow } from '@shared/models'
@Component({
selector: 'my-followers-list',
constructor (
private notifier: Notifier,
private confirmService: ConfirmService,
- private followService: FollowService,
+ private followService: InstanceFollowService,
private i18n: I18n
) {
super()
import { Routes } from '@angular/router'
-
-import { UserRightGuard } from '../../core'
-import { FollowsComponent } from './follows.component'
+import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
+import { UserRightGuard } from '@app/core'
+import { UserRight } from '@shared/models'
import { FollowersListComponent } from './followers-list'
-import { UserRight } from '../../../../../shared'
import { FollowingListComponent } from './following-list/following-list.component'
-import { VideoRedundanciesListComponent } from '@app/+admin/follows/video-redundancies-list'
+import { FollowsComponent } from './follows.component'
export const FollowsRoutes: Routes = [
{
import { Component, Input } from '@angular/core'
import { Notifier } from '@app/core'
+import { RedundancyService } from '@app/shared/shared-main'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { RedundancyService } from '@app/shared/video/redundancy.service'
@Component({
selector: 'my-redundancy-checkbox',
export * from './video-redundancies-list.component'
+export * from './video-redundancy-information.component'
-import { Component, OnInit } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
+import { BytesPipe } from 'ngx-pipes'
import { SortMeta } from 'primeng/api'
-import { ConfirmService } from '../../../core/confirm/confirm.service'
-import { RestPagination, RestTable } from '../../../shared'
+import { Component, OnInit } from '@angular/core'
+import { ConfirmService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
+import { peertubeLocalStorage } from '@app/helpers/peertube-web-storage'
+import { RedundancyService } from '@app/shared/shared-main'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
import { VideosRedundancyStats } from '@shared/models/server'
-import { BytesPipe } from 'ngx-pipes'
-import { RedundancyService } from '@app/shared/video/redundancy.service'
@Component({
selector: 'my-video-redundancies-list',
+export * from './instance-blocklist'
export * from './video-abuse-list'
export * from './video-block-list'
export * from './moderation.component'
import { Component } from '@angular/core'
-import { GenericAccountBlocklistComponent, BlocklistComponentType } from '@app/shared/blocklist'
+import { BlocklistComponentType, GenericAccountBlocklistComponent } from '@app/shared/shared-moderation'
@Component({
selector: 'my-instance-account-blocklist',
- styleUrls: [ '../moderation.component.scss', '../../../shared/blocklist/account-blocklist.component.scss' ],
- templateUrl: '../../../shared/blocklist/account-blocklist.component.html'
+ styleUrls: [ '../moderation.component.scss', '../../../shared/shared-moderation/account-blocklist.component.scss' ],
+ templateUrl: '../../../shared/shared-moderation/account-blocklist.component.html'
})
export class InstanceAccountBlocklistComponent extends GenericAccountBlocklistComponent {
mode = BlocklistComponentType.Instance
import { Component } from '@angular/core'
-import { GenericServerBlocklistComponent, BlocklistComponentType } from '@app/shared/blocklist'
+import { GenericServerBlocklistComponent, BlocklistComponentType } from '@app/shared/shared-moderation'
@Component({
selector: 'my-instance-server-blocklist',
- styleUrls: [ '../../../shared/blocklist/server-blocklist.component.scss' ],
- templateUrl: '../../../shared/blocklist/server-blocklist.component.html'
+ styleUrls: [ '../../../shared/shared-moderation/server-blocklist.component.scss' ],
+ templateUrl: '../../../shared/shared-moderation/server-blocklist.component.html'
})
export class InstanceServerBlocklistComponent extends GenericServerBlocklistComponent {
mode = BlocklistComponentType.Instance
import { Component, OnInit } from '@angular/core'
-import { UserRight } from '../../../../../shared'
import { AuthService, ServerService } from '@app/core'
+import { UserRight } from '@shared/models'
@Component({
templateUrl: './moderation.component.html',
import { Routes } from '@angular/router'
-import { UserRight } from '../../../../../shared'
-import { UserRightGuard } from '@app/core'
+import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
+import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
import { VideoAbuseListComponent } from '@app/+admin/moderation/video-abuse-list'
import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
-import { ModerationComponent } from '@app/+admin/moderation/moderation.component'
-import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
+import { UserRightGuard } from '@app/core'
+import { UserRight } from '@shared/models'
export const ModerationRoutes: Routes = [
{
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core'
-import { FormReactive, VideoAbuseService, VideoAbuseValidatorsService } from '../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms'
+import { VideoAbuseService } from '@app/shared/shared-moderation'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { FormValidatorService } from '../../../shared/forms/form-validators/form-validator.service'
-import { VideoAbuse } from '../../../../../../shared/models/videos'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoAbuse } from '@shared/models'
@Component({
selector: 'my-moderation-comment-modal',
import { Component, Input } from '@angular/core'
-import { Actor } from '@app/shared/actor/actor.model'
+import { Actor } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model'
import { ProcessedVideoAbuse } from './video-abuse-list.component'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { durationToString } from '@app/shared/misc/utils'
@Component({
selector: 'my-video-abuse-details',
-import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core'
-import { Account } from '@app/shared/account/account.model'
-import { Notifier } from '@app/core'
import { SortMeta } from 'primeng/api'
-import { VideoAbuse, VideoAbuseState } from '../../../../../../shared'
-import { RestPagination, RestTable, VideoAbuseService, VideoBlockService } from '../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { DropdownAction } from '../../../shared/buttons/action-dropdown.component'
-import { ConfirmService } from '../../../core/index'
-import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
-import { Video } from '../../../shared/video/video.model'
-import { MarkdownService } from '@app/shared/renderer'
-import { Actor } from '@app/shared/actor/actor.model'
+import { filter } from 'rxjs/operators'
import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
+import { environment } from 'src/environments/environment'
+import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
import { DomSanitizer } from '@angular/platform-browser'
-import { BlocklistService } from '@app/shared/blocklist'
-import { VideoService } from '@app/shared/video/video.service'
import { ActivatedRoute, Params, Router } from '@angular/router'
-import { filter } from 'rxjs/operators'
-import { environment } from 'src/environments/environment'
+import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
+import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
+import { BlocklistService, VideoAbuseService, VideoBlockService } from '@app/shared/shared-moderation'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoAbuse, VideoAbuseState } from '@shared/models'
+import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
export type ProcessedVideoAbuse = VideoAbuse & {
moderationCommentHtml?: string,
-import { Component, OnInit, AfterViewInit } from '@angular/core'
import { SortMeta } from 'primeng/api'
-import { Notifier, ServerService } from '@app/core'
-import { ConfirmService } from '../../../core'
-import { RestPagination, RestTable, VideoBlockService } from '../../../shared'
-import { VideoBlacklist, VideoBlacklistType } from '../../../../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { DropdownAction } from '../../../shared/buttons/action-dropdown.component'
-import { Video } from '../../../shared/video/video.model'
-import { MarkdownService } from '@app/shared/renderer'
-import { Params, ActivatedRoute, Router } from '@angular/router'
import { filter, switchMap } from 'rxjs/operators'
-import { VideoService } from '@app/shared/video/video.service'
+import { AfterViewInit, Component, OnInit } from '@angular/core'
+import { ActivatedRoute, Params, Router } from '@angular/router'
+import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
+import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
+import { VideoBlockService } from '@app/shared/shared-moderation'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoBlacklist, VideoBlacklistType } from '@shared/models'
@Component({
selector: 'my-video-block-list',
+import { Subject } from 'rxjs'
import { Component, OnInit } from '@angular/core'
-import { PluginType } from '@shared/models/plugins/plugin.type'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { ConfirmService, Notifier } from '@app/core'
-import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
import { ActivatedRoute, Router } from '@angular/router'
-import { compareSemVer } from '@shared/core-utils/miscs/miscs'
+import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
+import { ComponentPagination, ConfirmService, hasMoreItems, Notifier } from '@app/core'
import { PluginService } from '@app/core/plugins/plugin.service'
-import { Subject } from 'rxjs'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { compareSemVer } from '@shared/core-utils/miscs/miscs'
+import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
+import { PluginType } from '@shared/models/plugins/plugin.type'
@Component({
selector: 'my-plugin-list-installed',
+import { Subject } from 'rxjs'
+import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
import { Component, OnInit } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
-import { ConfirmService } from '../../../core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PluginType } from '@shared/models/plugins/plugin.type'
-import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
import { ActivatedRoute, Router } from '@angular/router'
+import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
+import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, ServerService } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
import { PeerTubePluginIndex } from '@shared/models/plugins/peertube-plugin-index.model'
-import { Subject } from 'rxjs'
-import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
+import { PluginType } from '@shared/models/plugins/plugin.type'
@Component({
selector: 'my-plugin-search',
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
-import { Notifier } from '@app/core'
-import { ActivatedRoute } from '@angular/router'
import { Subscription } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
-import { BuildFormArgument, FormReactive, FormValidatorService } from '@app/shared'
-import { RegisterServerSettingOptions } from '@shared/models/plugins/register-server-setting.model'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { Notifier } from '@app/core'
+import { BuildFormArgument, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { PeerTubePlugin, RegisterServerSettingOptions } from '@shared/models'
+import { PluginApiService } from '../shared/plugin-api.service'
@Component({
selector: 'my-plugin-show-installed',
+import { Observable } from 'rxjs'
import { catchError, map, switchMap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestService } from '../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PluginType } from '@shared/models/plugins/plugin.type'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { peertubeTranslate, ResultList } from '@shared/models'
-import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
-import { ManagePlugin } from '@shared/models/plugins/manage-plugin.model'
-import { InstallOrUpdatePlugin } from '@shared/models/plugins/install-plugin.model'
-import { PeerTubePluginIndex } from '@shared/models/plugins/peertube-plugin-index.model'
-import { RegisteredServerSettings, RegisterServerSettingOptions } from '@shared/models/plugins/register-server-setting.model'
+import { ComponentPagination, RestExtractor, RestService } from '@app/core'
import { PluginService } from '@app/core/plugins/plugin.service'
-import { Observable } from 'rxjs'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import {
+ InstallOrUpdatePlugin,
+ ManagePlugin,
+ PeerTubePlugin,
+ PeerTubePluginIndex,
+ peertubeTranslate,
+ PluginType,
+ RegisteredServerSettings,
+ ResultList
+} from '@shared/models'
+import { environment } from '../../../../environments/environment'
@Injectable()
export class PluginApiService {
import { Component, OnInit } from '@angular/core'
import { Notifier } from '@app/core'
-import { Debug } from '@shared/models/server'
-import { DebugService } from '@app/+admin/system/debug/debug.service'
+import { Debug } from '@shared/models'
+import { DebugService } from './debug.service'
@Component({
templateUrl: './debug.component.html',
+import { Observable } from 'rxjs'
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
+import { RestExtractor } from '@app/core'
+import { Debug } from '@shared/models'
import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestService } from '../../../shared'
-import { Debug } from '@shared/models/server'
@Injectable()
export class DebugService {
constructor (
private authHttp: HttpClient,
- private restService: RestService,
private restExtractor: RestExtractor
) {}
+export * from './debug'
export * from './jobs'
export * from './logs'
export * from './system.component'
+import { SortMeta } from 'primeng/api'
+import { Observable } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { SortMeta } from 'primeng/api'
-import { Observable } from 'rxjs'
-import { ResultList } from '../../../../../../shared'
-import { Job } from '../../../../../../shared/models/server/job.model'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { Job, ResultList } from '@shared/models'
import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestPagination, RestService } from '../../../shared'
import { JobStateClient } from '../../../../types/job-state-client.type'
import { JobTypeClient } from '../../../../types/job-type-client.type'
-import { Component, OnInit } from '@angular/core'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { Notifier } from '@app/core'
import { SortMeta } from 'primeng/api'
-import { Job, JobType } from '../../../../../../shared/index'
-import { JobState } from '../../../../../../shared/models'
-import { RestPagination, RestTable } from '../../../shared'
-import { JobService } from './job.service'
+import { Component, OnInit } from '@angular/core'
+import { Notifier, RestPagination, RestTable } from '@app/core'
+import { peertubeLocalStorage } from '@app/helpers/peertube-web-storage'
import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Job, JobState, JobType } from '@shared/models'
import { JobStateClient } from '../../../../types/job-state-client.type'
import { JobTypeClient } from '../../../../types/job-type-client.type'
+import { JobService } from './job.service'
@Component({
selector: 'my-jobs',
-import { LogLevel } from '@shared/models/server/log-level.type'
+import { LogLevel } from '@shared/models'
import omit from 'lodash-es/omit'
export class LogRow {
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
-import { LogsService } from '@app/+admin/system/logs/logs.service'
import { Notifier } from '@app/core'
-import { LogRow } from '@app/+admin/system/logs/log-row.model'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { LogLevel } from '@shared/models/server/log-level.type'
+import { LogLevel } from '@shared/models'
+import { LogRow } from './log-row.model'
+import { LogsService } from './logs.service'
@Component({
templateUrl: './logs.component.html',
+import { Observable } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
+import { RestExtractor } from '@app/core'
+import { LogLevel } from '@shared/models'
import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestService } from '../../../shared'
-import { LogRow } from '@app/+admin/system/logs/log-row.model'
-import { LogLevel } from '@shared/models/server/log-level.type'
+import { LogRow } from './log-row.model'
@Injectable()
export class LogsService {
constructor (
private authHttp: HttpClient,
- private restService: RestService,
private restExtractor: RestExtractor
) {}
import { Routes } from '@angular/router'
-import { UserRightGuard } from '../../core'
-import { UserRight } from '../../../../../shared'
-import { JobsComponent } from '@app/+admin/system/jobs/jobs.component'
-import { LogsComponent } from '@app/+admin/system/logs'
-import { SystemComponent } from '@app/+admin/system/system.component'
-import { DebugComponent } from '@app/+admin/system/debug'
+import { UserRightGuard } from '@app/core'
+import { UserRight } from '@shared/models'
+import { DebugComponent } from './debug'
+import { JobsComponent } from './jobs/jobs.component'
+import { LogsComponent } from './logs'
+import { SystemComponent } from './system.component'
export const SystemRoutes: Routes = [
{
import { Component, OnInit } from '@angular/core'
-import { Router, ActivatedRoute } from '@angular/router'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { UserCreate, UserRole } from '../../../../../../shared'
-import { UserEdit } from './user-edit'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
+import { ActivatedRoute, Router } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service'
-import { UserService } from '@app/shared'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { AuthService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserCreate, UserRole } from '@shared/models'
+import { UserEdit } from './user-edit'
@Component({
selector: 'my-user-create',
-import { AuthService, ServerService } from '../../../core'
-import { FormReactive } from '../../../shared'
-import { ServerConfig, USER_ROLE_LABELS, UserRole, VideoResolution } from '../../../../../../shared'
-import { ConfigService } from '@app/+admin/config/shared/config.service'
-import { UserAdminFlag } from '@shared/models/users/user-flag.model'
import { OnInit } from '@angular/core'
-import { User } from '@app/shared/users/user.model'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { ConfigService } from '@app/+admin/config/shared/config.service'
+import { AuthService, ScreenService, ServerService, User } from '@app/core'
+import { FormReactive } from '@app/shared/shared-forms'
+import { ServerConfig, USER_ROLE_LABELS, UserAdminFlag, UserRole, VideoResolution } from '@shared/models'
export abstract class UserEdit extends FormReactive implements OnInit {
videoQuotaOptions: { value: string, label: string, disabled?: boolean }[] = []
import { Component, Input, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { UserService } from '@app/shared/users/user.service'
-import { Notifier } from '../../../core'
-import { User, UserUpdate } from '../../../../../../shared'
+import { Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
-import { FormReactive } from '../../../shared'
+import { UserUpdate } from '@shared/models'
@Component({
selector: 'my-user-password',
+import { Subscription } from 'rxjs'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { Subscription } from 'rxjs'
-import { AuthService, Notifier } from '@app/core'
-import { ServerService } from '../../../core'
-import { UserEdit } from './user-edit'
-import { User as UserType, UserUpdate, UserRole } from '../../../../../../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
import { ConfigService } from '@app/+admin/config/shared/config.service'
-import { UserService } from '@app/shared'
-import { UserAdminFlag } from '@shared/models/users/user-flag.model'
-import { User } from '@app/shared/users/user.model'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { AuthService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
+import { FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { User as UserType, UserAdminFlag, UserRole, UserUpdate } from '@shared/models'
+import { UserEdit } from './user-edit'
@Component({
selector: 'my-user-update',
-import { Component, OnInit, ViewChild } from '@angular/core'
-import { AuthService, Notifier } from '@app/core'
import { SortMeta } from 'primeng/api'
-import { ConfirmService, ServerService } from '../../../core'
-import { RestPagination, RestTable, UserService } from '../../../shared'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { AuthService, ConfirmService, Notifier, RestPagination, RestTable, ServerService, UserService } from '@app/core'
+import { Actor, DropdownAction } from '@app/shared/shared-main'
+import { UserBanModalComponent } from '@app/shared/shared-moderation'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ServerConfig, User } from '../../../../../../shared'
-import { UserBanModalComponent } from '@app/shared/moderation'
-import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
-import { Actor } from '@app/shared/actor/actor.model'
+import { ServerConfig, User } from '@shared/models'
@Component({
selector: 'my-user-list',
import { Routes } from '@angular/router'
-
-import { UserRightGuard } from '../../core'
-import { UserRight } from '../../../../../shared'
-import { UsersComponent } from './users.component'
+import { ServerConfigResolver, UserRightGuard } from '@app/core'
+import { UserRight } from '@shared/models'
import { UserCreateComponent, UserUpdateComponent } from './user-edit'
import { UserListComponent } from './user-list'
-import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
+import { UsersComponent } from './users.component'
export const UsersRoutes: Routes = [
{
--- /dev/null
+import { Component, OnInit } from '@angular/core'
+import { Router } from '@angular/router'
+import { AuthService, Notifier } from '@app/core'
+import { FormValidatorService, VideoChannelValidatorsService } from '@app/shared/shared-forms'
+import { VideoChannelService } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoChannelCreate } from '@shared/models'
+import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit'
+
+@Component({
+ selector: 'my-account-video-channel-create',
+ templateUrl: './my-account-video-channel-edit.component.html',
+ styleUrls: [ './my-account-video-channel-edit.component.scss' ]
+})
+export class MyAccountVideoChannelCreateComponent extends MyAccountVideoChannelEdit implements OnInit {
+ error: string
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private authService: AuthService,
+ private videoChannelValidatorsService: VideoChannelValidatorsService,
+ private notifier: Notifier,
+ private router: Router,
+ private videoChannelService: VideoChannelService,
+ private i18n: I18n
+ ) {
+ super()
+ }
+
+ get instanceHost () {
+ return window.location.host
+ }
+
+ ngOnInit () {
+ this.buildForm({
+ name: this.videoChannelValidatorsService.VIDEO_CHANNEL_NAME,
+ 'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
+ description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION,
+ support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT
+ })
+ }
+
+ formValidated () {
+ this.error = undefined
+
+ const body = this.form.value
+ const videoChannelCreate: VideoChannelCreate = {
+ name: body.name,
+ displayName: body['display-name'],
+ description: body.description || null,
+ support: body.support || null
+ }
+
+ this.videoChannelService.createVideoChannel(videoChannelCreate).subscribe(
+ () => {
+ this.authService.refreshUserInformation()
+
+ this.notifier.success(
+ this.i18n('Video channel {{videoChannelName}} created.', { videoChannelName: videoChannelCreate.displayName })
+ )
+ this.router.navigate([ '/my-account', 'video-channels' ])
+ },
+
+ err => {
+ if (err.status === 409) {
+ this.error = this.i18n('This name already exists on this instance.')
+ return
+ }
+
+ this.error = err.message
+ }
+ )
+ }
+
+ isCreation () {
+ return true
+ }
+
+ getFormButtonTitle () {
+ return this.i18n('Create')
+ }
+}
--- /dev/null
+<nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item">
+ <a routerLink="/my-account/video-channels" i18n>My Channels</a>
+ </li>
+
+ <ng-container *ngIf="isCreation()">
+ <li class="breadcrumb-item active" i18n>Create</li>
+ </ng-container>
+ <ng-container *ngIf="!isCreation()">
+ <li class="breadcrumb-item active" i18n>Edit</li>
+ <li class="breadcrumb-item active" aria-current="page">
+ <a *ngIf="videoChannelToUpdate" [routerLink]="[ '/my-account/video-channels/update', videoChannelToUpdate?.nameWithHost ]">{{ videoChannelToUpdate?.displayName }}</a>
+ </li>
+ </ng-container>
+ </ol>
+</nav>
+
+<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
+
+<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
+
+ <div class="form-row"> <!-- channel grid -->
+ <div class="form-group col-12 col-lg-4 col-xl-3">
+ <div *ngIf="isCreation()" class="video-channel-title" i18n>NEW CHANNEL</div>
+ <div *ngIf="!isCreation() && videoChannelToUpdate" class="video-channel-title" i18n>CHANNEL</div>
+ </div>
+
+ <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
+
+ <div class="form-group" *ngIf="isCreation()">
+ <label i18n for="name">Name</label>
+ <div class="input-group">
+ <input
+ type="text" id="name" i18n-placeholder placeholder="Example: my_channel"
+ formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" class="form-control"
+ >
+ <div class="input-group-append">
+ <span class="input-group-text">@{{ instanceHost }}</span>
+ </div>
+ </div>
+ <div *ngIf="formErrors['name']" class="form-error">
+ {{ formErrors['name'] }}
+ </div>
+ </div>
+
+ <my-actor-avatar-info
+ *ngIf="!isCreation() && videoChannelToUpdate"
+ [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)"
+ ></my-actor-avatar-info>
+
+ <div class="form-group">
+ <label i18n for="display-name">Display name</label>
+ <input
+ type="text" id="display-name" class="form-control"
+ formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
+ >
+ <div *ngIf="formErrors['display-name']" class="form-error">
+ {{ formErrors['display-name'] }}
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label i18n for="description">Description</label>
+ <textarea
+ id="description" formControlName="description" class="form-control"
+ [ngClass]="{ 'input-error': formErrors['description'] }"
+ ></textarea>
+ <div *ngIf="formErrors.description" class="form-error">
+ {{ formErrors.description }}
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="support">Support</label>
+ <my-help
+ helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support your channel (membership platform...).<br /><br />
+ When you will upload a video in this channel, the video support field will be automatically filled by this text."
+ ></my-help>
+ <my-markdown-textarea
+ id="support" formControlName="support" textareaMaxWidth="500px" markdownType="enhanced"
+ [classes]="{ 'input-error': formErrors['support'] }"
+ ></my-markdown-textarea>
+ <div *ngIf="formErrors.support" class="form-error">
+ {{ formErrors.support }}
+ </div>
+ </div>
+
+ <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()">
+ <my-peertube-checkbox
+ inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate"
+ i18n-labelText labelText="Overwrite support field of all videos of this channel"
+ ></my-peertube-checkbox>
+ </div>
+
+ </div>
+ </div>
+
+ <div class="form-row"> <!-- submit placement block -->
+ <div class="col-md-7 col-xl-5"></div>
+ <div class="col-md-5 col-xl-5 d-inline-flex">
+ <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
+ </div>
+ </div>
+</form>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+label {
+ font-weight: $font-regular;
+ font-size: 100%;
+}
+
+.video-channel-title {
+ @include settings-big-title;
+}
+
+my-actor-avatar-info {
+ display: block;
+ margin-bottom: 20px;
+}
+
+.input-group {
+ @include peertube-input-group(fit-content);
+}
+
+.input-group-append {
+ height: 30px;
+}
+
+input {
+ &[type=text] {
+ @include peertube-input-text(340px);
+
+ display: block;
+
+ &#name {
+ width: auto;
+ flex-grow: 1;
+ }
+ }
+
+ &[type=submit] {
+ @include peertube-button;
+ @include orange-button;
+ margin-left: auto;
+ }
+}
+
+textarea {
+ @include peertube-textarea(500px, 150px);
+
+ display: block;
+}
+
+.peertube-select-container {
+ @include peertube-select-container(340px);
+}
+
+.breadcrumb {
+ @include breadcrumb;
+}
+
+@media screen and (max-width: $small-view) {
+ input[type=text]#name {
+ width: auto !important;
+ }
+
+ label[for=name] + div, textarea {
+ width: 100%;
+ }
+}
--- /dev/null
+import { FormReactive } from '@app/shared/shared-forms'
+import { VideoChannel } from '@app/shared/shared-main'
+
+export abstract class MyAccountVideoChannelEdit extends FormReactive {
+ // We need it even in the create component because it's used in the edit template
+ videoChannelToUpdate: VideoChannel
+ instanceHost: string
+
+ abstract isCreation (): boolean
+ abstract getFormButtonTitle (): string
+
+ // We need this method so angular does not complain in child template that doesn't need this
+ onAvatarChange (formData: FormData) { /* empty */ }
+
+ // Should be implemented by the child
+ isBulkUpdateVideosDisplayed () {
+ return false
+ }
+}
--- /dev/null
+import { Subscription } from 'rxjs'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { FormValidatorService, VideoChannelValidatorsService } from '@app/shared/shared-forms'
+import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, VideoChannelUpdate } from '@shared/models'
+import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit'
+
+@Component({
+ selector: 'my-account-video-channel-update',
+ templateUrl: './my-account-video-channel-edit.component.html',
+ styleUrls: [ './my-account-video-channel-edit.component.scss' ]
+})
+export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelEdit implements OnInit, OnDestroy {
+ error: string
+ videoChannelToUpdate: VideoChannel
+
+ private paramsSub: Subscription
+ private oldSupportField: string
+ private serverConfig: ServerConfig
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private authService: AuthService,
+ private videoChannelValidatorsService: VideoChannelValidatorsService,
+ private notifier: Notifier,
+ private router: Router,
+ private route: ActivatedRoute,
+ private videoChannelService: VideoChannelService,
+ private i18n: I18n,
+ private serverService: ServerService
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ this.serverConfig = this.serverService.getTmpConfig()
+ this.serverService.getConfig()
+ .subscribe(config => this.serverConfig = config)
+
+ this.buildForm({
+ 'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
+ description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION,
+ support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT,
+ bulkVideosSupportUpdate: null
+ })
+
+ this.paramsSub = this.route.params.subscribe(routeParams => {
+ const videoChannelId = routeParams['videoChannelId']
+
+ this.videoChannelService.getVideoChannel(videoChannelId).subscribe(
+ videoChannelToUpdate => {
+ this.videoChannelToUpdate = videoChannelToUpdate
+
+ this.oldSupportField = videoChannelToUpdate.support
+
+ this.form.patchValue({
+ 'display-name': videoChannelToUpdate.displayName,
+ description: videoChannelToUpdate.description,
+ support: videoChannelToUpdate.support
+ })
+ },
+
+ err => this.error = err.message
+ )
+ })
+ }
+
+ ngOnDestroy () {
+ if (this.paramsSub) this.paramsSub.unsubscribe()
+ }
+
+ formValidated () {
+ this.error = undefined
+
+ const body = this.form.value
+ const videoChannelUpdate: VideoChannelUpdate = {
+ displayName: body['display-name'],
+ description: body.description || null,
+ support: body.support || null,
+ bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false
+ }
+
+ this.videoChannelService.updateVideoChannel(this.videoChannelToUpdate.name, videoChannelUpdate).subscribe(
+ () => {
+ this.authService.refreshUserInformation()
+
+ this.notifier.success(
+ this.i18n('Video channel {{videoChannelName}} updated.', { videoChannelName: videoChannelUpdate.displayName })
+ )
+
+ this.router.navigate([ '/my-account', 'video-channels' ])
+ },
+
+ err => this.error = err.message
+ )
+ }
+
+ onAvatarChange (formData: FormData) {
+ this.videoChannelService.changeVideoChannelAvatar(this.videoChannelToUpdate.name, formData)
+ .subscribe(
+ data => {
+ this.notifier.success(this.i18n('Avatar changed.'))
+
+ this.videoChannelToUpdate.updateAvatar(data.avatar)
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ get maxAvatarSize () {
+ return this.serverConfig.avatar.file.size.max
+ }
+
+ get avatarExtensions () {
+ return this.serverConfig.avatar.file.extensions.join(',')
+ }
+
+ isCreation () {
+ return false
+ }
+
+ getFormButtonTitle () {
+ return this.i18n('Update')
+ }
+
+ isBulkUpdateVideosDisplayed () {
+ if (this.oldSupportField === undefined) return false
+
+ return this.oldSupportField !== this.form.value['support']
+ }
+}
--- /dev/null
+import { NgModule } from '@angular/core'
+import { RouterModule, Routes } from '@angular/router'
+import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
+import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
+import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
+
+const myAccountVideoChannelsRoutes: Routes = [
+ {
+ path: '',
+ component: MyAccountVideoChannelsComponent,
+ data: {
+ meta: {
+ title: 'Account video channels'
+ }
+ }
+ },
+ {
+ path: 'create',
+ component: MyAccountVideoChannelCreateComponent,
+ data: {
+ meta: {
+ title: 'Create new video channel'
+ }
+ }
+ },
+ {
+ path: 'update/:videoChannelId',
+ component: MyAccountVideoChannelUpdateComponent,
+ data: {
+ meta: {
+ title: 'Update video channel'
+ }
+ }
+ }
+]
+
+@NgModule({
+ imports: [ RouterModule.forChild(myAccountVideoChannelsRoutes) ],
+ exports: [ RouterModule ]
+})
+export class MyAccountVideoChannelsRoutingModule {}
--- /dev/null
+<h1 class="sr-only" i18n>My channels</h1>
+<div class="video-channels-header">
+ <a class="create-button" routerLink="create">
+ <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
+ <ng-container i18n>Create video channel</ng-container>
+ </a>
+</div>
+
+<div class="video-channels">
+ <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
+ <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]">
+ <img [src]="videoChannel.avatarUrl" alt="Avatar" />
+ </a>
+
+ <div class="video-channel-info">
+ <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
+ <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
+ <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
+ </a>
+
+ <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
+
+ <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div>
+
+ <div class="video-channel-buttons">
+ <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button>
+ <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
+ </div>
+
+ <div *ngIf="!isInSmallView" class="w-100 d-flex justify-content-end">
+ <p-chart *ngIf="videoChannelsChartData && videoChannelsChartData[i]" type="line" [data]="videoChannelsChartData[i]" [options]="chartOptions" width="40vw" height="100px"></p-chart>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.create-button {
+ @include create-button;
+}
+
+::ng-deep .action-button {
+ &.action-button-edit {
+ margin-right: 10px;
+ }
+}
+
+.video-channel {
+ @include row-blocks;
+ padding-bottom: 0;
+
+ img {
+ @include avatar(80px);
+
+ margin-right: 10px;
+ }
+
+ .video-channel-info {
+ flex-grow: 1;
+
+ a.video-channel-names {
+ @include disable-default-a-behaviour;
+
+ width: fit-content;
+ display: flex;
+ align-items: baseline;
+ color: pvar(--mainForegroundColor);
+
+ .video-channel-display-name {
+ font-weight: $font-semibold;
+ font-size: 18px;
+ }
+
+ .video-channel-name {
+ font-size: 14px;
+ color: $grey-actor-name;
+ margin-left: 5px;
+ }
+
+ .video-channel-followers {
+
+ }
+ }
+ }
+
+ .video-channel-buttons {
+ margin-top: 10px;
+ min-width: 190px;
+ }
+}
+
+.video-channels-header {
+ text-align: right;
+ margin: 20px 0 50px;
+}
+
+::ng-deep .chartjs-render-monitor {
+ position: relative;
+ top: 1px;
+}
+
+@media screen and (max-width: $small-view) {
+ .video-channels-header {
+ text-align: center;
+ }
+
+ .video-channel {
+ padding-bottom: 10px;
+
+ .video-channel-info {
+ padding-bottom: 10px;
+ text-align: center;
+
+ .video-channel-names {
+ flex-direction: column;
+ align-items: center !important;
+ margin: auto;
+
+ .video-channel-name {
+ margin-left: 0px !important;
+ }
+ }
+ }
+
+ img {
+ margin-right: 0;
+ }
+
+ .video-channel-buttons {
+ align-self: center;
+ }
+ }
+}
+
+@media screen and (min-width: breakpoint(lg)) {
+ :host-context(.main-col:not(.expanded)) {
+ .video-channel-buttons {
+ float: right;
+ }
+ }
+}
+
+@media screen and (min-width: $small-view) {
+ :host-context(.expanded) {
+ .video-channel-buttons {
+ float: right;
+ }
+ }
+}
--- /dev/null
+import { ChartData } from 'chart.js'
+import { max, maxBy, min, minBy } from 'lodash-es'
+import { flatMap } from 'rxjs/operators'
+import { Component, OnInit } from '@angular/core'
+import { AuthService, ConfirmService, Notifier, ScreenService, User } from '@app/core'
+import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+ selector: 'my-account-video-channels',
+ templateUrl: './my-account-video-channels.component.html',
+ styleUrls: [ './my-account-video-channels.component.scss' ]
+})
+export class MyAccountVideoChannelsComponent implements OnInit {
+ videoChannels: VideoChannel[] = []
+ videoChannelsChartData: ChartData[]
+ videoChannelsMinimumDailyViews = 0
+ videoChannelsMaximumDailyViews: number
+
+ private user: User
+
+ constructor (
+ private authService: AuthService,
+ private notifier: Notifier,
+ private confirmService: ConfirmService,
+ private videoChannelService: VideoChannelService,
+ private screenService: ScreenService,
+ private i18n: I18n
+ ) {}
+
+ ngOnInit () {
+ this.user = this.authService.getUser()
+
+ this.loadVideoChannels()
+ }
+
+ get isInSmallView () {
+ return this.screenService.isInSmallView()
+ }
+
+ get chartOptions () {
+ return {
+ legend: {
+ display: false
+ },
+ scales: {
+ xAxes: [{
+ display: false
+ }],
+ yAxes: [{
+ display: false,
+ ticks: {
+ min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)),
+ max: Math.max(1, this.videoChannelsMaximumDailyViews)
+ }
+ }]
+ },
+ layout: {
+ padding: {
+ left: 15,
+ right: 15,
+ top: 10,
+ bottom: 0
+ }
+ },
+ elements: {
+ point: {
+ radius: 0
+ }
+ },
+ tooltips: {
+ mode: 'index',
+ intersect: false,
+ custom: function (tooltip: any) {
+ if (!tooltip) return
+ // disable displaying the color box
+ tooltip.displayColors = false
+ },
+ callbacks: {
+ label: (tooltip: any, data: any) => `${tooltip.value} views`
+ }
+ },
+ hover: {
+ mode: 'index',
+ intersect: false
+ }
+ }
+ }
+
+ async deleteVideoChannel (videoChannel: VideoChannel) {
+ const res = await this.confirmService.confirmWithInput(
+ this.i18n(
+ // tslint:disable
+ 'Do you really want to delete {{channelDisplayName}}? It will delete {{videosCount}} videos uploaded in this channel, and you will not be able to create another channel with the same name ({{channelName}})!',
+ { channelDisplayName: videoChannel.displayName, videosCount: videoChannel.videosCount, channelName: videoChannel.name }
+ ),
+ this.i18n(
+ 'Please type the display name of the video channel ({{displayName}}) to confirm',
+ { displayName: videoChannel.displayName }
+ ),
+ videoChannel.displayName,
+ this.i18n('Delete')
+ )
+ if (res === false) return
+
+ this.videoChannelService.removeVideoChannel(videoChannel)
+ .subscribe(
+ () => {
+ this.loadVideoChannels()
+ this.notifier.success(
+ this.i18n('Video channel {{videoChannelName}} deleted.', { videoChannelName: videoChannel.displayName })
+ )
+ },
+
+ error => this.notifier.error(error.message)
+ )
+ }
+
+ private loadVideoChannels () {
+ this.authService.userInformationLoaded
+ .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true)))
+ .subscribe(res => {
+ this.videoChannels = res.data
+
+ // chart data
+ this.videoChannelsChartData = this.videoChannels.map(v => ({
+ labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()),
+ datasets: [
+ {
+ label: this.i18n('Views for the day'),
+ data: v.viewsPerDay.map(day => day.views),
+ fill: false,
+ borderColor: "#c6c6c6"
+ }
+ ]
+ } as ChartData))
+
+ // chart options that depend on chart data:
+ // we don't want to skew values and have min at 0, so we define what the floor/ceiling is here
+ this.videoChannelsMinimumDailyViews = min(
+ this.videoChannels.map(v => minBy( // compute local minimum daily views for each channel, by their "views" attribute
+ v.viewsPerDay,
+ day => day.views
+ ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
+ )
+ this.videoChannelsMaximumDailyViews = max(
+ this.videoChannels.map(v => maxBy( // compute local maximum daily views for each channel, by their "views" attribute
+ v.viewsPerDay,
+ day => day.views
+ ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
+ )
+ })
+ }
+}
--- /dev/null
+import { ChartModule } from 'primeng/chart'
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
+import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
+import { MyAccountVideoChannelsRoutingModule } from './my-account-video-channels-routing.module'
+import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
+
+@NgModule({
+ imports: [
+ MyAccountVideoChannelsRoutingModule,
+
+ ChartModule,
+
+ SharedMainModule,
+ SharedFormModule,
+ SharedGlobalIconModule
+ ],
+
+ declarations: [
+ MyAccountVideoChannelsComponent,
+ MyAccountVideoChannelCreateComponent,
+ MyAccountVideoChannelUpdateComponent
+ ],
+
+ exports: [],
+ providers: []
+})
+export class MyAccountVideoChannelsModule { }
import { Component } from '@angular/core'
-import { GenericAccountBlocklistComponent, BlocklistComponentType } from '@app/shared/blocklist'
+import { BlocklistComponentType, GenericAccountBlocklistComponent } from '@app/shared/shared-moderation'
@Component({
selector: 'my-account-blocklist',
- styleUrls: [ '../../shared/blocklist/account-blocklist.component.scss' ],
- templateUrl: '../../shared/blocklist/account-blocklist.component.html'
+ styleUrls: [ '../../shared/shared-moderation/account-blocklist.component.scss' ],
+ templateUrl: '../../shared/shared-moderation/account-blocklist.component.html'
})
export class MyAccountBlocklistComponent extends GenericAccountBlocklistComponent {
mode = BlocklistComponentType.Account
import { Component } from '@angular/core'
-import { GenericServerBlocklistComponent, BlocklistComponentType } from '@app/shared/blocklist'
+import { BlocklistComponentType, GenericServerBlocklistComponent } from '@app/shared/shared-moderation'
@Component({
selector: 'my-account-server-blocklist',
- styleUrls: [ '../../+admin/moderation/moderation.component.scss', '../../shared/blocklist/server-blocklist.component.scss' ],
- templateUrl: '../../shared/blocklist/server-blocklist.component.html'
+ styleUrls: [ '../../+admin/moderation/moderation.component.scss', '../../shared/shared-moderation/server-blocklist.component.scss' ],
+ templateUrl: '../../shared/shared-moderation/server-blocklist.component.html'
})
export class MyAccountServerBlocklistComponent extends GenericServerBlocklistComponent {
mode = BlocklistComponentType.Account
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoService } from '../../shared/video/video.service'
+import {
+ AuthService,
+ ComponentPagination,
+ ConfirmService,
+ LocalStorageService,
+ Notifier,
+ ScreenService,
+ ServerService,
+ UserService
+} from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { UserHistoryService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { UserHistoryService } from '@app/shared/users/user-history.service'
-import { UserService } from '@app/shared'
-import { Notifier, ServerService } from '@app/core'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
@Component({
selector: 'my-account-history',
protected screenService: ScreenService,
protected storageService: LocalStorageService,
private confirmService: ConfirmService,
- private videoService: VideoService,
private userHistoryService: UserHistoryService
) {
super()
import { Component, ViewChild } from '@angular/core'
-import { UserNotificationsComponent } from '@app/shared'
+import { UserNotificationsComponent } from '@app/shared/shared-main'
@Component({
templateUrl: './my-account-notifications.component.html',
import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { AuthService, Notifier } from '@app/core'
-import { FormReactive } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoOwnershipService } from '@app/shared/video-ownership'
-import { VideoChangeOwnership } from '../../../../../../shared/models/videos'
-import { VideoAcceptOwnershipValidatorsService } from '@app/shared/forms/form-validators'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FormReactive, FormValidatorService, VideoAcceptOwnershipValidatorsService } from '@app/shared/shared-forms'
+import { VideoChannelService, VideoOwnershipService } from '@app/shared/shared-main'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoChangeOwnership, VideoChannel } from '@shared/models'
@Component({
selector: 'my-account-accept-ownership',
-import { Component, OnInit, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
-import { RestPagination, RestTable } from '@app/shared'
import { SortMeta } from 'primeng/api'
-import { VideoChangeOwnership } from '../../../../../shared'
-import { VideoOwnershipService } from '@app/shared/video-ownership'
-import { Account } from '@app/shared/account/account.model'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { Notifier, RestPagination, RestTable } from '@app/core'
+import { Account, VideoOwnershipService } from '@app/shared/shared-main'
+import { VideoChangeOwnership } from '@shared/models'
import { MyAccountAcceptOwnershipComponent } from './my-account-accept-ownership/my-account-accept-ownership.component'
@Component({
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { LoginGuard } from '../core'
-import { MyAccountComponent } from './my-account.component'
+import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
+import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
+import { MyAccountHistoryComponent } from './my-account-history/my-account-history.component'
+import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
+import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
+import { MyAccountSubscriptionsComponent } from './my-account-subscriptions/my-account-subscriptions.component'
+import { MyAccountVideoImportsComponent } from './my-account-video-imports/my-account-video-imports.component'
+import { MyAccountVideoPlaylistCreateComponent } from './my-account-video-playlists/my-account-video-playlist-create.component'
+import { MyAccountVideoPlaylistElementsComponent } from './my-account-video-playlists/my-account-video-playlist-elements.component'
+import { MyAccountVideoPlaylistUpdateComponent } from './my-account-video-playlists/my-account-video-playlist-update.component'
+import { MyAccountVideoPlaylistsComponent } from './my-account-video-playlists/my-account-video-playlists.component'
import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component'
-import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component'
-import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component'
-import { MyAccountOwnershipComponent } from '@app/+my-account/my-account-ownership/my-account-ownership.component'
-import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component'
-import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
-import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
-import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
-import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
-import {
- MyAccountVideoPlaylistCreateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
-import {
- MyAccountVideoPlaylistUpdateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
-import {
- MyAccountVideoPlaylistElementsComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
+import { MyAccountComponent } from './my-account.component'
const myAccountRoutes: Routes = [
{
{
path: 'video-channels',
- loadChildren: () => import('./my-account-video-channels/my-account-video-channels.module').then(m => m.MyAccountVideoChannelsModule)
+ loadChildren: () => {
+ return import('./+my-account-video-channels/my-account-video-channels.module')
+ .then(m => m.MyAccountVideoChannelsModule)
+ }
},
{
+import { forkJoin } from 'rxjs'
+import { tap } from 'rxjs/operators'
import { Component, OnInit } from '@angular/core'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { FormReactive, UserService } from '../../../shared'
+import { AuthService, ServerService, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
-import { User } from '../../../../../../shared'
-import { tap } from 'rxjs/operators'
-import { forkJoin } from 'rxjs'
+import { User } from '@shared/models'
@Component({
selector: 'my-account-change-email',
constructor (
protected formValidatorService: FormValidatorService,
private userValidatorsService: UserValidatorsService,
- private notifier: Notifier,
private authService: AuthService,
private userService: UserService,
private serverService: ServerService,
+import { filter } from 'rxjs/operators'
import { Component, OnInit } from '@angular/core'
-import { AuthService, Notifier } from '@app/core'
-import { FormReactive, UserService } from '../../../shared'
+import { AuthService, Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
-import { filter } from 'rxjs/operators'
-import { User } from '../../../../../../shared'
+import { User } from '@shared/models'
@Component({
selector: 'my-account-change-password',
import { Component, Input } from '@angular/core'
-import { Notifier } from '@app/core'
-import { AuthService, ConfirmService, RedirectService } from '../../../core'
-import { UserService } from '../../../shared'
+import { AuthService, ConfirmService, Notifier, RedirectService, User, UserService } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { User } from '@app/shared'
@Component({
selector: 'my-account-danger-zone',
+++ /dev/null
-export * from './my-account-interface-settings.component'
+++ /dev/null
-<form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form">
-
- <div class="form-group">
- <label i18n for="theme">Theme</label>
-
- <div class="peertube-select-container">
- <select formControlName="theme" id="theme" class="form-control">
- <option i18n value="instance-default">instance default</option>
- <option i18n value="default">peertube default</option>
-
- <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
- </select>
- </div>
- </div>
-
- <input *ngIf="!reactiveUpdate" type="submit" class="mt-0" i18n-value value="Save" [disabled]="!form.valid">
-</form>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-label {
- font-weight: $font-regular;
- font-size: 100%;
-}
-
-input[type=submit] {
- @include peertube-button;
- @include orange-button;
-
- display: block;
- margin-top: 15px;
-}
-
-.peertube-select-container {
- @include peertube-select-container(340px);
-
- margin-bottom: 30px;
-}
+++ /dev/null
-import { Component, Input, OnInit, OnDestroy } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
-import { ServerConfig, UserUpdateMe } from '../../../../../../shared'
-import { AuthService } from '../../../core'
-import { FormReactive } from '../../../shared/forms/form-reactive'
-import { User, UserService } from '../../../shared/users'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { Subject, Subscription } from 'rxjs'
-
-@Component({
- selector: 'my-account-interface-settings',
- templateUrl: './my-account-interface-settings.component.html',
- styleUrls: [ './my-account-interface-settings.component.scss' ]
-})
-export class MyAccountInterfaceSettingsComponent extends FormReactive implements OnInit, OnDestroy {
- @Input() user: User = null
- @Input() reactiveUpdate = false
- @Input() notifyOnUpdate = true
- @Input() userInformationLoaded: Subject<any>
-
- formValuesWatcher: Subscription
-
- private serverConfig: ServerConfig
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private authService: AuthService,
- private notifier: Notifier,
- private userService: UserService,
- private serverService: ServerService,
- private i18n: I18n
- ) {
- super()
- }
-
- get availableThemes () {
- return this.serverConfig.theme.registered
- .map(t => t.name)
- }
-
- ngOnInit () {
- this.serverConfig = this.serverService.getTmpConfig()
- this.serverService.getConfig()
- .subscribe(config => this.serverConfig = config)
-
- this.buildForm({
- theme: null
- })
-
- this.userInformationLoaded
- .subscribe(() => {
- this.form.patchValue({
- theme: this.user.theme
- })
-
- if (this.reactiveUpdate) {
- this.formValuesWatcher = this.form.valueChanges.subscribe(val => this.updateInterfaceSettings())
- }
- })
- }
-
- ngOnDestroy () {
- this.formValuesWatcher?.unsubscribe()
- }
-
- updateInterfaceSettings () {
- const theme = this.form.value['theme']
-
- const details: UserUpdateMe = {
- theme
- }
-
- if (this.authService.isLoggedIn()) {
- this.userService.updateMyProfile(details).subscribe(
- () => {
- this.authService.refreshUserInformation()
-
- if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.'))
- },
-
- err => this.notifier.error(err.message)
- )
- } else {
- this.userService.updateMyAnonymousProfile(details)
- if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.'))
- }
- }
-}
+import { debounce } from 'lodash-es'
+import { Subject } from 'rxjs'
import { Component, Input, OnInit } from '@angular/core'
-import { User } from '@app/shared'
+import { Notifier, ServerService, User } from '@app/core'
+import { UserNotificationService } from '@app/shared/shared-main'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subject } from 'rxjs'
-import { UserNotificationSetting, UserNotificationSettingValue, UserRight } from '../../../../../../shared'
-import { Notifier, ServerService } from '@app/core'
-import { debounce } from 'lodash-es'
-import { UserNotificationService } from '@app/shared/users/user-notification.service'
+import { UserNotificationSetting, UserNotificationSettingValue, UserRight } from '@shared/models'
@Component({
selector: 'my-account-notification-preferences',
+import { Subject } from 'rxjs'
import { Component, Input, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { FormReactive, UserService } from '../../../shared'
-import { User } from '@app/shared'
+import { Notifier, User, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { Subject } from 'rxjs'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
@Component({
selector: 'my-account-profile',
</div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
- <my-account-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-video-settings>
+ <my-user-video-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-user-video-settings>
</div>
</div>
</div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
- <my-account-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-interface-settings>
+ <my-user-interface-settings [user]="user" [userInformationLoaded]="userInformationLoaded"></my-user-interface-settings>
</div>
</div>
-import { Component, OnInit, AfterViewChecked } from '@angular/core'
-import { Notifier } from '@app/core'
import { BytesPipe } from 'ngx-pipes'
-import { AuthService } from '../../core'
-import { User } from '../../shared'
-import { UserService } from '../../shared/users'
-import { I18n } from '@ngx-translate/i18n-polyfill'
import { ViewportScroller } from '@angular/common'
+import { AfterViewChecked, Component, OnInit } from '@angular/core'
+import { AuthService, Notifier, User, UserService } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
@Component({
selector: 'my-account-settings',
+++ /dev/null
-export * from './my-account-video-settings.component'
+++ /dev/null
-<form role="form" (ngSubmit)="updateDetails()" [formGroup]="form">
- <div class="form-group form-group-select">
- <label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label>
- <my-help>
- <ng-template ptTemplate="customHtml">
- <ng-container i18n>
- With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
- </ng-container>
- </ng-template>
- </my-help>
-
- <div class="peertube-select-container">
- <select id="nsfwPolicy" formControlName="nsfwPolicy" class="form-control">
- <option i18n value="undefined" disabled>Policy for sensitive videos</option>
- <option i18n value="do_not_list">Do not list</option>
- <option i18n value="blur">Blur thumbnails</option>
- <option i18n value="display">Display</option>
- </select>
- </div>
- </div>
-
- <div class="form-group form-group-select">
- <label i18n for="videoLanguages">Only display videos in the following languages/subtitles</label>
- <my-help>
- <ng-template ptTemplate="customHtml">
- <ng-container i18n>In Recently added, Trending, Local, Most liked and Search pages</ng-container>
- </ng-template>
- </my-help>
-
- <div>
- <p-multiSelect
- inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" [showToggleAll]="true"
- [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()"
- emptyFilterMessage="No results found" i18n-emptyFilterMessage
- ></p-multiSelect>
- </div>
- </div>
-
- <ng-content select="inner-title"></ng-content>
-
- <div class="form-group">
- <my-peertube-checkbox
- inputName="webTorrentEnabled" formControlName="webTorrentEnabled" [recommended]="true"
- i18n-labelText labelText="Help share videos being played"
- >
- <ng-container ngProjectAs="description">
- <span i18n>The <a routerLink="/about/peertube" fragment="privacy">sharing system</a> implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load.</span>
- </ng-container>
- </my-peertube-checkbox>
- </div>
-
- <div class="form-group">
- <my-peertube-checkbox
- inputName="autoPlayVideo" formControlName="autoPlayVideo"
- i18n-labelText labelText="Automatically play videos"
- >
- <ng-container ngProjectAs="description">
- <span i18n>When on a video page, directly start playing the video.</span>
- </ng-container>
- </my-peertube-checkbox>
- </div>
-
- <div class="form-group">
- <my-peertube-checkbox
- inputName="autoPlayNextVideo" formControlName="autoPlayNextVideo"
- i18n-labelText labelText="Automatically start playing the next video"
- >
- <ng-container ngProjectAs="description">
- <span i18n>When a video ends, follow up with the next suggested video.</span>
- </ng-container>
- </my-peertube-checkbox>
- </div>
-
- <input *ngIf="!reactiveUpdate" type="submit" i18n-value value="Save" [disabled]="!form.valid">
-</form>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-label {
- font-weight: $font-regular;
- font-size: 100%;
-}
-
-input[type=submit] {
- @include peertube-button;
- @include orange-button;
-
- margin-top: 15px;
-}
-
-.peertube-select-container {
- @include peertube-select-container(340px);
-
- margin-bottom: 30px;
-}
-
-.form-group-select {
- margin-bottom: 30px;
-}
+++ /dev/null
-import { Component, Input, OnInit, OnDestroy } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
-import { UserUpdateMe } from '../../../../../../shared/models/users'
-import { User, UserService } from '@app/shared/users'
-import { AuthService } from '../../../core'
-import { FormReactive } from '@app/shared/forms/form-reactive'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { forkJoin, Subject, Subscription } from 'rxjs'
-import { SelectItem } from 'primeng/api'
-import { first } from 'rxjs/operators'
-import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { pick } from 'lodash-es'
-
-@Component({
- selector: 'my-account-video-settings',
- templateUrl: './my-account-video-settings.component.html',
- styleUrls: [ './my-account-video-settings.component.scss' ]
-})
-export class MyAccountVideoSettingsComponent extends FormReactive implements OnInit, OnDestroy {
- @Input() user: User = null
- @Input() reactiveUpdate = false
- @Input() notifyOnUpdate = true
- @Input() userInformationLoaded: Subject<any>
-
- languageItems: SelectItem[] = []
- defaultNSFWPolicy: NSFWPolicyType
- formValuesWatcher: Subscription
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private authService: AuthService,
- private notifier: Notifier,
- private userService: UserService,
- private serverService: ServerService,
- private i18n: I18n
- ) {
- super()
- }
-
- ngOnInit () {
- let oldForm: any
-
- this.buildForm({
- nsfwPolicy: null,
- webTorrentEnabled: null,
- autoPlayVideo: null,
- autoPlayNextVideo: null,
- videoLanguages: null
- })
-
- forkJoin([
- this.serverService.getVideoLanguages(),
- this.serverService.getConfig(),
- this.userInformationLoaded.pipe(first())
- ]).subscribe(([ languages, config ]) => {
- this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ]
- this.languageItems = this.languageItems
- .concat(languages.map(l => ({ label: l.label, value: l.id })))
-
- const videoLanguages = this.user.videoLanguages
- ? this.user.videoLanguages
- : this.languageItems.map(l => l.value)
-
- this.defaultNSFWPolicy = config.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) {
- oldForm = { ...this.form.value }
- this.formValuesWatcher = this.form.valueChanges.subscribe((formValue: any) => {
- const updatedKey = Object.keys(formValue).find(k => formValue[k] !== oldForm[k])
- oldForm = { ...this.form.value }
- this.updateDetails([updatedKey])
- })
- }
- })
- }
-
- ngOnDestroy () {
- this.formValuesWatcher?.unsubscribe()
- }
-
- updateDetails (onlyKeys?: string[]) {
- const nsfwPolicy = this.form.value[ 'nsfwPolicy' ]
- const webTorrentEnabled = this.form.value['webTorrentEnabled']
- const autoPlayVideo = this.form.value['autoPlayVideo']
- const autoPlayNextVideo = this.form.value['autoPlayNextVideo']
-
- let videoLanguages: string[] = this.form.value['videoLanguages']
- if (Array.isArray(videoLanguages)) {
- if (videoLanguages.length === this.languageItems.length) {
- videoLanguages = null // null means "All"
- } else if (videoLanguages.length > 20) {
- this.notifier.error('Too many languages are enabled. Please enable them all or stay below 20 enabled languages.')
- return
- } else if (videoLanguages.length === 0) {
- this.notifier.error('You need to enabled at least 1 video language.')
- return
- }
- }
-
- let details: UserUpdateMe = {
- nsfwPolicy,
- webTorrentEnabled,
- autoPlayVideo,
- autoPlayNextVideo,
- videoLanguages
- }
-
- if (onlyKeys) details = pick(details, onlyKeys)
-
- if (this.authService.isLoggedIn()) {
- this.userService.updateMyProfile(details).subscribe(
- () => {
- this.authService.refreshUserInformation()
-
- if (this.notifyOnUpdate) this.notifier.success(this.i18n('Video settings updated.'))
- },
-
- err => this.notifier.error(err.message)
- )
- } else {
- this.userService.updateMyAnonymousProfile(details)
- if (this.notifyOnUpdate) this.notifier.success(this.i18n('Display/Video settings updated.'))
- }
- }
-
- getDefaultVideoLanguageLabel () {
- return this.i18n('No language')
- }
-
- getSelectedVideoLanguageLabel () {
- return this.i18n('{{\'{0} languages selected')
- }
-}
-import { Component, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { UserSubscriptionService } from '@app/shared/user-subscription'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
import { Subject } from 'rxjs'
+import { Component, OnInit } from '@angular/core'
+import { ComponentPagination, Notifier } from '@app/core'
+import { VideoChannel } from '@app/shared/shared-main'
+import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
@Component({
selector: 'my-account-subscriptions',
+++ /dev/null
-import { Component, OnInit } from '@angular/core'
-import { Router } from '@angular/router'
-import { AuthService, Notifier } from '@app/core'
-import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit'
-import { VideoChannelCreate } from '../../../../../shared/models/videos'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoChannelValidatorsService } from '@app/shared/forms/form-validators/video-channel-validators.service'
-
-@Component({
- selector: 'my-account-video-channel-create',
- templateUrl: './my-account-video-channel-edit.component.html',
- styleUrls: [ './my-account-video-channel-edit.component.scss' ]
-})
-export class MyAccountVideoChannelCreateComponent extends MyAccountVideoChannelEdit implements OnInit {
- error: string
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private authService: AuthService,
- private videoChannelValidatorsService: VideoChannelValidatorsService,
- private notifier: Notifier,
- private router: Router,
- private videoChannelService: VideoChannelService,
- private i18n: I18n
- ) {
- super()
- }
-
- get instanceHost () {
- return window.location.host
- }
-
- ngOnInit () {
- this.buildForm({
- name: this.videoChannelValidatorsService.VIDEO_CHANNEL_NAME,
- 'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
- description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION,
- support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT
- })
- }
-
- formValidated () {
- this.error = undefined
-
- const body = this.form.value
- const videoChannelCreate: VideoChannelCreate = {
- name: body.name,
- displayName: body['display-name'],
- description: body.description || null,
- support: body.support || null
- }
-
- this.videoChannelService.createVideoChannel(videoChannelCreate).subscribe(
- () => {
- this.authService.refreshUserInformation()
-
- this.notifier.success(
- this.i18n('Video channel {{videoChannelName}} created.', { videoChannelName: videoChannelCreate.displayName })
- )
- this.router.navigate([ '/my-account', 'video-channels' ])
- },
-
- err => {
- if (err.status === 409) {
- this.error = this.i18n('This name already exists on this instance.')
- return
- }
-
- this.error = err.message
- }
- )
- }
-
- isCreation () {
- return true
- }
-
- getFormButtonTitle () {
- return this.i18n('Create')
- }
-}
+++ /dev/null
-<nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item">
- <a routerLink="/my-account/video-channels" i18n>My Channels</a>
- </li>
-
- <ng-container *ngIf="isCreation()">
- <li class="breadcrumb-item active" i18n>Create</li>
- </ng-container>
- <ng-container *ngIf="!isCreation()">
- <li class="breadcrumb-item active" i18n>Edit</li>
- <li class="breadcrumb-item active" aria-current="page">
- <a *ngIf="videoChannelToUpdate" [routerLink]="[ '/my-account/video-channels/update', videoChannelToUpdate?.nameWithHost ]">{{ videoChannelToUpdate?.displayName }}</a>
- </li>
- </ng-container>
- </ol>
-</nav>
-
-<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
-
-<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
-
- <div class="form-row"> <!-- channel grid -->
- <div class="form-group col-12 col-lg-4 col-xl-3">
- <div *ngIf="isCreation()" class="video-channel-title" i18n>NEW CHANNEL</div>
- <div *ngIf="!isCreation() && videoChannelToUpdate" class="video-channel-title" i18n>CHANNEL</div>
- </div>
-
- <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
-
- <div class="form-group" *ngIf="isCreation()">
- <label i18n for="name">Name</label>
- <div class="input-group">
- <input
- type="text" id="name" i18n-placeholder placeholder="Example: my_channel"
- formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" class="form-control"
- >
- <div class="input-group-append">
- <span class="input-group-text">@{{ instanceHost }}</span>
- </div>
- </div>
- <div *ngIf="formErrors['name']" class="form-error">
- {{ formErrors['name'] }}
- </div>
- </div>
-
- <my-actor-avatar-info
- *ngIf="!isCreation() && videoChannelToUpdate"
- [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)"
- ></my-actor-avatar-info>
-
- <div class="form-group">
- <label i18n for="display-name">Display name</label>
- <input
- type="text" id="display-name" class="form-control"
- formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
- >
- <div *ngIf="formErrors['display-name']" class="form-error">
- {{ formErrors['display-name'] }}
- </div>
- </div>
-
- <div class="form-group">
- <label i18n for="description">Description</label>
- <textarea
- id="description" formControlName="description" class="form-control"
- [ngClass]="{ 'input-error': formErrors['description'] }"
- ></textarea>
- <div *ngIf="formErrors.description" class="form-error">
- {{ formErrors.description }}
- </div>
- </div>
-
- <div class="form-group">
- <label for="support">Support</label>
- <my-help
- helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support your channel (membership platform...).<br /><br />
- When you will upload a video in this channel, the video support field will be automatically filled by this text."
- ></my-help>
- <my-markdown-textarea
- id="support" formControlName="support" textareaMaxWidth="500px" markdownType="enhanced"
- [classes]="{ 'input-error': formErrors['support'] }"
- ></my-markdown-textarea>
- <div *ngIf="formErrors.support" class="form-error">
- {{ formErrors.support }}
- </div>
- </div>
-
- <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()">
- <my-peertube-checkbox
- inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate"
- i18n-labelText labelText="Overwrite support field of all videos of this channel"
- ></my-peertube-checkbox>
- </div>
-
- </div>
- </div>
-
- <div class="form-row"> <!-- submit placement block -->
- <div class="col-md-7 col-xl-5"></div>
- <div class="col-md-5 col-xl-5 d-inline-flex">
- <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
- </div>
- </div>
-</form>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-label {
- font-weight: $font-regular;
- font-size: 100%;
-}
-
-.video-channel-title {
- @include settings-big-title;
-}
-
-my-actor-avatar-info {
- display: block;
- margin-bottom: 20px;
-}
-
-.input-group {
- @include peertube-input-group(fit-content);
-}
-
-.input-group-append {
- height: 30px;
-}
-
-input {
- &[type=text] {
- @include peertube-input-text(340px);
-
- display: block;
-
- &#name {
- width: auto;
- flex-grow: 1;
- }
- }
-
- &[type=submit] {
- @include peertube-button;
- @include orange-button;
- margin-left: auto;
- }
-}
-
-textarea {
- @include peertube-textarea(500px, 150px);
-
- display: block;
-}
-
-.peertube-select-container {
- @include peertube-select-container(340px);
-}
-
-.breadcrumb {
- @include breadcrumb;
-}
-
-@media screen and (max-width: $small-view) {
- input[type=text]#name {
- width: auto !important;
- }
-
- label[for=name] + div, textarea {
- width: 100%;
- }
-}
+++ /dev/null
-import { FormReactive } from '@app/shared'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-
-export abstract class MyAccountVideoChannelEdit extends FormReactive {
- // We need it even in the create component because it's used in the edit template
- videoChannelToUpdate: VideoChannel
- instanceHost: string
-
- abstract isCreation (): boolean
- abstract getFormButtonTitle (): string
-
- // We need this method so angular does not complain in child template that doesn't need this
- onAvatarChange (formData: FormData) { /* empty */ }
-
- // Should be implemented by the child
- isBulkUpdateVideosDisplayed () {
- return false
- }
-}
+++ /dev/null
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit'
-import { VideoChannelUpdate } from '../../../../../shared/models/videos'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { Subscription } from 'rxjs'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoChannelValidatorsService } from '@app/shared/forms/form-validators/video-channel-validators.service'
-import { ServerConfig } from '@shared/models'
-
-@Component({
- selector: 'my-account-video-channel-update',
- templateUrl: './my-account-video-channel-edit.component.html',
- styleUrls: [ './my-account-video-channel-edit.component.scss' ]
-})
-export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelEdit implements OnInit, OnDestroy {
- error: string
- videoChannelToUpdate: VideoChannel
-
- private paramsSub: Subscription
- private oldSupportField: string
- private serverConfig: ServerConfig
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private authService: AuthService,
- private videoChannelValidatorsService: VideoChannelValidatorsService,
- private notifier: Notifier,
- private router: Router,
- private route: ActivatedRoute,
- private videoChannelService: VideoChannelService,
- private i18n: I18n,
- private serverService: ServerService
- ) {
- super()
- }
-
- ngOnInit () {
- this.serverConfig = this.serverService.getTmpConfig()
- this.serverService.getConfig()
- .subscribe(config => this.serverConfig = config)
-
- this.buildForm({
- 'display-name': this.videoChannelValidatorsService.VIDEO_CHANNEL_DISPLAY_NAME,
- description: this.videoChannelValidatorsService.VIDEO_CHANNEL_DESCRIPTION,
- support: this.videoChannelValidatorsService.VIDEO_CHANNEL_SUPPORT,
- bulkVideosSupportUpdate: null
- })
-
- this.paramsSub = this.route.params.subscribe(routeParams => {
- const videoChannelId = routeParams['videoChannelId']
-
- this.videoChannelService.getVideoChannel(videoChannelId).subscribe(
- videoChannelToUpdate => {
- this.videoChannelToUpdate = videoChannelToUpdate
-
- this.oldSupportField = videoChannelToUpdate.support
-
- this.form.patchValue({
- 'display-name': videoChannelToUpdate.displayName,
- description: videoChannelToUpdate.description,
- support: videoChannelToUpdate.support
- })
- },
-
- err => this.error = err.message
- )
- })
- }
-
- ngOnDestroy () {
- if (this.paramsSub) this.paramsSub.unsubscribe()
- }
-
- formValidated () {
- this.error = undefined
-
- const body = this.form.value
- const videoChannelUpdate: VideoChannelUpdate = {
- displayName: body['display-name'],
- description: body.description || null,
- support: body.support || null,
- bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false
- }
-
- this.videoChannelService.updateVideoChannel(this.videoChannelToUpdate.name, videoChannelUpdate).subscribe(
- () => {
- this.authService.refreshUserInformation()
-
- this.notifier.success(
- this.i18n('Video channel {{videoChannelName}} updated.', { videoChannelName: videoChannelUpdate.displayName })
- )
-
- this.router.navigate([ '/my-account', 'video-channels' ])
- },
-
- err => this.error = err.message
- )
- }
-
- onAvatarChange (formData: FormData) {
- this.videoChannelService.changeVideoChannelAvatar(this.videoChannelToUpdate.name, formData)
- .subscribe(
- data => {
- this.notifier.success(this.i18n('Avatar changed.'))
-
- this.videoChannelToUpdate.updateAvatar(data.avatar)
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- get maxAvatarSize () {
- return this.serverConfig.avatar.file.size.max
- }
-
- get avatarExtensions () {
- return this.serverConfig.avatar.file.extensions.join(',')
- }
-
- isCreation () {
- return false
- }
-
- getFormButtonTitle () {
- return this.i18n('Update')
- }
-
- isBulkUpdateVideosDisplayed () {
- if (this.oldSupportField === undefined) return false
-
- return this.oldSupportField !== this.form.value['support']
- }
-}
+++ /dev/null
-import { NgModule } from '@angular/core'
-import { RouterModule, Routes } from '@angular/router'
-import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
-import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
-import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
-
-const myAccountVideoChannelsRoutes: Routes = [
- {
- path: '',
- component: MyAccountVideoChannelsComponent,
- data: {
- meta: {
- title: 'Account video channels'
- }
- }
- },
- {
- path: 'create',
- component: MyAccountVideoChannelCreateComponent,
- data: {
- meta: {
- title: 'Create new video channel'
- }
- }
- },
- {
- path: 'update/:videoChannelId',
- component: MyAccountVideoChannelUpdateComponent,
- data: {
- meta: {
- title: 'Update video channel'
- }
- }
- }
-]
-
-@NgModule({
- imports: [ RouterModule.forChild(myAccountVideoChannelsRoutes) ],
- exports: [ RouterModule ]
-})
-export class MyAccountVideoChannelsRoutingModule {}
+++ /dev/null
-<h1 class="sr-only" i18n>My channels</h1>
-<div class="video-channels-header">
- <a class="create-button" routerLink="create">
- <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
- <ng-container i18n>Create video channel</ng-container>
- </a>
-</div>
-
-<div class="video-channels">
- <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
- <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]">
- <img [src]="videoChannel.avatarUrl" alt="Avatar" />
- </a>
-
- <div class="video-channel-info">
- <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
- <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
- <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
- </a>
-
- <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
-
- <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div>
-
- <div class="video-channel-buttons">
- <my-edit-button [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button>
- <my-delete-button (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
- </div>
-
- <div *ngIf="!isInSmallView" class="w-100 d-flex justify-content-end">
- <p-chart *ngIf="videoChannelsChartData && videoChannelsChartData[i]" type="line" [data]="videoChannelsChartData[i]" [options]="chartOptions" width="40vw" height="100px"></p-chart>
- </div>
- </div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.create-button {
- @include create-button;
-}
-
-::ng-deep .action-button {
- &.action-button-edit {
- margin-right: 10px;
- }
-}
-
-.video-channel {
- @include row-blocks;
- padding-bottom: 0;
-
- img {
- @include avatar(80px);
-
- margin-right: 10px;
- }
-
- .video-channel-info {
- flex-grow: 1;
-
- a.video-channel-names {
- @include disable-default-a-behaviour;
-
- width: fit-content;
- display: flex;
- align-items: baseline;
- color: pvar(--mainForegroundColor);
-
- .video-channel-display-name {
- font-weight: $font-semibold;
- font-size: 18px;
- }
-
- .video-channel-name {
- font-size: 14px;
- color: $grey-actor-name;
- margin-left: 5px;
- }
-
- .video-channel-followers {
-
- }
- }
- }
-
- .video-channel-buttons {
- margin-top: 10px;
- min-width: 190px;
- }
-}
-
-.video-channels-header {
- text-align: right;
- margin: 20px 0 50px;
-}
-
-::ng-deep .chartjs-render-monitor {
- position: relative;
- top: 1px;
-}
-
-@media screen and (max-width: $small-view) {
- .video-channels-header {
- text-align: center;
- }
-
- .video-channel {
- padding-bottom: 10px;
-
- .video-channel-info {
- padding-bottom: 10px;
- text-align: center;
-
- .video-channel-names {
- flex-direction: column;
- align-items: center !important;
- margin: auto;
-
- .video-channel-name {
- margin-left: 0px !important;
- }
- }
- }
-
- img {
- margin-right: 0;
- }
-
- .video-channel-buttons {
- align-self: center;
- }
- }
-}
-
-@media screen and (min-width: breakpoint(lg)) {
- :host-context(.main-col:not(.expanded)) {
- .video-channel-buttons {
- float: right;
- }
- }
-}
-
-@media screen and (min-width: $small-view) {
- :host-context(.expanded) {
- .video-channel-buttons {
- float: right;
- }
- }
-}
+++ /dev/null
-import { Component, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { User } from '@app/shared'
-import { flatMap } from 'rxjs/operators'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { min, minBy, max, maxBy } from 'lodash-es'
-import { ChartData } from 'chart.js'
-
-@Component({
- selector: 'my-account-video-channels',
- templateUrl: './my-account-video-channels.component.html',
- styleUrls: [ './my-account-video-channels.component.scss' ]
-})
-export class MyAccountVideoChannelsComponent implements OnInit {
- videoChannels: VideoChannel[] = []
- videoChannelsChartData: ChartData[]
- videoChannelsMinimumDailyViews = 0
- videoChannelsMaximumDailyViews: number
-
- private user: User
-
- constructor (
- private authService: AuthService,
- private notifier: Notifier,
- private confirmService: ConfirmService,
- private videoChannelService: VideoChannelService,
- private screenService: ScreenService,
- private i18n: I18n
- ) {}
-
- ngOnInit () {
- this.user = this.authService.getUser()
-
- this.loadVideoChannels()
- }
-
- get isInSmallView () {
- return this.screenService.isInSmallView()
- }
-
- get chartOptions () {
- return {
- legend: {
- display: false
- },
- scales: {
- xAxes: [{
- display: false
- }],
- yAxes: [{
- display: false,
- ticks: {
- min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)),
- max: Math.max(1, this.videoChannelsMaximumDailyViews)
- }
- }]
- },
- layout: {
- padding: {
- left: 15,
- right: 15,
- top: 10,
- bottom: 0
- }
- },
- elements: {
- point: {
- radius: 0
- }
- },
- tooltips: {
- mode: 'index',
- intersect: false,
- custom: function (tooltip: any) {
- if (!tooltip) return
- // disable displaying the color box
- tooltip.displayColors = false
- },
- callbacks: {
- label: (tooltip: any, data: any) => `${tooltip.value} views`
- }
- },
- hover: {
- mode: 'index',
- intersect: false
- }
- }
- }
-
- async deleteVideoChannel (videoChannel: VideoChannel) {
- const res = await this.confirmService.confirmWithInput(
- this.i18n(
- // tslint:disable
- 'Do you really want to delete {{channelDisplayName}}? It will delete {{videosCount}} videos uploaded in this channel, and you will not be able to create another channel with the same name ({{channelName}})!',
- { channelDisplayName: videoChannel.displayName, videosCount: videoChannel.videosCount, channelName: videoChannel.name }
- ),
- this.i18n(
- 'Please type the display name of the video channel ({{displayName}}) to confirm',
- { displayName: videoChannel.displayName }
- ),
- videoChannel.displayName,
- this.i18n('Delete')
- )
- if (res === false) return
-
- this.videoChannelService.removeVideoChannel(videoChannel)
- .subscribe(
- () => {
- this.loadVideoChannels()
- this.notifier.success(
- this.i18n('Video channel {{videoChannelName}} deleted.', { videoChannelName: videoChannel.displayName })
- )
- },
-
- error => this.notifier.error(error.message)
- )
- }
-
- private loadVideoChannels () {
- this.authService.userInformationLoaded
- .pipe(flatMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true)))
- .subscribe(res => {
- this.videoChannels = res.data
-
- // chart data
- this.videoChannelsChartData = this.videoChannels.map(v => ({
- labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()),
- datasets: [
- {
- label: this.i18n('Views for the day'),
- data: v.viewsPerDay.map(day => day.views),
- fill: false,
- borderColor: "#c6c6c6"
- }
- ]
- } as ChartData))
-
- // chart options that depend on chart data:
- // we don't want to skew values and have min at 0, so we define what the floor/ceiling is here
- this.videoChannelsMinimumDailyViews = min(
- this.videoChannels.map(v => minBy( // compute local minimum daily views for each channel, by their "views" attribute
- v.viewsPerDay,
- day => day.views
- ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
- )
- this.videoChannelsMaximumDailyViews = max(
- this.videoChannels.map(v => maxBy( // compute local maximum daily views for each channel, by their "views" attribute
- v.viewsPerDay,
- day => day.views
- ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
- )
- })
- }
-}
+++ /dev/null
-import { NgModule } from '@angular/core'
-import { ChartModule } from 'primeng/chart'
-import { MyAccountVideoChannelsRoutingModule } from './my-account-video-channels-routing.module'
-import { MyAccountVideoChannelsComponent } from './my-account-video-channels.component'
-import { MyAccountVideoChannelCreateComponent } from './my-account-video-channel-create.component'
-import { MyAccountVideoChannelUpdateComponent } from './my-account-video-channel-update.component'
-import { SharedModule } from '@app/shared'
-
-@NgModule({
- imports: [
- MyAccountVideoChannelsRoutingModule,
- SharedModule,
- ChartModule
- ],
-
- declarations: [
- MyAccountVideoChannelsComponent,
- MyAccountVideoChannelCreateComponent,
- MyAccountVideoChannelUpdateComponent
- ],
-
- exports: [],
- providers: []
-})
-export class MyAccountVideoChannelsModule { }
-import { Component, OnInit } from '@angular/core'
-import { RestPagination, RestTable } from '@app/shared'
import { SortMeta } from 'primeng/api'
-import { Notifier } from '@app/core'
-import { VideoImport, VideoImportState } from '../../../../../shared/models/videos'
-import { VideoImportService } from '@app/shared/video-import'
+import { Component, OnInit } from '@angular/core'
+import { Notifier, RestPagination, RestTable } from '@app/core'
+import { VideoImportService } from '@app/shared/shared-main'
+import { VideoImport, VideoImportState } from '@shared/models'
@Component({
selector: 'my-account-video-imports',
import { Component, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService, Notifier, ServerService } from '@app/core'
-import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit'
+import { FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/shared-forms'
+import { VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoPlaylistValidatorsService } from '@app/shared'
import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
-import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
+import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit'
+import { populateAsyncUserVideoChannels } from '@app/helpers'
@Component({
selector: 'my-account-video-playlist-create',
-import { FormReactive } from '@app/shared'
-import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
+import { FormReactive } from '@app/shared/shared-forms'
import { VideoConstant, VideoPlaylistPrivacy } from '@shared/models'
+import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
export abstract class MyAccountVideoPlaylistEdit extends FormReactive {
// Declare it here to avoid errors in create template
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { Notifier, ServerService } from '@app/core'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
import { Subject, Subscription } from 'rxjs'
-import { ActivatedRoute } from '@angular/router'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
import { CdkDragDrop } from '@angular/cdk/drag-drop'
-import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { ComponentPagination, Notifier, ScreenService } from '@app/core'
+import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
@Component({
selector: 'my-account-video-playlist-elements',
private paramsSub: Subscription
constructor (
- private authService: AuthService,
- private serverService: ServerService,
private notifier: Notifier,
- private confirmService: ConfirmService,
private route: ActivatedRoute,
- private i18n: I18n,
private screenService: ScreenService,
private videoPlaylistService: VideoPlaylistService
) {}
+import { forkJoin, Subscription } from 'rxjs'
+import { map, switchMap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, Notifier, ServerService } from '@app/core'
-import { forkJoin, Subscription } from 'rxjs'
+import { populateAsyncUserVideoChannels } from '@app/helpers'
+import { FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/shared-forms'
+import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { MyAccountVideoPlaylistEdit } from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-edit'
-import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { VideoPlaylistValidatorsService } from '@app/shared'
-import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { delayWhen, map, switchMap } from 'rxjs/operators'
+import { VideoPlaylistUpdate } from '@shared/models'
+import { MyAccountVideoPlaylistEdit } from './my-account-video-playlist-edit'
@Component({
selector: 'my-account-video-playlist-update',
+import { Subject } from 'rxjs'
+import { debounceTime, flatMap } from 'rxjs/operators'
import { Component, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { User } from '@app/shared'
-import { flatMap, debounceTime } from 'rxjs/operators'
+import { AuthService, ComponentPagination, ConfirmService, Notifier, User } from '@app/core'
+import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
import { VideoPlaylistType } from '@shared/models'
-import { Subject } from 'rxjs'
@Component({
selector: 'my-account-video-playlists',
import { concat, Observable, Subject } from 'rxjs'
-import { tap, toArray, debounceTime } from 'rxjs/operators'
-import { Component, ViewChild, OnInit } from '@angular/core'
+import { debounceTime, tap, toArray } from 'rxjs/operators'
+import { Component, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { Notifier, ServerService } from '@app/core'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { Video } from '../../shared/video/video.model'
-import { VideoService } from '../../shared/video/video.service'
+import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
+import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
+import { immutableAssign } from '@app/helpers'
+import { Video, VideoService } from '@app/shared/shared-main'
+import { MiniatureDisplayOptions, OwnerDisplayType, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { VideoSortField } from '@shared/models'
import { VideoChangeOwnershipComponent } from './video-change-ownership/video-change-ownership.component'
-import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component'
-import { SelectionType, VideosSelectionComponent } from '@app/shared/video/videos-selection.component'
-import { VideoSortField } from '@app/shared/video/sort-field.type'
-import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
@Component({
selector: 'my-account-videos',
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
+import { Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, VideoChangeOwnershipValidatorsService } from '@app/shared/shared-forms'
+import { Video, VideoOwnershipService } from '@app/shared/shared-main'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { FormReactive, UserService } from '../../../shared/index'
-import { Video } from '@app/shared/video/video.model'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService, VideoChangeOwnershipValidatorsService } from '@app/shared'
-import { VideoOwnershipService } from '@app/shared/video-ownership'
@Component({
selector: 'my-video-change-ownership',
import { Component, OnInit } from '@angular/core'
import { ServerService } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { TopMenuDropdownParam } from '@app/shared/menu/top-menu-dropdown.component'
import { ServerConfig } from '@shared/models'
+import { TopMenuDropdownParam } from './top-menu-dropdown.component'
@Component({
selector: 'my-my-account',
-import { NgModule } from '@angular/core'
-import { TableModule } from 'primeng/table'
import { AutoCompleteModule } from 'primeng/autocomplete'
import { InputSwitchModule } from 'primeng/inputswitch'
-import { SharedModule } from '../shared'
+import { TableModule } from 'primeng/table'
+import { DragDropModule } from '@angular/cdk/drag-drop'
+import { NgModule } from '@angular/core'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedModerationModule } from '@app/shared/shared-moderation'
+import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-settings'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription/shared-user-subscription.module'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module'
+import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
+import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
+import { MyAccountHistoryComponent } from './my-account-history/my-account-history.component'
+import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
+import { MyAccountAcceptOwnershipComponent } from './my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component'
+import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component'
import { MyAccountRoutingModule } from './my-account-routing.module'
+import { MyAccountChangeEmailComponent } from './my-account-settings/my-account-change-email'
import { MyAccountChangePasswordComponent } from './my-account-settings/my-account-change-password/my-account-change-password.component'
+import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-danger-zone'
+import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
+import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
-import { MyAccountComponent } from './my-account.component'
+import { MyAccountSubscriptionsComponent } from './my-account-subscriptions/my-account-subscriptions.component'
+import { MyAccountVideoImportsComponent } from './my-account-video-imports/my-account-video-imports.component'
+import { MyAccountVideoPlaylistCreateComponent } from './my-account-video-playlists/my-account-video-playlist-create.component'
+import { MyAccountVideoPlaylistElementsComponent } from './my-account-video-playlists/my-account-video-playlist-elements.component'
+import { MyAccountVideoPlaylistUpdateComponent } from './my-account-video-playlists/my-account-video-playlist-update.component'
+import { MyAccountVideoPlaylistsComponent } from './my-account-video-playlists/my-account-video-playlists.component'
import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component'
import { VideoChangeOwnershipComponent } from './my-account-videos/video-change-ownership/video-change-ownership.component'
-import { MyAccountOwnershipComponent } from './my-account-ownership/my-account-ownership.component'
-import { MyAccountAcceptOwnershipComponent } from './my-account-ownership/my-account-accept-ownership/my-account-accept-ownership.component'
-import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/my-account-profile/my-account-profile.component'
-import { MyAccountVideoImportsComponent } from '@app/+my-account/my-account-video-imports/my-account-video-imports.component'
-import { MyAccountDangerZoneComponent } from '@app/+my-account/my-account-settings/my-account-danger-zone'
-import { MyAccountSubscriptionsComponent } from '@app/+my-account/my-account-subscriptions/my-account-subscriptions.component'
-import { MyAccountBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-blocklist.component'
-import { MyAccountServerBlocklistComponent } from '@app/+my-account/my-account-blocklist/my-account-server-blocklist.component'
-import { MyAccountHistoryComponent } from '@app/+my-account/my-account-history/my-account-history.component'
-import { MyAccountNotificationsComponent } from '@app/+my-account/my-account-notifications/my-account-notifications.component'
-import { MyAccountNotificationPreferencesComponent } from '@app/+my-account/my-account-settings/my-account-notification-preferences'
-import {
- MyAccountVideoPlaylistCreateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-create.component'
-import {
- MyAccountVideoPlaylistUpdateComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-update.component'
-import { MyAccountVideoPlaylistsComponent } from '@app/+my-account/my-account-video-playlists/my-account-video-playlists.component'
-import {
- MyAccountVideoPlaylistElementsComponent
-} from '@app/+my-account/my-account-video-playlists/my-account-video-playlist-elements.component'
-import { DragDropModule } from '@angular/cdk/drag-drop'
-import { MyAccountChangeEmailComponent } from '@app/+my-account/my-account-settings/my-account-change-email'
+import { MyAccountComponent } from './my-account.component'
+import { TopMenuDropdownComponent } from './top-menu-dropdown.component'
@NgModule({
imports: [
- TableModule,
MyAccountRoutingModule,
+
AutoCompleteModule,
- SharedModule,
TableModule,
InputSwitchModule,
- DragDropModule
+ DragDropModule,
+
+ SharedMainModule,
+ SharedModerationModule,
+ SharedVideoMiniatureModule,
+ SharedUserSubscriptionModule,
+ SharedVideoPlaylistModule,
+ SharedUserInterfaceSettingsModule,
+ SharedGlobalIconModule
],
declarations: [
MyAccountVideoPlaylistCreateComponent,
MyAccountVideoPlaylistUpdateComponent,
MyAccountVideoPlaylistsComponent,
- MyAccountVideoPlaylistElementsComponent
+ MyAccountVideoPlaylistElementsComponent,
+
+ TopMenuDropdownComponent
],
exports: [
+++ /dev/null
-<ng-container *ngIf="actor">
- <div class="actor">
- <div class="d-flex">
- <img [src]="actor.avatarUrl" alt="Avatar" />
-
- <div class="actor-img-edit-container">
- <div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body">
- <my-global-icon iconName="edit"></my-global-icon>
- <label for="avatarfile" i18n>Change your avatar</label>
- <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
- </div>
- </div>
- </div>
-
-
- <div class="actor-info">
- <div class="actor-info-names">
- <div class="actor-info-display-name">{{ actor.displayName }}</div>
- <div class="actor-info-username">{{ actor.name }}</div>
- </div>
- <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
- </div>
- </div>
-</ng-container>
\ No newline at end of file
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.actor {
- display: flex;
-
- img {
- @include avatar(100px);
-
- margin-right: 15px;
- }
-
- .actor-img-edit-container {
- position: relative;
- width: 0;
-
- .actor-img-edit-button {
- @include peertube-button-file(21px);
- @include button-with-icon(19px);
-
- margin-top: 10px;
- margin-bottom: 5px;
- border-radius: 50%;
- top: 55px;
- right: 45px;
- cursor: pointer;
-
- input {
- width: 30px;
- height: 30px;
- }
-
- my-global-icon {
- right: 7px;
- }
- }
- }
-
- .actor-info {
- justify-content: center;
- display: inline-flex;
- flex-direction: column;
-
- .actor-info-names {
- display: flex;
- align-items: center;
-
- .actor-info-display-name {
- font-size: 20px;
- font-weight: $font-bold;
-
- @media screen and (max-width: $small-view) {
- font-size: 16px;
- }
- }
-
- .actor-info-username {
- margin-left: 7px;
- position: relative;
- top: 2px;
- font-size: 14px;
- color: $grey-actor-name;
- }
- }
-
- .actor-info-followers {
- font-size: 15px;
- padding-bottom: .5rem;
- }
- }
-}
+++ /dev/null
-import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { ServerService } from '../../core/server'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { Account } from '@app/shared/account/account.model'
-import { Notifier } from '@app/core'
-import { ServerConfig } from '@shared/models'
-import { BytesPipe } from 'ngx-pipes'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Component({
- selector: 'my-actor-avatar-info',
- templateUrl: './actor-avatar-info.component.html',
- styleUrls: [ './actor-avatar-info.component.scss' ]
-})
-export class ActorAvatarInfoComponent implements OnInit {
- @ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
-
- @Input() actor: VideoChannel | Account
-
- @Output() avatarChange = new EventEmitter<FormData>()
-
- maxSizeText: string
-
- private serverConfig: ServerConfig
- private bytesPipe: BytesPipe
-
- constructor (
- private serverService: ServerService,
- private notifier: Notifier,
- private i18n: I18n
- ) {
- this.bytesPipe = new BytesPipe()
- this.maxSizeText = this.i18n('max size')
- }
-
- ngOnInit (): void {
- this.serverConfig = this.serverService.getTmpConfig()
- this.serverService.getConfig()
- .subscribe(config => this.serverConfig = config)
- }
-
- onAvatarChange () {
- const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ]
- if (avatarfile.size > this.maxAvatarSize) {
- this.notifier.error('Error', 'This image is too large.')
- return
- }
-
- const formData = new FormData()
- formData.append('avatarfile', avatarfile)
-
- this.avatarChange.emit(formData)
- }
-
- get maxAvatarSize () {
- return this.serverConfig.avatar.file.size.max
- }
-
- get maxAvatarSizeInBytes () {
- return this.bytesPipe.transform(this.maxAvatarSize)
- }
-
- get avatarExtensions () {
- return this.serverConfig.avatar.file.extensions.join(', ')
- }
-}
--- /dev/null
+<div class="sub-menu" [ngClass]="{ 'no-scroll': isModalOpened }">
+ <ng-container *ngFor="let menuEntry of menuEntries; index as id">
+
+ <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page title-page-settings">{{ menuEntry.label }}</a>
+
+ <div *ngIf="!menuEntry.routerLink" ngbDropdown class="parent-entry"
+ #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)">
+ <span
+ *ngIf="isInSmallView"
+ [ngClass]="{ active: !!suffixLabels[menuEntry.label] }"
+ (click)="openModal(id)" role="button" class="title-page title-page-settings">
+ <ng-container i18n>{{ menuEntry.label }}</ng-container>
+ <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
+ </span>
+
+ <span
+ *ngIf="!isInSmallView"
+ (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor
+ (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page title-page-settings"
+ >
+ <ng-container i18n>{{ menuEntry.label }}</ng-container>
+ <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
+ </span>
+
+ <div ngbDropdownMenu>
+ <a *ngFor="let menuChild of menuEntry.children" class="dropdown-item" [ngClass]="{ icon: hasIcons }" [routerLink]="menuChild.routerLink">
+ <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
+
+ {{ menuChild.label }}
+ </a>
+ </div>
+ </div>
+ </ng-container>
+</div>
+
+<ng-template #modal let-close="close" let-dismiss="dismiss">
+ <div class="modal-body">
+ <ng-container *ngFor="let menuEntry of menuEntries; index as id">
+ <div [ngClass]="{ hidden: id !== currentMenuEntryIndex }">
+ <a *ngFor="let menuChild of menuEntry.children"
+ [ngClass]="{ icon: hasIcons }"
+ [routerLink]="menuChild.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
+ <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
+
+ {{ menuChild.label }}
+ </a>
+ </div>
+ </ng-container>
+ </div>
+</ng-template>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.parent-entry {
+ span[role=button] {
+ cursor: pointer;
+ }
+
+ a {
+ display: block;
+ }
+}
+
+::ng-deep .dropdown-toggle::after {
+ position: relative;
+ top: 2px;
+}
+
+::ng-deep .dropdown-menu {
+ margin-top: 0 !important;
+}
+
+.icon {
+ @include dropdown-with-icon-item;
+
+ top: -1px;
+}
+
+.sub-menu.no-scroll {
+ overflow-x: hidden;
+}
+
+.modal-body {
+ .hidden {
+ display: none;
+ }
+
+ a {
+ @include disable-default-a-behaviour;
+
+ color: currentColor;
+ box-sizing: border-box;
+ display: block;
+ font-size: 1.2rem;
+ padding: 9px 12px;
+ text-align: initial;
+ text-transform: unset;
+ width: 100%;
+
+ &.active {
+ color: pvar(--mainBackgroundColor) !important;
+ background-color: pvar(--mainHoverColor);
+ opacity: .9;
+ }
+ }
+}
--- /dev/null
+import { Subscription } from 'rxjs'
+import { filter, take } from 'rxjs/operators'
+import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { NavigationEnd, Router } from '@angular/router'
+import { MenuService, ScreenService } from '@app/core'
+import { GlobalIconName } from '@app/shared/shared-icons'
+import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
+
+export type TopMenuDropdownParam = {
+ label: string
+ routerLink?: string
+
+ children?: {
+ label: string
+ routerLink: string
+
+ iconName?: GlobalIconName
+ }[]
+}
+
+@Component({
+ selector: 'my-top-menu-dropdown',
+ templateUrl: './top-menu-dropdown.component.html',
+ styleUrls: [ './top-menu-dropdown.component.scss' ]
+})
+export class TopMenuDropdownComponent implements OnInit, OnDestroy {
+ @Input() menuEntries: TopMenuDropdownParam[] = []
+
+ @ViewChild('modal', { static: true }) modal: NgbModal
+
+ suffixLabels: { [ parentLabel: string ]: string }
+ hasIcons = false
+ isModalOpened = false
+ currentMenuEntryIndex: number
+
+ private openedOnHover = false
+ private routeSub: Subscription
+
+ constructor (
+ private router: Router,
+ private modalService: NgbModal,
+ private screen: ScreenService,
+ private menuService: MenuService
+ ) { }
+
+ get isInSmallView () {
+ let marginLeft = 0
+ if (this.menuService.isMenuDisplayed) {
+ marginLeft = this.menuService.menuWidth
+ }
+
+ return this.screen.isInSmallView(marginLeft)
+ }
+
+ ngOnInit () {
+ this.updateChildLabels(window.location.pathname)
+
+ this.routeSub = this.router.events
+ .pipe(filter(event => event instanceof NavigationEnd))
+ .subscribe(() => this.updateChildLabels(window.location.pathname))
+
+ this.hasIcons = this.menuEntries.some(
+ e => e.children && e.children.some(c => !!c.iconName)
+ )
+ }
+
+ ngOnDestroy () {
+ if (this.routeSub) this.routeSub.unsubscribe()
+ }
+
+ openDropdownOnHover (dropdown: NgbDropdown) {
+ this.openedOnHover = true
+ dropdown.open()
+
+ // Menu was closed
+ dropdown.openChange
+ .pipe(take(1))
+ .subscribe(() => this.openedOnHover = false)
+ }
+
+ dropdownAnchorClicked (dropdown: NgbDropdown) {
+ if (this.openedOnHover) {
+ this.openedOnHover = false
+ return
+ }
+
+ return dropdown.toggle()
+ }
+
+ closeDropdownIfHovered (dropdown: NgbDropdown) {
+ if (this.openedOnHover === false) return
+
+ dropdown.close()
+ this.openedOnHover = false
+ }
+
+ openModal (index: number) {
+ this.currentMenuEntryIndex = index
+ this.isModalOpened = true
+
+ this.modalService.open(this.modal, {
+ centered: true,
+ beforeDismiss: async () => {
+ this.onModalDismiss()
+ return true
+ }
+ })
+ }
+
+ onModalDismiss () {
+ this.isModalOpened = false
+ }
+
+ dismissOtherModals () {
+ this.modalService.dismissAll()
+ }
+
+ private updateChildLabels (path: string) {
+ this.suffixLabels = {}
+
+ for (const entry of this.menuEntries) {
+ if (!entry.children) continue
+
+ for (const child of entry.children) {
+ if (path.startsWith(child.routerLink)) {
+ this.suffixLabels[entry.label] = child.label
+ }
+ }
+ }
+ }
+}
+import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
-import { SharedModule } from '../shared'
-import { PageNotFoundComponent } from '@app/+page-not-found/page-not-found.component'
-import { PageNotFoundRoutingModule } from '@app/+page-not-found/page-not-found-routing.module'
+import { PageNotFoundRoutingModule } from './page-not-found-routing.module'
+import { PageNotFoundComponent } from './page-not-found.component'
@NgModule({
imports: [
- PageNotFoundRoutingModule,
- SharedModule
+ CommonModule,
+
+ PageNotFoundRoutingModule
],
declarations: [
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
+import { ServerConfigResolver, UnloggedGuard } from '@app/core'
import { MetaGuard } from '@ngx-meta/core'
import { RegisterComponent } from './register.component'
-import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
-import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
const registerRoutes: Routes = [
{
+import { concat, of } from 'rxjs'
+import { pairwise } from 'rxjs/operators'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
-import { AuthService } from '@app/core'
-import { FormReactive, UserService, VideoChannelValidatorsService } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { FormGroup } from '@angular/forms'
-import { pairwise } from 'rxjs/operators'
-import { concat, of } from 'rxjs'
+import { UserService } from '@app/core'
+import { FormReactive, FormValidatorService, VideoChannelValidatorsService } from '@app/shared/shared-forms'
@Component({
selector: 'my-register-step-channel',
constructor (
protected formValidatorService: FormValidatorService,
- private authService: AuthService,
private userService: UserService,
private videoChannelValidatorsService: VideoChannelValidatorsService
) {
+import { concat, of } from 'rxjs'
+import { pairwise } from 'rxjs/operators'
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
-import { AuthService } from '@app/core'
-import { FormReactive, UserService, UserValidatorsService } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { FormGroup } from '@angular/forms'
-import { pairwise } from 'rxjs/operators'
-import { concat, of } from 'rxjs'
+import { UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
@Component({
selector: 'my-register-step-user',
constructor (
protected formValidatorService: FormValidatorService,
- private authService: AuthService,
private userService: UserService,
private userValidatorsService: UserValidatorsService
) {
import { Component, OnInit, ViewChild } from '@angular/core'
-import { AuthService, Notifier, RedirectService, ServerService } from '@app/core'
-import { UserService, UserValidatorsService } from '@app/shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { UserRegister } from '@shared/models/users/user-register.model'
import { FormGroup } from '@angular/forms'
-import { About, ServerConfig } from '@shared/models/server'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { ActivatedRoute } from '@angular/router'
+import { AuthService, Notifier, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
+import { InstanceService } from '@app/shared/shared-instance'
import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap'
-import { ActivatedRoute } from '@angular/router'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserRegister } from '@shared/models'
+import { About, ServerConfig } from '@shared/models/server'
@Component({
selector: 'my-register',
constructor (
private route: ActivatedRoute,
private authService: AuthService,
- private userValidatorsService: UserValidatorsService,
private notifier: Notifier,
private userService: UserService,
- private serverService: ServerService,
- private redirectService: RedirectService,
private instanceService: InstanceService,
private hooks: HooksService,
private i18n: I18n
+import { CdkStepperModule } from '@angular/cdk/stepper'
import { NgModule } from '@angular/core'
+import { SignupSharedModule } from '@app/+signup/shared/signup-shared.module'
+import { SharedInstanceModule } from '@app/shared/shared-instance'
+import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
+import { CustomStepperComponent } from './custom-stepper.component'
import { RegisterRoutingModule } from './register-routing.module'
-import { RegisterComponent } from './register.component'
-import { SharedModule } from '@app/shared'
-import { CdkStepperModule } from '@angular/cdk/stepper'
import { RegisterStepChannelComponent } from './register-step-channel.component'
import { RegisterStepUserComponent } from './register-step-user.component'
-import { CustomStepperComponent } from './custom-stepper.component'
-import { SignupSharedModule } from '@app/+signup/shared/signup-shared.module'
-import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
+import { RegisterComponent } from './register.component'
@NgModule({
imports: [
RegisterRoutingModule,
- SharedModule,
+
CdkStepperModule,
+ NgbAccordionModule,
+
SignupSharedModule,
- NgbAccordionModule
+
+ SharedInstanceModule
],
declarations: [
import { Component, OnInit } from '@angular/core'
+import { Notifier, RedirectService, ServerService, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Notifier, RedirectService } from '@app/core'
-import { ServerService } from '@app/core/server'
-import { FormReactive, UserService } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { UserValidatorsService } from '@app/shared/forms/form-validators/user-validators.service'
import { ServerConfig } from '@shared/models'
@Component({
import { Component, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
+import { ActivatedRoute } from '@angular/router'
+import { AuthService, Notifier, UserService } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { AuthService, Notifier } from '@app/core'
-import { UserService } from '@app/shared'
@Component({
selector: 'my-verify-account-email',
private userService: UserService,
private authService: AuthService,
private notifier: Notifier,
- private router: Router,
private route: ActivatedRoute,
private i18n: I18n
) {
import { NgModule } from '@angular/core'
-import { VerifyAccountRoutingModule } from './verify-account-routing.module'
-import { VerifyAccountEmailComponent } from './verify-account-email/verify-account-email.component'
+import { SignupSharedModule } from '../shared/signup-shared.module'
import { VerifyAccountAskSendEmailComponent } from './verify-account-ask-send-email/verify-account-ask-send-email.component'
-import { SharedModule } from '@app/shared'
-import { SignupSharedModule } from '@app/+signup/shared/signup-shared.module'
+import { VerifyAccountEmailComponent } from './verify-account-email/verify-account-email.component'
+import { VerifyAccountRoutingModule } from './verify-account-routing.module'
@NgModule({
imports: [
VerifyAccountRoutingModule,
- SharedModule,
+
SignupSharedModule
],
import { NgModule } from '@angular/core'
-import { SignupSuccessComponent } from '../shared/signup-success.component'
-import { SharedModule } from '@app/shared'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SignupSuccessComponent } from './signup-success.component'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
@NgModule({
imports: [
- SharedModule
+ SharedMainModule,
+ SharedFormModule,
+ SharedGlobalIconModule
],
declarations: [
],
exports: [
+ SharedMainModule,
+ SharedFormModule,
+ SharedGlobalIconModule,
+
SignupSuccessComponent
],
+import { Subscription } from 'rxjs'
import { Component, OnDestroy, OnInit } from '@angular/core'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
+import { MarkdownService } from '@app/core'
+import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subscription } from 'rxjs'
-import { MarkdownService } from '@app/shared/renderer'
@Component({
selector: 'my-video-channel-about',
-import { Component, OnDestroy, OnInit } from '@angular/core'
-import { ConfirmService } from '../../core/confirm'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { Subject, Subscription } from 'rxjs'
-import { Notifier } from '@app/core'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
+import { Component, OnDestroy, OnInit } from '@angular/core'
+import { ComponentPagination, hasMoreItems } from '@app/core'
+import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
@Component({
selector: 'my-video-channel-playlists',
private videoChannel: VideoChannel
constructor (
- private notifier: Notifier,
- private confirmService: ConfirmService,
private videoPlaylistService: VideoPlaylistService,
private videoChannelService: VideoChannelService
) {}
+import { Subscription } from 'rxjs'
+import { first, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { ConfirmService } from '../../core/confirm'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoService } from '../../shared/video/video.service'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { first, tap } from 'rxjs/operators'
+import { AuthService, ConfirmService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Subscription } from 'rxjs'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
@Component({
selector: 'my-video-channel-videos',
- templateUrl: '../../shared/video/abstract-video-list.html',
+ templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html',
styleUrls: [
- '../../shared/video/abstract-video-list.scss',
+ '../../shared/shared-video-miniature/abstract-video-list.scss',
'./video-channel-videos.component.scss'
]
})
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
-import { VideoChannelsComponent } from './video-channels.component'
-import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component'
-import { VideoChannelPlaylistsComponent } from '@app/+video-channels/video-channel-playlists/video-channel-playlists.component'
+import { VideoChannelPlaylistsComponent } from './video-channel-playlists/video-channel-playlists.component'
+import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
+import { VideoChannelsComponent } from './video-channels.component'
const videoChannelsRoutes: Routes = [
{
+import { Hotkey, HotkeysService } from 'angular2-hotkeys'
+import { Subscription } from 'rxjs'
+import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { RestExtractor } from '@app/shared'
-import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
-import { Subscription } from 'rxjs'
-import { AuthService, Notifier } from '@app/core'
-import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
+import { AuthService, Notifier, RestExtractor, ScreenService } from '@app/core'
+import { ListOverflowItem, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ListOverflowItem } from '@app/shared/misc/list-overflow.component'
-import { ScreenService } from '@app/shared/misc/screen.service'
@Component({
templateUrl: './video-channels.component.html',
import { NgModule } from '@angular/core'
-import { SharedModule } from '../shared'
+import { SharedFormModule } from '@app/shared/shared-forms'
+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 { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
+import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component'
+import { VideoChannelPlaylistsComponent } from './video-channel-playlists/video-channel-playlists.component'
+import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
import { VideoChannelsRoutingModule } from './video-channels-routing.module'
import { VideoChannelsComponent } from './video-channels.component'
-import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
-import { VideoChannelAboutComponent } from './video-channel-about/video-channel-about.component'
-import { VideoChannelPlaylistsComponent } from '@app/+video-channels/video-channel-playlists/video-channel-playlists.component'
@NgModule({
imports: [
VideoChannelsRoutingModule,
- SharedModule
+
+ SharedMainModule,
+ SharedFormModule,
+ SharedVideoPlaylistModule,
+ SharedVideoMiniatureModule,
+ SharedUserSubscriptionModule,
+ SharedGlobalIconModule
],
declarations: [
import { NgModule } from '@angular/core'
import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router'
-
-import { PreloadSelectedModulesList } from './core'
import { AppComponent } from '@app/app.component'
import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy'
import { MenuGuards } from '@app/core/routing/menu-guard.service'
+import { PreloadSelectedModulesList } from './core'
const routes: Routes = [
{
-import { Component, OnInit, ViewChild, AfterViewInit, Inject, LOCALE_ID } from '@angular/core'
+import { Hotkey, HotkeysService } from 'angular2-hotkeys'
+import { concat } from 'rxjs'
+import { filter, first, map, pairwise } from 'rxjs/operators'
+import { DOCUMENT, PlatformLocation, ViewportScroller } 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, Router, Scroll } from '@angular/router'
-import { AuthService, RedirectService, ServerService, ThemeService } from '@app/core'
-import { is18nPath, getShortLocale } from '../../../shared/models/i18n'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { filter, map, pairwise, first } from 'rxjs/operators'
-import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { PlatformLocation, ViewportScroller, DOCUMENT } from '@angular/common'
-import { PluginService } from '@app/core/plugins/plugin.service'
+import { AuthService, MarkdownService, RedirectService, ScreenService, ServerService, ThemeService, User } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { POP_STATE_MODAL_DISMISS } from '@app/shared/misc/constants'
-import { WelcomeModalComponent } from '@app/modal/welcome-modal.component'
-import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
+import { PluginService } from '@app/core/plugins/plugin.service'
import { CustomModalComponent } from '@app/modal/custom-modal.component'
-import { ServerConfig, UserRole } from '@shared/models'
-import { User } from '@app/shared'
-import { InstanceService } from '@app/shared/instance/instance.service'
+import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
+import { WelcomeModalComponent } from '@app/modal/welcome-modal.component'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { BroadcastMessageLevel, getShortLocale, is18nPath, ServerConfig, UserRole } from '@shared/models'
import { MenuService } from './core/menu/menu.service'
-import { BroadcastMessageLevel } from '@shared/models/server'
-import { MarkdownService } from './shared/renderer'
-import { concat } from 'rxjs'
-import { peertubeLocalStorage } from './shared/misc/peertube-web-storage'
+import { peertubeLocalStorage, POP_STATE_MODAL_DISMISS } from './helpers'
+import { InstanceService } from './shared/shared-instance'
@Component({
selector: 'my-app',
+import 'focus-visible'
+import { APP_BASE_HREF, registerLocaleData } from '@angular/common'
import { LOCALE_ID, NgModule, TRANSLATIONS, TRANSLATIONS_FORMAT } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { ServerService } from '@app/core'
+import localeOc from '@app/helpers/locales/oc'
import { ResetPasswordModule } from '@app/reset-password'
+import { SearchModule } from '@app/search'
import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
-import 'focus-visible'
-
+import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '@shared/models'
import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { CoreModule } from './core'
import { HeaderComponent, SearchTypeaheadComponent, SuggestionComponent } from './header'
import { LoginModule } from './login'
import { AvatarNotificationComponent, LanguageChooserComponent, MenuComponent } from './menu'
-import { SharedModule } from './shared'
+import { ConfirmComponent } from './modal/confirm.component'
+import { CustomModalComponent } from './modal/custom-modal.component'
+import { InstanceConfigWarningModalComponent } from './modal/instance-config-warning-modal.component'
+import { QuickSettingsModalComponent } from './modal/quick-settings-modal.component'
+import { WelcomeModalComponent } from './modal/welcome-modal.component'
+import { SharedFormModule } from './shared/shared-forms'
+import { SharedGlobalIconModule } from './shared/shared-icons'
+import { SharedInstanceModule } from './shared/shared-instance'
+import { SharedMainModule } from './shared/shared-main'
+import { SharedUserInterfaceSettingsModule } from './shared/shared-user-settings'
import { VideosModule } from './videos'
-import { SearchModule } from '@app/search'
-import { WelcomeModalComponent } from '@app/modal/welcome-modal.component'
-import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
-import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '@shared/models'
-import { APP_BASE_HREF, registerLocaleData } from '@angular/common'
-import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
-import { CustomModalComponent } from '@app/modal/custom-modal.component'
-import localeOc from '@app/shared/locale/oc'
registerLocaleData(localeOc, 'oc')
@NgModule({
bootstrap: [ AppComponent ],
+
declarations: [
AppComponent,
CustomModalComponent,
WelcomeModalComponent,
- InstanceConfigWarningModalComponent
+ InstanceConfigWarningModalComponent,
+ ConfirmComponent
],
+
imports: [
BrowserModule,
CoreModule,
- SharedModule,
+ SharedMainModule,
+ SharedFormModule,
+ SharedUserInterfaceSettingsModule,
+ SharedGlobalIconModule,
+ SharedInstanceModule,
- CoreModule,
LoginModule,
ResetPasswordModule,
SearchModule,
- SharedModule,
+
VideosModule,
MetaModule.forRoot({
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { UserRight } from '../../../../../shared/models/users/user-right.enum'
-import { MyUser as ServerMyUserModel, User as ServerUserModel, MyUserSpecialPlaylist } from '../../../../../shared/models/users/user.model'
-// Do not use the barrel (dependency loop)
-import { hasUserRight, UserRole } from '../../../../../shared/models/users/user-role'
-import { User } from '../../shared/users/user.model'
-import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
+import { User } from '@app/core/users/user.model'
+import { peertubeLocalStorage } from '@app/helpers/peertube-web-storage'
+import {
+ hasUserRight,
+ MyUser as ServerMyUserModel,
+ MyUserSpecialPlaylist,
+ NSFWPolicyType,
+ User as ServerUserModel,
+ UserRight,
+ UserRole
+} from '@shared/models'
export type TokenOptions = {
accessToken: string
+import { Hotkey, HotkeysService } from 'angular2-hotkeys'
import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs'
import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import { Notifier } from '@app/core/notification/notifier.service'
-import { OAuthClientLocal, MyUser as UserServerModel, UserRefreshToken } from '../../../../../shared'
-import { User } from '../../../../../shared/models/users'
-import { UserLogin } from '../../../../../shared/models/users/user-login.model'
+import { objectToUrlEncoded, peertubeLocalStorage } from '@app/helpers'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models'
import { environment } from '../../../environments/environment'
-import { RestExtractor } from '../../shared/rest/rest-extractor.service'
+import { RestExtractor } from '../rest/rest-extractor.service'
import { AuthStatus } from './auth-status.model'
import { AuthUser } from './auth-user.model'
-import { objectToUrlEncoded } from '@app/shared/misc/utils'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Hotkey, HotkeysService } from 'angular2-hotkeys'
interface UserLoginWithUsername extends UserLogin {
access_token: string
+import { HotkeyModule } from 'angular2-hotkeys'
+import { MessageService } from 'primeng/api'
+import { ToastModule } from 'primeng/toast'
import { CommonModule } from '@angular/common'
import { NgModule, Optional, SkipSelf } from '@angular/core'
-import { FormsModule } from '@angular/forms'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
-import { RouterModule } from '@angular/router'
+import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { PluginService } from '@app/core/plugins/plugin.service'
+import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
import { LoadingBarModule } from '@ngx-loading-bar/core'
import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
import { LoadingBarRouterModule } from '@ngx-loading-bar/router'
-
import { AuthService } from './auth'
import { ConfirmService } from './confirm'
+import { CheatSheetComponent } from './hotkeys'
+import { MenuService } from './menu'
import { throwIfAlreadyLoaded } from './module-import-guard'
+import { Notifier } from './notification'
+import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer'
+import { RestExtractor, RestService } from './rest'
import { LoginGuard, RedirectService, UserRightGuard } from './routing'
+import { CanDeactivateGuard } from './routing/can-deactivate-guard.service'
+import { ServerConfigResolver } from './routing/server-config-resolver.service'
import { ServerService } from './server'
import { ThemeService } from './theme'
-import { MenuService } from './menu'
-import { HotkeyModule } from 'angular2-hotkeys'
-import { CheatSheetComponent } from './hotkeys'
-import { ToastModule } from 'primeng/toast'
-import { Notifier } from './notification'
-import { MessageService } from 'primeng/api'
-import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
-import { ServerConfigResolver } from './routing/server-config-resolver.service'
-import { UnloggedGuard } from '@app/core/routing/unlogged-guard.service'
-import { PluginService } from '@app/core/plugins/plugin.service'
-import { HooksService } from '@app/core/plugins/hooks.service'
+import { UserService } from './users'
+import { LocalStorageService, ScreenService, SessionStorageService } from './wrappers'
@NgModule({
imports: [
CommonModule,
- RouterModule,
- FormsModule,
BrowserAnimationsModule,
LoadingBarHttpClientModule,
PluginService,
HooksService,
+ HtmlRendererService,
+ LinkifierService,
+ MarkdownService,
+
+ RestExtractor,
+ RestService,
+
+ UserService,
+
+ ScreenService,
+ LocalStorageService,
+ SessionStorageService,
+
RedirectService,
Notifier,
MessageService,
UserNotificationSocket,
- ServerConfigResolver
+ ServerConfigResolver,
+ CanDeactivateGuard
]
})
export class CoreModule {
export * from './auth'
export * from './confirm'
+export * from './hotkeys'
+export * from './menu'
+export * from './notification'
+export * from './plugins'
+export * from './renderer'
+export * from './rest'
export * from './routing'
export * from './server'
-export * from './notification'
export * from './theme'
+export * from './users'
+export * from './wrappers'
export * from './core.module'
-import { Injectable } from '@angular/core'
-import { ScreenService } from '@app/shared/misc/screen.service'
import { fromEvent } from 'rxjs'
import { debounceTime } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { ScreenService } from '../wrappers'
@Injectable()
export class MenuService {
+import { Subject } from 'rxjs'
import { Injectable, NgZone } from '@angular/core'
+import { UserNotification as UserNotificationServer } from '@shared/models'
import { environment } from '../../../environments/environment'
-import { UserNotification as UserNotificationServer } from '../../../../../shared'
-import { Subject } from 'rxjs'
import { AuthService } from '../auth'
export type NotificationEvent = 'new' | 'read' | 'read-all'
import { mergeMap, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { PluginService } from '@app/core/plugins/plugin.service'
-import { ClientActionHookName, ClientFilterHookName } from '@shared/models/plugins/client-hook.model'
-import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.type'
+import { ClientActionHookName, ClientFilterHookName, PluginClientScope } from '@shared/models'
type RawFunction<U, T> = (params: U) => T
type ObservableFunction<U, T> = RawFunction<U, Observable<T>>
--- /dev/null
+export * from './hooks.service'
+export * from './plugin.service'
-import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
-import { Router } from '@angular/router'
-import { getCompleteLocale, isDefaultLocale, peertubeTranslate, ServerConfigPlugin } from '@shared/models'
-import { ServerService } from '@app/core/server/server.service'
-import { ClientScript } from '@shared/models/plugins/plugin-package-json.model'
-import { ClientScript as ClientScriptModule } from '../../../types/client-script.model'
-import { environment } from '../../../environments/environment'
import { Observable, of, ReplaySubject } from 'rxjs'
import { catchError, first, map, shareReplay } from 'rxjs/operators'
-import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
-import { ClientHook, ClientHookName, clientHookObject } from '@shared/models/plugins/client-hook.model'
-import { PluginClientScope } from '@shared/models/plugins/plugin-client-scope.type'
-import { RegisterClientHookOptions } from '@shared/models/plugins/register-client-hook.model'
import { HttpClient } from '@angular/common/http'
+import { Inject, Injectable, LOCALE_ID, NgZone } from '@angular/core'
import { AuthService } from '@app/core/auth'
import { Notifier } from '@app/core/notification'
-import { RestExtractor } from '@app/shared/rest'
-import { MarkdownService } from '@app/shared/renderer'
-import { PluginType } from '@shared/models/plugins/plugin.type'
-import { PublicServerSetting } from '@shared/models/plugins/public-server.setting'
-import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
-import { RegisterClientHelpers } from '../../../types/register-client-option.model'
-import { PluginTranslation } from '@shared/models/plugins/plugin-translation.model'
-import { importModule } from '@app/shared/misc/utils'
+import { MarkdownService } from '@app/core/renderer'
+import { RestExtractor } from '@app/core/rest'
+import { ServerService } from '@app/core/server/server.service'
+import { getDevLocale, importModule, isOnDevLocale } from '@app/helpers'
import { CustomModalComponent } from '@app/modal/custom-modal.component'
+import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
+import {
+ ClientHook,
+ ClientHookName,
+ clientHookObject,
+ ClientScript,
+ getCompleteLocale,
+ isDefaultLocale,
+ peertubeTranslate,
+ PluginClientScope,
+ PluginTranslation,
+ PluginType,
+ PublicServerSetting,
+ RegisterClientHookOptions,
+ ServerConfigPlugin
+} from '@shared/models'
+import { environment } from '../../../environments/environment'
+import { ClientScript as ClientScriptModule } from '../../../types/client-script.model'
+import { RegisterClientHelpers } from '../../../types/register-client-option.model'
interface HookStructValue extends RegisterClientHookOptions {
plugin: ServerConfigPlugin
private hooks: { [ name: string ]: HookStructValue[] } = {}
constructor (
- private router: Router,
private authService: AuthService,
private notifier: Notifier,
private markdownRenderer: MarkdownService,
--- /dev/null
+import { Injectable } from '@angular/core'
+import { LinkifierService } from './linkifier.service'
+
+@Injectable()
+export class HtmlRendererService {
+
+ constructor (private linkifier: LinkifierService) {
+
+ }
+
+ async toSafeHtml (text: string) {
+ // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
+ const sanitizeHtml: typeof import ('sanitize-html') = (await import('sanitize-html') as any).default
+
+ // Convert possible markdown to html
+ const html = this.linkifier.linkify(text)
+
+ return sanitizeHtml(html, {
+ allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
+ allowedSchemes: [ 'http', 'https' ],
+ allowedAttributes: {
+ 'a': [ 'href', 'class', 'target', 'rel' ]
+ },
+ transformTags: {
+ a: (tagName, attribs) => {
+ let rel = 'noopener noreferrer'
+ if (attribs.rel === 'me') rel += ' me'
+
+ return {
+ tagName,
+ attribs: Object.assign(attribs, {
+ target: '_blank',
+ rel
+ })
+ }
+ }
+ }
+ })
+ }
+}
--- /dev/null
+export * from './html-renderer.service'
+export * from './linkifier.service'
+export * from './markdown.service'
--- /dev/null
+import { Injectable } from '@angular/core'
+import { getAbsoluteAPIUrl } from '@app/helpers/utils'
+import * as linkify from 'linkifyjs'
+import linkifyHtml from 'linkifyjs/html'
+
+@Injectable()
+export class LinkifierService {
+
+ static CLASSNAME = 'linkified'
+
+ private linkifyOptions = {
+ className: {
+ mention: LinkifierService.CLASSNAME + '-mention',
+ url: LinkifierService.CLASSNAME + '-url'
+ }
+ }
+
+ constructor () {
+ // Apply plugin
+ this.mentionWithDomainPlugin(linkify)
+ }
+
+ linkify (text: string) {
+ return linkifyHtml(text, this.linkifyOptions)
+ }
+
+ private mentionWithDomainPlugin (linkify: any) {
+ const TT = linkify.scanner.TOKENS // Text tokens
+ const { TOKENS: MT, State } = linkify.parser // Multi tokens, state
+ const MultiToken = MT.Base
+ const S_START = linkify.parser.start
+
+ const TT_AT = TT.AT
+ const TT_DOMAIN = TT.DOMAIN
+ const TT_LOCALHOST = TT.LOCALHOST
+ const TT_NUM = TT.NUM
+ const TT_COLON = TT.COLON
+ const TT_SLASH = TT.SLASH
+ const TT_TLD = TT.TLD
+ const TT_UNDERSCORE = TT.UNDERSCORE
+ const TT_DOT = TT.DOT
+
+ function MENTION (this: any, value: any) {
+ this.v = value
+ }
+
+ linkify.inherits(MultiToken, MENTION, {
+ type: 'mentionWithDomain',
+ isLink: true,
+ toHref () {
+ return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + this.toString().substr(1)
+ }
+ })
+
+ const S_AT = S_START.jump(TT_AT) // @
+ const S_AT_SYMS = new State()
+ const S_MENTION = new State(MENTION)
+ const S_MENTION_DIVIDER = new State()
+ const S_MENTION_DIVIDER_SYMS = new State()
+
+ // @_,
+ S_AT.on(TT_UNDERSCORE, S_AT_SYMS)
+
+ // @_*
+ S_AT_SYMS
+ .on(TT_UNDERSCORE, S_AT_SYMS)
+ .on(TT_DOT, S_AT_SYMS)
+
+ // Valid mention (not made up entirely of symbols)
+ S_AT
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+
+ S_AT_SYMS
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+
+ // More valid mentions
+ S_MENTION
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_COLON, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+ .on(TT_UNDERSCORE, S_MENTION)
+
+ // Mention with a divider
+ S_MENTION
+ .on(TT_AT, S_MENTION_DIVIDER)
+ .on(TT_SLASH, S_MENTION_DIVIDER)
+ .on(TT_DOT, S_MENTION_DIVIDER)
+
+ // Mention _ trailing stash plus syms
+ S_MENTION_DIVIDER.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
+ S_MENTION_DIVIDER_SYMS.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
+
+ // Once we get a word token, mentions can start up again
+ S_MENTION_DIVIDER
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+
+ S_MENTION_DIVIDER_SYMS
+ .on(TT_DOMAIN, S_MENTION)
+ .on(TT_LOCALHOST, S_MENTION)
+ .on(TT_TLD, S_MENTION)
+ .on(TT_NUM, S_MENTION)
+ }
+}
--- /dev/null
+import * as MarkdownIt from 'markdown-it'
+import { buildVideoLink } from 'src/assets/player/utils'
+import { Injectable } from '@angular/core'
+import { HtmlRendererService } from './html-renderer.service'
+
+type MarkdownParsers = {
+ textMarkdownIt: MarkdownIt
+ textWithHTMLMarkdownIt: MarkdownIt
+
+ enhancedMarkdownIt: MarkdownIt
+ enhancedWithHTMLMarkdownIt: MarkdownIt
+
+ completeMarkdownIt: MarkdownIt
+}
+
+type MarkdownConfig = {
+ rules: string[]
+ html: boolean
+ escape?: boolean
+}
+
+type MarkdownParserConfigs = {
+ [id in keyof MarkdownParsers]: MarkdownConfig
+}
+
+@Injectable()
+export class MarkdownService {
+ static TEXT_RULES = [
+ 'linkify',
+ 'autolink',
+ 'emphasis',
+ 'link',
+ 'newline',
+ 'list'
+ ]
+ static TEXT_WITH_HTML_RULES = MarkdownService.TEXT_RULES.concat([ 'html_inline', 'html_block' ])
+
+ static ENHANCED_RULES = MarkdownService.TEXT_RULES.concat([ 'image' ])
+ static ENHANCED_WITH_HTML_RULES = MarkdownService.TEXT_WITH_HTML_RULES.concat([ 'image' ])
+
+ static COMPLETE_RULES = MarkdownService.ENHANCED_WITH_HTML_RULES.concat([ 'block', 'inline', 'heading', 'paragraph' ])
+
+ private markdownParsers: MarkdownParsers = {
+ textMarkdownIt: null,
+ textWithHTMLMarkdownIt: null,
+ enhancedMarkdownIt: null,
+ enhancedWithHTMLMarkdownIt: null,
+ completeMarkdownIt: null
+ }
+ private parsersConfig: MarkdownParserConfigs = {
+ textMarkdownIt: { rules: MarkdownService.TEXT_RULES, html: false },
+ textWithHTMLMarkdownIt: { rules: MarkdownService.TEXT_WITH_HTML_RULES, html: true, escape: true },
+
+ enhancedMarkdownIt: { rules: MarkdownService.ENHANCED_RULES, html: false },
+ enhancedWithHTMLMarkdownIt: { rules: MarkdownService.ENHANCED_WITH_HTML_RULES, html: true, escape: true },
+
+ completeMarkdownIt: { rules: MarkdownService.COMPLETE_RULES, html: true }
+ }
+
+ constructor (private htmlRenderer: HtmlRendererService) {}
+
+ textMarkdownToHTML (markdown: string, withHtml = false) {
+ if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown)
+
+ return this.render('textMarkdownIt', markdown)
+ }
+
+ enhancedMarkdownToHTML (markdown: string, withHtml = false) {
+ if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown)
+
+ return this.render('enhancedMarkdownIt', markdown)
+ }
+
+ completeMarkdownToHTML (markdown: string) {
+ return this.render('completeMarkdownIt', markdown)
+ }
+
+ async processVideoTimestamps (html: string) {
+ return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
+ const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
+ const url = buildVideoLink({ startTime: t })
+ return `<a class="video-timestamp" href="${url}">${str}</a>`
+ })
+ }
+
+ private async render (name: keyof MarkdownParsers, markdown: string) {
+ if (!markdown) return ''
+
+ const config = this.parsersConfig[ name ]
+ if (!this.markdownParsers[ name ]) {
+ this.markdownParsers[ name ] = await this.createMarkdownIt(config)
+ }
+
+ let html = this.markdownParsers[ name ].render(markdown)
+ html = this.avoidTruncatedTags(html)
+
+ if (config.escape) return this.htmlRenderer.toSafeHtml(html)
+
+ return html
+ }
+
+ private async createMarkdownIt (config: MarkdownConfig) {
+ // FIXME: import('...') returns a struct module, containing a "default" field
+ const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
+
+ const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html })
+
+ for (const rule of config.rules) {
+ markdownIt.enable(rule)
+ }
+
+ this.setTargetToLinks(markdownIt)
+
+ return markdownIt
+ }
+
+ private setTargetToLinks (markdownIt: MarkdownIt) {
+ // Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
+ const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) {
+ return self.renderToken(tokens, idx, options)
+ }
+
+ markdownIt.renderer.rules.link_open = function (tokens, index, options, env, self) {
+ const token = tokens[index]
+
+ const targetIndex = token.attrIndex('target')
+ if (targetIndex < 0) token.attrPush([ 'target', '_blank' ])
+ else token.attrs[targetIndex][1] = '_blank'
+
+ const relIndex = token.attrIndex('rel')
+ if (relIndex < 0) token.attrPush([ 'rel', 'noopener noreferrer' ])
+ else token.attrs[relIndex][1] = 'noopener noreferrer'
+
+ // pass token to default renderer.
+ return defaultRender(tokens, index, options, env, self)
+ }
+ }
+
+ private avoidTruncatedTags (html: string) {
+ return html.replace(/\*\*?([^*]+)$/, '$1')
+ .replace(/<a[^>]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...')
+ .replace(/\[[^\]]+\]\(([^\)]+)$/m, '$1')
+ .replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...</p>')
+ }
+}
--- /dev/null
+export interface ComponentPagination {
+ currentPage: number
+ itemsPerPage: number
+ totalItems: number
+}
+
+export type ComponentPaginationLight = Omit<ComponentPagination, 'totalItems'>
+
+export function hasMoreItems (componentPagination: ComponentPagination) {
+ // No results
+ if (componentPagination.totalItems === 0) return false
+
+ // Not loaded yet
+ if (!componentPagination.totalItems) return true
+
+ const maxPage = componentPagination.totalItems / componentPagination.itemsPerPage
+ return maxPage > componentPagination.currentPage
+}
--- /dev/null
+export * from './component-pagination.model'
+export * from './rest-extractor.service'
+export * from './rest-pagination'
+export * from './rest-table'
+export * from './rest.service'
--- /dev/null
+import { throwError as observableThrowError } from 'rxjs'
+import { Injectable } from '@angular/core'
+import { Router } from '@angular/router'
+import { dateToHuman } from '@app/helpers'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ResultList } from '@shared/models'
+
+@Injectable()
+export class RestExtractor {
+
+ constructor (
+ private router: Router,
+ private i18n: I18n
+ ) { }
+
+ extractDataBool () {
+ return true
+ }
+
+ applyToResultListData <T> (result: ResultList<T>, fun: Function, additionalArgs?: any[]): ResultList<T> {
+ const data: T[] = result.data
+ const newData: T[] = []
+
+ data.forEach(d => newData.push(fun.apply(this, [ d ].concat(additionalArgs))))
+
+ return {
+ total: result.total,
+ data: newData
+ }
+ }
+
+ convertResultListDateToHuman <T> (result: ResultList<T>, fieldsToConvert: string[] = [ 'createdAt' ]): ResultList<T> {
+ return this.applyToResultListData(result, this.convertDateToHuman, [ fieldsToConvert ])
+ }
+
+ convertDateToHuman (target: { [ id: string ]: string }, fieldsToConvert: string[]) {
+ fieldsToConvert.forEach(field => target[field] = dateToHuman(target[field]))
+
+ return target
+ }
+
+ handleError (err: any) {
+ let errorMessage
+
+ if (err.error instanceof Error) {
+ // A client-side or network error occurred. Handle it accordingly.
+ errorMessage = err.error.message
+ console.error('An error occurred:', errorMessage)
+ } else if (typeof err.error === 'string') {
+ errorMessage = err.error
+ } else if (err.status !== undefined) {
+ // A server-side error occurred.
+ if (err.error && err.error.errors) {
+ const errors = err.error.errors
+ const errorsArray: string[] = []
+
+ Object.keys(errors).forEach(key => {
+ errorsArray.push(errors[key].msg)
+ })
+
+ errorMessage = errorsArray.join('. ')
+ } else if (err.error && err.error.error) {
+ errorMessage = err.error.error
+ } else if (err.status === 413) {
+ errorMessage = this.i18n(
+ 'Request is too large for the server. Please contact you administrator if you want to increase the limit size.'
+ )
+ } else if (err.status === 429) {
+ const secondsLeft = err.headers.get('retry-after')
+ if (secondsLeft) {
+ const minutesLeft = Math.floor(parseInt(secondsLeft, 10) / 60)
+ errorMessage = this.i18n('Too many attempts, please try again after {{minutesLeft}} minutes.', { minutesLeft })
+ } else {
+ errorMessage = this.i18n('Too many attempts, please try again later.')
+ }
+ } else if (err.status === 500) {
+ errorMessage = this.i18n('Server error. Please retry later.')
+ }
+
+ errorMessage = errorMessage ? errorMessage : 'Unknown error.'
+ console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
+ } else {
+ console.error(err)
+ errorMessage = err
+ }
+
+ const errorObj: { message: string, status: string, body: string } = {
+ message: errorMessage,
+ status: undefined,
+ body: undefined
+ }
+
+ if (err.status) {
+ errorObj.status = err.status
+ errorObj.body = err.error
+ }
+
+ return observableThrowError(errorObj)
+ }
+
+ redirectTo404IfNotFound (obj: { status: number }, status = [ 404 ]) {
+ if (obj && obj.status && status.indexOf(obj.status) !== -1) {
+ // Do not use redirectService to avoid circular dependencies
+ this.router.navigate([ '/404' ], { skipLocationChange: true })
+ }
+
+ return observableThrowError(obj)
+ }
+}
--- /dev/null
+export interface RestPagination {
+ start: number
+ count: number
+}
--- /dev/null
+import { peertubeLocalStorage } from '@app/helpers/peertube-web-storage'
+import { LazyLoadEvent, SortMeta } from 'primeng/api'
+import { RestPagination } from './rest-pagination'
+import { Subject } from 'rxjs'
+import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
+
+export abstract class RestTable {
+
+ abstract totalRecords: number
+ abstract sort: SortMeta
+ abstract pagination: RestPagination
+
+ search: string
+ rowsPerPageOptions = [ 10, 20, 50, 100 ]
+ rowsPerPage = this.rowsPerPageOptions[0]
+ expandedRows = {}
+
+ private searchStream: Subject<string>
+
+ abstract getIdentifier (): string
+
+ initialize () {
+ this.loadSort()
+ this.initSearch()
+ }
+
+ loadSort () {
+ const result = peertubeLocalStorage.getItem(this.getSortLocalStorageKey())
+
+ if (result) {
+ try {
+ this.sort = JSON.parse(result)
+ } catch (err) {
+ console.error('Cannot load sort of local storage key ' + this.getSortLocalStorageKey(), err)
+ }
+ }
+ }
+
+ loadLazy (event: LazyLoadEvent) {
+ this.sort = {
+ order: event.sortOrder,
+ field: event.sortField
+ }
+
+ this.pagination = {
+ start: event.first,
+ count: this.rowsPerPage
+ }
+
+ this.loadData()
+ this.saveSort()
+ }
+
+ saveSort () {
+ peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort))
+ }
+
+ initSearch () {
+ this.searchStream = new Subject()
+
+ this.searchStream
+ .pipe(
+ debounceTime(400),
+ distinctUntilChanged()
+ )
+ .subscribe(search => {
+ this.search = search
+ this.loadData()
+ })
+ }
+
+ onSearch (event: Event) {
+ const target = event.target as HTMLInputElement
+ this.searchStream.next(target.value)
+ }
+
+ onPage (event: { first: number, rows: number }) {
+ if (this.rowsPerPage !== event.rows) {
+ this.rowsPerPage = event.rows
+ this.pagination = {
+ start: event.first,
+ count: this.rowsPerPage
+ }
+ this.loadData()
+ }
+ this.expandedRows = {}
+ }
+
+ setTableFilter (filter: string) {
+ // FIXME: cannot use ViewChild, so create a component for the filter input
+ const filterInput = document.getElementById('table-filter') as HTMLInputElement
+ if (filterInput) filterInput.value = filter
+ }
+
+ resetSearch () {
+ this.searchStream.next('')
+ this.setTableFilter('')
+ }
+
+ protected abstract loadData (): void
+
+ private getSortLocalStorageKey () {
+ return 'rest-table-sort-' + this.getIdentifier()
+ }
+}
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { ComponentPaginationLight } from './component-pagination.model'
+import { RestPagination } from './rest-pagination'
+
+interface QueryStringFilterPrefixes {
+ [key: string]: {
+ prefix: string
+ handler?: (v: string) => string | number
+ multiple?: boolean
+ }
+}
+
+type ParseQueryStringFilterResult = {
+ [key: string]: string | number | (string | number)[]
+}
+
+@Injectable()
+export class RestService {
+
+ addRestGetParams (params: HttpParams, pagination?: RestPagination, sort?: SortMeta | string) {
+ let newParams = params
+
+ if (pagination !== undefined) {
+ newParams = newParams.set('start', pagination.start.toString())
+ .set('count', pagination.count.toString())
+ }
+
+ if (sort !== undefined) {
+ let sortString = ''
+
+ if (typeof sort === 'string') {
+ sortString = sort
+ } else {
+ const sortPrefix = sort.order === 1 ? '' : '-'
+ sortString = sortPrefix + sort.field
+ }
+
+ newParams = newParams.set('sort', sortString)
+ }
+
+ return newParams
+ }
+
+ addObjectParams (params: HttpParams, object: { [ name: string ]: any }) {
+ for (const name of Object.keys(object)) {
+ const value = object[name]
+ if (value === undefined || value === null) continue
+
+ if (Array.isArray(value) && value.length !== 0) {
+ for (const v of value) params = params.append(name, v)
+ } else {
+ params = params.append(name, value)
+ }
+ }
+
+ return params
+ }
+
+ componentPaginationToRestPagination (componentPagination: ComponentPaginationLight): RestPagination {
+ const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
+ const count: number = componentPagination.itemsPerPage
+
+ return { start, count }
+ }
+
+ parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes): ParseQueryStringFilterResult {
+ if (!q) return {}
+
+ // Tokenize the strings using spaces
+ const tokens = q.split(' ').filter(token => !!token)
+
+ // Build prefix array
+ const prefixeStrings = Object.values(prefixes)
+ .map(p => p.prefix)
+
+ // Search is the querystring minus defined filters
+ const searchTokens = tokens.filter(t => {
+ return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false)
+ })
+
+ const additionalFilters: ParseQueryStringFilterResult = {}
+
+ for (const prefixKey of Object.keys(prefixes)) {
+ const prefixObj = prefixes[prefixKey]
+ const prefix = prefixObj.prefix
+
+ const matchedTokens = tokens.filter(t => t.startsWith(prefix))
+ .map(t => t.slice(prefix.length)) // Keep the value filter
+ .map(t => {
+ if (prefixObj.handler) return prefixObj.handler(t)
+
+ return t
+ })
+ .filter(t => !!t || t === 0)
+
+ if (matchedTokens.length === 0) continue
+
+ additionalFilters[prefixKey] = prefixObj.multiple === true
+ ? matchedTokens
+ : matchedTokens[0]
+ }
+
+ return {
+ search: searchTokens.join(' ') || undefined,
+
+ ...additionalFilters
+ }
+ }
+}
--- /dev/null
+import { Observable } from 'rxjs'
+import { Injectable } from '@angular/core'
+import { CanDeactivate } from '@angular/router'
+import { ConfirmService } from '@app/core/confirm'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+export type CanComponentDeactivateResult = { text?: string, canDeactivate: Observable<boolean> | boolean }
+
+export interface CanComponentDeactivate {
+ canDeactivate: () => CanComponentDeactivateResult
+}
+
+@Injectable()
+export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
+ constructor (
+ private confirmService: ConfirmService,
+ private i18n: I18n
+ ) { }
+
+ canDeactivate (component: CanComponentDeactivate) {
+ const result = component.canDeactivate()
+ const text = result.text || this.i18n('All unsaved data will be lost, are you sure you want to leave this page?')
+
+ return result.canDeactivate || this.confirmService.confirm(
+ text,
+ this.i18n('Warning')
+ )
+ }
+
+}
+export * from './can-deactivate-guard.service'
+export * from './custom-reuse-strategy'
+export * from './disable-for-reuse-hook'
export * from './login-guard.service'
-export * from './user-right-guard.service'
+export * from './menu-guard.service'
export * from './preload-selected-modules-list'
export * from './redirect.service'
-export * from './menu-guard.service'
+export * from './server-config-resolver.service'
+export * from './unlogged-guard.service'
+export * from './user-right-guard.service'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'
-
import { AuthService } from '../auth/auth.service'
@Injectable()
import { Injectable } from '@angular/core'
import { CanActivate, CanDeactivate } from '@angular/router'
-import { MenuService } from '@app/core/menu'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { MenuService } from '../menu'
+import { ScreenService } from '../wrappers'
abstract class MenuGuard implements CanActivate, CanDeactivate<any> {
display = true
@Injectable()
export class PreloadSelectedModulesList implements PreloadingStrategy {
+
preload (route: Route, load: Function): Observable<any> {
if (!route.data || !route.data.preload) return ofObservable(null)
import { Injectable } from '@angular/core'
import { Resolve } from '@angular/router'
-import { ServerService } from '@app/core/server'
+import { ServerService } from '../server'
import { ServerConfig } from '@shared/models'
@Injectable()
import { Injectable } from '@angular/core'
-import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'
+import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, RouterStateSnapshot } from '@angular/router'
import { AuthService } from '../auth/auth.service'
import { RedirectService } from './redirect.service'
export class UnloggedGuard implements CanActivate, CanActivateChild {
constructor (
- private router: Router,
private auth: AuthService,
private redirectService: RedirectService
) {}
import { Injectable } from '@angular/core'
-import {
- ActivatedRouteSnapshot,
- CanActivateChild,
- RouterStateSnapshot,
- CanActivate,
- Router
-} from '@angular/router'
-
+import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'
import { AuthService } from '../auth/auth.service'
@Injectable()
import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Inject, Injectable, LOCALE_ID } from '@angular/core'
-import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { sortBy } from '@app/shared/misc/utils'
-import { SearchTargetType } from '@shared/models/search/search-target-query.model'
-import { ServerStats } from '@shared/models/server'
-import { getCompleteLocale, ServerConfig } from '../../../../../shared'
-import { isDefaultLocale, peertubeTranslate } from '../../../../../shared/models/i18n'
-import { VideoConstant } from '../../../../../shared/models/videos'
+import { getDevLocale, isOnDevLocale, peertubeLocalStorage, sortBy } from '@app/helpers'
+import {
+ getCompleteLocale,
+ isDefaultLocale,
+ peertubeTranslate,
+ SearchTargetType,
+ ServerConfig,
+ ServerStats,
+ VideoConstant
+} from '@shared/models'
import { environment } from '../../../environments/environment'
@Injectable()
+import { first } from 'rxjs/operators'
import { Injectable } from '@angular/core'
-import { AuthService } from '@app/core/auth'
-import { ServerService } from '@app/core/server'
-import { environment } from '../../../environments/environment'
-import { PluginService } from '@app/core/plugins/plugin.service'
+import { AuthService } from '../auth'
+import { PluginService } from '../plugins/plugin.service'
+import { ServerService } from '../server'
+import { LocalStorageService } from '../wrappers/storage.service'
+import { User } from '../users/user.model'
+import { UserService } from '../users/user.service'
import { ServerConfig, ServerConfigTheme } from '@shared/models'
-import { first } from 'rxjs/operators'
-import { User } from '@app/shared/users/user.model'
-import { UserService } from '@app/shared/users/user.service'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { environment } from '../../../environments/environment'
@Injectable()
export class ThemeService {
--- /dev/null
+export * from './user.model'
+export * from './user.service'
--- /dev/null
+import { Account } from '@app/shared/shared-main/account/account.model'
+import {
+ Avatar,
+ hasUserRight,
+ NSFWPolicyType,
+ User as UserServerModel,
+ UserAdminFlag,
+ UserNotificationSetting,
+ UserRight,
+ UserRole,
+ VideoChannel
+} from '@shared/models'
+
+export class User implements UserServerModel {
+ static KEYS = {
+ ID: 'id',
+ ROLE: 'role',
+ EMAIL: 'email',
+ VIDEOS_HISTORY_ENABLED: 'videos-history-enabled',
+ USERNAME: 'username',
+ NSFW_POLICY: 'nsfw_policy',
+ WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled',
+ AUTO_PLAY_VIDEO: 'auto_play_video',
+ SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO: 'auto_play_next_video',
+ AUTO_PLAY_VIDEO_PLAYLIST: 'auto_play_video_playlist',
+ THEME: 'last_active_theme',
+ VIDEO_LANGUAGES: 'video_languages'
+ }
+
+ id: number
+ username: string
+ email: string
+ pendingEmail: string | null
+
+ emailVerified: boolean
+ nsfwPolicy: NSFWPolicyType
+
+ adminFlags?: UserAdminFlag
+
+ autoPlayVideo: boolean
+ autoPlayNextVideo: boolean
+ autoPlayNextVideoPlaylist: boolean
+ webTorrentEnabled: boolean
+ videosHistoryEnabled: boolean
+ videoLanguages: string[]
+
+ role: UserRole
+ roleLabel: string
+
+ videoQuota: number
+ videoQuotaDaily: number
+ videoQuotaUsed?: number
+ videoQuotaUsedDaily?: number
+ videosCount?: number
+ videoAbusesCount?: number
+ videoAbusesAcceptedCount?: number
+ videoAbusesCreatedCount?: number
+ videoCommentsCount?: number
+
+ theme: string
+
+ account: Account
+ notificationSettings?: UserNotificationSetting
+ videoChannels?: VideoChannel[]
+
+ blocked: boolean
+ blockedReason?: string
+
+ noInstanceConfigWarningModal: boolean
+ noWelcomeModal: boolean
+
+ pluginAuth: string | null
+
+ lastLoginDate: Date | null
+
+ createdAt: Date
+
+ constructor (hash: Partial<UserServerModel>) {
+ this.id = hash.id
+ this.username = hash.username
+ this.email = hash.email
+
+ this.role = hash.role
+
+ this.videoChannels = hash.videoChannels
+
+ this.videoQuota = hash.videoQuota
+ this.videoQuotaDaily = hash.videoQuotaDaily
+ this.videoQuotaUsed = hash.videoQuotaUsed
+ this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
+ this.videosCount = hash.videosCount
+ this.videoAbusesCount = hash.videoAbusesCount
+ this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount
+ this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount
+ this.videoCommentsCount = hash.videoCommentsCount
+
+ this.nsfwPolicy = hash.nsfwPolicy
+ this.webTorrentEnabled = hash.webTorrentEnabled
+ this.autoPlayVideo = hash.autoPlayVideo
+ this.autoPlayNextVideo = hash.autoPlayNextVideo
+ this.autoPlayNextVideoPlaylist = hash.autoPlayNextVideoPlaylist
+ this.videosHistoryEnabled = hash.videosHistoryEnabled
+ this.videoLanguages = hash.videoLanguages
+
+ this.theme = hash.theme
+
+ this.adminFlags = hash.adminFlags
+
+ this.blocked = hash.blocked
+ this.blockedReason = hash.blockedReason
+
+ this.noInstanceConfigWarningModal = hash.noInstanceConfigWarningModal
+ this.noWelcomeModal = hash.noWelcomeModal
+
+ this.notificationSettings = hash.notificationSettings
+
+ this.createdAt = hash.createdAt
+
+ this.pluginAuth = hash.pluginAuth
+ this.lastLoginDate = hash.lastLoginDate
+
+ if (hash.account !== undefined) {
+ this.account = new Account(hash.account)
+ }
+ }
+
+ get accountAvatarUrl () {
+ if (!this.account) return ''
+
+ return this.account.avatarUrl
+ }
+
+ hasRight (right: UserRight) {
+ return hasUserRight(this.role, right)
+ }
+
+ patch (obj: UserServerModel) {
+ for (const key of Object.keys(obj)) {
+ this[key] = obj[key]
+ }
+
+ if (obj.account !== undefined) {
+ this.account = new Account(obj.account)
+ }
+ }
+
+ updateAccountAvatar (newAccountAvatar: Avatar) {
+ this.account.updateAvatar(newAccountAvatar)
+ }
+}
--- /dev/null
+import { has } from 'lodash-es'
+import { BytesPipe } from 'ngx-pipes'
+import { SortMeta } from 'primeng/api'
+import { from, Observable, of } from 'rxjs'
+import { catchError, concatMap, filter, first, map, shareReplay, throttleTime, toArray } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { AuthService } from '@app/core/auth'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import {
+ Avatar,
+ NSFWPolicyType,
+ ResultList,
+ User as UserServerModel,
+ UserCreate,
+ UserRegister,
+ UserRole,
+ UserUpdate,
+ UserUpdateMe,
+ UserVideoQuota
+} from '@shared/models'
+import { environment } from '../../../environments/environment'
+import { RestExtractor, RestPagination, RestService } from '../rest'
+import { LocalStorageService, SessionStorageService } from '../wrappers/storage.service'
+import { User } from './user.model'
+
+@Injectable()
+export class UserService {
+ static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/'
+
+ private bytesPipe = new BytesPipe()
+
+ private userCache: { [ id: number ]: Observable<UserServerModel> } = {}
+
+ constructor (
+ private authHttp: HttpClient,
+ private authService: AuthService,
+ private restExtractor: RestExtractor,
+ private restService: RestService,
+ private localStorageService: LocalStorageService,
+ private sessionStorageService: SessionStorageService,
+ private i18n: I18n
+ ) { }
+
+ changePassword (currentPassword: string, newPassword: string) {
+ const url = UserService.BASE_USERS_URL + 'me'
+ const body: UserUpdateMe = {
+ currentPassword,
+ password: newPassword
+ }
+
+ return this.authHttp.put(url, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ changeEmail (password: string, newEmail: string) {
+ const url = UserService.BASE_USERS_URL + 'me'
+ const body: UserUpdateMe = {
+ currentPassword: password,
+ email: newEmail
+ }
+
+ return this.authHttp.put(url, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ updateMyProfile (profile: UserUpdateMe) {
+ const url = UserService.BASE_USERS_URL + 'me'
+
+ return this.authHttp.put(url, profile)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ updateMyAnonymousProfile (profile: UserUpdateMe) {
+ const supportedKeys = {
+ // local storage keys
+ nsfwPolicy: (val: NSFWPolicyType) => this.localStorageService.setItem(User.KEYS.NSFW_POLICY, val),
+ webTorrentEnabled: (val: boolean) => this.localStorageService.setItem(User.KEYS.WEBTORRENT_ENABLED, String(val)),
+ autoPlayVideo: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO, String(val)),
+ autoPlayNextVideoPlaylist: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST, String(val)),
+ theme: (val: string) => this.localStorageService.setItem(User.KEYS.THEME, val),
+ videoLanguages: (val: string[]) => this.localStorageService.setItem(User.KEYS.VIDEO_LANGUAGES, JSON.stringify(val)),
+
+ // session storage keys
+ autoPlayNextVideo: (val: boolean) =>
+ this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, String(val))
+ }
+
+ for (const key of Object.keys(profile)) {
+ try {
+ if (has(supportedKeys, key)) supportedKeys[key](profile[key])
+ } catch (err) {
+ console.error(`Cannot set item ${key} in localStorage. Likely due to a value impossible to stringify.`, err)
+ }
+ }
+ }
+
+ listenAnonymousUpdate () {
+ return this.localStorageService.watch([
+ User.KEYS.NSFW_POLICY,
+ User.KEYS.WEBTORRENT_ENABLED,
+ User.KEYS.AUTO_PLAY_VIDEO,
+ User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST,
+ User.KEYS.THEME,
+ User.KEYS.VIDEO_LANGUAGES
+ ]).pipe(
+ throttleTime(200),
+ filter(() => this.authService.isLoggedIn() !== true),
+ map(() => this.getAnonymousUser())
+ )
+ }
+
+ deleteMe () {
+ const url = UserService.BASE_USERS_URL + 'me'
+
+ return this.authHttp.delete(url)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ changeAvatar (avatarForm: FormData) {
+ const url = UserService.BASE_USERS_URL + 'me/avatar/pick'
+
+ return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ signup (userCreate: UserRegister) {
+ return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ getMyVideoQuotaUsed () {
+ const url = UserService.BASE_USERS_URL + 'me/video-quota-used'
+
+ return this.authHttp.get<UserVideoQuota>(url)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ askResetPassword (email: string) {
+ const url = UserService.BASE_USERS_URL + '/ask-reset-password'
+
+ return this.authHttp.post(url, { email })
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ resetPassword (userId: number, verificationString: string, password: string) {
+ const url = `${UserService.BASE_USERS_URL}/${userId}/reset-password`
+ const body = {
+ verificationString,
+ password
+ }
+
+ return this.authHttp.post(url, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) {
+ const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email`
+ const body = {
+ verificationString,
+ isPendingEmail
+ }
+
+ return this.authHttp.post(url, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ askSendVerifyEmail (email: string) {
+ const url = UserService.BASE_USERS_URL + '/ask-send-verify-email'
+
+ return this.authHttp.post(url, { email })
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ autocomplete (search: string): Observable<string[]> {
+ const url = UserService.BASE_USERS_URL + 'autocomplete'
+ const params = new HttpParams().append('search', search)
+
+ return this.authHttp
+ .get<string[]>(url, { params })
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+ }
+
+ getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {
+ // Don't update display name, the user seems to have changed it
+ if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername
+
+ return this.displayNameToUsername(newDisplayName)
+ }
+
+ displayNameToUsername (displayName: string) {
+ if (!displayName) return ''
+
+ return displayName
+ .toLowerCase()
+ .replace(/\s/g, '_')
+ .replace(/[^a-z0-9_.]/g, '')
+ }
+
+ /* ###### Admin methods ###### */
+
+ addUser (userCreate: UserCreate) {
+ return this.authHttp.post(UserService.BASE_USERS_URL, userCreate)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ updateUser (userId: number, userUpdate: UserUpdate) {
+ return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ updateUsers (users: UserServerModel[], userUpdate: UserUpdate) {
+ return from(users)
+ .pipe(
+ concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)),
+ toArray(),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ getUserWithCache (userId: number) {
+ if (!this.userCache[userId]) {
+ this.userCache[ userId ] = this.getUser(userId).pipe(shareReplay())
+ }
+
+ return this.userCache[userId]
+ }
+
+ getUser (userId: number, withStats = false) {
+ const params = new HttpParams().append('withStats', withStats + '')
+ return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId, { params })
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ getAnonymousUser () {
+ let videoLanguages: string[]
+
+ try {
+ videoLanguages = JSON.parse(this.localStorageService.getItem(User.KEYS.VIDEO_LANGUAGES))
+ } catch (err) {
+ videoLanguages = null
+ console.error('Cannot parse desired video languages from localStorage.', err)
+ }
+
+ return new User({
+ // local storage keys
+ nsfwPolicy: this.localStorageService.getItem(User.KEYS.NSFW_POLICY) as NSFWPolicyType,
+ webTorrentEnabled: this.localStorageService.getItem(User.KEYS.WEBTORRENT_ENABLED) !== 'false',
+ theme: this.localStorageService.getItem(User.KEYS.THEME) || 'instance-default',
+ videoLanguages,
+
+ autoPlayNextVideoPlaylist: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST) !== 'false',
+ autoPlayVideo: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO) === 'true',
+
+ // session storage keys
+ autoPlayNextVideo: this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
+ })
+ }
+
+ getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<UserServerModel>> {
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) params = params.append('search', search)
+
+ return this.authHttp.get<ResultList<UserServerModel>>(UserService.BASE_USERS_URL, { params })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ removeUser (usersArg: UserServerModel | UserServerModel[]) {
+ const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
+
+ return from(users)
+ .pipe(
+ concatMap(u => this.authHttp.delete(UserService.BASE_USERS_URL + u.id)),
+ toArray(),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ banUsers (usersArg: UserServerModel | UserServerModel[], reason?: string) {
+ const body = reason ? { reason } : {}
+ const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
+
+ return from(users)
+ .pipe(
+ concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/block', body)),
+ toArray(),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ unbanUsers (usersArg: UserServerModel | UserServerModel[]) {
+ const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
+
+ return from(users)
+ .pipe(
+ concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/unblock', {})),
+ toArray(),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ getAnonymousOrLoggedUser () {
+ if (!this.authService.isLoggedIn()) {
+ return of(this.getAnonymousUser())
+ }
+
+ return this.authService.userInformationLoaded
+ .pipe(
+ first(),
+ map(() => this.authService.getUser())
+ )
+ }
+
+ private formatUser (user: UserServerModel) {
+ let videoQuota
+ if (user.videoQuota === -1) {
+ videoQuota = this.i18n('Unlimited')
+ } else {
+ videoQuota = this.bytesPipe.transform(user.videoQuota, 0)
+ }
+
+ const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
+
+ const roleLabels: { [ id in UserRole ]: string } = {
+ [UserRole.USER]: this.i18n('User'),
+ [UserRole.ADMINISTRATOR]: this.i18n('Administrator'),
+ [UserRole.MODERATOR]: this.i18n('Moderator')
+ }
+
+ return Object.assign(user, {
+ roleLabel: roleLabels[user.role],
+ videoQuota,
+ videoQuotaUsed
+ })
+ }
+}
--- /dev/null
+export * from './screen.service'
+export * from './storage.service'
--- /dev/null
+import { Injectable } from '@angular/core'
+
+@Injectable()
+export class ScreenService {
+ private windowInnerWidth: number
+ private lastFunctionCallTime: number
+ private cacheForMs = 500
+
+ constructor () {
+ this.refreshWindowInnerWidth()
+ }
+
+ isInSmallView (marginLeft = 0) {
+ if (marginLeft > 0) {
+ const contentWidth = this.getWindowInnerWidth() - marginLeft
+ return contentWidth < 800
+ }
+
+ return this.getWindowInnerWidth() < 800
+ }
+
+ isInMediumView () {
+ return this.getWindowInnerWidth() < 1100
+ }
+
+ isInMobileView () {
+ return this.getWindowInnerWidth() < 500
+ }
+
+ isInTouchScreen () {
+ return 'ontouchstart' in window || navigator.msMaxTouchPoints
+ }
+
+ getNumberOfAvailableMiniatures () {
+ const screenWidth = this.getWindowInnerWidth()
+
+ let numberOfVideos = 1
+
+ if (screenWidth > 1850) numberOfVideos = 7
+ else if (screenWidth > 1600) numberOfVideos = 6
+ else if (screenWidth > 1370) numberOfVideos = 5
+ else if (screenWidth > 1100) numberOfVideos = 4
+ else if (screenWidth > 850) numberOfVideos = 3
+
+ return numberOfVideos
+ }
+
+ // Cache window inner width, because it's an expensive call
+ getWindowInnerWidth () {
+ if (this.cacheWindowInnerWidthExpired()) this.refreshWindowInnerWidth()
+
+ return this.windowInnerWidth
+ }
+
+ private refreshWindowInnerWidth () {
+ this.lastFunctionCallTime = new Date().getTime()
+
+ this.windowInnerWidth = window.innerWidth
+ }
+
+ private cacheWindowInnerWidthExpired () {
+ if (!this.lastFunctionCallTime) return true
+
+ return new Date().getTime() > (this.lastFunctionCallTime + this.cacheForMs)
+ }
+}
--- /dev/null
+import { Observable, Subject } from 'rxjs'
+import { filter } from 'rxjs/operators'
+import { Injectable } from '@angular/core'
+import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers'
+
+abstract class StorageService {
+ protected instance: Storage
+ static storageSub = new Subject<string>()
+
+ watch (keys?: string[]): Observable<string> {
+ return StorageService.storageSub.asObservable().pipe(filter(val => keys ? keys.includes(val) : true))
+ }
+
+ getItem (key: string) {
+ return this.instance.getItem(key)
+ }
+
+ setItem (key: string, data: any, notifyOfUpdate = true) {
+ this.instance.setItem(key, data)
+ if (notifyOfUpdate) StorageService.storageSub.next(key)
+ }
+
+ removeItem (key: string, notifyOfUpdate = true) {
+ this.instance.removeItem(key)
+ if (notifyOfUpdate) StorageService.storageSub.next(key)
+ }
+}
+
+@Injectable()
+export class LocalStorageService extends StorageService {
+ protected instance: Storage = peertubeLocalStorage
+}
+
+@Injectable()
+export class SessionStorageService extends StorageService {
+ protected instance: Storage = peertubeSessionStorage
+}
import { of } from 'rxjs'
-import { first, tap, delay } from 'rxjs/operators'
+import { first, tap } from 'rxjs/operators'
import { ListKeyManager } from '@angular/cdk/a11y'
-import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren, AfterViewChecked } from '@angular/core'
+import { AfterViewChecked, AfterViewInit, Component, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core'
import { ActivatedRoute, Params, Router } from '@angular/router'
import { AuthService, ServerService } from '@app/core'
-import { ServerConfig } from '@shared/models'
-import { SearchTargetType } from '@shared/models/search/search-target-query.model'
+import { SearchTargetType, ServerConfig } from '@shared/models'
import { SuggestionComponent, SuggestionPayload, SuggestionPayloadType } from './suggestion.component'
@Component({
-import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy, OnChanges } from '@angular/core'
-import { RouterLink } from '@angular/router'
import { ListKeyManagerOption } from '@angular/cdk/a11y'
+import { Component, Input, OnInit } from '@angular/core'
+import { RouterLink } from '@angular/router'
export type SuggestionPayload = {
text: string
--- /dev/null
+export const POP_STATE_MODAL_DISMISS = 'pop state dismiss'
--- /dev/null
+import { environment } from '../../environments/environment'
+
+function isOnDevLocale () {
+ return environment.production === false && window.location.search === '?lang=fr'
+}
+
+function getDevLocale () {
+ return 'fr-FR'
+}
+
+export {
+ getDevLocale,
+ isOnDevLocale
+}
--- /dev/null
+export * from './locales'
+export * from './constants'
+export * from './i18n-utils'
+export * from './peertube-web-storage'
+export * from './utils'
+export * from './zone'
--- /dev/null
+export * from './oc'
--- /dev/null
+
+// This code is not generated
+// See angular/tools/gulp-tasks/cldr/extract.js
+
+const u: any = undefined
+
+function plural (n: number): number {
+ const i = Math.floor(Math.abs(n))
+ if (i === 0 || i === 1) return 1
+ return 5
+}
+
+export default [
+ 'oc',
+ [['a. m.', 'p. m.'], u, u],
+ u,
+ [
+ ['dg', 'dl', 'dm', 'dc', 'dj', 'dv', 'ds'], ['dg.', 'dl.', 'dm.', 'dc.', 'dj.', 'dv.', 'ds.'],
+ ['dimenge', 'diluns', 'dimars', 'dimècres', 'dijòus', 'divendres', 'dissabte'],
+ ['dg.', 'dl.', 'dm.', 'dc.', 'dj.', 'dv.', 'ds.']
+ ],
+ u,
+ [
+ ['GN', 'FB', 'MÇ', 'AB', 'MA', 'JN', 'JL', 'AG', 'ST', 'OC', 'NV', 'DC'],
+ [
+ 'de gen.', 'de febr.', 'de març', 'd’abr.', 'de mai', 'de junh', 'de jul.', 'd’ag.',
+ 'de set.', 'd’oct.', 'de nov.', 'de dec.'
+ ],
+ [
+ 'de genièr', 'de febrièr', 'de març', 'd’abril', 'de mai', 'de junh', 'de julhet',
+ 'd’agòst', 'de setembre', 'd’octòbre', 'de novembre', 'de decembre'
+ ]
+ ],
+ [
+ ['GN', 'FB', 'MÇ', 'AB', 'MA', 'JN', 'JL', 'AG', 'ST', 'OC', 'NV', 'DC'],
+ [
+ 'gen.', 'febr.', 'març', 'abr.', 'mai', 'junh', 'jul.', 'ag.', 'set.', 'oct.', 'nov.',
+ 'dec.'
+ ],
+ [
+ 'genièr', 'febrièr', 'març', 'abril', 'mai', 'junh', 'julhet', 'agòst', 'setembre', 'octòbre',
+ 'novembre', 'decembre'
+ ]
+ ],
+ [['aC', 'dC'], u, ['abans Jèsus-Crist', 'aprèp Jèsus-Crist']],
+ 1,
+ [6, 0],
+ ['d/M/yy', 'd MMM y', 'd MMMM \'de\' y', 'EEEE, d MMMM \'de\' y'],
+ ['H:mm', 'H:mm:ss', 'H:mm:ss z', 'H:mm:ss zzzz'],
+ ['{1} {0}', '{1}, {0}', '{1} \'a\' \'les\' {0}', u],
+ [',', '.', ';', '%', '+', '-', 'E', '×', '‰', '∞', 'NaN', ':'],
+ ['#,##0.###', '#,##0%', '#,##0.00 ¤', '#E0'],
+ 'EUR',
+ '€',
+ 'euro',
+ {
+ 'ARS': ['$AR', '$'],
+ 'AUD': ['$AU', '$'],
+ 'BEF': ['FB'],
+ 'BMD': ['$BM', '$'],
+ 'BND': ['$BN', '$'],
+ 'BZD': ['$BZ', '$'],
+ 'CAD': ['$CA', '$'],
+ 'CLP': ['$CL', '$'],
+ 'CNY': [u, 'Â¥'],
+ 'COP': ['$CO', '$'],
+ 'CYP': ['£CY'],
+ 'EGP': [u, '£E'],
+ 'FJD': ['$FJ', '$'],
+ 'FKP': ['£FK', '£'],
+ 'FRF': ['F'],
+ 'GBP': ['£GB', '£'],
+ 'GIP': ['£GI', '£'],
+ 'HKD': [u, '$'],
+ 'IEP': ['£IE'],
+ 'ILP': ['£IL'],
+ 'ITL': ['₤IT'],
+ 'JPY': [u, 'Â¥'],
+ 'KMF': [u, 'FC'],
+ 'LBP': ['£LB', '£L'],
+ 'MTP': ['£MT'],
+ 'MXN': ['$MX', '$'],
+ 'NAD': ['$NA', '$'],
+ 'NIO': [u, '$C'],
+ 'NZD': ['$NZ', '$'],
+ 'RHD': ['$RH'],
+ 'RON': [u, 'L'],
+ 'RWF': [u, 'FR'],
+ 'SBD': ['$SB', '$'],
+ 'SGD': ['$SG', '$'],
+ 'SRD': ['$SR', '$'],
+ 'TOP': [u, '$T'],
+ 'TTD': ['$TT', '$'],
+ 'TWD': [u, 'NT$'],
+ 'USD': ['$US', '$'],
+ 'UYU': ['$UY', '$'],
+ 'WST': ['$WS'],
+ 'XCD': [u, '$'],
+ 'XPF': ['FCFP'],
+ 'ZMW': [u, 'Kw']
+ },
+ 'ltr',
+ plural
+]
--- /dev/null
+// Thanks: https://github.com/capaj/localstorage-polyfill
+
+const valuesMap = new Map()
+
+function proxify (instance: MemoryStorage) {
+ return new Proxy(instance, {
+ set: function (obj, prop: string | number, value) {
+ if (MemoryStorage.prototype.hasOwnProperty(prop)) {
+ instance[prop] = value
+ } else {
+ instance.setItem(prop, value)
+ }
+ return true
+ },
+ get: function (target, name: string | number) {
+ if (MemoryStorage.prototype.hasOwnProperty(name)) {
+ return instance[name]
+ }
+ if (valuesMap.has(name)) {
+ return instance.getItem(name)
+ }
+ }
+ })
+}
+
+class MemoryStorage {
+ [key: string]: any
+ [index: number]: string
+
+ getItem (key: any) {
+ const stringKey = String(key)
+ if (valuesMap.has(key)) {
+ return String(valuesMap.get(stringKey))
+ }
+
+ return null
+ }
+
+ setItem (key: any, val: any) {
+ valuesMap.set(String(key), String(val))
+ }
+
+ removeItem (key: any) {
+ valuesMap.delete(key)
+ }
+
+ clear () {
+ valuesMap.clear()
+ }
+
+ key (i: any) {
+ if (arguments.length === 0) {
+ throw new TypeError('Failed to execute "key" on "Storage": 1 argument required, but only 0 present.')
+ }
+
+ const arr = Array.from(valuesMap.keys())
+ return arr[i]
+ }
+
+ get length () {
+ return valuesMap.size
+ }
+}
+
+let peertubeLocalStorage: Storage
+let peertubeSessionStorage: Storage
+try {
+ peertubeLocalStorage = localStorage
+ peertubeSessionStorage = sessionStorage
+} catch (err) {
+ const instanceLocalStorage = new MemoryStorage()
+ const instanceSessionStorage = new MemoryStorage()
+
+ peertubeLocalStorage = proxify(instanceLocalStorage)
+ peertubeSessionStorage = proxify(instanceSessionStorage)
+}
+
+export {
+ peertubeLocalStorage,
+ peertubeSessionStorage
+}
--- /dev/null
+import { DatePipe } from '@angular/common'
+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 populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support?: string }[]) {
+ return new Promise(res => {
+ authService.userInformationLoaded
+ .subscribe(
+ () => {
+ const user = authService.getUser()
+ if (!user) return
+
+ const videoChannels = user.videoChannels
+ if (Array.isArray(videoChannels) === false) return
+
+ videoChannels.forEach(c => channel.push({ id: c.id, label: c.displayName, support: c.support }))
+
+ return res()
+ }
+ )
+ })
+}
+
+function getAbsoluteAPIUrl () {
+ let absoluteAPIUrl = environment.apiUrl
+ if (!absoluteAPIUrl) {
+ // The API is on the same domain
+ absoluteAPIUrl = window.location.origin
+ }
+
+ return absoluteAPIUrl
+}
+
+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)
+}
+
+function objectToUrlEncoded (obj: any) {
+ const str: string[] = []
+ for (const key of Object.keys(obj)) {
+ str.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]))
+ }
+
+ return str.join('&')
+}
+
+// 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 () {
+ window.scroll(0, 0)
+}
+
+// Thanks: https://github.com/uupaa/dynamic-import-polyfill
+function importModule (path: string) {
+ return new Promise((resolve, reject) => {
+ const vector = '$importModule$' + Math.random().toString(32).slice(2)
+ const script = document.createElement('script')
+
+ const destructor = () => {
+ delete window[ vector ]
+ script.onerror = null
+ script.onload = null
+ script.remove()
+ URL.revokeObjectURL(script.src)
+ script.src = ''
+ }
+
+ script.defer = true
+ script.type = 'module'
+
+ script.onerror = () => {
+ reject(new Error(`Failed to import: ${path}`))
+ destructor()
+ }
+ script.onload = () => {
+ resolve(window[ vector ])
+ destructor()
+ }
+ const absURL = (environment.apiUrl || window.location.origin) + path
+ const loader = `import * as m from "${absURL}"; window.${vector} = m;` // export Module
+ const blob = new Blob([ loader ], { type: 'text/javascript' })
+ script.src = URL.createObjectURL(blob)
+
+ document.head.appendChild(script)
+ })
+}
+
+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 {
+ sortBy,
+ durationToString,
+ lineFeedToHtml,
+ objectToUrlEncoded,
+ getParameterByName,
+ populateAsyncUserVideoChannels,
+ getAbsoluteAPIUrl,
+ dateToHuman,
+ immutableAssign,
+ objectToFormData,
+ objectLineFeedToHtml,
+ removeElementFromArray,
+ importModule,
+ scrollToTop,
+ isInViewport,
+ isXPercentInViewport
+}
--- /dev/null
+import { SchedulerLike, Subscription } from 'rxjs'
+import { NgZone } from '@angular/core'
+
+class LeaveZoneScheduler implements SchedulerLike {
+ constructor (private zone: NgZone, private scheduler: SchedulerLike) {
+ }
+
+ schedule (...args: any[]): Subscription {
+ return this.zone.runOutsideAngular(() =>
+ this.scheduler.schedule.apply(this.scheduler, args)
+ )
+ }
+
+ now (): number {
+ return this.scheduler.now()
+ }
+}
+
+class EnterZoneScheduler implements SchedulerLike {
+ constructor (private zone: NgZone, private scheduler: SchedulerLike) {
+ }
+
+ schedule (...args: any[]): Subscription {
+ return this.zone.run(() =>
+ this.scheduler.schedule.apply(this.scheduler, args)
+ )
+ }
+
+ now (): number {
+ return this.scheduler.now()
+ }
+}
+
+export function leaveZone (zone: NgZone, scheduler: SchedulerLike): SchedulerLike {
+ return new LeaveZoneScheduler(zone, scheduler)
+}
+
+export function enterZone (zone: NgZone, scheduler: SchedulerLike): SchedulerLike {
+ return new EnterZoneScheduler(zone, scheduler)
+}
-import { Component, ElementRef, OnInit, ViewChild, AfterViewInit } from '@angular/core'
-import { Notifier, RedirectService } from '@app/core'
-import { UserService } from '@app/shared'
-import { AuthService } from '../core'
-import { FormReactive } from '../shared'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { LoginValidatorsService } from '@app/shared/forms/form-validators/login-validators.service'
-import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
-import { ActivatedRoute } from '@angular/router'
-import { ServerConfig, RegisteredExternalAuthConfig } from '@shared/models/server/server-config.model'
import { environment } from 'src/environments/environment'
+import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
+import { ActivatedRoute } from '@angular/router'
+import { AuthService, Notifier, RedirectService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
+import { FormReactive, FormValidatorService, LoginValidatorsService } from '@app/shared/shared-forms'
+import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
@Component({
selector: 'my-login',
import { NgModule } from '@angular/core'
-
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
import { LoginRoutingModule } from './login-routing.module'
import { LoginComponent } from './login.component'
-import { SharedModule } from '../shared'
@NgModule({
imports: [
LoginRoutingModule,
- SharedModule
+
+ SharedMainModule,
+ SharedFormModule,
+ SharedGlobalIconModule
],
declarations: [
-import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
-import { User } from '../shared/users/user.model'
-import { UserNotificationService } from '@app/shared/users/user-notification.service'
import { Subject, Subscription } from 'rxjs'
-import { Notifier, UserNotificationSocket } from '@app/core'
-import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
-import { NavigationEnd, Router } from '@angular/router'
import { filter } from 'rxjs/operators'
+import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
+import { NavigationEnd, Router } from '@angular/router'
+import { Notifier, User, UserNotificationSocket } from '@app/core'
+import { UserNotificationService } from '@app/shared/shared-main'
+import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
@Component({
selector: 'my-avatar-notification',
-import { Component, ElementRef, ViewChild, Inject, LOCALE_ID } from '@angular/core'
-import { I18N_LOCALES, getShortLocale } from '../../../../shared'
+import { Component, ElementRef, Inject, LOCALE_ID, ViewChild } from '@angular/core'
+import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { sortBy } from '@app/shared/misc/utils'
-import { getCompleteLocale } from '@shared/models/i18n'
-import { isOnDevLocale, getDevLocale } from '@app/shared/i18n/i18n-utils'
+import { getCompleteLocale, getShortLocale, I18N_LOCALES } from '@shared/models'
@Component({
selector: 'my-language-chooser',
+import { HotkeysService } from 'angular2-hotkeys'
import { Component, OnInit, ViewChild } from '@angular/core'
-import { UserRight } from '../../../../shared/models/users/user-right.enum'
-import { AuthService, AuthStatus, RedirectService, ServerService } from '../core'
-import { User } from '@app/shared/users/user.model'
-import { UserService } from '@app/shared/users/user.service'
+import { AuthService, AuthStatus, RedirectService, ScreenService, ServerService, User, UserService } from '@app/core'
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
-import { HotkeysService } from 'angular2-hotkeys'
-import { ServerConfig, VideoConstant } from '@shared/models'
import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
+import { ServerConfig, UserRight, VideoConstant } from '@shared/models'
@Component({
selector: 'my-menu',
--- /dev/null
+<ng-template #confirmModal let-close="close" let-dismiss="dismiss">
+
+ <div class="modal-header">
+ <h4 class="modal-title">{{ title }}</h4>
+
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
+ </div>
+
+ <div class="modal-body" >
+ <div [innerHtml]="message"></div>
+
+ <div *ngIf="inputLabel && expectedInputValue" class="form-group">
+ <label for="confirmInput">{{ inputLabel }}</label>
+ <input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
+ </div>
+ </div>
+
+ <div class="modal-footer inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+ (click)="dismiss()" (key.enter)="dismiss()"
+ >
+
+ <input
+ ngbAutofocus
+ type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()"
+ (click)="close()" (key.enter)="confirm()"
+ >
+ </div>
+</ng-template>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.modal-body {
+ font-size: 15px;
+}
+
+.button {
+ padding: 0 13px;
+}
+
+input[type=text] {
+ @include peertube-input-text(100%);
+ display: block;
+}
+
+.form-group {
+ margin: 20px 0;
+}
+
+
--- /dev/null
+import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
+import { ConfirmService } from '@app/core/confirm/confirm.service'
+import { POP_STATE_MODAL_DISMISS } from '@app/helpers'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+ selector: 'my-confirm',
+ templateUrl: './confirm.component.html',
+ styleUrls: [ './confirm.component.scss' ]
+})
+export class ConfirmComponent implements OnInit {
+ @ViewChild('confirmModal', { static: true }) confirmModal: ElementRef
+
+ title = ''
+ message = ''
+ expectedInputValue = ''
+ inputLabel = ''
+
+ inputValue = ''
+ confirmButtonText = ''
+
+ private openedModal: NgbModalRef
+
+ constructor (
+ private modalService: NgbModal,
+ private confirmService: ConfirmService,
+ private i18n: I18n
+ ) { }
+
+ ngOnInit () {
+ this.confirmService.showConfirm.subscribe(
+ ({ title, message, expectedInputValue, inputLabel, confirmButtonText }) => {
+ this.title = title
+ this.message = message
+
+ this.inputLabel = inputLabel
+ this.expectedInputValue = expectedInputValue
+
+ this.confirmButtonText = confirmButtonText || this.i18n('Confirm')
+
+ this.showModal()
+ }
+ )
+ }
+
+ confirm () {
+ if (this.openedModal) this.openedModal.close()
+ }
+
+ isConfirmationDisabled () {
+ // No input validation
+ if (!this.inputLabel || !this.expectedInputValue) return false
+
+ return this.expectedInputValue !== this.inputValue
+ }
+
+ showModal () {
+ this.inputValue = ''
+
+ this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
+
+ this.openedModal.result
+ .then(() => this.confirmService.confirmResponse.next(true))
+ .catch((reason: string) => {
+ // If the reason was that the user used the back button, we don't care about the confirm dialog result
+ if (!reason || reason !== POP_STATE_MODAL_DISMISS) {
+ this.confirmService.confirmResponse.next(false)
+ }
+ })
+ }
+}
import { Component, ElementRef, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
+import { Notifier, UserService } from '@app/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { About } from '@shared/models/server'
-import { UserService } from '@app/shared'
@Component({
selector: 'my-instance-config-warning-modal',
<div class="modal-body">
<div i18n class="mb-4 quick-settings-title">Display settings</div>
- <my-account-video-settings
+ <my-user-video-settings
*ngIf="!isUserLoggedIn()"
[user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true"
>
<ng-container ngProjectAs="inner-title">
<div i18n class="mb-4 mt-4 quick-settings-title">Video settings</div>
</ng-container>
- </my-account-video-settings>
+ </my-user-video-settings>
<div i18n class="mb-4 mt-4 quick-settings-title">Interface settings</div>
- <my-account-interface-settings
+ <my-user-interface-settings
*ngIf="!isUserLoggedIn()"
[user]="user" [userInformationLoaded]="userInformationLoaded" [reactiveUpdate]="true" [notifyOnUpdate]="true"
- ></my-account-interface-settings>
+ ></my-user-interface-settings>
</div>
</ng-template>
-import { Component, ViewChild, OnInit } from '@angular/core'
-import { AuthService, AuthStatus } from '@app/core'
-import { FormReactive, FormValidatorService, UserService, User } from '@app/shared'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { ReplaySubject } from 'rxjs'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
import { filter } from 'rxjs/operators'
+import { Component, OnInit, ViewChild } from '@angular/core'
+import { AuthService, AuthStatus, LocalStorageService, User, UserService } from '@app/core'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@Component({
selector: 'my-quick-settings',
import { Component, ElementRef, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
+import { Notifier, UserService } from '@app/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { UserService } from '@app/shared'
@Component({
selector: 'my-welcome-modal',
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
-
import { MetaGuard } from '@ngx-meta/core'
-
import { ResetPasswordComponent } from './reset-password.component'
const resetPasswordRoutes: Routes = [
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { UserService, UserValidatorsService, FormReactive } from '@app/shared'
-import { Notifier } from '@app/core'
+import { Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, ResetPasswordValidatorsService, UserValidatorsService } from '@app/shared/shared-forms'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { ResetPasswordValidatorsService } from '@app/shared/forms/form-validators/reset-password-validators.service'
@Component({
selector: 'my-login',
import { NgModule } from '@angular/core'
-
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedMainModule } from '@app/shared/shared-main'
import { ResetPasswordRoutingModule } from './reset-password-routing.module'
import { ResetPasswordComponent } from './reset-password.component'
-import { SharedModule } from '../shared'
@NgModule({
imports: [
ResetPasswordRoutingModule,
- SharedModule
+
+ SharedMainModule,
+ SharedFormModule
],
declarations: [
-import { SearchTargetType } from '@shared/models/search/search-target-query.model'
-import { NSFWQuery } from '../../../../shared/models/search'
+import { NSFWQuery, SearchTargetType } from '@shared/models'
export class AdvancedSearch {
startDate: string // ISO 8601
--- /dev/null
+import { PipeTransform, Pipe } from '@angular/core'
+import { SafeHtml } from '@angular/platform-browser'
+
+// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
+@Pipe({ name: 'highlight' })
+export class HighlightPipe implements PipeTransform {
+ /* use this for single match search */
+ static SINGLE_MATCH = 'Single-Match'
+ /* use this for single match search with a restriction that target should start with search string */
+ static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match'
+ /* use this for global search */
+ static MULTI_MATCH = 'Multi-Match'
+
+ transform (
+ contentString: string = null,
+ stringToHighlight: string = null,
+ option = 'Single-And-StartsWith-Match',
+ caseSensitive = false,
+ highlightStyleName = 'search-highlight'
+ ): SafeHtml {
+ if (stringToHighlight && contentString && option) {
+ let regex: any = ''
+ const caseFlag: string = !caseSensitive ? 'i' : ''
+
+ switch (option) {
+ case 'Single-Match': {
+ regex = new RegExp(stringToHighlight, caseFlag)
+ break
+ }
+ case 'Single-And-StartsWith-Match': {
+ regex = new RegExp('^' + stringToHighlight, caseFlag)
+ break
+ }
+ case 'Multi-Match': {
+ regex = new RegExp(stringToHighlight, 'g' + caseFlag)
+ break
+ }
+ default: {
+ // default will be a global case-insensitive match
+ regex = new RegExp(stringToHighlight, 'gi')
+ }
+ }
+
+ const replaced = contentString.replace(
+ regex,
+ (match) => `<span class="${highlightStyleName}">${match}</span>`
+ )
+
+ return replaced
+ } else {
+ return contentString
+ }
+ }
+}
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { ValidatorFn } from '@angular/forms'
-import { VideoValidatorsService } from '@app/shared'
import { ServerService } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
import { AdvancedSearch } from '@app/search/advanced-search.model'
-import { ServerConfig, VideoConstant } from '../../../../shared'
+import { VideoValidatorsService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, VideoConstant } from '@shared/models'
@Component({
selector: 'my-search-filters',
import { forkJoin, of, Subscription } from 'rxjs'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { AdvancedSearch } from '@app/search/advanced-search.model'
-import { SearchService } from '@app/search/search.service'
-import { User, UserService } from '@app/shared'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
-import { Video } from '@app/shared/video/video.model'
+import { AuthService, ComponentPagination, HooksService, Notifier, ServerService, User, UserService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { Video, VideoChannel } from '@app/shared/shared-main'
+import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
import { MetaService } from '@ngx-meta/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ServerConfig } from '@shared/models'
-import { SearchTargetType } from '@shared/models/search/search-target-query.model'
+import { SearchTargetType, ServerConfig } from '@shared/models'
+import { AdvancedSearch } from './advanced-search.model'
+import { SearchService } from './search.service'
@Component({
selector: 'my-search',
import { TagInputModule } from 'ngx-chips'
import { NgModule } from '@angular/core'
-import { SearchFiltersComponent } from '@app/search/search-filters.component'
-import { SearchRoutingModule } from '@app/search/search-routing.module'
-import { SearchComponent } from '@app/search/search.component'
-import { SearchService } from '@app/search/search.service'
-import { SharedModule } from '../shared'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
+import { HighlightPipe } from './highlight.pipe'
+import { SearchFiltersComponent } from './search-filters.component'
+import { SearchRoutingModule } from './search-routing.module'
+import { SearchComponent } from './search.component'
+import { SearchService } from './search.service'
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
@NgModule({
TagInputModule,
SearchRoutingModule,
- SharedModule
+ SharedMainModule,
+ SharedFormModule,
+ SharedUserSubscriptionModule,
+ SharedVideoMiniatureModule
],
declarations: [
providers: [
SearchService,
VideoLazyLoadResolver,
- ChannelLazyLoadResolver
+ ChannelLazyLoadResolver,
+ HighlightPipe
]
})
export class SearchModule { }
import { catchError, map, switchMap } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
+import { ComponentPaginationLight, RestExtractor, RestPagination, RestService } from '@app/core'
+import { peertubeLocalStorage } from '@app/helpers'
import { AdvancedSearch } from '@app/search/advanced-search.model'
-import { RestExtractor, RestPagination, RestService } from '@app/shared'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { Video } from '@app/shared/video/video.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '../../../../shared'
-import { environment } from '../../environments/environment'
+import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { ResultList, Video as VideoServerModel, VideoChannel as VideoChannelServerModel } from '@shared/models'
import { SearchTargetType } from '@shared/models/search/search-target-query.model'
+import { environment } from '../../environments/environment'
@Injectable()
export class SearchService {
+++ /dev/null
-import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model'
-import { Actor } from '../actor/actor.model'
-
-export class Account extends Actor implements ServerAccount {
- displayName: string
- description: string
- nameWithHost: string
- nameWithHostForced: string
- mutedByUser: boolean
- mutedByInstance: boolean
- mutedServerByUser: boolean
- mutedServerByInstance: boolean
-
- userId?: number
-
- constructor (hash: ServerAccount) {
- super(hash)
-
- this.displayName = hash.displayName
- this.description = hash.description
- this.userId = hash.userId
- this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
- this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
-
- this.mutedByUser = false
- this.mutedByInstance = false
- this.mutedServerByUser = false
- this.mutedServerByInstance = false
- }
-}
+++ /dev/null
-import { map, tap, catchError } from 'rxjs/operators'
-import { Injectable } from '@angular/core'
-import { environment } from '../../../environments/environment'
-import { Observable, ReplaySubject } from 'rxjs'
-import { Account } from '@app/shared/account/account.model'
-import { RestExtractor } from '@app/shared/rest/rest-extractor.service'
-import { HttpClient } from '@angular/common/http'
-import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model'
-
-@Injectable()
-export class AccountService {
- static BASE_ACCOUNT_URL = environment.apiUrl + '/api/v1/accounts/'
-
- accountLoaded = new ReplaySubject<Account>(1)
-
- constructor (
- private authHttp: HttpClient,
- private restExtractor: RestExtractor
- ) {}
-
- getAccount (id: number | string): Observable<Account> {
- return this.authHttp.get<ServerAccount>(AccountService.BASE_ACCOUNT_URL + id)
- .pipe(
- map(accountHash => new Account(accountHash)),
- tap(account => this.accountLoaded.next(account)),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-}
+++ /dev/null
-import { Actor as ActorServer } from '../../../../../shared/models/actors/actor.model'
-import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
-
-export abstract class Actor implements ActorServer {
- id: number
- url: string
- name: string
- host: string
- followingCount: number
- followersCount: number
- createdAt: Date | string
- updatedAt: Date | string
- avatar: Avatar
-
- avatarUrl: string
-
- static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) {
- if (actor?.avatar?.url) return actor.avatar.url
-
- if (actor && actor.avatar) {
- const absoluteAPIUrl = getAbsoluteAPIUrl()
-
- return absoluteAPIUrl + actor.avatar.path
- }
-
- return this.GET_DEFAULT_AVATAR_URL()
- }
-
- static GET_DEFAULT_AVATAR_URL () {
- return window.location.origin + '/client/assets/images/default-avatar.png'
- }
-
- static CREATE_BY_STRING (accountName: string, host: string, forceHostname = false) {
- const absoluteAPIUrl = getAbsoluteAPIUrl()
- const thisHost = new URL(absoluteAPIUrl).host
-
- if (host.trim() === thisHost && !forceHostname) return accountName
-
- return accountName + '@' + host
- }
-
- protected constructor (hash: ActorServer) {
- this.id = hash.id
- this.url = hash.url
- this.name = hash.name
- this.host = hash.host
- this.followingCount = hash.followingCount
- this.followersCount = hash.followersCount
- this.createdAt = new Date(hash.createdAt.toString())
- this.updatedAt = new Date(hash.updatedAt.toString())
- this.avatar = hash.avatar
-
- this.updateComputedAttributes()
- }
-
- updateAvatar (newAvatar: Avatar) {
- this.avatar = newAvatar
-
- this.updateComputedAttributes()
- }
-
- private updateComputedAttributes () {
- this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this)
- }
-}
+++ /dev/null
-import { Pipe, PipeTransform } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
-@Pipe({ name: 'myFromNow' })
-export class FromNowPipe implements PipeTransform {
-
- constructor (private i18n: I18n) { }
-
- transform (arg: number | Date | string) {
- const argDate = new Date(arg)
- const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
-
- let interval = Math.floor(seconds / 31536000)
- if (interval > 1) return this.i18n('{{interval}} years ago', { interval })
- if (interval === 1) return this.i18n('{{interval}} year ago', { interval })
-
- interval = Math.floor(seconds / 2592000)
- if (interval > 1) return this.i18n('{{interval}} months ago', { interval })
- if (interval === 1) return this.i18n('{{interval}} month ago', { interval })
-
- interval = Math.floor(seconds / 604800)
- if (interval > 1) return this.i18n('{{interval}} weeks ago', { interval })
- if (interval === 1) return this.i18n('{{interval}} week ago', { interval })
-
- interval = Math.floor(seconds / 86400)
- if (interval > 1) return this.i18n('{{interval}} days ago', { interval })
- if (interval === 1) return this.i18n('{{interval}} day ago', { interval })
-
- interval = Math.floor(seconds / 3600)
- if (interval > 1) return this.i18n('{{interval}} hours ago', { interval })
- if (interval === 1) return this.i18n('{{interval}} hour ago', { interval })
-
- interval = Math.floor(seconds / 60)
- if (interval >= 1) return this.i18n('{{interval}} min ago', { interval })
-
- return this.i18n('just now')
- }
-}
+++ /dev/null
-import { PipeTransform, Pipe } from '@angular/core'
-import { SafeHtml } from '@angular/platform-browser'
-
-// Thanks https://gist.github.com/adamrecsko/0f28f474eca63e0279455476cc11eca7#gistcomment-2917369
-@Pipe({ name: 'highlight' })
-export class HighlightPipe implements PipeTransform {
- /* use this for single match search */
- static SINGLE_MATCH = 'Single-Match'
- /* use this for single match search with a restriction that target should start with search string */
- static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match'
- /* use this for global search */
- static MULTI_MATCH = 'Multi-Match'
-
- transform (
- contentString: string = null,
- stringToHighlight: string = null,
- option = 'Single-And-StartsWith-Match',
- caseSensitive = false,
- highlightStyleName = 'search-highlight'
- ): SafeHtml {
- if (stringToHighlight && contentString && option) {
- let regex: any = ''
- const caseFlag: string = !caseSensitive ? 'i' : ''
-
- switch (option) {
- case 'Single-Match': {
- regex = new RegExp(stringToHighlight, caseFlag)
- break
- }
- case 'Single-And-StartsWith-Match': {
- regex = new RegExp('^' + stringToHighlight, caseFlag)
- break
- }
- case 'Multi-Match': {
- regex = new RegExp(stringToHighlight, 'g' + caseFlag)
- break
- }
- default: {
- // default will be a global case-insensitive match
- regex = new RegExp(stringToHighlight, 'gi')
- }
- }
-
- const replaced = contentString.replace(
- regex,
- (match) => `<span class="${highlightStyleName}">${match}</span>`
- )
-
- return replaced
- } else {
- return contentString
- }
- }
-}
+++ /dev/null
-import { Pipe, PipeTransform } from '@angular/core'
-
-// Thanks: https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts
-
-@Pipe({ name: 'myNumberFormatter' })
-export class NumberFormatterPipe implements PipeTransform {
- private dictionary: Array<{max: number, type: string}> = [
- { max: 1000, type: '' },
- { max: 1000000, type: 'K' },
- { max: 1000000000, type: 'M' }
- ]
-
- transform (value: number) {
- const format = this.dictionary.find(d => value < d.max) || this.dictionary[this.dictionary.length - 1]
- const calc = Math.floor(value / (format.max / 1000))
-
- return `${calc}${format.type}`
- }
-}
+++ /dev/null
-import { Pipe, PipeTransform } from '@angular/core'
-
-@Pipe({ name: 'myObjectLength' })
-export class ObjectLengthPipe implements PipeTransform {
- transform (value: Object) {
- return Object.keys(value).length
- }
-}
+++ /dev/null
-import { Directive, Input, TemplateRef } from '@angular/core'
-
-@Directive({
- selector: '[ptTemplate]'
-})
-export class PeerTubeTemplateDirective <T extends string> {
- @Input('ptTemplate') name: T
-
- constructor (public template: TemplateRef<any>) {
- // empty
- }
-}
+++ /dev/null
-import { Directive, EventEmitter, HostListener, Output } from '@angular/core'
-
-@Directive({
- selector: '[timestampRouteTransformer]'
-})
-export class TimestampRouteTransformerDirective {
- @Output() timestampClicked = new EventEmitter<number>()
-
- @HostListener('click', ['$event'])
- public onClick ($event: Event) {
- const target = $event.target as HTMLLinkElement
-
- if (target.hasAttribute('href') !== true) return
-
- const ngxLink = document.createElement('a')
- ngxLink.href = target.getAttribute('href')
-
- // we only care about reflective links
- if (ngxLink.host !== window.location.host) return
-
- const ngxLinkParams = new URLSearchParams(ngxLink.search)
- if (ngxLinkParams.has('start') !== true) return
-
- const separators = ['h', 'm', 's']
- const start = ngxLinkParams
- .get('start')
- .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator
- .map(t => {
- if (t.includes('h')) return parseInt(t, 10) * 3600
- if (t.includes('m')) return parseInt(t, 10) * 60
- return parseInt(t, 10)
- })
- .reduce((acc, t) => acc + t)
-
- this.timestampClicked.emit(start)
-
- $event.preventDefault()
- }
-}
+++ /dev/null
-import { Pipe, PipeTransform } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Pipe({
- name: 'myVideoDurationFormatter'
-})
-export class VideoDurationPipe implements PipeTransform {
-
- constructor (private i18n: I18n) {
-
- }
-
- transform (value: number): string {
- const hours = Math.floor(value / 3600)
- const minutes = Math.floor((value % 3600) / 60)
- const seconds = value % 60
-
- if (hours > 0) {
- return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds })
- }
-
- if (minutes > 0) {
- return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds })
- }
-
- return this.i18n('{{seconds}} sec', { seconds })
- }
-}
+++ /dev/null
-import { Observable, throwError as observableThrowError } from 'rxjs'
-import { catchError, switchMap } from 'rxjs/operators'
-import { Injectable, Injector } from '@angular/core'
-import { HTTP_INTERCEPTORS, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'
-import { AuthService } from '../../core'
-
-@Injectable()
-export class AuthInterceptor implements HttpInterceptor {
- private authService: AuthService
-
- // https://github.com/angular/angular/issues/18224#issuecomment-316957213
- constructor (private injector: Injector) {}
-
- intercept (req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
- if (this.authService === undefined) {
- this.authService = this.injector.get(AuthService)
- }
-
- const authReq = this.cloneRequestWithAuth(req)
-
- // Pass on the cloned request instead of the original request
- // Catch 401 errors (refresh token expired)
- return next.handle(authReq)
- .pipe(
- catchError(err => {
- if (err.status === 401 && err.error && err.error.code === 'invalid_token') {
- return this.handleTokenExpired(req, next)
- }
-
- return observableThrowError(err)
- })
- )
- }
-
- private handleTokenExpired (req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
- return this.authService.refreshAccessToken()
- .pipe(
- switchMap(() => {
- const authReq = this.cloneRequestWithAuth(req)
-
- return next.handle(authReq)
- })
- )
- }
-
- private cloneRequestWithAuth (req: HttpRequest<any>) {
- const authHeaderValue = this.authService.getRequestHeaderValue()
-
- if (authHeaderValue === null) return req
-
- // Clone the request to add the new header
- return req.clone({ headers: req.headers.set('Authorization', authHeaderValue) })
- }
-}
-
-export const AUTH_INTERCEPTOR_PROVIDER = {
- provide: HTTP_INTERCEPTORS,
- useClass: AuthInterceptor,
- multi: true
-}
+++ /dev/null
-export * from './auth-interceptor.service'
+++ /dev/null
-import { AccountBlock as AccountBlockServer } from '../../../../../shared'
-import { Account } from '../account/account.model'
-
-export class AccountBlock implements AccountBlockServer {
- byAccount: Account
- blockedAccount: Account
- createdAt: Date | string
-
- constructor (block: AccountBlockServer) {
- this.byAccount = new Account(block.byAccount)
- this.blockedAccount = new Account(block.blockedAccount)
- this.createdAt = block.createdAt
- }
-}
+++ /dev/null
-<p-table
- [value]="blockedAccounts" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
- [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
- [showCurrentPageReport]="true" i18n-currentPageReportTemplate
- currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted accounts"
->
- <ng-template pTemplate="caption">
- <div class="caption">
- <div class="ml-auto has-feedback has-clear">
- <input
- type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
- (keyup)="onSearch($event)"
- >
- <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
- <span class="sr-only" i18n>Clear filters</span>
- </div>
- </div>
- </ng-template>
-
- <ng-template pTemplate="header">
- <tr>
- <th style="width: 100%;" i18n>Account</th>
- <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
- <th style="width: 150px;"></th> <!-- column for action buttons -->
- </tr>
- </ng-template>
-
- <ng-template pTemplate="body" let-accountBlock>
- <tr>
- <td>
- <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
- <div class="chip two-lines">
- <img
- class="avatar"
- [src]="accountBlock.blockedAccount.avatar?.path"
- (error)="switchToDefaultAvatar($event)"
- alt="Avatar"
- >
- <div>
- {{ accountBlock.blockedAccount.displayName }}
- <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>
- </div>
- </div>
- </a>
- </td>
-
- <td>{{ accountBlock.createdAt | date: 'short' }}</td>
- <td class="action-cell">
- <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button>
- </td>
- </tr>
- </ng-template>
-
- <ng-template pTemplate="emptymessage">
- <tr>
- <td colspan="6">
- <div class="no-results">
- <ng-container *ngIf="search" i18n>No account found matching current filters.</ng-container>
- <ng-container *ngIf="!search" i18n>No account found.</ng-container>
- </div>
- </td>
- </tr>
- </ng-template>
-</p-table>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.caption {
- justify-content: flex-end;
-
- input {
- @include peertube-input-text(250px);
- flex-grow: 1;
- }
-}
-
-.unblock-button {
- @include peertube-button;
- @include grey-button;
-}
\ No newline at end of file
+++ /dev/null
-import { OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { RestPagination, RestTable } from '@app/shared/rest'
-import { SortMeta } from 'primeng/api'
-import { AccountBlock } from './account-block.model'
-import { BlocklistService, BlocklistComponentType } from './blocklist.service'
-import { Actor } from '@app/shared/actor/actor.model'
-
-export class GenericAccountBlocklistComponent extends RestTable implements OnInit {
- // @ts-ignore: "Abstract methods can only appear within an abstract class"
- abstract mode: BlocklistComponentType
-
- blockedAccounts: AccountBlock[] = []
- totalRecords = 0
- sort: SortMeta = { field: 'createdAt', order: -1 }
- pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
-
- constructor (
- private notifier: Notifier,
- private blocklistService: BlocklistService,
- private i18n: I18n
- ) {
- super()
- }
-
- // @ts-ignore: "Abstract methods can only appear within an abstract class"
- abstract getIdentifier (): string
-
- ngOnInit () {
- this.initialize()
- }
-
- switchToDefaultAvatar ($event: Event) {
- ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
- }
-
- unblockAccount (accountBlock: AccountBlock) {
- const blockedAccount = accountBlock.blockedAccount
- const operation = this.mode === BlocklistComponentType.Account
- ? this.blocklistService.unblockAccountByUser(blockedAccount)
- : this.blocklistService.unblockAccountByInstance(blockedAccount)
-
- operation.subscribe(
- () => {
- this.notifier.success(
- this.mode === BlocklistComponentType.Account
- ? this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: blockedAccount.nameWithHost })
- : this.i18n('Account {{nameWithHost}} unmuted by your instance.', { nameWithHost: blockedAccount.nameWithHost })
- )
-
- this.loadData()
- }
- )
- }
-
- protected loadData () {
- const operation = this.mode === BlocklistComponentType.Account
- ? this.blocklistService.getUserAccountBlocklist({
- pagination: this.pagination,
- sort: this.sort,
- search: this.search
- })
- : this.blocklistService.getInstanceAccountBlocklist({
- pagination: this.pagination,
- sort: this.sort,
- search: this.search
- })
-
- return operation.subscribe(
- resultList => {
- this.blockedAccounts = resultList.data
- this.totalRecords = resultList.total
- },
-
- err => this.notifier.error(err.message)
- )
- }
-}
+++ /dev/null
-import { Injectable } from '@angular/core'
-import { environment } from '../../../environments/environment'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { RestExtractor, RestPagination, RestService } from '../rest'
-import { SortMeta } from 'primeng/api'
-import { catchError, map } from 'rxjs/operators'
-import { AccountBlock as AccountBlockServer, ResultList, ServerBlock } from '../../../../../shared'
-import { Account } from '@app/shared/account/account.model'
-import { AccountBlock } from '@app/shared/blocklist/account-block.model'
-
-export enum BlocklistComponentType { Account, Instance }
-
-@Injectable()
-export class BlocklistService {
- static BASE_USER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/users/me/blocklist'
- static BASE_SERVER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/server/blocklist'
-
- constructor (
- private authHttp: HttpClient,
- private restExtractor: RestExtractor,
- private restService: RestService
- ) { }
-
- /*********************** User -> Account blocklist ***********************/
-
- getUserAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
- const { pagination, sort, search } = options
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (search) params = params.append('search', search)
-
- return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', { params })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- blockAccountByUser (account: Account) {
- const body = { accountName: account.nameWithHost }
-
- return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', body)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- unblockAccountByUser (account: Account) {
- const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost
-
- return this.authHttp.delete(path)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- /*********************** User -> Server blocklist ***********************/
-
- getUserServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
- const { pagination, sort, search } = options
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (search) params = params.append('search', search)
-
- return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', { params })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- blockServerByUser (host: string) {
- const body = { host }
-
- return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', body)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- unblockServerByUser (host: string) {
- const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers/' + host
-
- return this.authHttp.delete(path)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- /*********************** Instance -> Account blocklist ***********************/
-
- getInstanceAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
- const { pagination, sort, search } = options
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (search) params = params.append('search', search)
-
- return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- blockAccountByInstance (account: Account) {
- const body = { accountName: account.nameWithHost }
-
- return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', body)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- unblockAccountByInstance (account: Account) {
- const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost
-
- return this.authHttp.delete(path)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- /*********************** Instance -> Server blocklist ***********************/
-
- getInstanceServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
- const { pagination, sort, search } = options
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (search) params = params.append('search', search)
-
- return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- blockServerByInstance (host: string) {
- const body = { host }
-
- return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', body)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- unblockServerByInstance (host: string) {
- const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers/' + host
-
- return this.authHttp.delete(path)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- private formatAccountBlock (accountBlock: AccountBlockServer) {
- return new AccountBlock(accountBlock)
- }
-}
+++ /dev/null
-export * from './blocklist.service'
-export * from './account-block.model'
-export * from './server-blocklist.component'
-export * from './account-blocklist.component'
+++ /dev/null
-<p-table
- [value]="blockedServers" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
- [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
- [showCurrentPageReport]="true" i18n-currentPageReportTemplate
- currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted instances"
->
- <ng-template pTemplate="caption">
- <div class="caption">
- <div class="ml-auto has-feedback has-clear">
- <input
- type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
- (keyup)="onSearch($event)"
- >
- <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
- <span class="sr-only" i18n>Clear filters</span>
- </div>
- <a class="ml-2 block-button" (click)="addServersToBlock()" (key.enter)="addServersToBlock()">
- <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
- <ng-container i18n>Mute domain</ng-container>
- </a>
- </div>
- </ng-template>
-
- <ng-template pTemplate="header">
- <tr>
- <th style="width: 100%;" i18n>Instance</th>
- <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
- <th style="width: 150px;"></th> <!-- column for action buttons -->
- </tr>
- </ng-template>
-
- <ng-template pTemplate="body" let-serverBlock>
- <tr>
- <td>
- <a [href]="'https://' + serverBlock.blockedServer.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
- {{ serverBlock.blockedServer.host }}
- <span class="glyphicon glyphicon-new-window"></span>
- </a>
- </td>
- <td>{{ serverBlock.createdAt | date: 'short' }}</td>
- <td class="action-cell">
- <button class="unblock-button" (click)="unblockServer(serverBlock)" i18n>Unmute</button>
- </td>
- </tr>
- </ng-template>
-
- <ng-template pTemplate="emptymessage">
- <tr>
- <td colspan="6">
- <div class="no-results">
- <ng-container *ngIf="search" i18n>No server found matching current filters.</ng-container>
- <ng-container *ngIf="!search" i18n>No server found.</ng-container>
- </div>
- </td>
- </tr>
- </ng-template>
-</p-table>
-
-<my-batch-domains-modal #batchDomainsModal i18n-action action="Mute domains" (domains)="onDomainsToBlock($event)"></my-batch-domains-modal>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-a {
- @include disable-default-a-behaviour;
- display: inline-block;
-
- &, &:hover {
- color: pvar(--mainForegroundColor);
- }
-
- span {
- font-size: 80%;
- color: pvar(--inputPlaceholderColor);
- }
-}
-
-.caption {
- justify-content: flex-end;
-
- input {
- @include peertube-input-text(250px);
- flex-grow: 1;
- }
-}
-
-.unblock-button {
- @include peertube-button;
- @include grey-button;
-}
-
-.block-button {
- @include create-button;
-}
+++ /dev/null
-import { OnInit, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { RestPagination, RestTable } from '@app/shared/rest'
-import { SortMeta } from 'primeng/api'
-import { BlocklistService, BlocklistComponentType } from './blocklist.service'
-import { ServerBlock } from '../../../../../shared/models/blocklist/server-block.model'
-import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component'
-
-export class GenericServerBlocklistComponent extends RestTable implements OnInit {
- @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent
-
- // @ts-ignore: "Abstract methods can only appear within an abstract class"
- public abstract mode: BlocklistComponentType
-
- blockedServers: ServerBlock[] = []
- totalRecords = 0
- sort: SortMeta = { field: 'createdAt', order: -1 }
- pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
-
- constructor (
- protected notifier: Notifier,
- protected blocklistService: BlocklistService,
- protected i18n: I18n
- ) {
- super()
- }
-
- ngOnInit () {
- this.initialize()
- }
-
- // @ts-ignore: "Abstract methods can only appear within an abstract class"
- public abstract getIdentifier (): string
-
- unblockServer (serverBlock: ServerBlock) {
- const operation = (host: string) => this.mode === BlocklistComponentType.Account
- ? this.blocklistService.unblockServerByUser(host)
- : this.blocklistService.unblockServerByInstance(host)
- const host = serverBlock.blockedServer.host
-
- operation(host).subscribe(
- () => {
- this.notifier.success(
- this.mode === BlocklistComponentType.Account
- ? this.i18n('Instance {{host}} unmuted.', { host })
- : this.i18n('Instance {{host}} unmuted by your instance.', { host })
- )
-
- this.loadData()
- }
- )
- }
-
- addServersToBlock () {
- this.batchDomainsModal.openModal()
- }
-
- onDomainsToBlock (domains: string[]) {
- const operation = (domain: string) => this.mode === BlocklistComponentType.Account
- ? this.blocklistService.blockServerByUser(domain)
- : this.blocklistService.blockServerByInstance(domain)
-
- domains.forEach(domain => {
- operation(domain).subscribe(
- () => {
- this.notifier.success(
- this.mode === BlocklistComponentType.Account
- ? this.i18n('Instance {{domain}} muted.', { domain })
- : this.i18n('Instance {{domain}} muted by your instance.', { domain })
- )
-
- this.loadData()
- }
- )
- })
- }
-
- protected loadData () {
- const operation = this.mode === BlocklistComponentType.Account
- ? this.blocklistService.getUserServerBlocklist({
- pagination: this.pagination,
- sort: this.sort,
- search: this.search
- })
- : this.blocklistService.getInstanceServerBlocklist({
- pagination: this.pagination,
- sort: this.sort,
- search: this.search
- })
-
- return operation.subscribe(
- resultList => {
- this.blockedServers = resultList.data
- this.totalRecords = resultList.total
- },
-
- err => this.notifier.error(err.message)
- )
- }
-}
+++ /dev/null
-import { HttpClient } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestService } from '../rest'
-import { BulkRemoveCommentsOfBody } from '../../../../../shared'
-import { catchError } from 'rxjs/operators'
-
-@Injectable()
-export class BulkService {
- static BASE_BULK_URL = environment.apiUrl + '/api/v1/bulk'
-
- constructor (
- private authHttp: HttpClient,
- private restExtractor: RestExtractor,
- private restService: RestService
- ) { }
-
- removeCommentsOf (body: BulkRemoveCommentsOfBody) {
- const url = BulkService.BASE_BULK_URL + '/remove-comments-of'
-
- return this.authHttp.post(url, body)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-}
+++ /dev/null
-<div class="dropdown-root" ngbDropdown [placement]="placement" [container]="container" *ngIf="areActionsDisplayed(actions, entry)">
- <button
- class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }"
- ngbDropdownToggle role="button"
- >
- <my-global-icon *ngIf="!label && buttonDirection === 'horizontal'" class="more-icon" iconName="more-horizontal"></my-global-icon>
- <my-global-icon *ngIf="!label && buttonDirection === 'vertical'" class="more-icon" iconName="more-vertical"></my-global-icon>
-
- <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
-</button>
-
- <div ngbDropdownMenu class="dropdown-menu">
- <ng-container *ngFor="let actions of getActions()">
-
- <ng-container *ngFor="let action of actions">
- <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true">
-
- <ng-template #templateActionLabel let-action>
- <my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName" aria-hidden="true"></my-global-icon>
-
- <div class="d-flex flex-column">
- <span i18n>{{ action.label }}</span>
- <small class="text-muted" *ngIf="action.description">{{ action.description }}</small>
- </div>
- </ng-template>
-
- <a
- *ngIf="action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
- class="dropdown-item" [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"
- >
- <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
- </a>
-
- <span
- *ngIf="!action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
- class="custom-action dropdown-item" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
- >
- <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
- </span>
-
- <h6
- *ngIf="!action.linkBuilder && action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
- class="dropdown-header" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
- >
- <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
- </h6>
-
- </ng-container>
- </ng-container>
-
- <div *ngIf="areActionsDisplayed(actions, entry)" class="dropdown-divider"></div>
-
- </ng-container>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.dropdown-divider:last-child {
- display: none;
-}
-
-.action-button {
- @include peertube-button;
-
- &.button-styled {
-
- &.grey {
- @include grey-button;
- }
-
- &.orange {
- @include orange-button;
- }
-
- &:hover, &:active, &:focus {
- background-color: $grey-background-color;
- }
- }
-
- display: inline-block;
- padding: 0 10px;
-
- &::after {
- display: none;
- }
-
- .more-icon {
- width: 21px;
-
- ::ng-deep {
- @include apply-svg-color(pvar(--actionButtonColor));
- }
- }
-
- &.small {
- font-size: 14px;
- height: 20px;
- line-height: 20px;
- }
-}
-
-.dropdown-toggle::after {
- position: relative;
- top: 1px;
-}
-
-.dropdown-menu {
- .dropdown-header {
- padding: 0.2rem 1rem;
- }
-
- .dropdown-item {
- display: flex;
- cursor: pointer;
- color: #000 !important;
-
- &.with-icon {
- @include dropdown-with-icon-item;
- }
-
- a, span {
- display: block;
- width: 100%;
- }
- }
-}
+++ /dev/null
-import { Component, Input } from '@angular/core'
-import { GlobalIconName } from '@app/shared/images/global-icon.component'
-
-export type DropdownAction<T> = {
- label?: string
- iconName?: GlobalIconName
- description?: string
- title?: string
- handler?: (a: T) => any
- linkBuilder?: (a: T) => (string | number)[]
- isDisplayed?: (a: T) => boolean
- isHeader?: boolean
-}
-
-export type DropdownButtonSize = 'normal' | 'small'
-export type DropdownTheme = 'orange' | 'grey'
-export type DropdownDirection = 'horizontal' | 'vertical'
-
-@Component({
- selector: 'my-action-dropdown',
- styleUrls: [ './action-dropdown.component.scss' ],
- templateUrl: './action-dropdown.component.html'
-})
-
-export class ActionDropdownComponent<T> {
- @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = []
- @Input() entry: T
-
- @Input() placement = 'bottom-left auto'
- @Input() container: null | 'body'
-
- @Input() buttonSize: DropdownButtonSize = 'normal'
- @Input() buttonDirection: DropdownDirection = 'horizontal'
- @Input() buttonStyled = true
-
- @Input() label: string
- @Input() theme: DropdownTheme = 'grey'
-
- getActions (): DropdownAction<T>[][] {
- if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction<T>[][]
-
- return [ this.actions as DropdownAction<T>[] ]
- }
-
- areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean {
- return actions.some(a => {
- if (Array.isArray(a)) return this.areActionsDisplayed(a, entry)
-
- return a.isDisplayed === undefined || a.isDisplayed(entry)
- })
- }
-}
+++ /dev/null
-<span class="action-button" [ngClass]="className" [title]="getTitle()">
- <my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon>
- <my-small-loader [loading]="loading"></my-small-loader>
-
- <span class="button-label">{{ label }}</span>
-</span>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-my-small-loader ::ng-deep .root {
- display: inline-block;
- margin: 0 3px 0 0;
- width: 20px;
-}
-
-.action-button {
- @include peertube-button-link;
- @include button-with-icon(21px, 0, -2px);
-}
-
-.orange-button {
- @include peertube-button;
- @include orange-button;
-}
-
-.orange-button-link {
- @include peertube-button-link;
- @include orange-button;
-}
-
-.grey-button {
- @include peertube-button;
- @include grey-button;
-}
-
-.grey-button-link {
- @include peertube-button-link;
- @include grey-button;
-}
-
-// In a table, try to minimize the space taken by this button
-@media screen and (max-width: 1400px) {
- :host-context(td) {
- .action-button {
- padding: 0 13px;
- }
-
- .button-label {
- display: none;
- }
- }
-}
+++ /dev/null
-import { Component, Input } from '@angular/core'
-import { GlobalIconName } from '@app/shared/images/global-icon.component'
-
-@Component({
- selector: 'my-button',
- styleUrls: ['./button.component.scss'],
- templateUrl: './button.component.html'
-})
-
-export class ButtonComponent {
- @Input() label = ''
- @Input() className = 'grey-button'
- @Input() icon: GlobalIconName = undefined
- @Input() title: string = undefined
- @Input() loading = false
-
- getTitle () {
- return this.title || this.label
- }
-}
+++ /dev/null
-<span class="action-button action-button-delete grey-button" [title]="title" role="button">
- <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
-
- <span class="button-label" *ngIf="label">{{ label }}</span>
- <span class="button-label" i18n *ngIf="!label">Delete</span>
-</span>
+++ /dev/null
-import { Component, Input, OnInit } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Component({
- selector: 'my-delete-button',
- styleUrls: [ './button.component.scss' ],
- templateUrl: './delete-button.component.html'
-})
-
-export class DeleteButtonComponent implements OnInit {
- @Input() label: string
-
- title: string
-
- constructor (private i18n: I18n) { }
-
- ngOnInit () {
- this.title = this.label || this.i18n('Delete')
- }
-}
+++ /dev/null
-<a class="action-button action-button-edit grey-button" [routerLink]="routerLink" i18n-title title="Edit">
- <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
-
- <span class="button-label" *ngIf="label">{{ label }}</span>
- <span i18n class="button-label" *ngIf="!label">Edit</span>
-</a>
+++ /dev/null
-import { Component, Input } from '@angular/core'
-
-@Component({
- selector: 'my-edit-button',
- styleUrls: [ './button.component.scss' ],
- templateUrl: './edit-button.component.html'
-})
-
-export class EditButtonComponent {
- @Input() label: string
- @Input() routerLink: string[] | string = []
-}
+++ /dev/null
-<div class="wrapper" [ngClass]="'avatar-' + size">
- <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
- <img [src]="video.videoChannelAvatarUrl" i18n-alt alt="Channel avatar" />
- </a>
- <a [routerLink]="[ '/accounts', video.byAccount ]" [title]="accountLinkTitle">
- <img [src]="video.accountAvatarUrl" i18n-alt alt="Account avatar" />
- </a>
-</div>
+++ /dev/null
-@import '_mixins';
-
-.wrapper {
- $avatar-size: 35px;
-
- width: $avatar-size;
- height: $avatar-size;
- position: relative;
- margin-right: 5px;
- margin-bottom: 5px;
-
- &.avatar-sm {
- width: 28px;
- height: 28px;
- margin-bottom: 3px;
- }
-
- a {
- @include disable-outline;
- }
-
- a img {
- height: 100%;
- object-fit: cover;
- position: absolute;
- top:50%;
- left:50%;
- border-radius: 50%;
- transform: translate(-50%,-50%)
- }
-
- a:nth-of-type(2) img {
- height: 60%;
- width: 60%;
- border: 2px solid pvar(--mainBackgroundColor);
- transform: translateX(15%);
- position: relative;
- background-color: pvar(--mainBackgroundColor);
- }
-}
+++ /dev/null
-import { Component, Input, OnInit } from '@angular/core'
-import { Video } from '../video/video.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Component({
- selector: 'avatar-channel',
- templateUrl: './avatar.component.html',
- styleUrls: [ './avatar.component.scss' ]
-})
-export class AvatarComponent implements OnInit {
- @Input() video: Video
- @Input() size: 'md' | 'sm' = 'md'
-
- channelLinkTitle = ''
- accountLinkTitle = ''
-
- constructor (
- private i18n: I18n
- ) {}
-
- ngOnInit () {
- this.channelLinkTitle = this.i18n(
- '{{name}} (channel page)',
- { name: this.video.channel.name, handle: this.video.byVideoChannel }
- )
- this.accountLinkTitle = this.i18n(
- '{{name}} (account page)',
- { name: this.video.account.name, handle: this.video.byAccount }
- )
- }
-}
+++ /dev/null
-<ng-template #confirmModal let-close="close" let-dismiss="dismiss">
-
- <div class="modal-header">
- <h4 class="modal-title">{{ title }}</h4>
-
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
- </div>
-
- <div class="modal-body" >
- <div [innerHtml]="message"></div>
-
- <div *ngIf="inputLabel && expectedInputValue" class="form-group">
- <label for="confirmInput">{{ inputLabel }}</label>
- <input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
- </div>
- </div>
-
- <div class="modal-footer inputs">
- <input
- type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
- (click)="dismiss()" (key.enter)="dismiss()"
- >
-
- <input
- ngbAutofocus
- type="submit" [value]="confirmButtonText" class="action-button-submit" [disabled]="isConfirmationDisabled()"
- (click)="close()" (key.enter)="confirm()"
- >
- </div>
-</ng-template>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.modal-body {
- font-size: 15px;
-}
-
-.button {
- padding: 0 13px;
-}
-
-input[type=text] {
- @include peertube-input-text(100%);
- display: block;
-}
-
-.form-group {
- margin: 20px 0;
-}
-
-
+++ /dev/null
-import { Component, ElementRef, HostListener, OnInit, ViewChild } from '@angular/core'
-import { ConfirmService } from '@app/core/confirm/confirm.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { POP_STATE_MODAL_DISMISS } from '@app/shared/misc/constants'
-
-@Component({
- selector: 'my-confirm',
- templateUrl: './confirm.component.html',
- styleUrls: [ './confirm.component.scss' ]
-})
-export class ConfirmComponent implements OnInit {
- @ViewChild('confirmModal', { static: true }) confirmModal: ElementRef
-
- title = ''
- message = ''
- expectedInputValue = ''
- inputLabel = ''
-
- inputValue = ''
- confirmButtonText = ''
-
- private openedModal: NgbModalRef
-
- constructor (
- private modalService: NgbModal,
- private confirmService: ConfirmService,
- private i18n: I18n
- ) { }
-
- ngOnInit () {
- this.confirmService.showConfirm.subscribe(
- ({ title, message, expectedInputValue, inputLabel, confirmButtonText }) => {
- this.title = title
- this.message = message
-
- this.inputLabel = inputLabel
- this.expectedInputValue = expectedInputValue
-
- this.confirmButtonText = confirmButtonText || this.i18n('Confirm')
-
- this.showModal()
- }
- )
- }
-
- confirm () {
- if (this.openedModal) this.openedModal.close()
- }
-
- isConfirmationDisabled () {
- // No input validation
- if (!this.inputLabel || !this.expectedInputValue) return false
-
- return this.expectedInputValue !== this.inputValue
- }
-
- showModal () {
- this.inputValue = ''
-
- this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
-
- this.openedModal.result
- .then(() => this.confirmService.confirmResponse.next(true))
- .catch((reason: string) => {
- // If the reason was that the user used the back button, we don't care about the confirm dialog result
- if (!reason || reason !== POP_STATE_MODAL_DISMISS) {
- this.confirmService.confirmResponse.next(false)
- }
- })
- }
-}
+++ /dev/null
-<span
- class="date-toggle"
- [title]="getTitle()"
- [innerHtml]="getContent()"
- (click)="toggle()"
-></span>
+++ /dev/null
-.date-toggle {
- &:hover {
- cursor: default
- }
-}
+++ /dev/null
-import { Component, Input, OnInit, OnChanges } from '@angular/core'
-import { DatePipe } from '@angular/common'
-import { FromNowPipe } from '../angular/from-now.pipe'
-
-@Component({
- selector: 'my-date-toggle',
- templateUrl: './date-toggle.component.html',
- styleUrls: [ './date-toggle.component.scss' ],
- providers: [ DatePipe, FromNowPipe ]
-})
-export class DateToggleComponent implements OnInit, OnChanges {
- @Input() date: Date
- @Input() toggled = false
-
- dateRelative: string
- dateAbsolute: string
-
- constructor (
- private datePipe: DatePipe,
- private fromNowPipe: FromNowPipe
- ) { }
-
- ngOnInit () {
- this.updateDates()
- }
-
- ngOnChanges () {
- this.updateDates()
- }
-
- toggle () {
- this.toggled = !this.toggled
- }
-
- getTitle () {
- return this.toggled ? this.dateRelative : this.dateAbsolute
- }
-
- getContent () {
- return this.toggled ? this.dateAbsolute : this.dateRelative
- }
-
- private updateDates () {
- this.dateRelative = this.fromNowPipe.transform(this.date)
- this.dateAbsolute = this.datePipe.transform(this.date, 'long')
- }
-}
+++ /dev/null
-import { FormGroup } from '@angular/forms'
-import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-
-export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
-export type FormReactiveValidationMessages = {
- [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
-}
-
-export abstract class FormReactive {
- protected abstract formValidatorService: FormValidatorService
- protected formChanged = false
-
- form: FormGroup
- formErrors: any // To avoid casting in template because of string | FormReactiveErrors
- validationMessages: FormReactiveValidationMessages
-
- buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
- const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
-
- this.form = form
- this.formErrors = formErrors
- this.validationMessages = validationMessages
-
- this.form.valueChanges.subscribe(() => this.onValueChanged(this.form, this.formErrors, this.validationMessages, false))
- }
-
- protected forceCheck () {
- return this.onValueChanged(this.form, this.formErrors, this.validationMessages, true)
- }
-
- protected check () {
- return this.onValueChanged(this.form, this.formErrors, this.validationMessages, false)
- }
-
- private onValueChanged (
- form: FormGroup,
- formErrors: FormReactiveErrors,
- validationMessages: FormReactiveValidationMessages,
- forceCheck = false
- ) {
- for (const field of Object.keys(formErrors)) {
- if (formErrors[field] && typeof formErrors[field] === 'object') {
- this.onValueChanged(
- form.controls[field] as FormGroup,
- formErrors[field] as FormReactiveErrors,
- validationMessages[field] as FormReactiveValidationMessages,
- forceCheck
- )
- continue
- }
-
- // clear previous error message (if any)
- formErrors[ field ] = ''
- const control = form.get(field)
-
- if (control.dirty) this.formChanged = true
-
- // Don't care if dirty on force check
- const isDirty = control.dirty || forceCheck === true
- if (control && isDirty && control.enabled && !control.valid) {
- const messages = validationMessages[ field ]
- for (const key of Object.keys(control.errors)) {
- formErrors[ field ] += messages[ key ] + ' '
- }
- }
- }
- }
-
-}
+++ /dev/null
-import { Validators } from '@angular/forms'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { BuildFormValidator } from '@app/shared'
-import { Injectable } from '@angular/core'
-
-@Injectable()
-export class CustomConfigValidatorsService {
- readonly INSTANCE_NAME: BuildFormValidator
- readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator
- readonly SERVICES_TWITTER_USERNAME: BuildFormValidator
- readonly CACHE_PREVIEWS_SIZE: BuildFormValidator
- readonly CACHE_CAPTIONS_SIZE: BuildFormValidator
- readonly SIGNUP_LIMIT: BuildFormValidator
- readonly ADMIN_EMAIL: BuildFormValidator
- readonly TRANSCODING_THREADS: BuildFormValidator
- readonly INDEX_URL: BuildFormValidator
- readonly SEARCH_INDEX_URL: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.INSTANCE_NAME = {
- VALIDATORS: [ Validators.required ],
- MESSAGES: {
- 'required': this.i18n('Instance name is required.')
- }
- }
-
- this.INSTANCE_SHORT_DESCRIPTION = {
- VALIDATORS: [ Validators.max(250) ],
- MESSAGES: {
- 'max': this.i18n('Short description should not be longer than 250 characters.')
- }
- }
-
- this.SERVICES_TWITTER_USERNAME = {
- VALIDATORS: [ Validators.required ],
- MESSAGES: {
- 'required': this.i18n('Twitter username is required.')
- }
- }
-
- this.CACHE_PREVIEWS_SIZE = {
- VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
- MESSAGES: {
- 'required': this.i18n('Previews cache size is required.'),
- 'min': this.i18n('Previews cache size must be greater than 1.'),
- 'pattern': this.i18n('Previews cache size must be a number.')
- }
- }
-
- this.CACHE_CAPTIONS_SIZE = {
- VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
- MESSAGES: {
- 'required': this.i18n('Captions cache size is required.'),
- 'min': this.i18n('Captions cache size must be greater than 1.'),
- 'pattern': this.i18n('Captions cache size must be a number.')
- }
- }
-
- this.SIGNUP_LIMIT = {
- VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ],
- MESSAGES: {
- 'required': this.i18n('Signup limit is required.'),
- 'min': this.i18n('Signup limit must be greater than 1.'),
- 'pattern': this.i18n('Signup limit must be a number.')
- }
- }
-
- this.ADMIN_EMAIL = {
- VALIDATORS: [ Validators.required, Validators.email ],
- MESSAGES: {
- 'required': this.i18n('Admin email is required.'),
- 'email': this.i18n('Admin email must be valid.')
- }
- }
-
- this.TRANSCODING_THREADS = {
- VALIDATORS: [ Validators.required, Validators.min(0) ],
- MESSAGES: {
- 'required': this.i18n('Transcoding threads is required.'),
- 'min': this.i18n('Transcoding threads must be greater or equal to 0.')
- }
- }
-
- this.INDEX_URL = {
- VALIDATORS: [ Validators.pattern(/^https:\/\//) ],
- MESSAGES: {
- 'pattern': this.i18n('Index URL should be a URL')
- }
- }
-
- this.SEARCH_INDEX_URL = {
- VALIDATORS: [ Validators.pattern(/^https?:\/\//) ],
- MESSAGES: {
- 'pattern': this.i18n('Search index URL should be a URL')
- }
- }
- }
-}
+++ /dev/null
-import { FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { FormReactiveErrors, FormReactiveValidationMessages } from '@app/shared/forms/form-reactive'
-
-export type BuildFormValidator = {
- VALIDATORS: ValidatorFn[],
- MESSAGES: { [ name: string ]: string }
-}
-export type BuildFormArgument = {
- [ id: string ]: BuildFormValidator | BuildFormArgument
-}
-export type BuildFormDefaultValues = {
- [ name: string ]: string | string[] | BuildFormDefaultValues
-}
-
-@Injectable()
-export class FormValidatorService {
-
- constructor (
- private formBuilder: FormBuilder
- ) {}
-
- buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
- const formErrors: FormReactiveErrors = {}
- const validationMessages: FormReactiveValidationMessages = {}
- const group: { [key: string]: any } = {}
-
- for (const name of Object.keys(obj)) {
- formErrors[name] = ''
-
- const field = obj[name]
- if (this.isRecursiveField(field)) {
- const result = this.buildForm(field as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues)
- group[name] = result.form
- formErrors[name] = result.formErrors
- validationMessages[name] = result.validationMessages
-
- continue
- }
-
- if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
-
- const defaultValue = defaultValues[name] || ''
-
- if (field && field.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
- else group[name] = [ defaultValue ]
- }
-
- const form = this.formBuilder.group(group)
- return { form, formErrors, validationMessages }
- }
-
- updateForm (
- form: FormGroup,
- formErrors: FormReactiveErrors,
- validationMessages: FormReactiveValidationMessages,
- obj: BuildFormArgument,
- defaultValues: BuildFormDefaultValues = {}
- ) {
- for (const name of Object.keys(obj)) {
- formErrors[name] = ''
-
- const field = obj[name]
- if (this.isRecursiveField(field)) {
- this.updateForm(
- form[name],
- formErrors[name] as FormReactiveErrors,
- validationMessages[name] as FormReactiveValidationMessages,
- obj[name] as BuildFormArgument,
- defaultValues[name] as BuildFormDefaultValues
- )
- continue
- }
-
- if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
-
- const defaultValue = defaultValues[name] || ''
-
- if (field && field.VALIDATORS) form.addControl(name, new FormControl(defaultValue, field.VALIDATORS as ValidatorFn[]))
- else form.addControl(name, new FormControl(defaultValue))
- }
- }
-
- private isRecursiveField (field: any) {
- return field && typeof field === 'object' && !field.MESSAGES && !field.VALIDATORS
- }
-}
+++ /dev/null
-export function validateHost (value: string) {
- // Thanks to http://stackoverflow.com/a/106223
- const HOST_REGEXP = new RegExp(
- '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'
- )
-
- return HOST_REGEXP.test(value)
-}
+++ /dev/null
-export * from './custom-config-validators.service'
-export * from './form-validator.service'
-export * from './host'
-export * from './instance-validators.service'
-export * from './login-validators.service'
-export * from './reset-password-validators.service'
-export * from './user-validators.service'
-export * from './video-abuse-validators.service'
-export * from './video-block-validators.service'
-export * from './video-channel-validators.service'
-export * from './video-comment-validators.service'
-export * from './video-validators.service'
-export * from './video-playlist-validators.service'
-export * from './video-captions-validators.service'
-export * from './video-change-ownership-validators.service'
-export * from './video-accept-ownership-validators.service'
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { BuildFormValidator } from '@app/shared'
-import { Injectable } from '@angular/core'
-
-@Injectable()
-export class InstanceValidatorsService {
- readonly FROM_EMAIL: BuildFormValidator
- readonly FROM_NAME: BuildFormValidator
- readonly SUBJECT: BuildFormValidator
- readonly BODY: BuildFormValidator
-
- constructor (private i18n: I18n) {
-
- this.FROM_EMAIL = {
- VALIDATORS: [ Validators.required, Validators.email ],
- MESSAGES: {
- 'required': this.i18n('Email is required.'),
- 'email': this.i18n('Email must be valid.')
- }
- }
-
- this.FROM_NAME = {
- VALIDATORS: [
- Validators.required,
- Validators.minLength(1),
- Validators.maxLength(120)
- ],
- MESSAGES: {
- 'required': this.i18n('Your name is required.'),
- 'minlength': this.i18n('Your name must be at least 1 character long.'),
- 'maxlength': this.i18n('Your name cannot be more than 120 characters long.')
- }
- }
-
- this.SUBJECT = {
- VALIDATORS: [
- Validators.required,
- Validators.minLength(1),
- Validators.maxLength(120)
- ],
- MESSAGES: {
- 'required': this.i18n('A subject is required.'),
- 'minlength': this.i18n('The subject must be at least 1 character long.'),
- 'maxlength': this.i18n('The subject cannot be more than 120 characters long.')
- }
- }
-
- this.BODY = {
- VALIDATORS: [
- Validators.required,
- Validators.minLength(3),
- Validators.maxLength(5000)
- ],
- MESSAGES: {
- 'required': this.i18n('A message is required.'),
- 'minlength': this.i18n('The message must be at least 3 characters long.'),
- 'maxlength': this.i18n('The message cannot be more than 5000 characters long.')
- }
- }
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class LoginValidatorsService {
- readonly LOGIN_USERNAME: BuildFormValidator
- readonly LOGIN_PASSWORD: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.LOGIN_USERNAME = {
- VALIDATORS: [
- Validators.required
- ],
- MESSAGES: {
- 'required': this.i18n('Username is required.')
- }
- }
-
- this.LOGIN_PASSWORD = {
- VALIDATORS: [
- Validators.required
- ],
- MESSAGES: {
- 'required': this.i18n('Password is required.')
- }
- }
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class ResetPasswordValidatorsService {
- readonly RESET_PASSWORD_CONFIRM: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.RESET_PASSWORD_CONFIRM = {
- VALIDATORS: [
- Validators.required
- ],
- MESSAGES: {
- 'required': this.i18n('Confirmation of the password is required.')
- }
- }
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { BuildFormValidator } from '@app/shared'
-import { Injectable } from '@angular/core'
-
-@Injectable()
-export class UserValidatorsService {
- readonly USER_USERNAME: BuildFormValidator
- readonly USER_EMAIL: BuildFormValidator
- readonly USER_PASSWORD: BuildFormValidator
- readonly USER_PASSWORD_OPTIONAL: BuildFormValidator
- readonly USER_CONFIRM_PASSWORD: BuildFormValidator
- readonly USER_VIDEO_QUOTA: BuildFormValidator
- readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator
- readonly USER_ROLE: BuildFormValidator
- readonly USER_DISPLAY_NAME_REQUIRED: BuildFormValidator
- readonly USER_DESCRIPTION: BuildFormValidator
- readonly USER_TERMS: BuildFormValidator
-
- readonly USER_BAN_REASON: BuildFormValidator
-
- constructor (private i18n: I18n) {
-
- this.USER_USERNAME = {
- VALIDATORS: [
- Validators.required,
- Validators.minLength(1),
- Validators.maxLength(50),
- Validators.pattern(/^[a-z0-9][a-z0-9._]*$/)
- ],
- MESSAGES: {
- 'required': this.i18n('Username is required.'),
- 'minlength': this.i18n('Username must be at least 1 character long.'),
- 'maxlength': this.i18n('Username cannot be more than 50 characters long.'),
- 'pattern': this.i18n('Username should be lowercase alphanumeric; dots and underscores are allowed.')
- }
- }
-
- this.USER_EMAIL = {
- VALIDATORS: [ Validators.required, Validators.email ],
- MESSAGES: {
- 'required': this.i18n('Email is required.'),
- 'email': this.i18n('Email must be valid.')
- }
- }
-
- this.USER_PASSWORD = {
- VALIDATORS: [
- Validators.required,
- Validators.minLength(6),
- Validators.maxLength(255)
- ],
- MESSAGES: {
- 'required': this.i18n('Password is required.'),
- 'minlength': this.i18n('Password must be at least 6 characters long.'),
- 'maxlength': this.i18n('Password cannot be more than 255 characters long.')
- }
- }
-
- this.USER_PASSWORD_OPTIONAL = {
- VALIDATORS: [
- Validators.minLength(6),
- Validators.maxLength(255)
- ],
- MESSAGES: {
- 'minlength': this.i18n('Password must be at least 6 characters long.'),
- 'maxlength': this.i18n('Password cannot be more than 255 characters long.')
- }
- }
-
- this.USER_CONFIRM_PASSWORD = {
- VALIDATORS: [],
- MESSAGES: {
- 'matchPassword': this.i18n('The new password and the confirmed password do not correspond.')
- }
- }
-
- this.USER_VIDEO_QUOTA = {
- VALIDATORS: [ Validators.required, Validators.min(-1) ],
- MESSAGES: {
- 'required': this.i18n('Video quota is required.'),
- 'min': this.i18n('Quota must be greater than -1.')
- }
- }
- this.USER_VIDEO_QUOTA_DAILY = {
- VALIDATORS: [ Validators.required, Validators.min(-1) ],
- MESSAGES: {
- 'required': this.i18n('Daily upload limit is required.'),
- 'min': this.i18n('Daily upload limit must be greater than -1.')
- }
- }
-
- this.USER_ROLE = {
- VALIDATORS: [ Validators.required ],
- MESSAGES: {
- 'required': this.i18n('User role is required.')
- }
- }
-
- this.USER_DISPLAY_NAME_REQUIRED = this.getDisplayName(true)
-
- this.USER_DESCRIPTION = {
- VALIDATORS: [
- Validators.minLength(3),
- Validators.maxLength(1000)
- ],
- MESSAGES: {
- 'minlength': this.i18n('Description must be at least 3 characters long.'),
- 'maxlength': this.i18n('Description cannot be more than 1000 characters long.')
- }
- }
-
- this.USER_TERMS = {
- VALIDATORS: [
- Validators.requiredTrue
- ],
- MESSAGES: {
- 'required': this.i18n('You must agree with the instance terms in order to register on it.')
- }
- }
-
- this.USER_BAN_REASON = {
- VALIDATORS: [
- Validators.minLength(3),
- Validators.maxLength(250)
- ],
- MESSAGES: {
- 'minlength': this.i18n('Ban reason must be at least 3 characters long.'),
- 'maxlength': this.i18n('Ban reason cannot be more than 250 characters long.')
- }
- }
- }
-
- private getDisplayName (required: boolean) {
- const control = {
- VALIDATORS: [
- Validators.minLength(1),
- Validators.maxLength(120)
- ],
- MESSAGES: {
- 'required': this.i18n('Display name is required.'),
- 'minlength': this.i18n('Display name must be at least 1 character long.'),
- 'maxlength': this.i18n('Display name cannot be more than 50 characters long.')
- }
- }
-
- if (required) control.VALIDATORS.push(Validators.required)
-
- return control
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class VideoAbuseValidatorsService {
- readonly VIDEO_ABUSE_REASON: BuildFormValidator
- readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.VIDEO_ABUSE_REASON = {
- VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
- MESSAGES: {
- 'required': this.i18n('Report reason is required.'),
- 'minlength': this.i18n('Report reason must be at least 2 characters long.'),
- 'maxlength': this.i18n('Report reason cannot be more than 3000 characters long.')
- }
- }
-
- this.VIDEO_ABUSE_MODERATION_COMMENT = {
- VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
- MESSAGES: {
- 'required': this.i18n('Moderation comment is required.'),
- 'minlength': this.i18n('Moderation comment must be at least 2 characters long.'),
- 'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.')
- }
- }
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class VideoAcceptOwnershipValidatorsService {
- readonly CHANNEL: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.CHANNEL = {
- VALIDATORS: [ Validators.required ],
- MESSAGES: {
- 'required': this.i18n('The channel is required.')
- }
- }
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class VideoBlockValidatorsService {
- readonly VIDEO_BLOCK_REASON: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.VIDEO_BLOCK_REASON = {
- VALIDATORS: [ Validators.minLength(2), Validators.maxLength(300) ],
- MESSAGES: {
- 'minlength': this.i18n('Block reason must be at least 2 characters long.'),
- 'maxlength': this.i18n('Block reason cannot be more than 300 characters long.')
- }
- }
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class VideoCaptionsValidatorsService {
- readonly VIDEO_CAPTION_LANGUAGE: BuildFormValidator
- readonly VIDEO_CAPTION_FILE: BuildFormValidator
-
- constructor (private i18n: I18n) {
-
- this.VIDEO_CAPTION_LANGUAGE = {
- VALIDATORS: [ Validators.required ],
- MESSAGES: {
- 'required': this.i18n('Video caption language is required.')
- }
- }
-
- this.VIDEO_CAPTION_FILE = {
- VALIDATORS: [ Validators.required ],
- MESSAGES: {
- 'required': this.i18n('Video caption file is required.')
- }
- }
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { AbstractControl, ValidationErrors, Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class VideoChangeOwnershipValidatorsService {
- readonly USERNAME: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.USERNAME = {
- VALIDATORS: [ Validators.required, this.localAccountValidator ],
- MESSAGES: {
- 'required': this.i18n('The username is required.'),
- 'localAccountOnly': this.i18n('You can only transfer ownership to a local account')
- }
- }
- }
-
- localAccountValidator (control: AbstractControl): ValidationErrors {
- if (control.value.includes('@')) {
- return { 'localAccountOnly': true }
- }
-
- return null
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class VideoChannelValidatorsService {
- readonly VIDEO_CHANNEL_NAME: BuildFormValidator
- readonly VIDEO_CHANNEL_DISPLAY_NAME: BuildFormValidator
- readonly VIDEO_CHANNEL_DESCRIPTION: BuildFormValidator
- readonly VIDEO_CHANNEL_SUPPORT: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.VIDEO_CHANNEL_NAME = {
- VALIDATORS: [
- Validators.required,
- Validators.minLength(1),
- Validators.maxLength(50),
- Validators.pattern(/^[a-z0-9][a-z0-9._]*$/)
- ],
- MESSAGES: {
- 'required': this.i18n('Name is required.'),
- 'minlength': this.i18n('Name must be at least 1 character long.'),
- 'maxlength': this.i18n('Name cannot be more than 50 characters long.'),
- 'pattern': this.i18n('Name should be lowercase alphanumeric; dots and underscores are allowed.')
- }
- }
-
- this.VIDEO_CHANNEL_DISPLAY_NAME = {
- VALIDATORS: [
- Validators.required,
- Validators.minLength(1),
- Validators.maxLength(50)
- ],
- MESSAGES: {
- 'required': i18n('Display name is required.'),
- 'minlength': i18n('Display name must be at least 1 character long.'),
- 'maxlength': i18n('Display name cannot be more than 50 characters long.')
- }
- }
-
- this.VIDEO_CHANNEL_DESCRIPTION = {
- VALIDATORS: [
- Validators.minLength(3),
- Validators.maxLength(1000)
- ],
- MESSAGES: {
- 'minlength': i18n('Description must be at least 3 characters long.'),
- 'maxlength': i18n('Description cannot be more than 1000 characters long.')
- }
- }
-
- this.VIDEO_CHANNEL_SUPPORT = {
- VALIDATORS: [
- Validators.minLength(3),
- Validators.maxLength(1000)
- ],
- MESSAGES: {
- 'minlength': i18n('Support text must be at least 3 characters long.'),
- 'maxlength': i18n('Support text cannot be more than 1000 characters long.')
- }
- }
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class VideoCommentValidatorsService {
- readonly VIDEO_COMMENT_TEXT: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.VIDEO_COMMENT_TEXT = {
- VALIDATORS: [ Validators.required, Validators.minLength(1), Validators.maxLength(3000) ],
- MESSAGES: {
- 'required': this.i18n('Comment is required.'),
- 'minlength': this.i18n('Comment must be at least 2 characters long.'),
- 'maxlength': this.i18n('Comment cannot be more than 3000 characters long.')
- }
- }
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { AbstractControl, FormControl, Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-import { VideoPlaylistPrivacy } from '@shared/models'
-
-@Injectable()
-export class VideoPlaylistValidatorsService {
- readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator
- readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator
- readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator
- readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator
-
- constructor (private i18n: I18n) {
- this.VIDEO_PLAYLIST_DISPLAY_NAME = {
- VALIDATORS: [
- Validators.required,
- Validators.minLength(1),
- Validators.maxLength(120)
- ],
- MESSAGES: {
- 'required': this.i18n('Display name is required.'),
- 'minlength': this.i18n('Display name must be at least 1 character long.'),
- 'maxlength': this.i18n('Display name cannot be more than 120 characters long.')
- }
- }
-
- this.VIDEO_PLAYLIST_PRIVACY = {
- VALIDATORS: [
- Validators.required
- ],
- MESSAGES: {
- 'required': this.i18n('Privacy is required.')
- }
- }
-
- this.VIDEO_PLAYLIST_DESCRIPTION = {
- VALIDATORS: [
- Validators.minLength(3),
- Validators.maxLength(1000)
- ],
- MESSAGES: {
- 'minlength': i18n('Description must be at least 3 characters long.'),
- 'maxlength': i18n('Description cannot be more than 1000 characters long.')
- }
- }
-
- this.VIDEO_PLAYLIST_CHANNEL_ID = {
- VALIDATORS: [ ],
- MESSAGES: {
- 'required': this.i18n('The channel is required when the playlist is public.')
- }
- }
- }
-
- setChannelValidator (channelControl: AbstractControl, privacy: VideoPlaylistPrivacy) {
- if (privacy.toString() === VideoPlaylistPrivacy.PUBLIC.toString()) {
- channelControl.setValidators([ Validators.required ])
- } else {
- channelControl.setValidators(null)
- }
-
- channelControl.markAsDirty()
- channelControl.updateValueAndValidity()
- }
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Validators } from '@angular/forms'
-import { Injectable } from '@angular/core'
-import { BuildFormValidator } from '@app/shared'
-
-@Injectable()
-export class VideoValidatorsService {
- readonly VIDEO_NAME: BuildFormValidator
- readonly VIDEO_PRIVACY: BuildFormValidator
- readonly VIDEO_CATEGORY: BuildFormValidator
- readonly VIDEO_LICENCE: BuildFormValidator
- readonly VIDEO_LANGUAGE: BuildFormValidator
- readonly VIDEO_IMAGE: BuildFormValidator
- readonly VIDEO_CHANNEL: BuildFormValidator
- readonly VIDEO_DESCRIPTION: BuildFormValidator
- readonly VIDEO_TAGS: BuildFormValidator
- readonly VIDEO_SUPPORT: BuildFormValidator
- readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator
- readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator
-
- constructor (private i18n: I18n) {
-
- this.VIDEO_NAME = {
- VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(120) ],
- MESSAGES: {
- 'required': this.i18n('Video name is required.'),
- 'minlength': this.i18n('Video name must be at least 3 characters long.'),
- 'maxlength': this.i18n('Video name cannot be more than 120 characters long.')
- }
- }
-
- this.VIDEO_PRIVACY = {
- VALIDATORS: [ Validators.required ],
- MESSAGES: {
- 'required': this.i18n('Video privacy is required.')
- }
- }
-
- this.VIDEO_CATEGORY = {
- VALIDATORS: [ ],
- MESSAGES: {}
- }
-
- this.VIDEO_LICENCE = {
- VALIDATORS: [ ],
- MESSAGES: {}
- }
-
- this.VIDEO_LANGUAGE = {
- VALIDATORS: [ ],
- MESSAGES: {}
- }
-
- this.VIDEO_IMAGE = {
- VALIDATORS: [ ],
- MESSAGES: {}
- }
-
- this.VIDEO_CHANNEL = {
- VALIDATORS: [ Validators.required ],
- MESSAGES: {
- 'required': this.i18n('Video channel is required.')
- }
- }
-
- this.VIDEO_DESCRIPTION = {
- VALIDATORS: [ Validators.minLength(3), Validators.maxLength(10000) ],
- MESSAGES: {
- 'minlength': this.i18n('Video description must be at least 3 characters long.'),
- 'maxlength': this.i18n('Video description cannot be more than 10000 characters long.')
- }
- }
-
- this.VIDEO_TAGS = {
- VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ],
- MESSAGES: {
- 'minlength': this.i18n('A tag should be more than 2 characters long.'),
- 'maxlength': this.i18n('A tag should be less than 30 characters long.')
- }
- }
-
- this.VIDEO_SUPPORT = {
- VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ],
- MESSAGES: {
- 'minlength': this.i18n('Video support must be at least 3 characters long.'),
- 'maxlength': this.i18n('Video support cannot be more than 1000 characters long.')
- }
- }
-
- this.VIDEO_SCHEDULE_PUBLICATION_AT = {
- VALIDATORS: [ ],
- MESSAGES: {
- 'required': this.i18n('A date is required to schedule video update.')
- }
- }
-
- this.VIDEO_ORIGINALLY_PUBLISHED_AT = {
- VALIDATORS: [ ],
- MESSAGES: {}
- }
- }
-}
+++ /dev/null
-export * from './form-validators'
-export * from './form-reactive'
-export * from './reactive-file.component'
-export * from './textarea-autoresize.directive'
+++ /dev/null
-<div class="input-group input-group-sm">
- <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
-
- <div class="input-group-append">
- <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
- <span class="glyphicon glyphicon-copy"></span>
- </button>
- </div>
-</div>
+++ /dev/null
-input.readonly {
- font-size: 15px;
-}
+++ /dev/null
-import { Component, Input } from '@angular/core'
-import { Notifier } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Component({
- selector: 'my-input-readonly-copy',
- templateUrl: './input-readonly-copy.component.html',
- styleUrls: [ './input-readonly-copy.component.scss' ]
-})
-export class InputReadonlyCopyComponent {
- @Input() value = ''
-
- constructor (
- private notifier: Notifier,
- private i18n: I18n
- ) { }
-
- activateCopiedMessage () {
- this.notifier.success(this.i18n('Copied'))
- }
-}
+++ /dev/null
-<div class="root" [ngClass]="{ 'maximized': isMaximized }" [ngStyle]="{ 'max-width': textareaMaxWidth }">
- <textarea #textarea
- [(ngModel)]="content" (ngModelChange)="onModelChange()"
- class="form-control" [ngClass]="classes"
- [ngStyle]="{ height: textareaHeight }"
- [id]="name" [name]="name">
- </textarea>
-
- <div ngbNav #nav="ngbNav" class="nav-pills nav-preview">
- <ng-container ngbNavItem *ngIf="truncate !== undefined">
- <a ngbNavLink i18n>Truncated preview</a>
-
- <ng-template ngbNavContent>
- <div [innerHTML]="truncatedPreviewHTML"></div>
- </ng-template>
- </ng-container>
-
- <ng-container ngbNavItem>
- <a ngbNavLink i18n>Complete preview</a>
-
- <ng-template ngbNavContent>
- <div [innerHTML]="previewHTML"></div>
- </ng-template>
- </ng-container>
-
- <my-button
- *ngIf="!isMaximized" icon="fullscreen" (click)="onMaximizeClick()"
- ></my-button>
-
- <my-button
- *ngIf="isMaximized" icon="exit-fullscreen" (click)="onMaximizeClick()"
- ></my-button>
- </div>
-
- <div [ngbNavOutlet]="nav"></div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-$nav-preview-tab-height: 30px;
-$base-padding: 15px;
-$input-border-color: #C6C6C6;
-$input-border-radius: 3px;
-
-@mixin in-small-view {
- .root {
- display: flex;
- flex-direction: column;
-
- textarea {
- @include peertube-textarea(100%, 150px);
-
- background-color: pvar(--markdownTextareaBackgroundColor);
-
- font-family: monospace;
- font-size: 13px;
- border-bottom: none;
- border-bottom-left-radius: unset;
- border-bottom-right-radius: unset;
- }
-
- .nav-preview {
- display: block;
- text-align: right;
- padding-top: 10px;
- padding-bottom: 10px;
- padding-left: 10px;
- padding-right: 10px;
- border-top: 1px dashed $input-border-color;
- border-left: 1px solid $input-border-color;
- border-right: 1px solid $input-border-color;
- border-bottom: 1px solid $input-border-color;
- border-bottom-right-radius: $input-border-radius;
-
- border-bottom-left-radius: $input-border-radius;
- ::ng-deep {
- .nav-link {
- display: none !important;
- }
-
- .grey-button {
- padding: 0 12px 0 12px;
- }
- }
- }
-
- ::ng-deep {
- .tab-content {
- display: none;
- }
- }
- }
-}
-
-@mixin nav-preview-medium {
- display: flex;
- flex-grow: 1;
- border-bottom-left-radius: unset;
- border-bottom-right-radius: unset;
- border-bottom: 2px solid pvar(--mainColor);
-
- :first-child {
- margin-left: auto;
- }
-
- ::ng-deep {
- .nav-link {
- display: flex !important;
- align-items: center;
- height: $nav-preview-tab-height !important;
- padding: 0 15px !important;
- font-size: 85% !important;
- opacity: .7;
- }
-
- .grey-button {
- margin-left: 5px;
- }
- }
-}
-
-@mixin content-preview-base {
- display: block;
- min-height: 75px;
- padding: $base-padding;
- overflow-y: auto;
- font-size: 15px;
- word-wrap: break-word;
-}
-
-@mixin maximized-base {
- flex-direction: row;
- z-index: #{z(header) - 1};
- position: fixed;
- top: $header-height;
- left: $menu-width;
- max-height: none !important;
- max-width: none !important;
- width: calc(100% - #{$menu-width});
- height: calc(100vh - #{$header-height}) !important;
-
- $nav-preview-vertical-padding: 40px;
-
- .nav-preview {
- @include nav-preview-medium();
- padding-top: #{$nav-preview-vertical-padding / 2};
- padding-bottom: #{$nav-preview-vertical-padding / 2};
- padding-left: 0px;
- padding-right: 0px;
- position: absolute;
- background-color: pvar(--mainBackgroundColor);
- width: 100% !important;
- border-top: none;
- border-left: none;
- border-right: none;
-
- :last-child {
- margin-right: $not-expanded-horizontal-margins;
- }
- }
-
- ::ng-deep .tab-content {
- @include content-preview-base();
- background-color: pvar(--mainBackgroundColor);
- scrollbar-color: pvar(--actionButtonColor) pvar(--mainBackgroundColor);
- }
-
- textarea,
- ::ng-deep .tab-content {
- max-height: none !important;
- max-width: none !important;
- margin-top: #{$nav-preview-tab-height + $nav-preview-vertical-padding} !important;
- height: calc(100vh - #{$header-height + $nav-preview-tab-height + $nav-preview-vertical-padding}) !important;
- width: 50% !important;
- border: none !important;
- border-radius: unset !important;
- }
-
- :host-context(.expanded) {
- .root.maximized {
- left: 0;
- width: 100%;
- }
- }
-}
-
-@mixin maximized-in-small-view {
- .root.maximized {
- @include maximized-base();
-
- textarea {
- display: none;
- }
-
- ::ng-deep .tab-content {
- width: 100% !important;
- }
- }
-}
-
-@mixin maximized-tabs-in-mobile-view {
- // Ellipsis on tabs for mobile view
- .root.maximized {
- .nav-preview {
- ::ng-deep .nav-link {
- @include ellipsis();
-
- display: block !important;
- max-width: 45% !important;
- padding: 5px 0 !important;
- margin-right: 10px !important;
- text-align: center;
-
- &:not(.active) {
- max-width: 15% !important;
- }
-
- &.active {
- padding: 5px 15px !important;
- }
- }
- }
- }
-}
-
-@mixin in-medium-view {
- .root {
- .nav-preview {
- @include nav-preview-medium();
- }
-
- ::ng-deep .tab-content {
- @include content-preview-base();
- max-height: 210px;
- border-bottom: 1px solid $input-border-color;
- border-left: 1px solid $input-border-color;
- border-right: 1px solid $input-border-color;
- border-bottom-left-radius: $input-border-radius;
- border-bottom-right-radius: $input-border-radius;
- }
- }
-}
-
-@mixin maximized-in-medium-view {
- .root.maximized {
- @include maximized-base();
-
- textarea {
- display: block;
- padding: $base-padding;
- border-right: 1px dashed $input-border-color !important;
- resize: none;
- scrollbar-color: pvar(--actionButtonColor) pvar(--markdownTextareaBackgroundColor);
-
- &:focus {
- box-shadow: none;
- }
- }
- }
-}
-
-@include in-small-view();
-@include maximized-in-small-view();
-
-@media only screen and (max-width: $mobile-view) {
- @include maximized-tabs-in-mobile-view();
-}
-
-@media only screen and (max-width: #{$mobile-view + $menu-width}) {
- :host-context(.main-col:not(.expanded)) {
- @include maximized-tabs-in-mobile-view();
- }
-}
-
-@media only screen and (min-width: $small-view) {
- :host-context(.expanded) {
- @include in-medium-view();
- }
-
- @include maximized-in-medium-view();
-}
-
-@media only screen and (min-width: #{$small-view + $menu-width}) {
- :host-context(.main-col:not(.expanded)) {
- @include in-medium-view();
- }
-}
+++ /dev/null
-import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
-import { Component, forwardRef, Input, OnInit, ViewChild, ElementRef } from '@angular/core'
-import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { Subject } from 'rxjs'
-import truncate from 'lodash-es/truncate'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { MarkdownService } from '@app/shared/renderer'
-
-@Component({
- selector: 'my-markdown-textarea',
- templateUrl: './markdown-textarea.component.html',
- styleUrls: [ './markdown-textarea.component.scss' ],
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: forwardRef(() => MarkdownTextareaComponent),
- multi: true
- }
- ]
-})
-
-export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
- @Input() content = ''
- @Input() classes: string[] | { [klass: string]: any[] | any } = []
- @Input() textareaMaxWidth = '100%'
- @Input() textareaHeight = '150px'
- @Input() truncate: number
- @Input() markdownType: 'text' | 'enhanced' = 'text'
- @Input() markdownVideo = false
- @Input() name = 'description'
-
- @ViewChild('textarea') textareaElement: ElementRef
-
- truncatedPreviewHTML = ''
- previewHTML = ''
- isMaximized = false
-
- private contentChanged = new Subject<string>()
-
- constructor (
- private screenService: ScreenService,
- private markdownService: MarkdownService
-) {}
-
- ngOnInit () {
- this.contentChanged
- .pipe(
- debounceTime(150),
- distinctUntilChanged()
- )
- .subscribe(() => this.updatePreviews())
-
- this.contentChanged.next(this.content)
- }
-
- propagateChange = (_: any) => { /* empty */ }
-
- writeValue (description: string) {
- this.content = description
-
- this.contentChanged.next(this.content)
- }
-
- registerOnChange (fn: (_: any) => void) {
- this.propagateChange = fn
- }
-
- registerOnTouched () {
- // Unused
- }
-
- onModelChange () {
- this.propagateChange(this.content)
-
- this.contentChanged.next(this.content)
- }
-
- onMaximizeClick () {
- this.isMaximized = !this.isMaximized
-
- // Make sure textarea have the focus
- this.textareaElement.nativeElement.focus()
-
- // Make sure the window has no scrollbars
- if (!this.isMaximized) {
- this.unlockBodyScroll()
- } else {
- this.lockBodyScroll()
- }
- }
-
- private lockBodyScroll () {
- document.getElementById('content').classList.add('lock-scroll')
- }
-
- private unlockBodyScroll () {
- document.getElementById('content').classList.remove('lock-scroll')
- }
-
- private async updatePreviews () {
- if (this.content === null || this.content === undefined) return
-
- this.truncatedPreviewHTML = await this.markdownRender(truncate(this.content, { length: this.truncate }))
- this.previewHTML = await this.markdownRender(this.content)
- }
-
- private async markdownRender (text: string) {
- const html = this.markdownType === 'text' ?
- await this.markdownService.textMarkdownToHTML(text) :
- await this.markdownService.enhancedMarkdownToHTML(text)
-
- return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html
- }
-}
+++ /dev/null
-<div class="root flex-column">
- <div class="d-flex">
- <label class="form-group-checkbox">
- <input
- type="checkbox"
- [(ngModel)]="checked"
- (ngModelChange)="onModelChange()"
- [id]="inputName"
- [disabled]="disabled"
- />
- <span role="checkbox" [attr.aria-checked]="checked"></span>
- <span *ngIf="labelText">{{ labelText }}</span>
- <span
- *ngIf="!labelText && labelInnerHTML"
- [innerHTML]="labelInnerHTML"
- ></span>
-
- <span *ngIf="labelTemplate">
- <ng-container *ngTemplateOutlet="labelTemplate"></ng-container>
- </span>
- </label>
-
- <my-help
- *ngIf="helpTemplate"
- [tooltipPlacement]="helpPlacement"
- helpType="custom"
- >
- <ng-template ptTemplate="customHtml">
- <ng-template *ngTemplateOutlet="helpTemplate"></ng-template>
- </ng-template>
- </my-help>
-
- <div *ngIf="recommended" class="recommended" i18n>Recommended</div>
- </div>
-
- <div class="ml-4 d-flex flex-column">
- <small class="wrapper mt-2 text-muted">
- <ng-content select="description"></ng-content>
- </small>
-
- <span class="wrapper mt-3">
- <ng-content select="extra"></ng-content>
- </span>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.root {
- display: flex;
-
- .form-group-checkbox {
- display: flex;
- align-items: center;
-
- .label-text {
- font-weight: $font-regular;
- margin: 0;
- }
-
- input {
- @include peertube-checkbox(1px);
- }
- }
-
- label {
- margin-bottom: 0;
- }
-
- my-help {
- position: relative;
- top: 2px;
- }
-
- small {
- font-size: 90%;
- }
-
- .wrapper:empty {
- display: none;
- }
-
- .recommended {
- margin-left: .5rem;
- align-self: baseline;
- display: inline-block;
- padding: 4px 6px;
- cursor: default;
- border-radius: 3px;
- font-size: 12px;
- line-height: 12px;
- font-weight: 500;
- color: pvar(--inputPlaceholderColor);
- background-color: rgba(217,225,232,.1);
- border: 1px solid rgba(217,225,232,.5);
- }
-}
\ No newline at end of file
+++ /dev/null
-import { AfterContentInit, ChangeDetectorRef, Component, ContentChildren, forwardRef, Input, QueryList, TemplateRef } from '@angular/core'
-import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
-
-@Component({
- selector: 'my-peertube-checkbox',
- styleUrls: [ './peertube-checkbox.component.scss' ],
- templateUrl: './peertube-checkbox.component.html',
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: forwardRef(() => PeertubeCheckboxComponent),
- multi: true
- }
- ]
-})
-export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterContentInit {
- @Input() checked = false
- @Input() inputName: string
- @Input() labelText: string
- @Input() labelInnerHTML: string
- @Input() helpPlacement = 'top auto'
- @Input() disabled = false
- @Input() recommended = false
-
- @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'label' | 'help'>>
-
- // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836
- @Input() onPushWorkaround = false
-
- labelTemplate: TemplateRef<any>
- helpTemplate: TemplateRef<any>
-
- constructor (private cdr: ChangeDetectorRef) { }
-
- ngAfterContentInit () {
- {
- const t = this.templates.find(t => t.name === 'label')
- if (t) this.labelTemplate = t.template
- }
-
- {
- const t = this.templates.find(t => t.name === 'help')
- if (t) this.helpTemplate = t.template
- }
- }
-
- propagateChange = (_: any) => { /* empty */ }
-
- writeValue (checked: boolean) {
- this.checked = checked
-
- if (this.onPushWorkaround) {
- this.cdr.markForCheck()
- }
- }
-
- registerOnChange (fn: (_: any) => void) {
- this.propagateChange = fn
- }
-
- registerOnTouched () {
- // Unused
- }
-
- onModelChange () {
- this.propagateChange(this.checked)
- }
-
- setDisabledState (isDisabled: boolean) {
- this.disabled = isDisabled
- }
-}
+++ /dev/null
-<div class="root">
- <div class="button-file" [ngClass]="{ 'with-icon': !!icon }">
- <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
-
- <span>{{ inputLabel }}</span>
-
- <input
- type="file"
- [name]="inputName" [id]="inputName" [accept]="extensions"
- (change)="fileChange($event)" [(ngModel)]="fileInputValue"
- />
- </div>
-
- <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.root {
- height: auto;
- display: flex;
- align-items: center;
-
- .button-file {
- @include peertube-button-file(auto);
- @include grey-button;
-
- &.with-icon {
- @include button-with-icon;
- }
- }
-
- .filename {
- font-weight: $font-semibold;
- margin-left: 5px;
- }
-}
+++ /dev/null
-import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
-import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { Notifier } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { GlobalIconName } from '@app/shared/images/global-icon.component'
-
-@Component({
- selector: 'my-reactive-file',
- styleUrls: [ './reactive-file.component.scss' ],
- templateUrl: './reactive-file.component.html',
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: forwardRef(() => ReactiveFileComponent),
- multi: true
- }
- ]
-})
-export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
- @Input() inputLabel: string
- @Input() inputName: string
- @Input() extensions: string[] = []
- @Input() maxFileSize: number
- @Input() displayFilename = false
- @Input() icon: GlobalIconName
-
- @Output() fileChanged = new EventEmitter<Blob>()
-
- allowedExtensionsMessage = ''
- fileInputValue: any
-
- private file: File
-
- constructor (
- private notifier: Notifier,
- private i18n: I18n
- ) {}
-
- get filename () {
- if (!this.file) return ''
-
- return this.file.name
- }
-
- ngOnInit () {
- this.allowedExtensionsMessage = this.extensions.join(', ')
- }
-
- fileChange (event: any) {
- if (event.target.files && event.target.files.length) {
- const [ file ] = event.target.files
-
- if (file.size > this.maxFileSize) {
- this.notifier.error(this.i18n('This file is too large.'))
- return
- }
-
- const extension = '.' + file.name.split('.').pop()
- if (this.extensions.includes(extension) === false) {
- const message = this.i18n(
- 'PeerTube cannot handle this kind of file. Accepted extensions are {{extensions}}.',
- { extensions: this.allowedExtensionsMessage }
- )
- this.notifier.error(message)
-
- return
- }
-
- this.file = file
-
- this.propagateChange(this.file)
- this.fileChanged.emit(this.file)
- }
- }
-
- propagateChange = (_: any) => { /* empty */ }
-
- writeValue (file: any) {
- this.file = file
-
- if (!this.file) this.fileInputValue = null
- }
-
- registerOnChange (fn: (_: any) => void) {
- this.propagateChange = fn
- }
-
- registerOnTouched () {
- // Unused
- }
-}
+++ /dev/null
-// Thanks: https://github.com/evseevdev/ngx-textarea-autosize
-import { AfterViewInit, Directive, ElementRef, HostBinding, HostListener } from '@angular/core'
-
-@Directive({
- selector: 'textarea[myAutoResize]'
-})
-export class TextareaAutoResizeDirective implements AfterViewInit {
- @HostBinding('attr.rows') rows = '1'
- @HostBinding('style.overflow') overflow = 'hidden'
-
- constructor (private elem: ElementRef) { }
-
- public ngAfterViewInit () {
- this.resize()
- }
-
- @HostListener('input')
- resize () {
- const textarea = this.elem.nativeElement as HTMLTextAreaElement
- // Reset textarea height to auto that correctly calculate the new height
- textarea.style.height = 'auto'
- // Set new height
- textarea.style.height = `${textarea.scrollHeight}px`
- }
-}
+++ /dev/null
-<p-inputMask
- [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
- mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
-></p-inputMask>
+++ /dev/null
-@import 'variables';
-
-p-inputmask {
- ::ng-deep input {
- width: 80px;
- font-size: 15px;
-
- border: none;
-
- &:focus-within,
- &:focus {
- box-shadow: #{$focus-box-shadow-form} pvar(--mainColorLightest);
- }
- }
-}
+++ /dev/null
-import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'
-import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { secondsToTime, timeToInt } from '../../../assets/player/utils'
-
-@Component({
- selector: 'my-timestamp-input',
- styleUrls: [ './timestamp-input.component.scss' ],
- templateUrl: './timestamp-input.component.html',
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: forwardRef(() => TimestampInputComponent),
- multi: true
- }
- ]
-})
-export class TimestampInputComponent implements ControlValueAccessor, OnInit {
- @Input() maxTimestamp: number
- @Input() timestamp: number
- @Input() disabled = false
-
- timestampString: string
-
- constructor (private changeDetector: ChangeDetectorRef) {}
-
- ngOnInit () {
- this.writeValue(this.timestamp || 0)
- }
-
- propagateChange = (_: any) => { /* empty */ }
-
- writeValue (timestamp: number) {
- this.timestamp = timestamp
-
- this.timestampString = secondsToTime(this.timestamp, true, ':')
- }
-
- registerOnChange (fn: (_: any) => void) {
- this.propagateChange = fn
- }
-
- registerOnTouched () {
- // Unused
- }
-
- onModelChange () {
- this.timestamp = timeToInt(this.timestampString)
-
- this.propagateChange(this.timestamp)
- }
-
- onBlur () {
- if (this.maxTimestamp && this.timestamp > this.maxTimestamp) {
- this.writeValue(this.maxTimestamp)
-
- this.changeDetector.detectChanges()
-
- this.propagateChange(this.timestamp)
- }
- }
-}
+++ /dev/null
-import { Injectable } from '@angular/core'
-import { CanDeactivate } from '@angular/router'
-import { Observable } from 'rxjs'
-import { ConfirmService } from '../../core/index'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-export type CanComponentDeactivateResult = { text?: string, canDeactivate: Observable<boolean> | boolean }
-
-export interface CanComponentDeactivate {
- canDeactivate: () => CanComponentDeactivateResult
-}
-
-@Injectable()
-export class CanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
- constructor (
- private confirmService: ConfirmService,
- private i18n: I18n
- ) { }
-
- canDeactivate (component: CanComponentDeactivate) {
- const result = component.canDeactivate()
- const text = result.text || this.i18n('All unsaved data will be lost, are you sure you want to leave this page?')
-
- return result.canDeactivate || this.confirmService.confirm(
- text,
- this.i18n('Warning')
- )
- }
-
-}
+++ /dev/null
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Injectable } from '@angular/core'
-
-@Injectable()
-export class I18nPrimengCalendarService {
- private readonly calendarLocale: any = {}
-
- constructor (private i18n: I18n) {
- this.calendarLocale = {
- firstDayOfWeek: 0,
- dayNames: [
- this.i18n('Sunday'),
- this.i18n('Monday'),
- this.i18n('Tuesday'),
- this.i18n('Wednesday'),
- this.i18n('Thursday'),
- this.i18n('Friday'),
- this.i18n('Saturday')
- ],
-
- dayNamesShort: [
- this.i18n({ value: 'Sun', description: 'Day name short' }),
- this.i18n({ value: 'Mon', description: 'Day name short' }),
- this.i18n({ value: 'Tue', description: 'Day name short' }),
- this.i18n({ value: 'Wed', description: 'Day name short' }),
- this.i18n({ value: 'Thu', description: 'Day name short' }),
- this.i18n({ value: 'Fri', description: 'Day name short' }),
- this.i18n({ value: 'Sat', description: 'Day name short' })
- ],
-
- dayNamesMin: [
- this.i18n({ value: 'Su', description: 'Day name min' }),
- this.i18n({ value: 'Mo', description: 'Day name min' }),
- this.i18n({ value: 'Tu', description: 'Day name min' }),
- this.i18n({ value: 'We', description: 'Day name min' }),
- this.i18n({ value: 'Th', description: 'Day name min' }),
- this.i18n({ value: 'Fr', description: 'Day name min' }),
- this.i18n({ value: 'Sa', description: 'Day name min' })
- ],
-
- monthNames: [
- this.i18n('January'),
- this.i18n('February'),
- this.i18n('March'),
- this.i18n('April'),
- this.i18n('May'),
- this.i18n('June'),
- this.i18n('July'),
- this.i18n('August'),
- this.i18n('September'),
- this.i18n('October'),
- this.i18n('November'),
- this.i18n('December')
- ],
-
- monthNamesShort: [
- this.i18n({ value: 'Jan', description: 'Month name short' }),
- this.i18n({ value: 'Feb', description: 'Month name short' }),
- this.i18n({ value: 'Mar', description: 'Month name short' }),
- this.i18n({ value: 'Apr', description: 'Month name short' }),
- this.i18n({ value: 'May', description: 'Month name short' }),
- this.i18n({ value: 'Jun', description: 'Month name short' }),
- this.i18n({ value: 'Jul', description: 'Month name short' }),
- this.i18n({ value: 'Aug', description: 'Month name short' }),
- this.i18n({ value: 'Sep', description: 'Month name short' }),
- this.i18n({ value: 'Oct', description: 'Month name short' }),
- this.i18n({ value: 'Nov', description: 'Month name short' }),
- this.i18n({ value: 'Dec', description: 'Month name short' })
- ],
-
- today: this.i18n('Today'),
-
- clear: this.i18n('Clear')
- }
- }
-
- getCalendarLocale () {
- return this.calendarLocale
- }
-
- getTimezone () {
- const gmt = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1]
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
-
- return `${timezone} - ${gmt}`
- }
-
- getDateFormat () {
- return this.i18n({
- value: 'yy-mm-dd ',
- description: 'Date format in this locale.'
- })
- }
-}
+++ /dev/null
-import { environment } from '../../../environments/environment'
-
-function isOnDevLocale () {
- return environment.production === false && window.location.search === '?lang=fr'
-}
-
-function getDevLocale () {
- return 'fr-FR'
-}
-
-export {
- getDevLocale,
- isOnDevLocale
-}
+++ /dev/null
-::ng-deep {
- svg {
- width: inherit;
- height: inherit;
- }
-}
+++ /dev/null
-import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core'
-import { HooksService } from '@app/core/plugins/hooks.service'
-
-const icons = {
- 'add': require('!!raw-loader?!../../../assets/images/global/add.svg').default,
- 'user': require('!!raw-loader?!../../../assets/images/global/user.svg').default,
- 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg').default,
- 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg').default,
- 'help': require('!!raw-loader?!../../../assets/images/global/help.svg').default,
- 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg').default,
- 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg').default,
- 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg').default,
- 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg').default,
- 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg').default,
- 'no': require('!!raw-loader?!../../../assets/images/global/no.svg').default,
- 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg').default,
- 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg').default,
- 'history': require('!!raw-loader?!../../../assets/images/global/history.svg').default,
- 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg').default,
- 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg').default,
- 'download': require('!!raw-loader?!../../../assets/images/global/download.svg').default,
- 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg').default,
- 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg').default,
- 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg').default,
- 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg').default,
- 'server': require('!!raw-loader?!../../../assets/images/global/server.svg').default,
- 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg').default,
- 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg').default,
- 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg').default,
- 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg').default,
- 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg').default,
- 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg').default,
- 'support': require('!!raw-loader?!../../../assets/images/video/support.svg').default,
- 'like': require('!!raw-loader?!../../../assets/images/video/like.svg').default,
- 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg').default,
- 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg').default,
- 'share': require('!!raw-loader?!../../../assets/images/video/share.svg').default,
- 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg').default,
- 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg').default,
- 'play': require('!!raw-loader?!../../../assets/images/global/play.svg').default,
- 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg').default,
- 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg').default,
- 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg').default,
- 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg').default,
- 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg').default,
- 'video-lang': require('!!raw-loader?!../../../assets/images/global/video-lang.svg').default,
- 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg').default,
- 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg').default,
- 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg').default,
- 'language': require('!!raw-loader?!../../../assets/images/menu/language.svg').default,
- 'unsensitive': require('!!raw-loader?!../../../assets/images/menu/eye.svg').default,
- 'sensitive': require('!!raw-loader?!../../../assets/images/menu/eye-closed.svg').default,
- 'p2p': require('!!raw-loader?!../../../assets/images/menu/p2p.svg').default,
- 'users': require('!!raw-loader?!../../../assets/images/global/users.svg').default,
- 'search': require('!!raw-loader?!../../../assets/images/global/search.svg').default,
- 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg').default,
- 'npm': require('!!raw-loader?!../../../assets/images/global/npm.svg').default,
- 'fullscreen': require('!!raw-loader?!../../../assets/images/global/fullscreen.svg').default,
- 'exit-fullscreen': require('!!raw-loader?!../../../assets/images/global/exit-fullscreen.svg').default,
- 'robot': require('!!raw-loader?!../../../assets/images/global/robot.svg').default
-}
-
-export type GlobalIconName = keyof typeof icons
-
-@Component({
- selector: 'my-global-icon',
- template: '',
- styleUrls: [ './global-icon.component.scss' ],
- changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class GlobalIconComponent implements OnInit {
- @Input() iconName: GlobalIconName
-
- constructor (
- private el: ElementRef,
- private hooks: HooksService
- ) { }
-
- async ngOnInit () {
- const nativeElement = this.el.nativeElement as HTMLElement
- nativeElement.innerHTML = await this.hooks.wrapFun(
- this.getSVGContent.bind(this),
- { name: this.iconName },
- 'common',
- 'filter:internal.common.svg-icons.get-content.params',
- 'filter:internal.common.svg-icons.get-content.result'
- )
- }
-
- private getSVGContent (options: { name: string }) {
- return icons[options.name]
- }
-}
+++ /dev/null
-<div class="root">
- <div class="preview-container">
- <my-reactive-file
- [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right"
- icon="edit" (fileChanged)="onFileChanged($event)" [ngbTooltip]="'(extensions: '+ videoImageExtensions +', '+ maxSizeText +': '+ maxVideoImageSizeInBytes +')'"
- ></my-reactive-file>
-
- <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
- <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.root {
- height: auto;
- display: flex;
- flex-direction: column;
-
- .preview-container {
- position: relative;
-
- my-reactive-file {
- position: absolute;
- bottom: 10px;
- left: 10px;
- }
-
- .preview {
- object-fit: cover;
- border-radius: 4px;
- max-width: 100%;
-
- &.no-image {
- border: 2px solid grey;
- background-color: pvar(--mainBackgroundColor);
- }
- }
- }
-}
+++ /dev/null
-import { Component, forwardRef, Input, OnInit } from '@angular/core'
-import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
-import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
-import { ServerService } from '@app/core'
-import { ServerConfig } from '@shared/models'
-import { BytesPipe } from 'ngx-pipes'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Component({
- selector: 'my-preview-upload',
- styleUrls: [ './preview-upload.component.scss' ],
- templateUrl: './preview-upload.component.html',
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: forwardRef(() => PreviewUploadComponent),
- multi: true
- }
- ]
-})
-export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
- @Input() inputLabel: string
- @Input() inputName: string
- @Input() previewWidth: string
- @Input() previewHeight: string
-
- imageSrc: SafeResourceUrl
- allowedExtensionsMessage = ''
- maxSizeText: string
-
- private serverConfig: ServerConfig
- private bytesPipe: BytesPipe
- private file: Blob
-
- constructor (
- private sanitizer: DomSanitizer,
- private serverService: ServerService,
- private i18n: I18n
- ) {
- this.bytesPipe = new BytesPipe()
- this.maxSizeText = this.i18n('max size')
- }
-
- get videoImageExtensions () {
- return this.serverConfig.video.image.extensions
- }
-
- get maxVideoImageSize () {
- return this.serverConfig.video.image.size.max
- }
-
- get maxVideoImageSizeInBytes () {
- return this.bytesPipe.transform(this.maxVideoImageSize)
- }
-
- ngOnInit () {
- this.serverConfig = this.serverService.getTmpConfig()
- this.serverService.getConfig()
- .subscribe(config => this.serverConfig = config)
-
- this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
- }
-
- onFileChanged (file: Blob) {
- this.file = file
-
- this.propagateChange(this.file)
- this.updatePreview()
- }
-
- propagateChange = (_: any) => { /* empty */ }
-
- writeValue (file: any) {
- this.file = file
- this.updatePreview()
- }
-
- registerOnChange (fn: (_: any) => void) {
- this.propagateChange = fn
- }
-
- registerOnTouched () {
- // Unused
- }
-
- private updatePreview () {
- if (this.file) {
- const url = URL.createObjectURL(this.file)
- this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url)
- }
- }
-}
+++ /dev/null
-export * from './auth'
-export * from './forms'
-export * from './rest'
-export * from './users'
-export * from './video-abuse'
-export * from './video-block'
-export * from './shared.module'
+++ /dev/null
-<span *ngIf="value === true" class="glyphicon glyphicon-ok" i18n-aria-label aria-label="yes"></span>
-<span *ngIf="value === false" class="glyphicon glyphicon-remove" i18n-aria-label aria-label="no"></span>
-
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.glyphicon-ok {
- color: $green;
-}
-
-.glyphicon-remove {
- color: $red;
-}
+++ /dev/null
-import { Component, Input } from '@angular/core'
-
-@Component({
- selector: 'my-feature-boolean',
- templateUrl: './feature-boolean.component.html',
- styleUrls: [ './feature-boolean.component.scss' ]
-})
-export class FeatureBooleanComponent {
- @Input() value: boolean
-}
+++ /dev/null
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
-import { ActivityPubActorType, ActorFollow, FollowState, ResultList } from '@shared/index'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestPagination, RestService } from '../rest'
-import { SortMeta } from 'primeng/api'
-
-@Injectable()
-export class FollowService {
- private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/server'
-
- constructor (
- private authHttp: HttpClient,
- private restService: RestService,
- private restExtractor: RestExtractor
- ) {
- }
-
- getFollowing (options: {
- pagination: RestPagination,
- sort: SortMeta,
- search?: string,
- actorType?: ActivityPubActorType,
- state?: FollowState
- }): Observable<ResultList<ActorFollow>> {
- const { pagination, sort, search, state, actorType } = options
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (search) params = params.append('search', search)
- if (state) params = params.append('state', state)
- if (actorType) params = params.append('actorType', actorType)
-
- return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/following', { params })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- getFollowers (options: {
- pagination: RestPagination,
- sort: SortMeta,
- search?: string,
- actorType?: ActivityPubActorType,
- state?: FollowState
- }): Observable<ResultList<ActorFollow>> {
- const { pagination, sort, search, state, actorType } = options
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (search) params = params.append('search', search)
- if (state) params = params.append('state', state)
- if (actorType) params = params.append('actorType', actorType)
-
- return this.authHttp.get<ResultList<ActorFollow>>(FollowService.BASE_APPLICATION_URL + '/followers', { params })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- follow (notEmptyHosts: string[]) {
- const body = {
- hosts: notEmptyHosts
- }
-
- return this.authHttp.post(FollowService.BASE_APPLICATION_URL + '/following', body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- unfollow (follow: ActorFollow) {
- return this.authHttp.delete(FollowService.BASE_APPLICATION_URL + '/following/' + follow.following.host)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- acceptFollower (follow: ActorFollow) {
- const handle = follow.follower.name + '@' + follow.follower.host
-
- return this.authHttp.post(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}/accept`, {})
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- rejectFollower (follow: ActorFollow) {
- const handle = follow.follower.name + '@' + follow.follower.host
-
- return this.authHttp.post(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}/reject`, {})
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- removeFollower (follow: ActorFollow) {
- const handle = follow.follower.name + '@' + follow.follower.host
-
- return this.authHttp.delete(`${FollowService.BASE_APPLICATION_URL}/followers/${handle}`)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-}
+++ /dev/null
-<div class="feature-table">
-
- <table class="table" *ngIf="serverConfig">
- <caption i18n>Features found on this instance</caption>
- <tr>
- <th i18n class="label" scope="row">PeerTube version</th>
-
- <td class="value">{{ getServerVersionAndCommit() }}</td>
- </tr>
-
- <tr>
- <th i18n class="label" scope="row">
- <div>Default NSFW/sensitive videos policy</div>
- <div class="more-info">can be redefined by the users</div>
- </th>
-
- <td class="value">{{ buildNSFWLabel() }}</td>
- </tr>
-
- <tr>
- <th i18n class="label" scope="row">User registration allowed</th>
- <td>
- <my-feature-boolean [value]="serverConfig.signup.allowed"></my-feature-boolean>
- </td>
- </tr>
-
- <tr>
- <th i18n class="label" colspan="2">Video uploads</th>
- </tr>
-
- <tr>
- <th i18n class="sub-label" scope="row">Transcoding in multiple resolutions</th>
- <td>
- <my-feature-boolean [value]="serverConfig.transcoding.enabledResolutions.length !== 0"></my-feature-boolean>
- </td>
- </tr>
-
- <tr>
- <th i18n class="sub-label" scope="row">Video uploads</th>
- <td>
- <span i18n *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span>
- <span i18n *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span>
- </td>
- </tr>
-
- <tr>
- <th i18n class="sub-label" scope="row">Video quota</th>
-
- <td class="value">
- <ng-container *ngIf="initialUserVideoQuota !== -1">
- {{ initialUserVideoQuota | bytes: 0 }} <ng-container *ngIf="dailyUserVideoQuota !== -1">({{ dailyUserVideoQuota | bytes: 0 }} per day)</ng-container>
-
- <my-help tooltipPlacement="auto" helpType="custom">
- <ng-template ptTemplate="customHtml">
- <div [innerHTML]="quotaHelpIndication"></div>
- </ng-template>
- </my-help>
- </ng-container>
-
- <ng-container i18n *ngIf="initialUserVideoQuota === -1">
- Unlimited <ng-container *ngIf="dailyUserVideoQuota !== -1">({{ dailyUserVideoQuota | bytes: 0 }} per day)</ng-container>
- </ng-container>
- </td>
- </tr>
-
- <tr>
- <th i18n class="label" colspan="2">Import</th>
- </tr>
-
- <tr>
- <th i18n class="sub-label" scope="row">HTTP import (YouTube, Vimeo, direct URL...)</th>
- <td>
- <my-feature-boolean [value]="serverConfig.import.videos.http.enabled"></my-feature-boolean>
- </td>
- </tr>
-
- <tr>
- <th i18n class="sub-label" scope="row">Torrent import</th>
- <td>
- <my-feature-boolean [value]="serverConfig.import.videos.torrent.enabled"></my-feature-boolean>
- </td>
- </tr>
-
-
- <tr>
- <th i18n class="label" colspan="2">Player</th>
- </tr>
-
- <tr>
- <th i18n class="sub-label" scope="row">P2P enabled</th>
- <td>
- <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean>
- </td>
- </tr>
-
- <tr>
- <th i18n class="label" colspan="2">Search</th>
- </tr>
-
- <tr>
- <th i18n class="sub-label" scope="row">Users can resolve distant content</th>
- <td>
- <my-feature-boolean [value]="serverConfig.search.remoteUri.users"></my-feature-boolean>
- </td>
- </tr>
- </table>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-table {
- font-size: 14px;
- color: pvar(--mainForegroundColor);
-
- .label,
- .sub-label {
- min-width: 330px;
-
- &.label {
- font-weight: $font-semibold;
- }
-
- &.sub-label {
- font-weight: $font-regular;
- padding-left: 30px;
- }
-
- .more-info {
- font-style: italic;
- font-weight: initial;
- font-size: 14px
- }
- }
-
- td {
- vertical-align: middle;
- }
-
- caption {
- caption-side: top;
- font-size: 15px;
- font-weight: $font-semibold;
- color: pvar(--mainForegroundColor);
- }
-}
-
-
+++ /dev/null
-import { Component, OnInit } from '@angular/core'
-import { ServerService } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ServerConfig } from '@shared/models'
-
-@Component({
- selector: 'my-instance-features-table',
- templateUrl: './instance-features-table.component.html',
- styleUrls: [ './instance-features-table.component.scss' ]
-})
-export class InstanceFeaturesTableComponent implements OnInit {
- quotaHelpIndication = ''
- serverConfig: ServerConfig
-
- constructor (
- private i18n: I18n,
- private serverService: ServerService
- ) {
- }
-
- get initialUserVideoQuota () {
- return this.serverConfig.user.videoQuota
- }
-
- get dailyUserVideoQuota () {
- return Math.min(this.initialUserVideoQuota, this.serverConfig.user.videoQuotaDaily)
- }
-
- ngOnInit () {
- this.serverConfig = this.serverService.getTmpConfig()
- this.serverService.getConfig()
- .subscribe(config => {
- this.serverConfig = config
- this.buildQuotaHelpIndication()
- })
- }
-
- buildNSFWLabel () {
- const policy = this.serverConfig.instance.defaultNSFWPolicy
-
- if (policy === 'do_not_list') return this.i18n('Hidden')
- if (policy === 'blur') return this.i18n('Blurred with confirmation request')
- if (policy === 'display') return this.i18n('Displayed')
- }
-
- getServerVersionAndCommit () {
- return this.serverService.getServerVersionAndCommit()
- }
-
- private getApproximateTime (seconds: number) {
- const hours = Math.floor(seconds / 3600)
- let pluralSuffix = ''
- if (hours > 1) pluralSuffix = 's'
- if (hours > 0) return `~ ${hours} hour${pluralSuffix}`
-
- const minutes = Math.floor(seconds % 3600 / 60)
-
- return this.i18n('~ {{minutes}} {minutes, plural, =1 {minute} other {minutes}}', { minutes })
- }
-
- private buildQuotaHelpIndication () {
- if (this.initialUserVideoQuota === -1) return
-
- const initialUserVideoQuotaBit = this.initialUserVideoQuota * 8
-
- // 1080p: ~ 6Mbps
- // 720p: ~ 4Mbps
- // 360p: ~ 1.5Mbps
- const fullHdSeconds = initialUserVideoQuotaBit / (6 * 1000 * 1000)
- const hdSeconds = initialUserVideoQuotaBit / (4 * 1000 * 1000)
- const normalSeconds = initialUserVideoQuotaBit / (1.5 * 1000 * 1000)
-
- const lines = [
- this.i18n('{{seconds}} of full HD videos', { seconds: this.getApproximateTime(fullHdSeconds) }),
- this.i18n('{{seconds}} of HD videos', { seconds: this.getApproximateTime(hdSeconds) }),
- this.i18n('{{seconds}} of average quality videos', { seconds: this.getApproximateTime(normalSeconds) })
- ]
-
- this.quotaHelpIndication = lines.join('<br />')
- }
-}
+++ /dev/null
-<p i18n *ngIf="null === serverStats">Loading instance statistics...</p>
-
-<section *ngIf="null !== serverStats">
- <h3 i18n>Local</h3>
-
- <div class="row">
- <div class="col-6 col-lg-4 col-xl-3">
- <div class="card stat">
- <div class="card-body">
- <p class="stat-value">{{ serverStats.totalUsers }}</p>
- <p class="stat-label" i18n>users</p>
- </div>
- <i class="glyphicon glyphicon-user icon-bottom"></i>
- </div>
- </div>
-
- <div class="col-6 col-lg-4 col-xl-3">
- <div class="card stat">
- <div class="card-body">
- <p class="stat-value">{{ serverStats.totalLocalVideos }}</p>
- <p class="stat-label" i18n>videos</p>
- </div>
- <i class="glyphicon glyphicon-facetime-video"></i>
- </div>
- </div>
-
- <div class="col-6 col-lg-4 col-xl-3">
- <div class="card stat">
- <div class="card-body">
- <p class="stat-value">{{ serverStats.totalLocalVideoViews }}</p>
- <p class="stat-label" i18n>video views</p>
- </div>
- <i class="glyphicon glyphicon-eye-open"></i>
- </div>
- </div>
-
- <div class="col-6 col-lg-4 col-xl-3">
- <div class="card stat">
- <div class="card-body">
- <p class="stat-value">{{ serverStats.totalLocalVideoComments }}</p>
- <p class="stat-label" i18n>video comments</p>
- </div>
- <i class="glyphicon glyphicon-comment"></i>
- </div>
- </div>
-
- <div class="col-6 col-lg-4 col-xl-3">
- <div class="card stat">
- <div class="card-body">
- <p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p>
- <p class="stat-label" i18n>of hosted video</p>
- </div>
- <i class="glyphicon glyphicon-hdd"></i>
- </div>
- </div>
- </div>
-
- <h3 i18n>Federation</h3>
-
- <div class="row">
- <div class="col-6 col-lg-4 col-xl-3">
- <div class="card stat">
- <div class="card-body">
- <p class="stat-value">{{ serverStats.totalVideos }}</p>
- <p class="stat-label" i18n>videos</p>
- </div>
- <i class="glyphicon glyphicon-facetime-video"></i>
- </div>
- </div>
-
- <div class="col-6 col-lg-4 col-xl-3">
- <div class="card stat">
- <div class="card-body">
- <p class="stat-value">{{ serverStats.totalVideoComments }}</p>
- <p class="stat-label" i18n>video comments</p>
- </div>
- <i class="glyphicon glyphicon-comment"></i>
- </div>
- </div>
-
- <div class="col-6 col-lg-4 col-xl-3">
- <div class="card stat">
- <div class="card-body">
- <p class="stat-value">{{ serverStats.totalInstanceFollowers }}</p>
- <p class="stat-label" i18n>followers</p>
- </div>
- <i class="glyphicon glyphicon-retweet"></i>
- </div>
- </div>
-
- <div class="col-6 col-lg-4 col-xl-3">
- <div class="card stat">
- <div class="card-body">
- <p class="stat-value">{{ serverStats.totalInstanceFollowing }}</p>
- <p class="stat-label" i18n>following</p>
- </div>
- <i class="glyphicon glyphicon-retweet"></i>
- </div>
- </div>
- </div>
-</section>
+++ /dev/null
-
-h3 {
- font-size: 1.25rem;
-}
-
-.stat {
- text-align: center;
- margin-bottom: 1em;
- overflow: hidden;
-
- .stat-value {
- font-size: 2.25em;
- line-height: 1em;
- margin: 0;
- }
-
- .stat-label {
- font-size: 1.15em;
- margin: 0;
- }
-
- .glyphicon {
- opacity: 0.12;
- position: absolute;
- left: 16px;
- top: -24px;
-
- &.icon-bottom {
- top: 4px;
- }
-
- &::before {
- font-size: 8em;
- }
- }
-
- .card-body {
- z-index: 2;
- }
-}
+++ /dev/null
-import { Component, OnInit } from '@angular/core'
-import { ServerStats } from '@shared/models/server'
-import { ServerService } from '@app/core'
-
-@Component({
- selector: 'my-instance-statistics',
- templateUrl: './instance-statistics.component.html',
- styleUrls: [ './instance-statistics.component.scss' ]
-})
-export class InstanceStatisticsComponent implements OnInit {
- serverStats: ServerStats = null
-
- constructor (
- private serverService: ServerService
- ) {
- }
-
- ngOnInit () {
- this.serverService.getServerStats()
- .subscribe(res => this.serverStats = res)
- }
-}
+++ /dev/null
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestService } from '../rest'
-import { About } from '../../../../../shared/models/server'
-import { MarkdownService } from '@app/shared/renderer'
-import { peertubeTranslate } from '@shared/models'
-import { ServerService } from '@app/core'
-import { forkJoin } from 'rxjs'
-
-@Injectable()
-export class InstanceService {
- private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
- private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server'
-
- constructor (
- private authHttp: HttpClient,
- private restService: RestService,
- private restExtractor: RestExtractor,
- private markdownService: MarkdownService,
- private serverService: ServerService
- ) {
- }
-
- getAbout () {
- return this.authHttp.get<About>(InstanceService.BASE_CONFIG_URL + '/about')
- .pipe(catchError(res => this.restExtractor.handleError(res)))
- }
-
- contactAdministrator (fromEmail: string, fromName: string, subject: string, message: string) {
- const body = {
- fromEmail,
- fromName,
- subject,
- body: message
- }
-
- return this.authHttp.post(InstanceService.BASE_SERVER_URL + '/contact', body)
- .pipe(catchError(res => this.restExtractor.handleError(res)))
-
- }
-
- async buildHtml (about: About) {
- const html = {
- description: '',
- terms: '',
- codeOfConduct: '',
- moderationInformation: '',
- administrator: '',
- hardwareInformation: ''
- }
-
- for (const key of Object.keys(html)) {
- html[ key ] = await this.markdownService.textMarkdownToHTML(about.instance[ key ])
- }
-
- return html
- }
-
- buildTranslatedLanguages (about: About) {
- return forkJoin([
- this.serverService.getVideoLanguages(),
- this.serverService.getServerLocale()
- ]).pipe(
- map(([ languagesArray, translations ]) => {
- return about.instance.languages
- .map(l => {
- const languageObj = languagesArray.find(la => la.id === l)
-
- return peertubeTranslate(languageObj.label, translations)
- })
- })
- )
- }
-
- buildTranslatedCategories (about: About) {
- return forkJoin([
- this.serverService.getVideoCategories(),
- this.serverService.getServerLocale()
- ]).pipe(
- map(([ categoriesArray, translations ]) => {
- return about.instance.categories
- .map(c => {
- const categoryObj = categoriesArray.find(ca => ca.id === c)
-
- return peertubeTranslate(categoryObj.label, translations)
- })
- })
- )
- }
-}
+++ /dev/null
-
-// This code is not generated
-// See angular/tools/gulp-tasks/cldr/extract.js
-
-const u: any = undefined
-
-function plural (n: number): number {
- const i = Math.floor(Math.abs(n))
- if (i === 0 || i === 1) return 1
- return 5
-}
-
-export default [
- 'oc',
- [['a. m.', 'p. m.'], u, u],
- u,
- [
- ['dg', 'dl', 'dm', 'dc', 'dj', 'dv', 'ds'], ['dg.', 'dl.', 'dm.', 'dc.', 'dj.', 'dv.', 'ds.'],
- ['dimenge', 'diluns', 'dimars', 'dimècres', 'dijòus', 'divendres', 'dissabte'],
- ['dg.', 'dl.', 'dm.', 'dc.', 'dj.', 'dv.', 'ds.']
- ],
- u,
- [
- ['GN', 'FB', 'MÇ', 'AB', 'MA', 'JN', 'JL', 'AG', 'ST', 'OC', 'NV', 'DC'],
- [
- 'de gen.', 'de febr.', 'de març', 'd’abr.', 'de mai', 'de junh', 'de jul.', 'd’ag.',
- 'de set.', 'd’oct.', 'de nov.', 'de dec.'
- ],
- [
- 'de genièr', 'de febrièr', 'de març', 'd’abril', 'de mai', 'de junh', 'de julhet',
- 'd’agòst', 'de setembre', 'd’octòbre', 'de novembre', 'de decembre'
- ]
- ],
- [
- ['GN', 'FB', 'MÇ', 'AB', 'MA', 'JN', 'JL', 'AG', 'ST', 'OC', 'NV', 'DC'],
- [
- 'gen.', 'febr.', 'març', 'abr.', 'mai', 'junh', 'jul.', 'ag.', 'set.', 'oct.', 'nov.',
- 'dec.'
- ],
- [
- 'genièr', 'febrièr', 'març', 'abril', 'mai', 'junh', 'julhet', 'agòst', 'setembre', 'octòbre',
- 'novembre', 'decembre'
- ]
- ],
- [['aC', 'dC'], u, ['abans Jèsus-Crist', 'aprèp Jèsus-Crist']],
- 1,
- [6, 0],
- ['d/M/yy', 'd MMM y', 'd MMMM \'de\' y', 'EEEE, d MMMM \'de\' y'],
- ['H:mm', 'H:mm:ss', 'H:mm:ss z', 'H:mm:ss zzzz'],
- ['{1} {0}', '{1}, {0}', '{1} \'a\' \'les\' {0}', u],
- [',', '.', ';', '%', '+', '-', 'E', '×', '‰', '∞', 'NaN', ':'],
- ['#,##0.###', '#,##0%', '#,##0.00 ¤', '#E0'],
- 'EUR',
- '€',
- 'euro',
- {
- 'ARS': ['$AR', '$'],
- 'AUD': ['$AU', '$'],
- 'BEF': ['FB'],
- 'BMD': ['$BM', '$'],
- 'BND': ['$BN', '$'],
- 'BZD': ['$BZ', '$'],
- 'CAD': ['$CA', '$'],
- 'CLP': ['$CL', '$'],
- 'CNY': [u, 'Â¥'],
- 'COP': ['$CO', '$'],
- 'CYP': ['£CY'],
- 'EGP': [u, '£E'],
- 'FJD': ['$FJ', '$'],
- 'FKP': ['£FK', '£'],
- 'FRF': ['F'],
- 'GBP': ['£GB', '£'],
- 'GIP': ['£GI', '£'],
- 'HKD': [u, '$'],
- 'IEP': ['£IE'],
- 'ILP': ['£IL'],
- 'ITL': ['₤IT'],
- 'JPY': [u, 'Â¥'],
- 'KMF': [u, 'FC'],
- 'LBP': ['£LB', '£L'],
- 'MTP': ['£MT'],
- 'MXN': ['$MX', '$'],
- 'NAD': ['$NA', '$'],
- 'NIO': [u, '$C'],
- 'NZD': ['$NZ', '$'],
- 'RHD': ['$RH'],
- 'RON': [u, 'L'],
- 'RWF': [u, 'FR'],
- 'SBD': ['$SB', '$'],
- 'SGD': ['$SG', '$'],
- 'SRD': ['$SR', '$'],
- 'TOP': [u, '$T'],
- 'TTD': ['$TT', '$'],
- 'TWD': [u, 'NT$'],
- 'USD': ['$US', '$'],
- 'UYU': ['$UY', '$'],
- 'WST': ['$WS'],
- 'XCD': [u, '$'],
- 'XPF': ['FCFP'],
- 'ZMW': [u, 'Kw']
- },
- 'ltr',
- plural
-]
+++ /dev/null
-<div class="sub-menu" [ngClass]="{ 'no-scroll': isModalOpened }">
- <ng-container *ngFor="let menuEntry of menuEntries; index as id">
-
- <a *ngIf="menuEntry.routerLink" [routerLink]="menuEntry.routerLink" routerLinkActive="active" class="title-page title-page-settings">{{ menuEntry.label }}</a>
-
- <div *ngIf="!menuEntry.routerLink" ngbDropdown class="parent-entry"
- #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)">
- <span
- *ngIf="isInSmallView"
- [ngClass]="{ active: !!suffixLabels[menuEntry.label] }"
- (click)="openModal(id)" role="button" class="title-page title-page-settings">
- <ng-container i18n>{{ menuEntry.label }}</ng-container>
- <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
- </span>
-
- <span
- *ngIf="!isInSmallView"
- (mouseenter)="openDropdownOnHover(dropdown)" [ngClass]="{ active: !!suffixLabels[menuEntry.label] }" ngbDropdownAnchor
- (click)="dropdownAnchorClicked(dropdown)" role="button" class="title-page title-page-settings"
- >
- <ng-container i18n>{{ menuEntry.label }}</ng-container>
- <ng-container *ngIf="!!suffixLabels[menuEntry.label]"> - {{ suffixLabels[menuEntry.label] }}</ng-container>
- </span>
-
- <div ngbDropdownMenu>
- <a *ngFor="let menuChild of menuEntry.children" class="dropdown-item" [ngClass]="{ icon: hasIcons }" [routerLink]="menuChild.routerLink">
- <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
-
- {{ menuChild.label }}
- </a>
- </div>
- </div>
- </ng-container>
-</div>
-
-<ng-template #modal let-close="close" let-dismiss="dismiss">
- <div class="modal-body">
- <ng-container *ngFor="let menuEntry of menuEntries; index as id">
- <div [ngClass]="{ hidden: id !== currentMenuEntryIndex }">
- <a *ngFor="let menuChild of menuEntry.children"
- [ngClass]="{ icon: hasIcons }"
- [routerLink]="menuChild.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
- <my-global-icon *ngIf="menuChild.iconName" [iconName]="menuChild.iconName" aria-hidden="true"></my-global-icon>
-
- {{ menuChild.label }}
- </a>
- </div>
- </ng-container>
- </div>
-</ng-template>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.parent-entry {
- span[role=button] {
- cursor: pointer;
- }
-
- a {
- display: block;
- }
-}
-
-::ng-deep .dropdown-toggle::after {
- position: relative;
- top: 2px;
-}
-
-::ng-deep .dropdown-menu {
- margin-top: 0 !important;
-}
-
-.icon {
- @include dropdown-with-icon-item;
-
- top: -1px;
-}
-
-.sub-menu.no-scroll {
- overflow-x: hidden;
-}
-
-.modal-body {
- .hidden {
- display: none;
- }
-
- a {
- @include disable-default-a-behaviour;
-
- color: currentColor;
- box-sizing: border-box;
- display: block;
- font-size: 1.2rem;
- padding: 9px 12px;
- text-align: initial;
- text-transform: unset;
- width: 100%;
-
- &.active {
- color: pvar(--mainBackgroundColor) !important;
- background-color: pvar(--mainHoverColor);
- opacity: .9;
- }
- }
-}
+++ /dev/null
-import {
- Component,
- Input,
- OnDestroy,
- OnInit,
- ViewChild
-} from '@angular/core'
-import { filter, take } from 'rxjs/operators'
-import { NavigationEnd, Router } from '@angular/router'
-import { Subscription } from 'rxjs'
-import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { GlobalIconName } from '@app/shared/images/global-icon.component'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { MenuService } from '@app/core/menu'
-
-export type TopMenuDropdownParam = {
- label: string
- routerLink?: string
-
- children?: {
- label: string
- routerLink: string
-
- iconName?: GlobalIconName
- }[]
-}
-
-@Component({
- selector: 'my-top-menu-dropdown',
- templateUrl: './top-menu-dropdown.component.html',
- styleUrls: [ './top-menu-dropdown.component.scss' ]
-})
-export class TopMenuDropdownComponent implements OnInit, OnDestroy {
- @Input() menuEntries: TopMenuDropdownParam[] = []
-
- @ViewChild('modal', { static: true }) modal: NgbModal
-
- suffixLabels: { [ parentLabel: string ]: string }
- hasIcons = false
- isModalOpened = false
- currentMenuEntryIndex: number
-
- private openedOnHover = false
- private routeSub: Subscription
-
- constructor (
- private router: Router,
- private modalService: NgbModal,
- private screen: ScreenService,
- private menuService: MenuService
- ) { }
-
- get isInSmallView () {
- let marginLeft = 0
- if (this.menuService.isMenuDisplayed) {
- marginLeft = this.menuService.menuWidth
- }
-
- return this.screen.isInSmallView(marginLeft)
- }
-
- ngOnInit () {
- this.updateChildLabels(window.location.pathname)
-
- this.routeSub = this.router.events
- .pipe(filter(event => event instanceof NavigationEnd))
- .subscribe(() => this.updateChildLabels(window.location.pathname))
-
- this.hasIcons = this.menuEntries.some(
- e => e.children && e.children.some(c => !!c.iconName)
- )
- }
-
- ngOnDestroy () {
- if (this.routeSub) this.routeSub.unsubscribe()
- }
-
- openDropdownOnHover (dropdown: NgbDropdown) {
- this.openedOnHover = true
- dropdown.open()
-
- // Menu was closed
- dropdown.openChange
- .pipe(take(1))
- .subscribe(() => this.openedOnHover = false)
- }
-
- dropdownAnchorClicked (dropdown: NgbDropdown) {
- if (this.openedOnHover) {
- this.openedOnHover = false
- return
- }
-
- return dropdown.toggle()
- }
-
- closeDropdownIfHovered (dropdown: NgbDropdown) {
- if (this.openedOnHover === false) return
-
- dropdown.close()
- this.openedOnHover = false
- }
-
- openModal (index: number) {
- this.currentMenuEntryIndex = index
- this.isModalOpened = true
-
- this.modalService.open(this.modal, {
- centered: true,
- beforeDismiss: async () => {
- this.onModalDismiss()
- return true
- }
- })
- }
-
- onModalDismiss () {
- this.isModalOpened = false
- }
-
- dismissOtherModals () {
- this.modalService.dismissAll()
- }
-
- private updateChildLabels (path: string) {
- this.suffixLabels = {}
-
- for (const entry of this.menuEntries) {
- if (!entry.children) continue
-
- for (const child of entry.children) {
- if (path.startsWith(child.routerLink)) {
- this.suffixLabels[entry.label] = child.label
- }
- }
- }
- }
-}
+++ /dev/null
-export const POP_STATE_MODAL_DISMISS = 'pop state dismiss'
+++ /dev/null
-<ng-template #tooltipTemplate>
- <p *ngIf="preHtmlTemplate">
- <ng-template *ngTemplateOutlet="preHtmlTemplate"></ng-template>
- </p>
-
- <ng-container *ngIf="preHtmlTemplate && (customHtmlTemplate || mainHtml || postHtmlTemplate)">
- <br /><br />
- </ng-container>
-
- <p *ngIf="customHtmlTemplate">
- <ng-template *ngTemplateOutlet="customHtmlTemplate"></ng-template>
- </p>
-
- <p *ngIf="mainHtml" [innerHTML]="mainHtml"></p>
-
- <ng-container *ngIf="(customHtmlTemplate || mainHtml) && postHtmlTemplate">
- <br /><br />
- </ng-container>
-
- <p *ngIf="postHtmlTemplate">
- <ng-template *ngTemplateOutlet="postHtmlTemplate"></ng-template>
- </p>
-</ng-template>
-
-<span
- role="button"
- class="help-tooltip-button"
- container="body"
- title="Get help"
- i18n-title
- popoverClass="help-popover"
- [attr.aria-pressed]="isPopoverOpened"
- [ngbPopover]="tooltipTemplate"
- [placement]="tooltipPlacement"
- autoClose="outside"
- (onHidden)="onPopoverHidden()"
- (onShown)="onPopoverShown()"
->
- <my-global-icon iconName="help"></my-global-icon>
-</span>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.help-tooltip-button {
- cursor: pointer;
- border: none;
-
- my-global-icon {
- width: 17px;
- position: relative;
- top: -2px;
- margin: 5px;
-
- @include apply-svg-color(pvar(--mainForegroundColor))
- }
-}
-
-::ng-deep {
- .help-popover {
- z-index: z(help-popover) !important;
- max-width: 300px;
-
- .popover-body {
- font-family: $main-fonts;
- text-align: left;
- padding: 10px;
- font-size: 13px;
- background-color: pvar(--mainBackgroundColor);
- color: pvar(--mainForegroundColor);
- border-radius: 3px;
-
- p {
- margin-bottom: 0;
- }
-
- ul {
- padding-left: 20px;
- margin-bottom: 0;
- }
- }
- }
-}
+++ /dev/null
-import { AfterContentInit, Component, ContentChildren, Input, OnChanges, OnInit, QueryList, TemplateRef } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { MarkdownService } from '@app/shared/renderer'
-import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
-
-@Component({
- selector: 'my-help',
- styleUrls: [ './help.component.scss' ],
- templateUrl: './help.component.html'
-})
-
-export class HelpComponent implements OnInit, OnChanges, AfterContentInit {
- @Input() helpType: 'custom' | 'markdownText' | 'markdownEnhanced' = 'custom'
- @Input() tooltipPlacement = 'right auto'
-
- @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'preHtml' | 'customHtml' | 'postHtml'>>
-
- isPopoverOpened = false
- mainHtml = ''
-
- preHtmlTemplate: TemplateRef<any>
- customHtmlTemplate: TemplateRef<any>
- postHtmlTemplate: TemplateRef<any>
-
- constructor (private i18n: I18n) { }
-
- ngOnInit () {
- this.init()
- }
-
- ngAfterContentInit () {
- {
- const t = this.templates.find(t => t.name === 'preHtml')
- if (t) this.preHtmlTemplate = t.template
- }
-
- {
- const t = this.templates.find(t => t.name === 'customHtml')
- if (t) this.customHtmlTemplate = t.template
- }
-
- {
- const t = this.templates.find(t => t.name === 'postHtml')
- if (t) this.postHtmlTemplate = t.template
- }
- }
-
- ngOnChanges () {
- this.init()
- }
-
- onPopoverHidden () {
- this.isPopoverOpened = false
- }
-
- onPopoverShown () {
- this.isPopoverOpened = true
- }
-
- private init () {
- if (this.helpType === 'markdownText') {
- this.mainHtml = this.formatMarkdownSupport(MarkdownService.TEXT_RULES)
- return
- }
-
- if (this.helpType === 'markdownEnhanced') {
- this.mainHtml = this.formatMarkdownSupport(MarkdownService.ENHANCED_RULES)
- return
- }
- }
-
- private formatMarkdownSupport (rules: string[]) {
- // tslint:disable:max-line-length
- return this.i18n('<a href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noopener noreferrer">Markdown</a> compatible that supports:') +
- this.createMarkdownList(rules)
- }
-
- private createMarkdownList (rules: string[]) {
- const rulesToText = {
- 'emphasis': this.i18n('Emphasis'),
- 'link': this.i18n('Links'),
- 'newline': this.i18n('New lines'),
- 'list': this.i18n('Lists'),
- 'image': this.i18n('Images')
- }
-
- const bullets = rules.map(r => rulesToText[r])
- .filter(text => text)
- .map(text => '<li>' + text + '</li>')
- .join('')
-
- return '<ul>' + bullets + '</ul>'
- }
-}
+++ /dev/null
-<div #itemsParent class="d-flex align-items-center text-nowrap w-100 list-overflow-parent">
- <span [id]="getId(id)" #itemsRendered *ngFor="let item of items; index as id">
- <ng-container *ngTemplateOutlet="itemTemplate; context: {item: item}"></ng-container>
- </span>
-
- <ng-container *ngIf="isMenuDisplayed()">
- <button *ngIf="isInMobileView" class="btn btn-outline-secondary btn-sm list-overflow-menu" (click)="toggleModal()">
- <span class="glyphicon glyphicon-chevron-down"></span>
- </button>
-
- <div *ngIf="!isInMobileView" class="list-overflow-menu" ngbDropdown container="body" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)" (mouseenter)="openDropdownOnHover(dropdown)">
- <button class="btn btn-outline-secondary btn-sm" [ngClass]="{ routeActive: active }"
- ngbDropdownAnchor (click)="dropdownAnchorClicked(dropdown)" role="button"
- >
- <span class="glyphicon glyphicon-chevron-down"></span>
- </button>
-
- <div ngbDropdownMenu>
- <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
- [routerLink]="item.routerLink" routerLinkActive="active" class="dropdown-item">
- {{ item.label }}
- </a>
- </div>
- </div>
- </ng-container>
-</div >
-
-<ng-template #modal let-close="close" let-dismiss="dismiss">
- <div class="modal-body">
- <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
- [routerLink]="item.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
- {{ item.label }}
- </a>
- </div>
-</ng-template>
+++ /dev/null
-@import '_mixins';
-
-:host {
- width: 100%;
-}
-
-.list-overflow-parent {
- overflow: hidden;
-}
-
-.list-overflow-menu {
- position: absolute;
- right: 25px;
-}
-
-button {
- width: 30px;
- border: none;
-
- &::after {
- display: none;
- }
-
- &.routeActive {
- &::after {
- display: inherit;
- border: 2px solid pvar(--mainColor);
- position: relative;
- right: 95%;
- top: 50%;
- }
- }
-}
-
-::ng-deep .dropdown-menu {
- margin-top: 0 !important;
- position: static;
- right: auto;
- bottom: auto
-}
-
-.modal-body {
- a {
- @include disable-default-a-behaviour;
-
- color: currentColor;
- box-sizing: border-box;
- display: block;
- font-size: 1.2rem;
- padding: 9px 12px;
- text-align: initial;
- text-transform: unset;
- width: 100%;
-
- &.active {
- color: pvar(--mainBackgroundColor) !important;
- background-color: pvar(--mainHoverColor);
- opacity: .9;
- }
- }
-}
+++ /dev/null
-import {
- AfterViewInit,
- ChangeDetectionStrategy,
- ChangeDetectorRef,
- Component,
- ElementRef,
- HostListener,
- Input,
- QueryList,
- TemplateRef,
- ViewChild,
- ViewChildren
-} from '@angular/core'
-import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { lowerFirst, uniqueId } from 'lodash-es'
-import { ScreenService } from './screen.service'
-import { take } from 'rxjs/operators'
-
-export interface ListOverflowItem {
- label: string
- routerLink: string | any[]
-}
-
-@Component({
- selector: 'list-overflow',
- templateUrl: './list-overflow.component.html',
- styleUrls: [ './list-overflow.component.scss' ],
- changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class ListOverflowComponent<T extends ListOverflowItem> implements AfterViewInit {
- @Input() items: T[]
- @Input() itemTemplate: TemplateRef<{item: T}>
-
- @ViewChild('modal', { static: true }) modal: ElementRef
- @ViewChild('itemsParent', { static: true }) parent: ElementRef<HTMLDivElement>
- @ViewChildren('itemsRendered') itemsRendered: QueryList<ElementRef>
-
- showItemsUntilIndexExcluded: number
- active = false
- isInTouchScreen = false
- isInMobileView = false
-
- private openedOnHover = false
-
- constructor (
- private cdr: ChangeDetectorRef,
- private modalService: NgbModal,
- private screenService: ScreenService
- ) {}
-
- ngAfterViewInit () {
- setTimeout(() => this.onWindowResize(), 0)
- }
-
- isMenuDisplayed () {
- return !!this.showItemsUntilIndexExcluded
- }
-
- @HostListener('window:resize')
- onWindowResize () {
- this.isInTouchScreen = !!this.screenService.isInTouchScreen()
- this.isInMobileView = !!this.screenService.isInMobileView()
-
- const parentWidth = this.parent.nativeElement.getBoundingClientRect().width
- let showItemsUntilIndexExcluded: number
- let accWidth = 0
-
- for (const [index, el] of this.itemsRendered.toArray().entries()) {
- accWidth += el.nativeElement.getBoundingClientRect().width
- if (showItemsUntilIndexExcluded === undefined) {
- showItemsUntilIndexExcluded = (parentWidth < accWidth) ? index : undefined
- }
-
- const e = document.getElementById(this.getId(index))
- const shouldBeVisible = showItemsUntilIndexExcluded ? index < showItemsUntilIndexExcluded : true
- e.style.visibility = shouldBeVisible ? 'inherit' : 'hidden'
- }
-
- this.showItemsUntilIndexExcluded = showItemsUntilIndexExcluded
- this.cdr.markForCheck()
- }
-
- openDropdownOnHover (dropdown: NgbDropdown) {
- this.openedOnHover = true
- dropdown.open()
-
- // Menu was closed
- dropdown.openChange
- .pipe(take(1))
- .subscribe(() => this.openedOnHover = false)
- }
-
- dropdownAnchorClicked (dropdown: NgbDropdown) {
- if (this.openedOnHover) {
- this.openedOnHover = false
- return
- }
-
- return dropdown.toggle()
- }
-
- closeDropdownIfHovered (dropdown: NgbDropdown) {
- if (this.openedOnHover === false) return
-
- dropdown.close()
- this.openedOnHover = false
- }
-
- toggleModal () {
- this.modalService.open(this.modal, { centered: true })
- }
-
- dismissOtherModals () {
- this.modalService.dismissAll()
- }
-
- getId (id: number | string = uniqueId()): string {
- return lowerFirst(this.constructor.name) + '_' + id
- }
-}
+++ /dev/null
-<div *ngIf="loading">
- <div class="loader">
- <div></div>
- <div></div>
- <div></div>
- <div></div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-// Thanks to https://loading.io/css/ (CC0 License)
-
-.loader {
- display: inline-block;
- position: relative;
- width: 50px;
- height: 50px;
-}
-
-.loader div {
- box-sizing: border-box;
- display: block;
- position: absolute;
- width: 44px;
- height: 44px;
- margin: 6px;
- border: 4px solid;
- border-radius: 50%;
- animation: loader 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
- border-color: #999999 transparent transparent transparent;
-}
-
-.loader div:nth-child(1) {
- animation-delay: -0.45s;
-}
-
-.loader div:nth-child(2) {
- animation-delay: -0.3s;
-}
-
-.loader div:nth-child(3) {
- animation-delay: -0.15s;
-}
-
-@keyframes loader {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
-}
+++ /dev/null
-import { Component, Input } from '@angular/core'
-
-@Component({
- selector: 'my-loader',
- styleUrls: [ './loader.component.scss' ],
- templateUrl: './loader.component.html'
-})
-export class LoaderComponent {
- @Input() loading: boolean
-}
+++ /dev/null
-// Thanks: https://github.com/capaj/localstorage-polyfill
-
-const valuesMap = new Map()
-
-function proxify (instance: MemoryStorage) {
- return new Proxy(instance, {
- set: function (obj, prop: string | number, value) {
- if (MemoryStorage.prototype.hasOwnProperty(prop)) {
- instance[prop] = value
- } else {
- instance.setItem(prop, value)
- }
- return true
- },
- get: function (target, name: string | number) {
- if (MemoryStorage.prototype.hasOwnProperty(name)) {
- return instance[name]
- }
- if (valuesMap.has(name)) {
- return instance.getItem(name)
- }
- }
- })
-}
-
-class MemoryStorage {
- [key: string]: any
- [index: number]: string
-
- getItem (key: any) {
- const stringKey = String(key)
- if (valuesMap.has(key)) {
- return String(valuesMap.get(stringKey))
- }
-
- return null
- }
-
- setItem (key: any, val: any) {
- valuesMap.set(String(key), String(val))
- }
-
- removeItem (key: any) {
- valuesMap.delete(key)
- }
-
- clear () {
- valuesMap.clear()
- }
-
- key (i: any) {
- if (arguments.length === 0) {
- throw new TypeError('Failed to execute "key" on "Storage": 1 argument required, but only 0 present.')
- }
-
- const arr = Array.from(valuesMap.keys())
- return arr[i]
- }
-
- get length () {
- return valuesMap.size
- }
-}
-
-let peertubeLocalStorage: Storage
-let peertubeSessionStorage: Storage
-try {
- peertubeLocalStorage = localStorage
- peertubeSessionStorage = sessionStorage
-} catch (err) {
- const instanceLocalStorage = new MemoryStorage()
- const instanceSessionStorage = new MemoryStorage()
-
- peertubeLocalStorage = proxify(instanceLocalStorage)
- peertubeSessionStorage = proxify(instanceSessionStorage)
-}
-
-export {
- peertubeLocalStorage,
- peertubeSessionStorage
-}
+++ /dev/null
-import { Injectable } from '@angular/core'
-
-@Injectable()
-export class ScreenService {
- private windowInnerWidth: number
- private lastFunctionCallTime: number
- private cacheForMs = 500
-
- constructor () {
- this.refreshWindowInnerWidth()
- }
-
- isInSmallView (marginLeft = 0) {
- if (marginLeft > 0) {
- const contentWidth = this.getWindowInnerWidth() - marginLeft
- return contentWidth < 800
- }
-
- return this.getWindowInnerWidth() < 800
- }
-
- isInMediumView () {
- return this.getWindowInnerWidth() < 1100
- }
-
- isInMobileView () {
- return this.getWindowInnerWidth() < 500
- }
-
- isInTouchScreen () {
- return 'ontouchstart' in window || navigator.msMaxTouchPoints
- }
-
- getNumberOfAvailableMiniatures () {
- const screenWidth = this.getWindowInnerWidth()
-
- let numberOfVideos = 1
-
- if (screenWidth > 1850) numberOfVideos = 7
- else if (screenWidth > 1600) numberOfVideos = 6
- else if (screenWidth > 1370) numberOfVideos = 5
- else if (screenWidth > 1100) numberOfVideos = 4
- else if (screenWidth > 850) numberOfVideos = 3
-
- return numberOfVideos
- }
-
- // Cache window inner width, because it's an expensive call
- getWindowInnerWidth () {
- if (this.cacheWindowInnerWidthExpired()) this.refreshWindowInnerWidth()
-
- return this.windowInnerWidth
- }
-
- private refreshWindowInnerWidth () {
- this.lastFunctionCallTime = new Date().getTime()
-
- this.windowInnerWidth = window.innerWidth
- }
-
- private cacheWindowInnerWidthExpired () {
- if (!this.lastFunctionCallTime) return true
-
- return new Date().getTime() > (this.lastFunctionCallTime + this.cacheForMs)
- }
-}
+++ /dev/null
-<div class="root" *ngIf="loading">
- <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div>
-</div>
+++ /dev/null
-import { Component, Input } from '@angular/core'
-
-@Component({
- selector: 'my-small-loader',
- styleUrls: [ ],
- templateUrl: './small-loader.component.html'
-})
-
-export class SmallLoaderComponent {
- @Input() loading: boolean
-}
+++ /dev/null
-import { Injectable } from '@angular/core'
-import { Observable, Subject } from 'rxjs'
-import {
- peertubeLocalStorage,
- peertubeSessionStorage
-} from './peertube-web-storage'
-import { filter } from 'rxjs/operators'
-
-abstract class StorageService {
- protected instance: Storage
- static storageSub = new Subject<string>()
-
- watch (keys?: string[]): Observable<string> {
- return StorageService.storageSub.asObservable().pipe(filter(val => keys ? keys.includes(val) : true))
- }
-
- getItem (key: string) {
- return this.instance.getItem(key)
- }
-
- setItem (key: string, data: any, notifyOfUpdate = true) {
- this.instance.setItem(key, data)
- if (notifyOfUpdate) StorageService.storageSub.next(key)
- }
-
- removeItem (key: string, notifyOfUpdate = true) {
- this.instance.removeItem(key)
- if (notifyOfUpdate) StorageService.storageSub.next(key)
- }
-}
-
-@Injectable()
-export class LocalStorageService extends StorageService {
- protected instance: Storage = peertubeLocalStorage
-}
-
-@Injectable()
-export class SessionStorageService extends StorageService {
- protected instance: Storage = peertubeSessionStorage
-}
+++ /dev/null
-import { DatePipe } from '@angular/common'
-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 populateAsyncUserVideoChannels (authService: AuthService, channel: { id: number, label: string, support?: string }[]) {
- return new Promise(res => {
- authService.userInformationLoaded
- .subscribe(
- () => {
- const user = authService.getUser()
- if (!user) return
-
- const videoChannels = user.videoChannels
- if (Array.isArray(videoChannels) === false) return
-
- videoChannels.forEach(c => channel.push({ id: c.id, label: c.displayName, support: c.support }))
-
- return res()
- }
- )
- })
-}
-
-function getAbsoluteAPIUrl () {
- let absoluteAPIUrl = environment.apiUrl
- if (!absoluteAPIUrl) {
- // The API is on the same domain
- absoluteAPIUrl = window.location.origin
- }
-
- return absoluteAPIUrl
-}
-
-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)
-}
-
-function objectToUrlEncoded (obj: any) {
- const str: string[] = []
- for (const key of Object.keys(obj)) {
- str.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]))
- }
-
- return str.join('&')
-}
-
-// 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 () {
- window.scroll(0, 0)
-}
-
-// Thanks: https://github.com/uupaa/dynamic-import-polyfill
-function importModule (path: string) {
- return new Promise((resolve, reject) => {
- const vector = '$importModule$' + Math.random().toString(32).slice(2)
- const script = document.createElement('script')
-
- const destructor = () => {
- delete window[ vector ]
- script.onerror = null
- script.onload = null
- script.remove()
- URL.revokeObjectURL(script.src)
- script.src = ''
- }
-
- script.defer = true
- script.type = 'module'
-
- script.onerror = () => {
- reject(new Error(`Failed to import: ${path}`))
- destructor()
- }
- script.onload = () => {
- resolve(window[ vector ])
- destructor()
- }
- const absURL = (environment.apiUrl || window.location.origin) + path
- const loader = `import * as m from "${absURL}"; window.${vector} = m;` // export Module
- const blob = new Blob([ loader ], { type: 'text/javascript' })
- script.src = URL.createObjectURL(blob)
-
- document.head.appendChild(script)
- })
-}
-
-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 {
- sortBy,
- durationToString,
- lineFeedToHtml,
- objectToUrlEncoded,
- getParameterByName,
- populateAsyncUserVideoChannels,
- getAbsoluteAPIUrl,
- dateToHuman,
- immutableAssign,
- objectToFormData,
- objectLineFeedToHtml,
- removeElementFromArray,
- importModule,
- scrollToTop,
- isInViewport,
- isXPercentInViewport
-}
+++ /dev/null
-export * from './user-ban-modal.component'
-export * from './user-moderation-dropdown.component'
+++ /dev/null
-<ng-template #modal>
- <div class="modal-header">
- <h4 i18n class="modal-title">Ban</h4>
-
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
- </div>
-
- <div class="modal-body">
- <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
- <div class="form-group">
- <textarea
- i18n-placeholder placeholder="Reason..." formControlName="reason"
- class="form-control" [ngClass]="{ 'input-error': formErrors['reason'] }"
- ></textarea>
- <div *ngIf="formErrors.reason" class="form-error">
- {{ formErrors.reason }}
- </div>
- </div>
-
- <div i18n>
- A banned user will no longer be able to login.
- </div>
-
- <div class="form-group inputs">
- <input
- type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
- (click)="hide()" (key.enter)="hide()"
- >
-
- <input
- type="submit" i18n-value value="Ban this user" class="action-button-submit"
- [disabled]="!form.valid"
- >
- </div>
- </form>
- </div>
-
-</ng-template>
+++ /dev/null
-@import 'variables';
-@import 'mixins';
-
-textarea {
- @include peertube-textarea(100%, 60px);
-}
+++ /dev/null
-import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { FormReactive, UserValidatorsService } from '@app/shared/forms'
-import { UserService } from '@app/shared/users'
-import { User } from '../../../../../shared'
-
-@Component({
- selector: 'my-user-ban-modal',
- templateUrl: './user-ban-modal.component.html',
- styleUrls: [ './user-ban-modal.component.scss' ]
-})
-export class UserBanModalComponent extends FormReactive implements OnInit {
- @ViewChild('modal', { static: true }) modal: NgbModal
- @Output() userBanned = new EventEmitter<User | User[]>()
-
- private usersToBan: User | User[]
- private openedModal: NgbModalRef
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private modalService: NgbModal,
- private notifier: Notifier,
- private userService: UserService,
- private userValidatorsService: UserValidatorsService,
- private i18n: I18n
- ) {
- super()
- }
-
- ngOnInit () {
- this.buildForm({
- reason: this.userValidatorsService.USER_BAN_REASON
- })
- }
-
- openModal (user: User | User[]) {
- this.usersToBan = user
- this.openedModal = this.modalService.open(this.modal, { centered: true })
- }
-
- hide () {
- this.usersToBan = undefined
- this.openedModal.close()
- }
-
- async banUser () {
- const reason = this.form.value['reason'] || undefined
-
- this.userService.banUsers(this.usersToBan, reason)
- .subscribe(
- () => {
- const message = Array.isArray(this.usersToBan)
- ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length })
- : this.i18n('User {{username}} banned.', { username: this.usersToBan.username })
-
- this.notifier.success(message)
-
- this.userBanned.emit(this.usersToBan)
- this.hide()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
-}
+++ /dev/null
-<ng-container *ngIf="userActions.length !== 0">
- <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
-
- <my-action-dropdown
- [actions]="userActions" [entry]="{ user: user, account: account }"
- [buttonSize]="buttonSize" [placement]="placement" [label]="label"
- [container]="container"
- ></my-action-dropdown>
-</ng-container>
+++ /dev/null
-import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { DropdownAction } from '@app/shared/buttons/action-dropdown.component'
-import { UserBanModalComponent } from '@app/shared/moderation/user-ban-modal.component'
-import { UserService } from '@app/shared/users'
-import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
-import { User, UserRight } from '../../../../../shared/models/users'
-import { Account } from '@app/shared/account/account.model'
-import { BlocklistService } from '@app/shared/blocklist'
-import { ServerConfig, BulkRemoveCommentsOfBody } from '@shared/models'
-import { BulkService } from '../bulk/bulk.service'
-
-@Component({
- selector: 'my-user-moderation-dropdown',
- templateUrl: './user-moderation-dropdown.component.html'
-})
-export class UserModerationDropdownComponent implements OnInit, OnChanges {
- @ViewChild('userBanModal') userBanModal: UserBanModalComponent
-
- @Input() user: User
- @Input() account: Account
-
- @Input() buttonSize: 'normal' | 'small' = 'normal'
- @Input() placement = 'left-top left-bottom auto'
- @Input() label: string
- @Input() container: 'body' | undefined = undefined
-
- @Output() userChanged = new EventEmitter()
- @Output() userDeleted = new EventEmitter()
-
- userActions: DropdownAction<{ user: User, account: Account }>[][] = []
-
- private serverConfig: ServerConfig
-
- constructor (
- private authService: AuthService,
- private notifier: Notifier,
- private confirmService: ConfirmService,
- private serverService: ServerService,
- private userService: UserService,
- private blocklistService: BlocklistService,
- private bulkService: BulkService,
- private i18n: I18n
- ) { }
-
- get requiresEmailVerification () {
- return this.serverConfig.signup.requiresEmailVerification
- }
-
- ngOnInit (): void {
- this.serverConfig = this.serverService.getTmpConfig()
- this.serverService.getConfig()
- .subscribe(config => this.serverConfig = config)
- }
-
- ngOnChanges () {
- this.buildActions()
- }
-
- openBanUserModal (user: User) {
- if (user.username === 'root') {
- this.notifier.error(this.i18n('You cannot ban root.'))
- return
- }
-
- this.userBanModal.openModal(user)
- }
-
- onUserBanned () {
- this.userChanged.emit()
- }
-
- async unbanUser (user: User) {
- const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username })
- const res = await this.confirmService.confirm(message, this.i18n('Unban'))
- if (res === false) return
-
- this.userService.unbanUsers(user)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('User {{username}} unbanned.', { username: user.username }))
-
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- async removeUser (user: User) {
- if (user.username === 'root') {
- this.notifier.error(this.i18n('You cannot delete root.'))
- return
- }
-
- const message = this.i18n('If you remove this user, you will not be able to create another with the same username!')
- const res = await this.confirmService.confirm(message, this.i18n('Delete'))
- if (res === false) return
-
- this.userService.removeUser(user).subscribe(
- () => {
- this.notifier.success(this.i18n('User {{username}} deleted.', { username: user.username }))
- this.userDeleted.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- setEmailAsVerified (user: User) {
- this.userService.updateUser(user.id, { emailVerified: true }).subscribe(
- () => {
- this.notifier.success(this.i18n('User {{username}} email set as verified', { username: user.username }))
-
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- blockAccountByUser (account: Account) {
- this.blocklistService.blockAccountByUser(account)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Account {{nameWithHost}} muted.', { nameWithHost: account.nameWithHost }))
-
- this.account.mutedByUser = true
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- unblockAccountByUser (account: Account) {
- this.blocklistService.unblockAccountByUser(account)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: account.nameWithHost }))
-
- this.account.mutedByUser = false
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- blockServerByUser (host: string) {
- this.blocklistService.blockServerByUser(host)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Instance {{host}} muted.', { host }))
-
- this.account.mutedServerByUser = true
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- unblockServerByUser (host: string) {
- this.blocklistService.unblockServerByUser(host)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Instance {{host}} unmuted.', { host }))
-
- this.account.mutedServerByUser = false
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- blockAccountByInstance (account: Account) {
- this.blocklistService.blockAccountByInstance(account)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost }))
-
- this.account.mutedByInstance = true
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- unblockAccountByInstance (account: Account) {
- this.blocklistService.unblockAccountByInstance(account)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted by the instance.', { nameWithHost: account.nameWithHost }))
-
- this.account.mutedByInstance = false
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- blockServerByInstance (host: string) {
- this.blocklistService.blockServerByInstance(host)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Instance {{host}} muted by the instance.', { host }))
-
- this.account.mutedServerByInstance = true
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- unblockServerByInstance (host: string) {
- this.blocklistService.unblockServerByInstance(host)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Instance {{host}} unmuted by the instance.', { host }))
-
- this.account.mutedServerByInstance = false
- this.userChanged.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- async bulkRemoveCommentsOf (body: BulkRemoveCommentsOfBody) {
- const message = this.i18n('Are you sure you want to remove all the comments of this account?')
- const res = await this.confirmService.confirm(message, this.i18n('Delete account comments'))
- if (res === false) return
-
- this.bulkService.removeCommentsOf(body)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Will remove comments of this account (may take several minutes).'))
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- getRouterUserEditLink (user: User) {
- return [ '/admin', 'users', 'update', user.id ]
- }
-
- private buildActions () {
- this.userActions = []
-
- if (this.authService.isLoggedIn()) {
- const authUser = this.authService.getUser()
-
- if (this.user && authUser.id === this.user.id) return
-
- if (this.user && authUser.hasRight(UserRight.MANAGE_USERS) && authUser.canManage(this.user)) {
- this.userActions.push([
- {
- label: this.i18n('Edit user'),
- description: this.i18n('Change quota, role, and more.'),
- linkBuilder: ({ user }) => this.getRouterUserEditLink(user)
- },
- {
- label: this.i18n('Delete user'),
- description: this.i18n('Videos will be deleted, comments will be tombstoned.'),
- handler: ({ user }) => this.removeUser(user)
- },
- {
- label: this.i18n('Ban'),
- description: this.i18n('User won\'t be able to login anymore, but videos and comments will be kept as is.'),
- handler: ({ user }) => this.openBanUserModal(user),
- isDisplayed: ({ user }) => !user.blocked
- },
- {
- label: this.i18n('Unban user'),
- description: this.i18n('Allow the user to login and create videos/comments again'),
- handler: ({ user }) => this.unbanUser(user),
- isDisplayed: ({ user }) => user.blocked
- },
- {
- label: this.i18n('Set Email as Verified'),
- handler: ({ user }) => this.setEmailAsVerified(user),
- isDisplayed: ({ user }) => this.requiresEmailVerification && !user.blocked && user.emailVerified === false
- }
- ])
- }
-
- // Actions on accounts/servers
- if (this.account) {
- // User actions
- this.userActions.push([
- {
- label: this.i18n('Mute this account'),
- description: this.i18n('Hide any content from that user for you.'),
- isDisplayed: ({ account }) => account.mutedByUser === false,
- handler: ({ account }) => this.blockAccountByUser(account)
- },
- {
- label: this.i18n('Unmute this account'),
- description: this.i18n('Show back content from that user for you.'),
- isDisplayed: ({ account }) => account.mutedByUser === true,
- handler: ({ account }) => this.unblockAccountByUser(account)
- },
- {
- label: this.i18n('Mute the instance'),
- description: this.i18n('Hide any content from that instance for you.'),
- isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false,
- handler: ({ account }) => this.blockServerByUser(account.host)
- },
- {
- label: this.i18n('Unmute the instance'),
- description: this.i18n('Show back content from that instance for you.'),
- isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true,
- handler: ({ account }) => this.unblockServerByUser(account.host)
- },
- {
- label: this.i18n('Remove comments from your videos'),
- description: this.i18n('Remove comments of this account from your videos.'),
- handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'my-videos' })
- }
- ])
-
- let instanceActions: DropdownAction<{ user: User, account: Account }>[] = []
-
- // Instance actions on account blocklists
- if (authUser.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) {
- instanceActions = instanceActions.concat([
- {
- label: this.i18n('Mute this account by your instance'),
- description: this.i18n('Hide any content from that user for you, your instance and its users.'),
- isDisplayed: ({ account }) => account.mutedByInstance === false,
- handler: ({ account }) => this.blockAccountByInstance(account)
- },
- {
- label: this.i18n('Unmute this account by your instance'),
- description: this.i18n('Show back content from that user for you, your instance and its users.'),
- isDisplayed: ({ account }) => account.mutedByInstance === true,
- handler: ({ account }) => this.unblockAccountByInstance(account)
- }
- ])
- }
-
- // Instance actions on server blocklists
- if (authUser.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) {
- instanceActions = instanceActions.concat([
- {
- label: this.i18n('Mute the instance by your instance'),
- description: this.i18n('Hide any content from that instance for you, your instance and its users.'),
- isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false,
- handler: ({ account }) => this.blockServerByInstance(account.host)
- },
- {
- label: this.i18n('Unmute the instance by your instance'),
- description: this.i18n('Show back content from that instance for you, your instance and its users.'),
- isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true,
- handler: ({ account }) => this.unblockServerByInstance(account.host)
- }
- ])
- }
-
- if (authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) {
- instanceActions = instanceActions.concat([
- {
- label: this.i18n('Remove comments from your instance'),
- description: this.i18n('Remove comments of this account from your instance.'),
- handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'instance' })
- }
- ])
- }
-
- if (instanceActions.length !== 0) {
- this.userActions.push(instanceActions)
- }
- }
- }
- }
-}
+++ /dev/null
-export * from './overview.service'
+++ /dev/null
-import { catchError, map, switchMap, tap } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { forkJoin, Observable, of } from 'rxjs'
-import { VideosOverview as VideosOverviewServer, peertubeTranslate } from '../../../../../shared/models'
-import { environment } from '../../../environments/environment'
-import { RestExtractor } from '../rest/rest-extractor.service'
-import { VideosOverview } from '@app/shared/overview/videos-overview.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { ServerService } from '@app/core'
-import { immutableAssign } from '@app/shared/misc/utils'
-
-@Injectable()
-export class OverviewService {
- static BASE_OVERVIEW_URL = environment.apiUrl + '/api/v1/overviews/'
-
- constructor (
- private authHttp: HttpClient,
- private restExtractor: RestExtractor,
- private videosService: VideoService,
- private serverService: ServerService
- ) {}
-
- getVideosOverview (page: number): Observable<VideosOverview> {
- let params = new HttpParams()
- params = params.append('page', page + '')
-
- return this.authHttp
- .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params })
- .pipe(
- switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable<VideosOverview> {
- const observables: Observable<any>[] = []
- const videosOverviewResult: VideosOverview = {
- tags: [],
- categories: [],
- channels: []
- }
-
- // Build videos objects
- for (const key of Object.keys(serverVideosOverview)) {
- for (const object of serverVideosOverview[ key ]) {
- observables.push(
- of(object.videos)
- .pipe(
- switchMap(videos => this.videosService.extractVideos({ total: 0, data: videos })),
- map(result => result.data),
- tap(videos => {
- videosOverviewResult[key].push(immutableAssign(object, { videos }))
- })
- )
- )
- }
- }
-
- if (observables.length === 0) return of(videosOverviewResult)
-
- return forkJoin(observables)
- .pipe(
- // Translate categories
- switchMap(() => {
- return this.serverService.getServerLocale()
- .pipe(
- tap(translations => {
- for (const c of videosOverviewResult.categories) {
- c.category.label = peertubeTranslate(c.category.label, translations)
- }
- })
- )
- }),
- map(() => videosOverviewResult)
- )
- }
-
-}
+++ /dev/null
-import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '../../../../../shared/models'
-import { Video } from '@app/shared/video/video.model'
-
-export class VideosOverview implements VideosOverviewServer {
- channels: {
- channel: VideoChannelSummary
- videos: Video[]
- }[]
-
- categories: {
- category: VideoConstant<number>
- videos: Video[]
- }[]
-
- tags: {
- tag: string
- videos: Video[]
- }[]
- [key: string]: any
-}
+++ /dev/null
-import { Injectable } from '@angular/core'
-import { LinkifierService } from '@app/shared/renderer/linkifier.service'
-
-@Injectable()
-export class HtmlRendererService {
-
- constructor (private linkifier: LinkifierService) {
-
- }
-
- async toSafeHtml (text: string) {
- // FIXME: import('..') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
- const sanitizeHtml: typeof import ('sanitize-html') = (await import('sanitize-html') as any).default
-
- // Convert possible markdown to html
- const html = this.linkifier.linkify(text)
-
- return sanitizeHtml(html, {
- allowedTags: [ 'a', 'p', 'span', 'br', 'strong', 'em', 'ul', 'ol', 'li' ],
- allowedSchemes: [ 'http', 'https' ],
- allowedAttributes: {
- 'a': [ 'href', 'class', 'target', 'rel' ]
- },
- transformTags: {
- a: (tagName, attribs) => {
- let rel = 'noopener noreferrer'
- if (attribs.rel === 'me') rel += ' me'
-
- return {
- tagName,
- attribs: Object.assign(attribs, {
- target: '_blank',
- rel
- })
- }
- }
- }
- })
- }
-}
+++ /dev/null
-export * from './html-renderer.service'
-export * from './linkifier.service'
-export * from './markdown.service'
+++ /dev/null
-import { Injectable } from '@angular/core'
-import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
-import * as linkify from 'linkifyjs'
-import linkifyHtml from 'linkifyjs/html'
-
-@Injectable()
-export class LinkifierService {
-
- static CLASSNAME = 'linkified'
-
- private linkifyOptions = {
- className: {
- mention: LinkifierService.CLASSNAME + '-mention',
- url: LinkifierService.CLASSNAME + '-url'
- }
- }
-
- constructor () {
- // Apply plugin
- this.mentionWithDomainPlugin(linkify)
- }
-
- linkify (text: string) {
- return linkifyHtml(text, this.linkifyOptions)
- }
-
- private mentionWithDomainPlugin (linkify: any) {
- const TT = linkify.scanner.TOKENS // Text tokens
- const { TOKENS: MT, State } = linkify.parser // Multi tokens, state
- const MultiToken = MT.Base
- const S_START = linkify.parser.start
-
- const TT_AT = TT.AT
- const TT_DOMAIN = TT.DOMAIN
- const TT_LOCALHOST = TT.LOCALHOST
- const TT_NUM = TT.NUM
- const TT_COLON = TT.COLON
- const TT_SLASH = TT.SLASH
- const TT_TLD = TT.TLD
- const TT_UNDERSCORE = TT.UNDERSCORE
- const TT_DOT = TT.DOT
-
- function MENTION (this: any, value: any) {
- this.v = value
- }
-
- linkify.inherits(MultiToken, MENTION, {
- type: 'mentionWithDomain',
- isLink: true,
- toHref () {
- return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + this.toString().substr(1)
- }
- })
-
- const S_AT = S_START.jump(TT_AT) // @
- const S_AT_SYMS = new State()
- const S_MENTION = new State(MENTION)
- const S_MENTION_DIVIDER = new State()
- const S_MENTION_DIVIDER_SYMS = new State()
-
- // @_,
- S_AT.on(TT_UNDERSCORE, S_AT_SYMS)
-
- // @_*
- S_AT_SYMS
- .on(TT_UNDERSCORE, S_AT_SYMS)
- .on(TT_DOT, S_AT_SYMS)
-
- // Valid mention (not made up entirely of symbols)
- S_AT
- .on(TT_DOMAIN, S_MENTION)
- .on(TT_LOCALHOST, S_MENTION)
- .on(TT_TLD, S_MENTION)
- .on(TT_NUM, S_MENTION)
-
- S_AT_SYMS
- .on(TT_DOMAIN, S_MENTION)
- .on(TT_LOCALHOST, S_MENTION)
- .on(TT_TLD, S_MENTION)
- .on(TT_NUM, S_MENTION)
-
- // More valid mentions
- S_MENTION
- .on(TT_DOMAIN, S_MENTION)
- .on(TT_LOCALHOST, S_MENTION)
- .on(TT_TLD, S_MENTION)
- .on(TT_COLON, S_MENTION)
- .on(TT_NUM, S_MENTION)
- .on(TT_UNDERSCORE, S_MENTION)
-
- // Mention with a divider
- S_MENTION
- .on(TT_AT, S_MENTION_DIVIDER)
- .on(TT_SLASH, S_MENTION_DIVIDER)
- .on(TT_DOT, S_MENTION_DIVIDER)
-
- // Mention _ trailing stash plus syms
- S_MENTION_DIVIDER.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
- S_MENTION_DIVIDER_SYMS.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
-
- // Once we get a word token, mentions can start up again
- S_MENTION_DIVIDER
- .on(TT_DOMAIN, S_MENTION)
- .on(TT_LOCALHOST, S_MENTION)
- .on(TT_TLD, S_MENTION)
- .on(TT_NUM, S_MENTION)
-
- S_MENTION_DIVIDER_SYMS
- .on(TT_DOMAIN, S_MENTION)
- .on(TT_LOCALHOST, S_MENTION)
- .on(TT_TLD, S_MENTION)
- .on(TT_NUM, S_MENTION)
- }
-}
+++ /dev/null
-import { Injectable } from '@angular/core'
-import { buildVideoLink } from '../../../assets/player/utils'
-import { HtmlRendererService } from '@app/shared/renderer/html-renderer.service'
-import * as MarkdownIt from 'markdown-it'
-
-type MarkdownParsers = {
- textMarkdownIt: MarkdownIt
- textWithHTMLMarkdownIt: MarkdownIt
-
- enhancedMarkdownIt: MarkdownIt
- enhancedWithHTMLMarkdownIt: MarkdownIt
-
- completeMarkdownIt: MarkdownIt
-}
-
-type MarkdownConfig = {
- rules: string[]
- html: boolean
- escape?: boolean
-}
-
-type MarkdownParserConfigs = {
- [id in keyof MarkdownParsers]: MarkdownConfig
-}
-
-@Injectable()
-export class MarkdownService {
- static TEXT_RULES = [
- 'linkify',
- 'autolink',
- 'emphasis',
- 'link',
- 'newline',
- 'list'
- ]
- static TEXT_WITH_HTML_RULES = MarkdownService.TEXT_RULES.concat([ 'html_inline', 'html_block' ])
-
- static ENHANCED_RULES = MarkdownService.TEXT_RULES.concat([ 'image' ])
- static ENHANCED_WITH_HTML_RULES = MarkdownService.TEXT_WITH_HTML_RULES.concat([ 'image' ])
-
- static COMPLETE_RULES = MarkdownService.ENHANCED_WITH_HTML_RULES.concat([ 'block', 'inline', 'heading', 'paragraph' ])
-
- private markdownParsers: MarkdownParsers = {
- textMarkdownIt: null,
- textWithHTMLMarkdownIt: null,
- enhancedMarkdownIt: null,
- enhancedWithHTMLMarkdownIt: null,
- completeMarkdownIt: null
- }
- private parsersConfig: MarkdownParserConfigs = {
- textMarkdownIt: { rules: MarkdownService.TEXT_RULES, html: false },
- textWithHTMLMarkdownIt: { rules: MarkdownService.TEXT_WITH_HTML_RULES, html: true, escape: true },
-
- enhancedMarkdownIt: { rules: MarkdownService.ENHANCED_RULES, html: false },
- enhancedWithHTMLMarkdownIt: { rules: MarkdownService.ENHANCED_WITH_HTML_RULES, html: true, escape: true },
-
- completeMarkdownIt: { rules: MarkdownService.COMPLETE_RULES, html: true }
- }
-
- constructor (private htmlRenderer: HtmlRendererService) {}
-
- textMarkdownToHTML (markdown: string, withHtml = false) {
- if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown)
-
- return this.render('textMarkdownIt', markdown)
- }
-
- enhancedMarkdownToHTML (markdown: string, withHtml = false) {
- if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown)
-
- return this.render('enhancedMarkdownIt', markdown)
- }
-
- completeMarkdownToHTML (markdown: string) {
- return this.render('completeMarkdownIt', markdown)
- }
-
- async processVideoTimestamps (html: string) {
- return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
- const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
- const url = buildVideoLink({ startTime: t })
- return `<a class="video-timestamp" href="${url}">${str}</a>`
- })
- }
-
- private async render (name: keyof MarkdownParsers, markdown: string) {
- if (!markdown) return ''
-
- const config = this.parsersConfig[ name ]
- if (!this.markdownParsers[ name ]) {
- this.markdownParsers[ name ] = await this.createMarkdownIt(config)
- }
-
- let html = this.markdownParsers[ name ].render(markdown)
- html = this.avoidTruncatedTags(html)
-
- if (config.escape) return this.htmlRenderer.toSafeHtml(html)
-
- return html
- }
-
- private async createMarkdownIt (config: MarkdownConfig) {
- // FIXME: import('...') returns a struct module, containing a "default" field
- const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
-
- const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html })
-
- for (const rule of config.rules) {
- markdownIt.enable(rule)
- }
-
- this.setTargetToLinks(markdownIt)
-
- return markdownIt
- }
-
- private setTargetToLinks (markdownIt: MarkdownIt) {
- // Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
- const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) {
- return self.renderToken(tokens, idx, options)
- }
-
- markdownIt.renderer.rules.link_open = function (tokens, index, options, env, self) {
- const token = tokens[index]
-
- const targetIndex = token.attrIndex('target')
- if (targetIndex < 0) token.attrPush([ 'target', '_blank' ])
- else token.attrs[targetIndex][1] = '_blank'
-
- const relIndex = token.attrIndex('rel')
- if (relIndex < 0) token.attrPush([ 'rel', 'noopener noreferrer' ])
- else token.attrs[relIndex][1] = 'noopener noreferrer'
-
- // pass token to default renderer.
- return defaultRender(tokens, index, options, env, self)
- }
- }
-
- private avoidTruncatedTags (html: string) {
- return html.replace(/\*\*?([^*]+)$/, '$1')
- .replace(/<a[^>]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...')
- .replace(/\[[^\]]+\]\(([^\)]+)$/m, '$1')
- .replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...</p>')
- }
-}
+++ /dev/null
-export interface ComponentPagination {
- currentPage: number
- itemsPerPage: number
- totalItems: number
-}
-
-export type ComponentPaginationLight = Omit<ComponentPagination, 'totalItems'>
-
-export function hasMoreItems (componentPagination: ComponentPagination) {
- // No results
- if (componentPagination.totalItems === 0) return false
-
- // Not loaded yet
- if (!componentPagination.totalItems) return true
-
- const maxPage = componentPagination.totalItems / componentPagination.itemsPerPage
- return maxPage > componentPagination.currentPage
-}
+++ /dev/null
-export * from './rest-extractor.service'
-export * from './rest-pagination'
-export * from './rest.service'
-export * from './rest-table'
+++ /dev/null
-import { throwError as observableThrowError } from 'rxjs'
-import { Injectable } from '@angular/core'
-import { dateToHuman } from '@app/shared/misc/utils'
-import { ResultList } from '../../../../../shared'
-import { Router } from '@angular/router'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Injectable()
-export class RestExtractor {
-
- constructor (
- private router: Router,
- private i18n: I18n
- ) { }
-
- extractDataBool () {
- return true
- }
-
- applyToResultListData <T> (result: ResultList<T>, fun: Function, additionalArgs?: any[]): ResultList<T> {
- const data: T[] = result.data
- const newData: T[] = []
-
- data.forEach(d => newData.push(fun.apply(this, [ d ].concat(additionalArgs))))
-
- return {
- total: result.total,
- data: newData
- }
- }
-
- convertResultListDateToHuman <T> (result: ResultList<T>, fieldsToConvert: string[] = [ 'createdAt' ]): ResultList<T> {
- return this.applyToResultListData(result, this.convertDateToHuman, [ fieldsToConvert ])
- }
-
- convertDateToHuman (target: { [ id: string ]: string }, fieldsToConvert: string[]) {
- fieldsToConvert.forEach(field => target[field] = dateToHuman(target[field]))
-
- return target
- }
-
- handleError (err: any) {
- let errorMessage
-
- if (err.error instanceof Error) {
- // A client-side or network error occurred. Handle it accordingly.
- errorMessage = err.error.message
- console.error('An error occurred:', errorMessage)
- } else if (typeof err.error === 'string') {
- errorMessage = err.error
- } else if (err.status !== undefined) {
- // A server-side error occurred.
- if (err.error && err.error.errors) {
- const errors = err.error.errors
- const errorsArray: string[] = []
-
- Object.keys(errors).forEach(key => {
- errorsArray.push(errors[key].msg)
- })
-
- errorMessage = errorsArray.join('. ')
- } else if (err.error && err.error.error) {
- errorMessage = err.error.error
- } else if (err.status === 413) {
- errorMessage = this.i18n(
- 'Request is too large for the server. Please contact you administrator if you want to increase the limit size.'
- )
- } else if (err.status === 429) {
- const secondsLeft = err.headers.get('retry-after')
- if (secondsLeft) {
- const minutesLeft = Math.floor(parseInt(secondsLeft, 10) / 60)
- errorMessage = this.i18n('Too many attempts, please try again after {{minutesLeft}} minutes.', { minutesLeft })
- } else {
- errorMessage = this.i18n('Too many attempts, please try again later.')
- }
- } else if (err.status === 500) {
- errorMessage = this.i18n('Server error. Please retry later.')
- }
-
- errorMessage = errorMessage ? errorMessage : 'Unknown error.'
- console.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`)
- } else {
- console.error(err)
- errorMessage = err
- }
-
- const errorObj: { message: string, status: string, body: string } = {
- message: errorMessage,
- status: undefined,
- body: undefined
- }
-
- if (err.status) {
- errorObj.status = err.status
- errorObj.body = err.error
- }
-
- return observableThrowError(errorObj)
- }
-
- redirectTo404IfNotFound (obj: { status: number }, status = [ 404 ]) {
- if (obj && obj.status && status.indexOf(obj.status) !== -1) {
- // Do not use redirectService to avoid circular dependencies
- this.router.navigate([ '/404' ], { skipLocationChange: true })
- }
-
- return observableThrowError(obj)
- }
-}
+++ /dev/null
-export interface RestPagination {
- start: number
- count: number
-}
+++ /dev/null
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { LazyLoadEvent, SortMeta } from 'primeng/api'
-import { RestPagination } from './rest-pagination'
-import { Subject } from 'rxjs'
-import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
-
-export abstract class RestTable {
-
- abstract totalRecords: number
- abstract sort: SortMeta
- abstract pagination: RestPagination
-
- search: string
- rowsPerPageOptions = [ 10, 20, 50, 100 ]
- rowsPerPage = this.rowsPerPageOptions[0]
- expandedRows = {}
-
- private searchStream: Subject<string>
-
- abstract getIdentifier (): string
-
- initialize () {
- this.loadSort()
- this.initSearch()
- }
-
- loadSort () {
- const result = peertubeLocalStorage.getItem(this.getSortLocalStorageKey())
-
- if (result) {
- try {
- this.sort = JSON.parse(result)
- } catch (err) {
- console.error('Cannot load sort of local storage key ' + this.getSortLocalStorageKey(), err)
- }
- }
- }
-
- loadLazy (event: LazyLoadEvent) {
- this.sort = {
- order: event.sortOrder,
- field: event.sortField
- }
-
- this.pagination = {
- start: event.first,
- count: this.rowsPerPage
- }
-
- this.loadData()
- this.saveSort()
- }
-
- saveSort () {
- peertubeLocalStorage.setItem(this.getSortLocalStorageKey(), JSON.stringify(this.sort))
- }
-
- initSearch () {
- this.searchStream = new Subject()
-
- this.searchStream
- .pipe(
- debounceTime(400),
- distinctUntilChanged()
- )
- .subscribe(search => {
- this.search = search
- this.loadData()
- })
- }
-
- onSearch (event: Event) {
- const target = event.target as HTMLInputElement
- this.searchStream.next(target.value)
- }
-
- onPage (event: { first: number, rows: number }) {
- if (this.rowsPerPage !== event.rows) {
- this.rowsPerPage = event.rows
- this.pagination = {
- start: event.first,
- count: this.rowsPerPage
- }
- this.loadData()
- }
- this.expandedRows = {}
- }
-
- setTableFilter (filter: string) {
- // FIXME: cannot use ViewChild, so create a component for the filter input
- const filterInput = document.getElementById('table-filter') as HTMLInputElement
- if (filterInput) filterInput.value = filter
- }
-
- resetSearch () {
- this.searchStream.next('')
- this.setTableFilter('')
- }
-
- protected abstract loadData (): void
-
- private getSortLocalStorageKey () {
- return 'rest-table-sort-' + this.getIdentifier()
- }
-}
+++ /dev/null
-import { SortMeta } from 'primeng/api'
-import { HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { ComponentPaginationLight } from './component-pagination.model'
-import { RestPagination } from './rest-pagination'
-
-interface QueryStringFilterPrefixes {
- [key: string]: {
- prefix: string
- handler?: (v: string) => string | number
- multiple?: boolean
- }
-}
-
-type ParseQueryStringFilterResult = {
- [key: string]: string | number | (string | number)[]
-}
-
-@Injectable()
-export class RestService {
-
- addRestGetParams (params: HttpParams, pagination?: RestPagination, sort?: SortMeta | string) {
- let newParams = params
-
- if (pagination !== undefined) {
- newParams = newParams.set('start', pagination.start.toString())
- .set('count', pagination.count.toString())
- }
-
- if (sort !== undefined) {
- let sortString = ''
-
- if (typeof sort === 'string') {
- sortString = sort
- } else {
- const sortPrefix = sort.order === 1 ? '' : '-'
- sortString = sortPrefix + sort.field
- }
-
- newParams = newParams.set('sort', sortString)
- }
-
- return newParams
- }
-
- addObjectParams (params: HttpParams, object: { [ name: string ]: any }) {
- for (const name of Object.keys(object)) {
- const value = object[name]
- if (value === undefined || value === null) continue
-
- if (Array.isArray(value) && value.length !== 0) {
- for (const v of value) params = params.append(name, v)
- } else {
- params = params.append(name, value)
- }
- }
-
- return params
- }
-
- componentPaginationToRestPagination (componentPagination: ComponentPaginationLight): RestPagination {
- const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
- const count: number = componentPagination.itemsPerPage
-
- return { start, count }
- }
-
- parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes): ParseQueryStringFilterResult {
- if (!q) return {}
-
- // Tokenize the strings using spaces
- const tokens = q.split(' ').filter(token => !!token)
-
- // Build prefix array
- const prefixeStrings = Object.values(prefixes)
- .map(p => p.prefix)
-
- // Search is the querystring minus defined filters
- const searchTokens = tokens.filter(t => {
- return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false)
- })
-
- const additionalFilters: ParseQueryStringFilterResult = {}
-
- for (const prefixKey of Object.keys(prefixes)) {
- const prefixObj = prefixes[prefixKey]
- const prefix = prefixObj.prefix
-
- const matchedTokens = tokens.filter(t => t.startsWith(prefix))
- .map(t => t.slice(prefix.length)) // Keep the value filter
- .map(t => {
- if (prefixObj.handler) return prefixObj.handler(t)
-
- return t
- })
- .filter(t => !!t || t === 0)
-
- if (matchedTokens.length === 0) continue
-
- additionalFilters[prefixKey] = prefixObj.multiple === true
- ? matchedTokens
- : matchedTokens[0]
- }
-
- return {
- search: searchTokens.join(' ') || undefined,
-
- ...additionalFilters
- }
- }
-}
+++ /dev/null
-import { SchedulerLike, Subscription } from 'rxjs'
-import { NgZone } from '@angular/core'
-
-class LeaveZoneScheduler implements SchedulerLike {
- constructor (private zone: NgZone, private scheduler: SchedulerLike) {
- }
-
- schedule (...args: any[]): Subscription {
- return this.zone.runOutsideAngular(() =>
- this.scheduler.schedule.apply(this.scheduler, args)
- )
- }
-
- now (): number {
- return this.scheduler.now()
- }
-}
-
-class EnterZoneScheduler implements SchedulerLike {
- constructor (private zone: NgZone, private scheduler: SchedulerLike) {
- }
-
- schedule (...args: any[]): Subscription {
- return this.zone.run(() =>
- this.scheduler.schedule.apply(this.scheduler, args)
- )
- }
-
- now (): number {
- return this.scheduler.now()
- }
-}
-
-export function leaveZone (zone: NgZone, scheduler: SchedulerLike): SchedulerLike {
- return new LeaveZoneScheduler(zone, scheduler)
-}
-
-export function enterZone (zone: NgZone, scheduler: SchedulerLike): SchedulerLike {
- return new EnterZoneScheduler(zone, scheduler)
-}
--- /dev/null
+import { FormGroup } from '@angular/forms'
+import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from './form-validators'
+
+export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
+export type FormReactiveValidationMessages = {
+ [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
+}
+
+export abstract class FormReactive {
+ protected abstract formValidatorService: FormValidatorService
+ protected formChanged = false
+
+ form: FormGroup
+ formErrors: any // To avoid casting in template because of string | FormReactiveErrors
+ validationMessages: FormReactiveValidationMessages
+
+ buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
+ const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
+
+ this.form = form
+ this.formErrors = formErrors
+ this.validationMessages = validationMessages
+
+ this.form.valueChanges.subscribe(() => this.onValueChanged(this.form, this.formErrors, this.validationMessages, false))
+ }
+
+ protected forceCheck () {
+ return this.onValueChanged(this.form, this.formErrors, this.validationMessages, true)
+ }
+
+ protected check () {
+ return this.onValueChanged(this.form, this.formErrors, this.validationMessages, false)
+ }
+
+ private onValueChanged (
+ form: FormGroup,
+ formErrors: FormReactiveErrors,
+ validationMessages: FormReactiveValidationMessages,
+ forceCheck = false
+ ) {
+ for (const field of Object.keys(formErrors)) {
+ if (formErrors[field] && typeof formErrors[field] === 'object') {
+ this.onValueChanged(
+ form.controls[field] as FormGroup,
+ formErrors[field] as FormReactiveErrors,
+ validationMessages[field] as FormReactiveValidationMessages,
+ forceCheck
+ )
+ continue
+ }
+
+ // clear previous error message (if any)
+ formErrors[ field ] = ''
+ const control = form.get(field)
+
+ if (control.dirty) this.formChanged = true
+
+ // Don't care if dirty on force check
+ const isDirty = control.dirty || forceCheck === true
+ if (control && isDirty && control.enabled && !control.valid) {
+ const messages = validationMessages[ field ]
+ for (const key of Object.keys(control.errors)) {
+ formErrors[ field ] += messages[ key ] + ' '
+ }
+ }
+ }
+ }
+
+}
--- /dev/null
+import { Injectable } from '@angular/core'
+import { ValidatorFn, Validators } from '@angular/forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { BuildFormValidator } from './form-validator.service'
+import { validateHost } from './host'
+
+@Injectable()
+export class BatchDomainsValidatorsService {
+ readonly DOMAINS: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.DOMAINS = {
+ VALIDATORS: [ Validators.required, this.validDomains, this.isHostsUnique ],
+ MESSAGES: {
+ 'required': this.i18n('Domain is required.'),
+ 'validDomains': this.i18n('Domains entered are invalid.'),
+ 'uniqueDomains': this.i18n('Domains entered contain duplicates.')
+ }
+ }
+ }
+
+ getNotEmptyHosts (hosts: string) {
+ return hosts
+ .split('\n')
+ .filter((host: string) => host && host.length !== 0) // Eject empty hosts
+ }
+
+ private validDomains: ValidatorFn = (control) => {
+ if (!control.value) return null
+
+ const newHostsErrors = []
+ const hosts = this.getNotEmptyHosts(control.value)
+
+ for (const host of hosts) {
+ if (validateHost(host) === false) {
+ newHostsErrors.push(this.i18n('{{host}} is not valid', { host }))
+ }
+ }
+
+ /* Is not valid. */
+ if (newHostsErrors.length !== 0) {
+ return {
+ 'validDomains': {
+ reason: 'invalid',
+ value: newHostsErrors.join('. ') + '.'
+ }
+ }
+ }
+
+ /* Is valid. */
+ return null
+ }
+
+ private isHostsUnique: ValidatorFn = (control) => {
+ if (!control.value) return null
+
+ const hosts = this.getNotEmptyHosts(control.value)
+
+ if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
+ return null
+ } else {
+ return {
+ 'uniqueDomains': {
+ reason: 'invalid'
+ }
+ }
+ }
+ }
+}
--- /dev/null
+import { Validators } from '@angular/forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { BuildFormValidator } from './form-validator.service'
+import { Injectable } from '@angular/core'
+
+@Injectable()
+export class CustomConfigValidatorsService {
+ readonly INSTANCE_NAME: BuildFormValidator
+ readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator
+ readonly SERVICES_TWITTER_USERNAME: BuildFormValidator
+ readonly CACHE_PREVIEWS_SIZE: BuildFormValidator
+ readonly CACHE_CAPTIONS_SIZE: BuildFormValidator
+ readonly SIGNUP_LIMIT: BuildFormValidator
+ readonly ADMIN_EMAIL: BuildFormValidator
+ readonly TRANSCODING_THREADS: BuildFormValidator
+ readonly INDEX_URL: BuildFormValidator
+ readonly SEARCH_INDEX_URL: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.INSTANCE_NAME = {
+ VALIDATORS: [ Validators.required ],
+ MESSAGES: {
+ 'required': this.i18n('Instance name is required.')
+ }
+ }
+
+ this.INSTANCE_SHORT_DESCRIPTION = {
+ VALIDATORS: [ Validators.max(250) ],
+ MESSAGES: {
+ 'max': this.i18n('Short description should not be longer than 250 characters.')
+ }
+ }
+
+ this.SERVICES_TWITTER_USERNAME = {
+ VALIDATORS: [ Validators.required ],
+ MESSAGES: {
+ 'required': this.i18n('Twitter username is required.')
+ }
+ }
+
+ this.CACHE_PREVIEWS_SIZE = {
+ VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
+ MESSAGES: {
+ 'required': this.i18n('Previews cache size is required.'),
+ 'min': this.i18n('Previews cache size must be greater than 1.'),
+ 'pattern': this.i18n('Previews cache size must be a number.')
+ }
+ }
+
+ this.CACHE_CAPTIONS_SIZE = {
+ VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
+ MESSAGES: {
+ 'required': this.i18n('Captions cache size is required.'),
+ 'min': this.i18n('Captions cache size must be greater than 1.'),
+ 'pattern': this.i18n('Captions cache size must be a number.')
+ }
+ }
+
+ this.SIGNUP_LIMIT = {
+ VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ],
+ MESSAGES: {
+ 'required': this.i18n('Signup limit is required.'),
+ 'min': this.i18n('Signup limit must be greater than 1.'),
+ 'pattern': this.i18n('Signup limit must be a number.')
+ }
+ }
+
+ this.ADMIN_EMAIL = {
+ VALIDATORS: [ Validators.required, Validators.email ],
+ MESSAGES: {
+ 'required': this.i18n('Admin email is required.'),
+ 'email': this.i18n('Admin email must be valid.')
+ }
+ }
+
+ this.TRANSCODING_THREADS = {
+ VALIDATORS: [ Validators.required, Validators.min(0) ],
+ MESSAGES: {
+ 'required': this.i18n('Transcoding threads is required.'),
+ 'min': this.i18n('Transcoding threads must be greater or equal to 0.')
+ }
+ }
+
+ this.INDEX_URL = {
+ VALIDATORS: [ Validators.pattern(/^https:\/\//) ],
+ MESSAGES: {
+ 'pattern': this.i18n('Index URL should be a URL')
+ }
+ }
+
+ this.SEARCH_INDEX_URL = {
+ VALIDATORS: [ Validators.pattern(/^https?:\/\//) ],
+ MESSAGES: {
+ 'pattern': this.i18n('Search index URL should be a URL')
+ }
+ }
+ }
+}
--- /dev/null
+import { FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { FormReactiveErrors, FormReactiveValidationMessages } from '../form-reactive'
+
+export type BuildFormValidator = {
+ VALIDATORS: ValidatorFn[],
+ MESSAGES: { [ name: string ]: string }
+}
+export type BuildFormArgument = {
+ [ id: string ]: BuildFormValidator | BuildFormArgument
+}
+export type BuildFormDefaultValues = {
+ [ name: string ]: string | string[] | BuildFormDefaultValues
+}
+
+@Injectable()
+export class FormValidatorService {
+
+ constructor (
+ private formBuilder: FormBuilder
+ ) {}
+
+ buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
+ const formErrors: FormReactiveErrors = {}
+ const validationMessages: FormReactiveValidationMessages = {}
+ const group: { [key: string]: any } = {}
+
+ for (const name of Object.keys(obj)) {
+ formErrors[name] = ''
+
+ const field = obj[name]
+ if (this.isRecursiveField(field)) {
+ const result = this.buildForm(field as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues)
+ group[name] = result.form
+ formErrors[name] = result.formErrors
+ validationMessages[name] = result.validationMessages
+
+ continue
+ }
+
+ if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
+
+ const defaultValue = defaultValues[name] || ''
+
+ if (field && field.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
+ else group[name] = [ defaultValue ]
+ }
+
+ const form = this.formBuilder.group(group)
+ return { form, formErrors, validationMessages }
+ }
+
+ updateForm (
+ form: FormGroup,
+ formErrors: FormReactiveErrors,
+ validationMessages: FormReactiveValidationMessages,
+ obj: BuildFormArgument,
+ defaultValues: BuildFormDefaultValues = {}
+ ) {
+ for (const name of Object.keys(obj)) {
+ formErrors[name] = ''
+
+ const field = obj[name]
+ if (this.isRecursiveField(field)) {
+ this.updateForm(
+ form[name],
+ formErrors[name] as FormReactiveErrors,
+ validationMessages[name] as FormReactiveValidationMessages,
+ obj[name] as BuildFormArgument,
+ defaultValues[name] as BuildFormDefaultValues
+ )
+ continue
+ }
+
+ if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
+
+ const defaultValue = defaultValues[name] || ''
+
+ if (field && field.VALIDATORS) form.addControl(name, new FormControl(defaultValue, field.VALIDATORS as ValidatorFn[]))
+ else form.addControl(name, new FormControl(defaultValue))
+ }
+ }
+
+ private isRecursiveField (field: any) {
+ return field && typeof field === 'object' && !field.MESSAGES && !field.VALIDATORS
+ }
+}
--- /dev/null
+export function validateHost (value: string) {
+ // Thanks to http://stackoverflow.com/a/106223
+ const HOST_REGEXP = new RegExp(
+ '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'
+ )
+
+ return HOST_REGEXP.test(value)
+}
--- /dev/null
+export * from './batch-domains-validators.service'
+export * from './custom-config-validators.service'
+export * from './form-validator.service'
+export * from './host'
+export * from './instance-validators.service'
+export * from './login-validators.service'
+export * from './reset-password-validators.service'
+export * from './user-validators.service'
+export * from './video-abuse-validators.service'
+export * from './video-accept-ownership-validators.service'
+export * from './video-block-validators.service'
+export * from './video-captions-validators.service'
+export * from './video-change-ownership-validators.service'
+export * from './video-channel-validators.service'
+export * from './video-comment-validators.service'
+export * from './video-playlist-validators.service'
+export * from './video-validators.service'
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { BuildFormValidator } from './form-validator.service'
+import { Injectable } from '@angular/core'
+
+@Injectable()
+export class InstanceValidatorsService {
+ readonly FROM_EMAIL: BuildFormValidator
+ readonly FROM_NAME: BuildFormValidator
+ readonly SUBJECT: BuildFormValidator
+ readonly BODY: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+
+ this.FROM_EMAIL = {
+ VALIDATORS: [ Validators.required, Validators.email ],
+ MESSAGES: {
+ 'required': this.i18n('Email is required.'),
+ 'email': this.i18n('Email must be valid.')
+ }
+ }
+
+ this.FROM_NAME = {
+ VALIDATORS: [
+ Validators.required,
+ Validators.minLength(1),
+ Validators.maxLength(120)
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Your name is required.'),
+ 'minlength': this.i18n('Your name must be at least 1 character long.'),
+ 'maxlength': this.i18n('Your name cannot be more than 120 characters long.')
+ }
+ }
+
+ this.SUBJECT = {
+ VALIDATORS: [
+ Validators.required,
+ Validators.minLength(1),
+ Validators.maxLength(120)
+ ],
+ MESSAGES: {
+ 'required': this.i18n('A subject is required.'),
+ 'minlength': this.i18n('The subject must be at least 1 character long.'),
+ 'maxlength': this.i18n('The subject cannot be more than 120 characters long.')
+ }
+ }
+
+ this.BODY = {
+ VALIDATORS: [
+ Validators.required,
+ Validators.minLength(3),
+ Validators.maxLength(5000)
+ ],
+ MESSAGES: {
+ 'required': this.i18n('A message is required.'),
+ 'minlength': this.i18n('The message must be at least 3 characters long.'),
+ 'maxlength': this.i18n('The message cannot be more than 5000 characters long.')
+ }
+ }
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class LoginValidatorsService {
+ readonly LOGIN_USERNAME: BuildFormValidator
+ readonly LOGIN_PASSWORD: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.LOGIN_USERNAME = {
+ VALIDATORS: [
+ Validators.required
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Username is required.')
+ }
+ }
+
+ this.LOGIN_PASSWORD = {
+ VALIDATORS: [
+ Validators.required
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Password is required.')
+ }
+ }
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class ResetPasswordValidatorsService {
+ readonly RESET_PASSWORD_CONFIRM: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.RESET_PASSWORD_CONFIRM = {
+ VALIDATORS: [
+ Validators.required
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Confirmation of the password is required.')
+ }
+ }
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { BuildFormValidator } from './form-validator.service'
+import { Injectable } from '@angular/core'
+
+@Injectable()
+export class UserValidatorsService {
+ readonly USER_USERNAME: BuildFormValidator
+ readonly USER_EMAIL: BuildFormValidator
+ readonly USER_PASSWORD: BuildFormValidator
+ readonly USER_PASSWORD_OPTIONAL: BuildFormValidator
+ readonly USER_CONFIRM_PASSWORD: BuildFormValidator
+ readonly USER_VIDEO_QUOTA: BuildFormValidator
+ readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator
+ readonly USER_ROLE: BuildFormValidator
+ readonly USER_DISPLAY_NAME_REQUIRED: BuildFormValidator
+ readonly USER_DESCRIPTION: BuildFormValidator
+ readonly USER_TERMS: BuildFormValidator
+
+ readonly USER_BAN_REASON: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+
+ this.USER_USERNAME = {
+ VALIDATORS: [
+ Validators.required,
+ Validators.minLength(1),
+ Validators.maxLength(50),
+ Validators.pattern(/^[a-z0-9][a-z0-9._]*$/)
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Username is required.'),
+ 'minlength': this.i18n('Username must be at least 1 character long.'),
+ 'maxlength': this.i18n('Username cannot be more than 50 characters long.'),
+ 'pattern': this.i18n('Username should be lowercase alphanumeric; dots and underscores are allowed.')
+ }
+ }
+
+ this.USER_EMAIL = {
+ VALIDATORS: [ Validators.required, Validators.email ],
+ MESSAGES: {
+ 'required': this.i18n('Email is required.'),
+ 'email': this.i18n('Email must be valid.')
+ }
+ }
+
+ this.USER_PASSWORD = {
+ VALIDATORS: [
+ Validators.required,
+ Validators.minLength(6),
+ Validators.maxLength(255)
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Password is required.'),
+ 'minlength': this.i18n('Password must be at least 6 characters long.'),
+ 'maxlength': this.i18n('Password cannot be more than 255 characters long.')
+ }
+ }
+
+ this.USER_PASSWORD_OPTIONAL = {
+ VALIDATORS: [
+ Validators.minLength(6),
+ Validators.maxLength(255)
+ ],
+ MESSAGES: {
+ 'minlength': this.i18n('Password must be at least 6 characters long.'),
+ 'maxlength': this.i18n('Password cannot be more than 255 characters long.')
+ }
+ }
+
+ this.USER_CONFIRM_PASSWORD = {
+ VALIDATORS: [],
+ MESSAGES: {
+ 'matchPassword': this.i18n('The new password and the confirmed password do not correspond.')
+ }
+ }
+
+ this.USER_VIDEO_QUOTA = {
+ VALIDATORS: [ Validators.required, Validators.min(-1) ],
+ MESSAGES: {
+ 'required': this.i18n('Video quota is required.'),
+ 'min': this.i18n('Quota must be greater than -1.')
+ }
+ }
+ this.USER_VIDEO_QUOTA_DAILY = {
+ VALIDATORS: [ Validators.required, Validators.min(-1) ],
+ MESSAGES: {
+ 'required': this.i18n('Daily upload limit is required.'),
+ 'min': this.i18n('Daily upload limit must be greater than -1.')
+ }
+ }
+
+ this.USER_ROLE = {
+ VALIDATORS: [ Validators.required ],
+ MESSAGES: {
+ 'required': this.i18n('User role is required.')
+ }
+ }
+
+ this.USER_DISPLAY_NAME_REQUIRED = this.getDisplayName(true)
+
+ this.USER_DESCRIPTION = {
+ VALIDATORS: [
+ Validators.minLength(3),
+ Validators.maxLength(1000)
+ ],
+ MESSAGES: {
+ 'minlength': this.i18n('Description must be at least 3 characters long.'),
+ 'maxlength': this.i18n('Description cannot be more than 1000 characters long.')
+ }
+ }
+
+ this.USER_TERMS = {
+ VALIDATORS: [
+ Validators.requiredTrue
+ ],
+ MESSAGES: {
+ 'required': this.i18n('You must agree with the instance terms in order to register on it.')
+ }
+ }
+
+ this.USER_BAN_REASON = {
+ VALIDATORS: [
+ Validators.minLength(3),
+ Validators.maxLength(250)
+ ],
+ MESSAGES: {
+ 'minlength': this.i18n('Ban reason must be at least 3 characters long.'),
+ 'maxlength': this.i18n('Ban reason cannot be more than 250 characters long.')
+ }
+ }
+ }
+
+ private getDisplayName (required: boolean) {
+ const control = {
+ VALIDATORS: [
+ Validators.minLength(1),
+ Validators.maxLength(120)
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Display name is required.'),
+ 'minlength': this.i18n('Display name must be at least 1 character long.'),
+ 'maxlength': this.i18n('Display name cannot be more than 50 characters long.')
+ }
+ }
+
+ if (required) control.VALIDATORS.push(Validators.required)
+
+ return control
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class VideoAbuseValidatorsService {
+ readonly VIDEO_ABUSE_REASON: BuildFormValidator
+ readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.VIDEO_ABUSE_REASON = {
+ VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
+ MESSAGES: {
+ 'required': this.i18n('Report reason is required.'),
+ 'minlength': this.i18n('Report reason must be at least 2 characters long.'),
+ 'maxlength': this.i18n('Report reason cannot be more than 3000 characters long.')
+ }
+ }
+
+ this.VIDEO_ABUSE_MODERATION_COMMENT = {
+ VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
+ MESSAGES: {
+ 'required': this.i18n('Moderation comment is required.'),
+ 'minlength': this.i18n('Moderation comment must be at least 2 characters long.'),
+ 'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.')
+ }
+ }
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class VideoAcceptOwnershipValidatorsService {
+ readonly CHANNEL: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.CHANNEL = {
+ VALIDATORS: [ Validators.required ],
+ MESSAGES: {
+ 'required': this.i18n('The channel is required.')
+ }
+ }
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class VideoBlockValidatorsService {
+ readonly VIDEO_BLOCK_REASON: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.VIDEO_BLOCK_REASON = {
+ VALIDATORS: [ Validators.minLength(2), Validators.maxLength(300) ],
+ MESSAGES: {
+ 'minlength': this.i18n('Block reason must be at least 2 characters long.'),
+ 'maxlength': this.i18n('Block reason cannot be more than 300 characters long.')
+ }
+ }
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class VideoCaptionsValidatorsService {
+ readonly VIDEO_CAPTION_LANGUAGE: BuildFormValidator
+ readonly VIDEO_CAPTION_FILE: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+
+ this.VIDEO_CAPTION_LANGUAGE = {
+ VALIDATORS: [ Validators.required ],
+ MESSAGES: {
+ 'required': this.i18n('Video caption language is required.')
+ }
+ }
+
+ this.VIDEO_CAPTION_FILE = {
+ VALIDATORS: [ Validators.required ],
+ MESSAGES: {
+ 'required': this.i18n('Video caption file is required.')
+ }
+ }
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { AbstractControl, ValidationErrors, Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class VideoChangeOwnershipValidatorsService {
+ readonly USERNAME: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.USERNAME = {
+ VALIDATORS: [ Validators.required, this.localAccountValidator ],
+ MESSAGES: {
+ 'required': this.i18n('The username is required.'),
+ 'localAccountOnly': this.i18n('You can only transfer ownership to a local account')
+ }
+ }
+ }
+
+ localAccountValidator (control: AbstractControl): ValidationErrors {
+ if (control.value.includes('@')) {
+ return { 'localAccountOnly': true }
+ }
+
+ return null
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class VideoChannelValidatorsService {
+ readonly VIDEO_CHANNEL_NAME: BuildFormValidator
+ readonly VIDEO_CHANNEL_DISPLAY_NAME: BuildFormValidator
+ readonly VIDEO_CHANNEL_DESCRIPTION: BuildFormValidator
+ readonly VIDEO_CHANNEL_SUPPORT: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.VIDEO_CHANNEL_NAME = {
+ VALIDATORS: [
+ Validators.required,
+ Validators.minLength(1),
+ Validators.maxLength(50),
+ Validators.pattern(/^[a-z0-9][a-z0-9._]*$/)
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Name is required.'),
+ 'minlength': this.i18n('Name must be at least 1 character long.'),
+ 'maxlength': this.i18n('Name cannot be more than 50 characters long.'),
+ 'pattern': this.i18n('Name should be lowercase alphanumeric; dots and underscores are allowed.')
+ }
+ }
+
+ this.VIDEO_CHANNEL_DISPLAY_NAME = {
+ VALIDATORS: [
+ Validators.required,
+ Validators.minLength(1),
+ Validators.maxLength(50)
+ ],
+ MESSAGES: {
+ 'required': i18n('Display name is required.'),
+ 'minlength': i18n('Display name must be at least 1 character long.'),
+ 'maxlength': i18n('Display name cannot be more than 50 characters long.')
+ }
+ }
+
+ this.VIDEO_CHANNEL_DESCRIPTION = {
+ VALIDATORS: [
+ Validators.minLength(3),
+ Validators.maxLength(1000)
+ ],
+ MESSAGES: {
+ 'minlength': i18n('Description must be at least 3 characters long.'),
+ 'maxlength': i18n('Description cannot be more than 1000 characters long.')
+ }
+ }
+
+ this.VIDEO_CHANNEL_SUPPORT = {
+ VALIDATORS: [
+ Validators.minLength(3),
+ Validators.maxLength(1000)
+ ],
+ MESSAGES: {
+ 'minlength': i18n('Support text must be at least 3 characters long.'),
+ 'maxlength': i18n('Support text cannot be more than 1000 characters long.')
+ }
+ }
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class VideoCommentValidatorsService {
+ readonly VIDEO_COMMENT_TEXT: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.VIDEO_COMMENT_TEXT = {
+ VALIDATORS: [ Validators.required, Validators.minLength(1), Validators.maxLength(3000) ],
+ MESSAGES: {
+ 'required': this.i18n('Comment is required.'),
+ 'minlength': this.i18n('Comment must be at least 2 characters long.'),
+ 'maxlength': this.i18n('Comment cannot be more than 3000 characters long.')
+ }
+ }
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { AbstractControl, FormControl, Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+import { VideoPlaylistPrivacy } from '@shared/models'
+
+@Injectable()
+export class VideoPlaylistValidatorsService {
+ readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator
+ readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator
+ readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator
+ readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+ this.VIDEO_PLAYLIST_DISPLAY_NAME = {
+ VALIDATORS: [
+ Validators.required,
+ Validators.minLength(1),
+ Validators.maxLength(120)
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Display name is required.'),
+ 'minlength': this.i18n('Display name must be at least 1 character long.'),
+ 'maxlength': this.i18n('Display name cannot be more than 120 characters long.')
+ }
+ }
+
+ this.VIDEO_PLAYLIST_PRIVACY = {
+ VALIDATORS: [
+ Validators.required
+ ],
+ MESSAGES: {
+ 'required': this.i18n('Privacy is required.')
+ }
+ }
+
+ this.VIDEO_PLAYLIST_DESCRIPTION = {
+ VALIDATORS: [
+ Validators.minLength(3),
+ Validators.maxLength(1000)
+ ],
+ MESSAGES: {
+ 'minlength': i18n('Description must be at least 3 characters long.'),
+ 'maxlength': i18n('Description cannot be more than 1000 characters long.')
+ }
+ }
+
+ this.VIDEO_PLAYLIST_CHANNEL_ID = {
+ VALIDATORS: [ ],
+ MESSAGES: {
+ 'required': this.i18n('The channel is required when the playlist is public.')
+ }
+ }
+ }
+
+ setChannelValidator (channelControl: AbstractControl, privacy: VideoPlaylistPrivacy) {
+ if (privacy.toString() === VideoPlaylistPrivacy.PUBLIC.toString()) {
+ channelControl.setValidators([ Validators.required ])
+ } else {
+ channelControl.setValidators(null)
+ }
+
+ channelControl.markAsDirty()
+ channelControl.updateValueAndValidity()
+ }
+}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Validators } from '@angular/forms'
+import { Injectable } from '@angular/core'
+import { BuildFormValidator } from './form-validator.service'
+
+@Injectable()
+export class VideoValidatorsService {
+ readonly VIDEO_NAME: BuildFormValidator
+ readonly VIDEO_PRIVACY: BuildFormValidator
+ readonly VIDEO_CATEGORY: BuildFormValidator
+ readonly VIDEO_LICENCE: BuildFormValidator
+ readonly VIDEO_LANGUAGE: BuildFormValidator
+ readonly VIDEO_IMAGE: BuildFormValidator
+ readonly VIDEO_CHANNEL: BuildFormValidator
+ readonly VIDEO_DESCRIPTION: BuildFormValidator
+ readonly VIDEO_TAGS: BuildFormValidator
+ readonly VIDEO_SUPPORT: BuildFormValidator
+ readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator
+ readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator
+
+ constructor (private i18n: I18n) {
+
+ this.VIDEO_NAME = {
+ VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(120) ],
+ MESSAGES: {
+ 'required': this.i18n('Video name is required.'),
+ 'minlength': this.i18n('Video name must be at least 3 characters long.'),
+ 'maxlength': this.i18n('Video name cannot be more than 120 characters long.')
+ }
+ }
+
+ this.VIDEO_PRIVACY = {
+ VALIDATORS: [ Validators.required ],
+ MESSAGES: {
+ 'required': this.i18n('Video privacy is required.')
+ }
+ }
+
+ this.VIDEO_CATEGORY = {
+ VALIDATORS: [ ],
+ MESSAGES: {}
+ }
+
+ this.VIDEO_LICENCE = {
+ VALIDATORS: [ ],
+ MESSAGES: {}
+ }
+
+ this.VIDEO_LANGUAGE = {
+ VALIDATORS: [ ],
+ MESSAGES: {}
+ }
+
+ this.VIDEO_IMAGE = {
+ VALIDATORS: [ ],
+ MESSAGES: {}
+ }
+
+ this.VIDEO_CHANNEL = {
+ VALIDATORS: [ Validators.required ],
+ MESSAGES: {
+ 'required': this.i18n('Video channel is required.')
+ }
+ }
+
+ this.VIDEO_DESCRIPTION = {
+ VALIDATORS: [ Validators.minLength(3), Validators.maxLength(10000) ],
+ MESSAGES: {
+ 'minlength': this.i18n('Video description must be at least 3 characters long.'),
+ 'maxlength': this.i18n('Video description cannot be more than 10000 characters long.')
+ }
+ }
+
+ this.VIDEO_TAGS = {
+ VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ],
+ MESSAGES: {
+ 'minlength': this.i18n('A tag should be more than 2 characters long.'),
+ 'maxlength': this.i18n('A tag should be less than 30 characters long.')
+ }
+ }
+
+ this.VIDEO_SUPPORT = {
+ VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ],
+ MESSAGES: {
+ 'minlength': this.i18n('Video support must be at least 3 characters long.'),
+ 'maxlength': this.i18n('Video support cannot be more than 1000 characters long.')
+ }
+ }
+
+ this.VIDEO_SCHEDULE_PUBLICATION_AT = {
+ VALIDATORS: [ ],
+ MESSAGES: {
+ 'required': this.i18n('A date is required to schedule video update.')
+ }
+ }
+
+ this.VIDEO_ORIGINALLY_PUBLISHED_AT = {
+ VALIDATORS: [ ],
+ MESSAGES: {}
+ }
+ }
+}
--- /dev/null
+export * from './form-validators'
+export * from './form-reactive'
+export * from './input-readonly-copy.component'
+export * from './markdown-textarea.component'
+export * from './peertube-checkbox.component'
+export * from './preview-upload.component'
+export * from './reactive-file.component'
+export * from './textarea-autoresize.directive'
+export * from './timestamp-input.component'
+export * from './shared-form.module'
--- /dev/null
+<div class="input-group input-group-sm">
+ <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
+
+ <div class="input-group-append">
+ <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
+ <span class="glyphicon glyphicon-copy"></span>
+ </button>
+ </div>
+</div>
--- /dev/null
+input.readonly {
+ font-size: 15px;
+}
--- /dev/null
+import { Component, Input } from '@angular/core'
+import { Notifier } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+ selector: 'my-input-readonly-copy',
+ templateUrl: './input-readonly-copy.component.html',
+ styleUrls: [ './input-readonly-copy.component.scss' ]
+})
+export class InputReadonlyCopyComponent {
+ @Input() value = ''
+
+ constructor (
+ private notifier: Notifier,
+ private i18n: I18n
+ ) { }
+
+ activateCopiedMessage () {
+ this.notifier.success(this.i18n('Copied'))
+ }
+}
--- /dev/null
+<div class="root" [ngClass]="{ 'maximized': isMaximized }" [ngStyle]="{ 'max-width': textareaMaxWidth }">
+ <textarea #textarea
+ [(ngModel)]="content" (ngModelChange)="onModelChange()"
+ class="form-control" [ngClass]="classes"
+ [ngStyle]="{ height: textareaHeight }"
+ [id]="name" [name]="name">
+ </textarea>
+
+ <div ngbNav #nav="ngbNav" class="nav-pills nav-preview">
+ <ng-container ngbNavItem *ngIf="truncate !== undefined">
+ <a ngbNavLink i18n>Truncated preview</a>
+
+ <ng-template ngbNavContent>
+ <div [innerHTML]="truncatedPreviewHTML"></div>
+ </ng-template>
+ </ng-container>
+
+ <ng-container ngbNavItem>
+ <a ngbNavLink i18n>Complete preview</a>
+
+ <ng-template ngbNavContent>
+ <div [innerHTML]="previewHTML"></div>
+ </ng-template>
+ </ng-container>
+
+ <my-button
+ *ngIf="!isMaximized" icon="fullscreen" (click)="onMaximizeClick()"
+ ></my-button>
+
+ <my-button
+ *ngIf="isMaximized" icon="exit-fullscreen" (click)="onMaximizeClick()"
+ ></my-button>
+ </div>
+
+ <div [ngbNavOutlet]="nav"></div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+$nav-preview-tab-height: 30px;
+$base-padding: 15px;
+$input-border-color: #C6C6C6;
+$input-border-radius: 3px;
+
+@mixin in-small-view {
+ .root {
+ display: flex;
+ flex-direction: column;
+
+ textarea {
+ @include peertube-textarea(100%, 150px);
+
+ background-color: pvar(--markdownTextareaBackgroundColor);
+
+ font-family: monospace;
+ font-size: 13px;
+ border-bottom: none;
+ border-bottom-left-radius: unset;
+ border-bottom-right-radius: unset;
+ }
+
+ .nav-preview {
+ display: block;
+ text-align: right;
+ padding-top: 10px;
+ padding-bottom: 10px;
+ padding-left: 10px;
+ padding-right: 10px;
+ border-top: 1px dashed $input-border-color;
+ border-left: 1px solid $input-border-color;
+ border-right: 1px solid $input-border-color;
+ border-bottom: 1px solid $input-border-color;
+ border-bottom-right-radius: $input-border-radius;
+
+ border-bottom-left-radius: $input-border-radius;
+ ::ng-deep {
+ .nav-link {
+ display: none !important;
+ }
+
+ .grey-button {
+ padding: 0 12px 0 12px;
+ }
+ }
+ }
+
+ ::ng-deep {
+ .tab-content {
+ display: none;
+ }
+ }
+ }
+}
+
+@mixin nav-preview-medium {
+ display: flex;
+ flex-grow: 1;
+ border-bottom-left-radius: unset;
+ border-bottom-right-radius: unset;
+ border-bottom: 2px solid pvar(--mainColor);
+
+ :first-child {
+ margin-left: auto;
+ }
+
+ ::ng-deep {
+ .nav-link {
+ display: flex !important;
+ align-items: center;
+ height: $nav-preview-tab-height !important;
+ padding: 0 15px !important;
+ font-size: 85% !important;
+ opacity: .7;
+ }
+
+ .grey-button {
+ margin-left: 5px;
+ }
+ }
+}
+
+@mixin content-preview-base {
+ display: block;
+ min-height: 75px;
+ padding: $base-padding;
+ overflow-y: auto;
+ font-size: 15px;
+ word-wrap: break-word;
+}
+
+@mixin maximized-base {
+ flex-direction: row;
+ z-index: #{z(header) - 1};
+ position: fixed;
+ top: $header-height;
+ left: $menu-width;
+ max-height: none !important;
+ max-width: none !important;
+ width: calc(100% - #{$menu-width});
+ height: calc(100vh - #{$header-height}) !important;
+
+ $nav-preview-vertical-padding: 40px;
+
+ .nav-preview {
+ @include nav-preview-medium();
+ padding-top: #{$nav-preview-vertical-padding / 2};
+ padding-bottom: #{$nav-preview-vertical-padding / 2};
+ padding-left: 0px;
+ padding-right: 0px;
+ position: absolute;
+ background-color: pvar(--mainBackgroundColor);
+ width: 100% !important;
+ border-top: none;
+ border-left: none;
+ border-right: none;
+
+ :last-child {
+ margin-right: $not-expanded-horizontal-margins;
+ }
+ }
+
+ ::ng-deep .tab-content {
+ @include content-preview-base();
+ background-color: pvar(--mainBackgroundColor);
+ scrollbar-color: pvar(--actionButtonColor) pvar(--mainBackgroundColor);
+ }
+
+ textarea,
+ ::ng-deep .tab-content {
+ max-height: none !important;
+ max-width: none !important;
+ margin-top: #{$nav-preview-tab-height + $nav-preview-vertical-padding} !important;
+ height: calc(100vh - #{$header-height + $nav-preview-tab-height + $nav-preview-vertical-padding}) !important;
+ width: 50% !important;
+ border: none !important;
+ border-radius: unset !important;
+ }
+
+ :host-context(.expanded) {
+ .root.maximized {
+ left: 0;
+ width: 100%;
+ }
+ }
+}
+
+@mixin maximized-in-small-view {
+ .root.maximized {
+ @include maximized-base();
+
+ textarea {
+ display: none;
+ }
+
+ ::ng-deep .tab-content {
+ width: 100% !important;
+ }
+ }
+}
+
+@mixin maximized-tabs-in-mobile-view {
+ // Ellipsis on tabs for mobile view
+ .root.maximized {
+ .nav-preview {
+ ::ng-deep .nav-link {
+ @include ellipsis();
+
+ display: block !important;
+ max-width: 45% !important;
+ padding: 5px 0 !important;
+ margin-right: 10px !important;
+ text-align: center;
+
+ &:not(.active) {
+ max-width: 15% !important;
+ }
+
+ &.active {
+ padding: 5px 15px !important;
+ }
+ }
+ }
+ }
+}
+
+@mixin in-medium-view {
+ .root {
+ .nav-preview {
+ @include nav-preview-medium();
+ }
+
+ ::ng-deep .tab-content {
+ @include content-preview-base();
+ max-height: 210px;
+ border-bottom: 1px solid $input-border-color;
+ border-left: 1px solid $input-border-color;
+ border-right: 1px solid $input-border-color;
+ border-bottom-left-radius: $input-border-radius;
+ border-bottom-right-radius: $input-border-radius;
+ }
+ }
+}
+
+@mixin maximized-in-medium-view {
+ .root.maximized {
+ @include maximized-base();
+
+ textarea {
+ display: block;
+ padding: $base-padding;
+ border-right: 1px dashed $input-border-color !important;
+ resize: none;
+ scrollbar-color: pvar(--actionButtonColor) pvar(--markdownTextareaBackgroundColor);
+
+ &:focus {
+ box-shadow: none;
+ }
+ }
+ }
+}
+
+@include in-small-view();
+@include maximized-in-small-view();
+
+@media only screen and (max-width: $mobile-view) {
+ @include maximized-tabs-in-mobile-view();
+}
+
+@media only screen and (max-width: #{$mobile-view + $menu-width}) {
+ :host-context(.main-col:not(.expanded)) {
+ @include maximized-tabs-in-mobile-view();
+ }
+}
+
+@media only screen and (min-width: $small-view) {
+ :host-context(.expanded) {
+ @include in-medium-view();
+ }
+
+ @include maximized-in-medium-view();
+}
+
+@media only screen and (min-width: #{$small-view + $menu-width}) {
+ :host-context(.main-col:not(.expanded)) {
+ @include in-medium-view();
+ }
+}
--- /dev/null
+import truncate from 'lodash-es/truncate'
+import { Subject } from 'rxjs'
+import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
+import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { MarkdownService } from '@app/core'
+
+@Component({
+ selector: 'my-markdown-textarea',
+ templateUrl: './markdown-textarea.component.html',
+ styleUrls: [ './markdown-textarea.component.scss' ],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => MarkdownTextareaComponent),
+ multi: true
+ }
+ ]
+})
+
+export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
+ @Input() content = ''
+ @Input() classes: string[] | { [klass: string]: any[] | any } = []
+ @Input() textareaMaxWidth = '100%'
+ @Input() textareaHeight = '150px'
+ @Input() truncate: number
+ @Input() markdownType: 'text' | 'enhanced' = 'text'
+ @Input() markdownVideo = false
+ @Input() name = 'description'
+
+ @ViewChild('textarea') textareaElement: ElementRef
+
+ truncatedPreviewHTML = ''
+ previewHTML = ''
+ isMaximized = false
+
+ private contentChanged = new Subject<string>()
+
+ constructor (private markdownService: MarkdownService) {}
+
+ ngOnInit () {
+ this.contentChanged
+ .pipe(
+ debounceTime(150),
+ distinctUntilChanged()
+ )
+ .subscribe(() => this.updatePreviews())
+
+ this.contentChanged.next(this.content)
+ }
+
+ propagateChange = (_: any) => { /* empty */ }
+
+ writeValue (description: string) {
+ this.content = description
+
+ this.contentChanged.next(this.content)
+ }
+
+ registerOnChange (fn: (_: any) => void) {
+ this.propagateChange = fn
+ }
+
+ registerOnTouched () {
+ // Unused
+ }
+
+ onModelChange () {
+ this.propagateChange(this.content)
+
+ this.contentChanged.next(this.content)
+ }
+
+ onMaximizeClick () {
+ this.isMaximized = !this.isMaximized
+
+ // Make sure textarea have the focus
+ this.textareaElement.nativeElement.focus()
+
+ // Make sure the window has no scrollbars
+ if (!this.isMaximized) {
+ this.unlockBodyScroll()
+ } else {
+ this.lockBodyScroll()
+ }
+ }
+
+ private lockBodyScroll () {
+ document.getElementById('content').classList.add('lock-scroll')
+ }
+
+ private unlockBodyScroll () {
+ document.getElementById('content').classList.remove('lock-scroll')
+ }
+
+ private async updatePreviews () {
+ if (this.content === null || this.content === undefined) return
+
+ this.truncatedPreviewHTML = await this.markdownRender(truncate(this.content, { length: this.truncate }))
+ this.previewHTML = await this.markdownRender(this.content)
+ }
+
+ private async markdownRender (text: string) {
+ const html = this.markdownType === 'text' ?
+ await this.markdownService.textMarkdownToHTML(text) :
+ await this.markdownService.enhancedMarkdownToHTML(text)
+
+ return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html
+ }
+}
--- /dev/null
+<div class="root flex-column">
+ <div class="d-flex">
+ <label class="form-group-checkbox">
+ <input
+ type="checkbox"
+ [(ngModel)]="checked"
+ (ngModelChange)="onModelChange()"
+ [id]="inputName"
+ [disabled]="disabled"
+ />
+ <span role="checkbox" [attr.aria-checked]="checked"></span>
+ <span *ngIf="labelText">{{ labelText }}</span>
+ <span
+ *ngIf="!labelText && labelInnerHTML"
+ [innerHTML]="labelInnerHTML"
+ ></span>
+
+ <span *ngIf="labelTemplate">
+ <ng-container *ngTemplateOutlet="labelTemplate"></ng-container>
+ </span>
+ </label>
+
+ <my-help
+ *ngIf="helpTemplate"
+ [tooltipPlacement]="helpPlacement"
+ helpType="custom"
+ >
+ <ng-template ptTemplate="customHtml">
+ <ng-template *ngTemplateOutlet="helpTemplate"></ng-template>
+ </ng-template>
+ </my-help>
+
+ <div *ngIf="recommended" class="recommended" i18n>Recommended</div>
+ </div>
+
+ <div class="ml-4 d-flex flex-column">
+ <small class="wrapper mt-2 text-muted">
+ <ng-content select="description"></ng-content>
+ </small>
+
+ <span class="wrapper mt-3">
+ <ng-content select="extra"></ng-content>
+ </span>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.root {
+ display: flex;
+
+ .form-group-checkbox {
+ display: flex;
+ align-items: center;
+
+ .label-text {
+ font-weight: $font-regular;
+ margin: 0;
+ }
+
+ input {
+ @include peertube-checkbox(1px);
+ }
+ }
+
+ label {
+ margin-bottom: 0;
+ }
+
+ my-help {
+ position: relative;
+ top: 2px;
+ }
+
+ small {
+ font-size: 90%;
+ }
+
+ .wrapper:empty {
+ display: none;
+ }
+
+ .recommended {
+ margin-left: .5rem;
+ align-self: baseline;
+ display: inline-block;
+ padding: 4px 6px;
+ cursor: default;
+ border-radius: 3px;
+ font-size: 12px;
+ line-height: 12px;
+ font-weight: 500;
+ color: pvar(--inputPlaceholderColor);
+ background-color: rgba(217,225,232,.1);
+ border: 1px solid rgba(217,225,232,.5);
+ }
+}
\ No newline at end of file
--- /dev/null
+import { AfterContentInit, ChangeDetectorRef, Component, ContentChildren, forwardRef, Input, QueryList, TemplateRef } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { PeerTubeTemplateDirective } from '@app/shared/shared-main'
+
+@Component({
+ selector: 'my-peertube-checkbox',
+ styleUrls: [ './peertube-checkbox.component.scss' ],
+ templateUrl: './peertube-checkbox.component.html',
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => PeertubeCheckboxComponent),
+ multi: true
+ }
+ ]
+})
+export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterContentInit {
+ @Input() checked = false
+ @Input() inputName: string
+ @Input() labelText: string
+ @Input() labelInnerHTML: string
+ @Input() helpPlacement = 'top auto'
+ @Input() disabled = false
+ @Input() recommended = false
+
+ @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'label' | 'help'>>
+
+ // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836
+ @Input() onPushWorkaround = false
+
+ labelTemplate: TemplateRef<any>
+ helpTemplate: TemplateRef<any>
+
+ constructor (private cdr: ChangeDetectorRef) { }
+
+ ngAfterContentInit () {
+ {
+ const t = this.templates.find(t => t.name === 'label')
+ if (t) this.labelTemplate = t.template
+ }
+
+ {
+ const t = this.templates.find(t => t.name === 'help')
+ if (t) this.helpTemplate = t.template
+ }
+ }
+
+ propagateChange = (_: any) => { /* empty */ }
+
+ writeValue (checked: boolean) {
+ this.checked = checked
+
+ if (this.onPushWorkaround) {
+ this.cdr.markForCheck()
+ }
+ }
+
+ registerOnChange (fn: (_: any) => void) {
+ this.propagateChange = fn
+ }
+
+ registerOnTouched () {
+ // Unused
+ }
+
+ onModelChange () {
+ this.propagateChange(this.checked)
+ }
+
+ setDisabledState (isDisabled: boolean) {
+ this.disabled = isDisabled
+ }
+}
--- /dev/null
+<div class="root">
+ <div class="preview-container">
+ <my-reactive-file
+ [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right"
+ icon="edit" (fileChanged)="onFileChanged($event)" [ngbTooltip]="'(extensions: '+ videoImageExtensions +', '+ maxSizeText +': '+ maxVideoImageSizeInBytes +')'"
+ ></my-reactive-file>
+
+ <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
+ <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.root {
+ height: auto;
+ display: flex;
+ flex-direction: column;
+
+ .preview-container {
+ position: relative;
+
+ my-reactive-file {
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ }
+
+ .preview {
+ object-fit: cover;
+ border-radius: 4px;
+ max-width: 100%;
+
+ &.no-image {
+ border: 2px solid grey;
+ background-color: pvar(--mainBackgroundColor);
+ }
+ }
+ }
+}
--- /dev/null
+import { Component, forwardRef, Input, OnInit } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
+import { ServerService } from '@app/core'
+import { ServerConfig } from '@shared/models'
+import { BytesPipe } from 'ngx-pipes'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+ selector: 'my-preview-upload',
+ styleUrls: [ './preview-upload.component.scss' ],
+ templateUrl: './preview-upload.component.html',
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => PreviewUploadComponent),
+ multi: true
+ }
+ ]
+})
+export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
+ @Input() inputLabel: string
+ @Input() inputName: string
+ @Input() previewWidth: string
+ @Input() previewHeight: string
+
+ imageSrc: SafeResourceUrl
+ allowedExtensionsMessage = ''
+ maxSizeText: string
+
+ private serverConfig: ServerConfig
+ private bytesPipe: BytesPipe
+ private file: Blob
+
+ constructor (
+ private sanitizer: DomSanitizer,
+ private serverService: ServerService,
+ private i18n: I18n
+ ) {
+ this.bytesPipe = new BytesPipe()
+ this.maxSizeText = this.i18n('max size')
+ }
+
+ get videoImageExtensions () {
+ return this.serverConfig.video.image.extensions
+ }
+
+ get maxVideoImageSize () {
+ return this.serverConfig.video.image.size.max
+ }
+
+ get maxVideoImageSizeInBytes () {
+ return this.bytesPipe.transform(this.maxVideoImageSize)
+ }
+
+ ngOnInit () {
+ this.serverConfig = this.serverService.getTmpConfig()
+ this.serverService.getConfig()
+ .subscribe(config => this.serverConfig = config)
+
+ this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
+ }
+
+ onFileChanged (file: Blob) {
+ this.file = file
+
+ this.propagateChange(this.file)
+ this.updatePreview()
+ }
+
+ propagateChange = (_: any) => { /* empty */ }
+
+ writeValue (file: any) {
+ this.file = file
+ this.updatePreview()
+ }
+
+ registerOnChange (fn: (_: any) => void) {
+ this.propagateChange = fn
+ }
+
+ registerOnTouched () {
+ // Unused
+ }
+
+ private updatePreview () {
+ if (this.file) {
+ const url = URL.createObjectURL(this.file)
+ this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url)
+ }
+ }
+}
--- /dev/null
+<div class="root">
+ <div class="button-file" [ngClass]="{ 'with-icon': !!icon }">
+ <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
+
+ <span>{{ inputLabel }}</span>
+
+ <input
+ type="file"
+ [name]="inputName" [id]="inputName" [accept]="extensions"
+ (change)="fileChange($event)" [(ngModel)]="fileInputValue"
+ />
+ </div>
+
+ <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.root {
+ height: auto;
+ display: flex;
+ align-items: center;
+
+ .button-file {
+ @include peertube-button-file(auto);
+ @include grey-button;
+
+ &.with-icon {
+ @include button-with-icon;
+ }
+ }
+
+ .filename {
+ font-weight: $font-semibold;
+ margin-left: 5px;
+ }
+}
--- /dev/null
+import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { Notifier } from '@app/core'
+import { GlobalIconName } from '@app/shared/shared-icons'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+ selector: 'my-reactive-file',
+ styleUrls: [ './reactive-file.component.scss' ],
+ templateUrl: './reactive-file.component.html',
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => ReactiveFileComponent),
+ multi: true
+ }
+ ]
+})
+export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
+ @Input() inputLabel: string
+ @Input() inputName: string
+ @Input() extensions: string[] = []
+ @Input() maxFileSize: number
+ @Input() displayFilename = false
+ @Input() icon: GlobalIconName
+
+ @Output() fileChanged = new EventEmitter<Blob>()
+
+ allowedExtensionsMessage = ''
+ fileInputValue: any
+
+ private file: File
+
+ constructor (
+ private notifier: Notifier,
+ private i18n: I18n
+ ) {}
+
+ get filename () {
+ if (!this.file) return ''
+
+ return this.file.name
+ }
+
+ ngOnInit () {
+ this.allowedExtensionsMessage = this.extensions.join(', ')
+ }
+
+ fileChange (event: any) {
+ if (event.target.files && event.target.files.length) {
+ const [ file ] = event.target.files
+
+ if (file.size > this.maxFileSize) {
+ this.notifier.error(this.i18n('This file is too large.'))
+ return
+ }
+
+ const extension = '.' + file.name.split('.').pop()
+ if (this.extensions.includes(extension) === false) {
+ const message = this.i18n(
+ 'PeerTube cannot handle this kind of file. Accepted extensions are {{extensions}}.',
+ { extensions: this.allowedExtensionsMessage }
+ )
+ this.notifier.error(message)
+
+ return
+ }
+
+ this.file = file
+
+ this.propagateChange(this.file)
+ this.fileChanged.emit(this.file)
+ }
+ }
+
+ propagateChange = (_: any) => { /* empty */ }
+
+ writeValue (file: any) {
+ this.file = file
+
+ if (!this.file) this.fileInputValue = null
+ }
+
+ registerOnChange (fn: (_: any) => void) {
+ this.propagateChange = fn
+ }
+
+ registerOnTouched () {
+ // Unused
+ }
+}
--- /dev/null
+
+import { NgModule } from '@angular/core'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { BatchDomainsValidatorsService } from '@app/shared/shared-forms/form-validators/batch-domains-validators.service'
+import { SharedGlobalIconModule } from '../shared-icons'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import {
+ CustomConfigValidatorsService,
+ FormValidatorService,
+ InstanceValidatorsService,
+ LoginValidatorsService,
+ ResetPasswordValidatorsService,
+ UserValidatorsService,
+ VideoAbuseValidatorsService,
+ VideoAcceptOwnershipValidatorsService,
+ VideoBlockValidatorsService,
+ VideoCaptionsValidatorsService,
+ VideoChangeOwnershipValidatorsService,
+ VideoChannelValidatorsService,
+ VideoCommentValidatorsService,
+ VideoPlaylistValidatorsService,
+ VideoValidatorsService
+} from './form-validators'
+import { InputReadonlyCopyComponent } from './input-readonly-copy.component'
+import { MarkdownTextareaComponent } from './markdown-textarea.component'
+import { PeertubeCheckboxComponent } from './peertube-checkbox.component'
+import { PreviewUploadComponent } from './preview-upload.component'
+import { ReactiveFileComponent } from './reactive-file.component'
+import { TextareaAutoResizeDirective } from './textarea-autoresize.directive'
+import { TimestampInputComponent } from './timestamp-input.component'
+
+@NgModule({
+ imports: [
+ FormsModule,
+ ReactiveFormsModule,
+
+ SharedMainModule,
+ SharedGlobalIconModule
+ ],
+
+ declarations: [
+ InputReadonlyCopyComponent,
+ MarkdownTextareaComponent,
+ PeertubeCheckboxComponent,
+ PreviewUploadComponent,
+ ReactiveFileComponent,
+ TextareaAutoResizeDirective,
+ TimestampInputComponent
+ ],
+
+ exports: [
+ FormsModule,
+ ReactiveFormsModule,
+
+ InputReadonlyCopyComponent,
+ MarkdownTextareaComponent,
+ PeertubeCheckboxComponent,
+ PreviewUploadComponent,
+ ReactiveFileComponent,
+ TextareaAutoResizeDirective,
+ TimestampInputComponent
+ ],
+
+ providers: [
+ CustomConfigValidatorsService,
+ FormValidatorService,
+ LoginValidatorsService,
+ InstanceValidatorsService,
+ LoginValidatorsService,
+ ResetPasswordValidatorsService,
+ UserValidatorsService,
+ VideoAbuseValidatorsService,
+ VideoAcceptOwnershipValidatorsService,
+ VideoBlockValidatorsService,
+ VideoCaptionsValidatorsService,
+ VideoChangeOwnershipValidatorsService,
+ VideoChannelValidatorsService,
+ VideoCommentValidatorsService,
+ VideoPlaylistValidatorsService,
+ VideoValidatorsService,
+ BatchDomainsValidatorsService
+ ]
+})
+export class SharedFormModule { }
--- /dev/null
+// Thanks: https://github.com/evseevdev/ngx-textarea-autosize
+import { AfterViewInit, Directive, ElementRef, HostBinding, HostListener } from '@angular/core'
+
+@Directive({
+ selector: 'textarea[myAutoResize]'
+})
+export class TextareaAutoResizeDirective implements AfterViewInit {
+ @HostBinding('attr.rows') rows = '1'
+ @HostBinding('style.overflow') overflow = 'hidden'
+
+ constructor (private elem: ElementRef) { }
+
+ public ngAfterViewInit () {
+ this.resize()
+ }
+
+ @HostListener('input')
+ resize () {
+ const textarea = this.elem.nativeElement as HTMLTextAreaElement
+ // Reset textarea height to auto that correctly calculate the new height
+ textarea.style.height = 'auto'
+ // Set new height
+ textarea.style.height = `${textarea.scrollHeight}px`
+ }
+}
--- /dev/null
+<p-inputMask
+ [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
+ mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
+></p-inputMask>
--- /dev/null
+@import 'variables';
+
+p-inputmask {
+ ::ng-deep input {
+ width: 80px;
+ font-size: 15px;
+
+ border: none;
+
+ &:focus-within,
+ &:focus {
+ box-shadow: #{$focus-box-shadow-form} pvar(--mainColorLightest);
+ }
+ }
+}
--- /dev/null
+import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
+import { secondsToTime, timeToInt } from '../../../assets/player/utils'
+
+@Component({
+ selector: 'my-timestamp-input',
+ styleUrls: [ './timestamp-input.component.scss' ],
+ templateUrl: './timestamp-input.component.html',
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => TimestampInputComponent),
+ multi: true
+ }
+ ]
+})
+export class TimestampInputComponent implements ControlValueAccessor, OnInit {
+ @Input() maxTimestamp: number
+ @Input() timestamp: number
+ @Input() disabled = false
+
+ timestampString: string
+
+ constructor (private changeDetector: ChangeDetectorRef) {}
+
+ ngOnInit () {
+ this.writeValue(this.timestamp || 0)
+ }
+
+ propagateChange = (_: any) => { /* empty */ }
+
+ writeValue (timestamp: number) {
+ this.timestamp = timestamp
+
+ this.timestampString = secondsToTime(this.timestamp, true, ':')
+ }
+
+ registerOnChange (fn: (_: any) => void) {
+ this.propagateChange = fn
+ }
+
+ registerOnTouched () {
+ // Unused
+ }
+
+ onModelChange () {
+ this.timestamp = timeToInt(this.timestampString)
+
+ this.propagateChange(this.timestamp)
+ }
+
+ onBlur () {
+ if (this.maxTimestamp && this.timestamp > this.maxTimestamp) {
+ this.writeValue(this.maxTimestamp)
+
+ this.changeDetector.detectChanges()
+
+ this.propagateChange(this.timestamp)
+ }
+ }
+}
--- /dev/null
+::ng-deep {
+ svg {
+ width: inherit;
+ height: inherit;
+ }
+}
--- /dev/null
+import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit } from '@angular/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+
+const icons = {
+ 'add': require('!!raw-loader?!../../../assets/images/global/add.svg').default,
+ 'user': require('!!raw-loader?!../../../assets/images/global/user.svg').default,
+ 'sign-out': require('!!raw-loader?!../../../assets/images/global/sign-out.svg').default,
+ 'syndication': require('!!raw-loader?!../../../assets/images/global/syndication.svg').default,
+ 'help': require('!!raw-loader?!../../../assets/images/global/help.svg').default,
+ 'sparkle': require('!!raw-loader?!../../../assets/images/global/sparkle.svg').default,
+ 'alert': require('!!raw-loader?!../../../assets/images/global/alert.svg').default,
+ 'cloud-error': require('!!raw-loader?!../../../assets/images/global/cloud-error.svg').default,
+ 'clock': require('!!raw-loader?!../../../assets/images/global/clock.svg').default,
+ 'user-add': require('!!raw-loader?!../../../assets/images/global/user-add.svg').default,
+ 'no': require('!!raw-loader?!../../../assets/images/global/no.svg').default,
+ 'cloud-download': require('!!raw-loader?!../../../assets/images/global/cloud-download.svg').default,
+ 'undo': require('!!raw-loader?!../../../assets/images/global/undo.svg').default,
+ 'history': require('!!raw-loader?!../../../assets/images/global/history.svg').default,
+ 'circle-tick': require('!!raw-loader?!../../../assets/images/global/circle-tick.svg').default,
+ 'cog': require('!!raw-loader?!../../../assets/images/global/cog.svg').default,
+ 'download': require('!!raw-loader?!../../../assets/images/global/download.svg').default,
+ 'go': require('!!raw-loader?!../../../assets/images/menu/go.svg').default,
+ 'edit': require('!!raw-loader?!../../../assets/images/global/edit.svg').default,
+ 'im-with-her': require('!!raw-loader?!../../../assets/images/global/im-with-her.svg').default,
+ 'delete': require('!!raw-loader?!../../../assets/images/global/delete.svg').default,
+ 'server': require('!!raw-loader?!../../../assets/images/global/server.svg').default,
+ 'cross': require('!!raw-loader?!../../../assets/images/global/cross.svg').default,
+ 'validate': require('!!raw-loader?!../../../assets/images/global/validate.svg').default,
+ 'tick': require('!!raw-loader?!../../../assets/images/global/tick.svg').default,
+ 'repeat': require('!!raw-loader?!../../../assets/images/global/repeat.svg').default,
+ 'inbox-full': require('!!raw-loader?!../../../assets/images/global/inbox-full.svg').default,
+ 'dislike': require('!!raw-loader?!../../../assets/images/video/dislike.svg').default,
+ 'support': require('!!raw-loader?!../../../assets/images/video/support.svg').default,
+ 'like': require('!!raw-loader?!../../../assets/images/video/like.svg').default,
+ 'more-horizontal': require('!!raw-loader?!../../../assets/images/global/more-horizontal.svg').default,
+ 'more-vertical': require('!!raw-loader?!../../../assets/images/global/more-vertical.svg').default,
+ 'share': require('!!raw-loader?!../../../assets/images/video/share.svg').default,
+ 'upload': require('!!raw-loader?!../../../assets/images/video/upload.svg').default,
+ 'playlist-add': require('!!raw-loader?!../../../assets/images/video/playlist-add.svg').default,
+ 'play': require('!!raw-loader?!../../../assets/images/global/play.svg').default,
+ 'playlists': require('!!raw-loader?!../../../assets/images/global/playlists.svg').default,
+ 'globe': require('!!raw-loader?!../../../assets/images/menu/globe.svg').default,
+ 'home': require('!!raw-loader?!../../../assets/images/menu/home.svg').default,
+ 'recently-added': require('!!raw-loader?!../../../assets/images/menu/recently-added.svg').default,
+ 'trending': require('!!raw-loader?!../../../assets/images/menu/trending.svg').default,
+ 'video-lang': require('!!raw-loader?!../../../assets/images/global/video-lang.svg').default,
+ 'videos': require('!!raw-loader?!../../../assets/images/global/videos.svg').default,
+ 'folder': require('!!raw-loader?!../../../assets/images/global/folder.svg').default,
+ 'subscriptions': require('!!raw-loader?!../../../assets/images/menu/subscriptions.svg').default,
+ 'language': require('!!raw-loader?!../../../assets/images/menu/language.svg').default,
+ 'unsensitive': require('!!raw-loader?!../../../assets/images/menu/eye.svg').default,
+ 'sensitive': require('!!raw-loader?!../../../assets/images/menu/eye-closed.svg').default,
+ 'p2p': require('!!raw-loader?!../../../assets/images/menu/p2p.svg').default,
+ 'users': require('!!raw-loader?!../../../assets/images/global/users.svg').default,
+ 'search': require('!!raw-loader?!../../../assets/images/global/search.svg').default,
+ 'refresh': require('!!raw-loader?!../../../assets/images/global/refresh.svg').default,
+ 'npm': require('!!raw-loader?!../../../assets/images/global/npm.svg').default,
+ 'fullscreen': require('!!raw-loader?!../../../assets/images/global/fullscreen.svg').default,
+ 'exit-fullscreen': require('!!raw-loader?!../../../assets/images/global/exit-fullscreen.svg').default,
+ 'robot': require('!!raw-loader?!../../../assets/images/global/robot.svg').default
+}
+
+export type GlobalIconName = keyof typeof icons
+
+@Component({
+ selector: 'my-global-icon',
+ template: '',
+ styleUrls: [ './global-icon.component.scss' ],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class GlobalIconComponent implements OnInit {
+ @Input() iconName: GlobalIconName
+
+ constructor (
+ private el: ElementRef,
+ private hooks: HooksService
+ ) { }
+
+ async ngOnInit () {
+ const nativeElement = this.el.nativeElement as HTMLElement
+ nativeElement.innerHTML = await this.hooks.wrapFun(
+ this.getSVGContent.bind(this),
+ { name: this.iconName },
+ 'common',
+ 'filter:internal.common.svg-icons.get-content.params',
+ 'filter:internal.common.svg-icons.get-content.result'
+ )
+ }
+
+ private getSVGContent (options: { name: string }) {
+ return icons[options.name]
+ }
+}
--- /dev/null
+export * from './global-icon.component'
+
+export * from './shared-global-icon.module'
--- /dev/null
+
+import { CommonModule } from '@angular/common'
+import { NgModule } from '@angular/core'
+import { GlobalIconComponent } from './global-icon.component'
+
+@NgModule({
+ imports: [
+ CommonModule
+ ],
+
+ declarations: [
+ GlobalIconComponent
+ ],
+
+ exports: [
+ GlobalIconComponent
+ ],
+
+ providers: [ ]
+})
+export class SharedGlobalIconModule { }
--- /dev/null
+<span *ngIf="value === true" class="glyphicon glyphicon-ok" i18n-aria-label aria-label="yes"></span>
+<span *ngIf="value === false" class="glyphicon glyphicon-remove" i18n-aria-label aria-label="no"></span>
+
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.glyphicon-ok {
+ color: $green;
+}
+
+.glyphicon-remove {
+ color: $red;
+}
--- /dev/null
+import { Component, Input } from '@angular/core'
+
+@Component({
+ selector: 'my-feature-boolean',
+ templateUrl: './feature-boolean.component.html',
+ styleUrls: [ './feature-boolean.component.scss' ]
+})
+export class FeatureBooleanComponent {
+ @Input() value: boolean
+}
--- /dev/null
+export * from './feature-boolean.component'
+export * from './instance-features-table.component'
+export * from './instance-follow.service'
+export * from './instance-statistics.component'
+export * from './instance.service'
+export * from './shared-instance.module'
--- /dev/null
+<div class="feature-table">
+
+ <table class="table" *ngIf="serverConfig">
+ <caption i18n>Features found on this instance</caption>
+ <tr>
+ <th i18n class="label" scope="row">PeerTube version</th>
+
+ <td class="value">{{ getServerVersionAndCommit() }}</td>
+ </tr>
+
+ <tr>
+ <th i18n class="label" scope="row">
+ <div>Default NSFW/sensitive videos policy</div>
+ <div class="more-info">can be redefined by the users</div>
+ </th>
+
+ <td class="value">{{ buildNSFWLabel() }}</td>
+ </tr>
+
+ <tr>
+ <th i18n class="label" scope="row">User registration allowed</th>
+ <td>
+ <my-feature-boolean [value]="serverConfig.signup.allowed"></my-feature-boolean>
+ </td>
+ </tr>
+
+ <tr>
+ <th i18n class="label" colspan="2">Video uploads</th>
+ </tr>
+
+ <tr>
+ <th i18n class="sub-label" scope="row">Transcoding in multiple resolutions</th>
+ <td>
+ <my-feature-boolean [value]="serverConfig.transcoding.enabledResolutions.length !== 0"></my-feature-boolean>
+ </td>
+ </tr>
+
+ <tr>
+ <th i18n class="sub-label" scope="row">Video uploads</th>
+ <td>
+ <span i18n *ngIf="serverConfig.autoBlacklist.videos.ofUsers.enabled">Requires manual validation by moderators</span>
+ <span i18n *ngIf="!serverConfig.autoBlacklist.videos.ofUsers.enabled">Automatically published</span>
+ </td>
+ </tr>
+
+ <tr>
+ <th i18n class="sub-label" scope="row">Video quota</th>
+
+ <td class="value">
+ <ng-container *ngIf="initialUserVideoQuota !== -1">
+ {{ initialUserVideoQuota | bytes: 0 }} <ng-container *ngIf="dailyUserVideoQuota !== -1">({{ dailyUserVideoQuota | bytes: 0 }} per day)</ng-container>
+
+ <my-help tooltipPlacement="auto" helpType="custom">
+ <ng-template ptTemplate="customHtml">
+ <div [innerHTML]="quotaHelpIndication"></div>
+ </ng-template>
+ </my-help>
+ </ng-container>
+
+ <ng-container i18n *ngIf="initialUserVideoQuota === -1">
+ Unlimited <ng-container *ngIf="dailyUserVideoQuota !== -1">({{ dailyUserVideoQuota | bytes: 0 }} per day)</ng-container>
+ </ng-container>
+ </td>
+ </tr>
+
+ <tr>
+ <th i18n class="label" colspan="2">Import</th>
+ </tr>
+
+ <tr>
+ <th i18n class="sub-label" scope="row">HTTP import (YouTube, Vimeo, direct URL...)</th>
+ <td>
+ <my-feature-boolean [value]="serverConfig.import.videos.http.enabled"></my-feature-boolean>
+ </td>
+ </tr>
+
+ <tr>
+ <th i18n class="sub-label" scope="row">Torrent import</th>
+ <td>
+ <my-feature-boolean [value]="serverConfig.import.videos.torrent.enabled"></my-feature-boolean>
+ </td>
+ </tr>
+
+
+ <tr>
+ <th i18n class="label" colspan="2">Player</th>
+ </tr>
+
+ <tr>
+ <th i18n class="sub-label" scope="row">P2P enabled</th>
+ <td>
+ <my-feature-boolean [value]="serverConfig.tracker.enabled"></my-feature-boolean>
+ </td>
+ </tr>
+
+ <tr>
+ <th i18n class="label" colspan="2">Search</th>
+ </tr>
+
+ <tr>
+ <th i18n class="sub-label" scope="row">Users can resolve distant content</th>
+ <td>
+ <my-feature-boolean [value]="serverConfig.search.remoteUri.users"></my-feature-boolean>
+ </td>
+ </tr>
+ </table>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+table {
+ font-size: 14px;
+ color: pvar(--mainForegroundColor);
+
+ .label,
+ .sub-label {
+ min-width: 330px;
+
+ &.label {
+ font-weight: $font-semibold;
+ }
+
+ &.sub-label {
+ font-weight: $font-regular;
+ padding-left: 30px;
+ }
+
+ .more-info {
+ font-style: italic;
+ font-weight: initial;
+ font-size: 14px
+ }
+ }
+
+ td {
+ vertical-align: middle;
+ }
+
+ caption {
+ caption-side: top;
+ font-size: 15px;
+ font-weight: $font-semibold;
+ color: pvar(--mainForegroundColor);
+ }
+}
+
+
--- /dev/null
+import { Component, OnInit } from '@angular/core'
+import { ServerService } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig } from '@shared/models'
+
+@Component({
+ selector: 'my-instance-features-table',
+ templateUrl: './instance-features-table.component.html',
+ styleUrls: [ './instance-features-table.component.scss' ]
+})
+export class InstanceFeaturesTableComponent implements OnInit {
+ quotaHelpIndication = ''
+ serverConfig: ServerConfig
+
+ constructor (
+ private i18n: I18n,
+ private serverService: ServerService
+ ) {
+ }
+
+ get initialUserVideoQuota () {
+ return this.serverConfig.user.videoQuota
+ }
+
+ get dailyUserVideoQuota () {
+ return Math.min(this.initialUserVideoQuota, this.serverConfig.user.videoQuotaDaily)
+ }
+
+ ngOnInit () {
+ this.serverConfig = this.serverService.getTmpConfig()
+ this.serverService.getConfig()
+ .subscribe(config => {
+ this.serverConfig = config
+ this.buildQuotaHelpIndication()
+ })
+ }
+
+ buildNSFWLabel () {
+ const policy = this.serverConfig.instance.defaultNSFWPolicy
+
+ if (policy === 'do_not_list') return this.i18n('Hidden')
+ if (policy === 'blur') return this.i18n('Blurred with confirmation request')
+ if (policy === 'display') return this.i18n('Displayed')
+ }
+
+ getServerVersionAndCommit () {
+ return this.serverService.getServerVersionAndCommit()
+ }
+
+ private getApproximateTime (seconds: number) {
+ const hours = Math.floor(seconds / 3600)
+ let pluralSuffix = ''
+ if (hours > 1) pluralSuffix = 's'
+ if (hours > 0) return `~ ${hours} hour${pluralSuffix}`
+
+ const minutes = Math.floor(seconds % 3600 / 60)
+
+ return this.i18n('~ {{minutes}} {minutes, plural, =1 {minute} other {minutes}}', { minutes })
+ }
+
+ private buildQuotaHelpIndication () {
+ if (this.initialUserVideoQuota === -1) return
+
+ const initialUserVideoQuotaBit = this.initialUserVideoQuota * 8
+
+ // 1080p: ~ 6Mbps
+ // 720p: ~ 4Mbps
+ // 360p: ~ 1.5Mbps
+ const fullHdSeconds = initialUserVideoQuotaBit / (6 * 1000 * 1000)
+ const hdSeconds = initialUserVideoQuotaBit / (4 * 1000 * 1000)
+ const normalSeconds = initialUserVideoQuotaBit / (1.5 * 1000 * 1000)
+
+ const lines = [
+ this.i18n('{{seconds}} of full HD videos', { seconds: this.getApproximateTime(fullHdSeconds) }),
+ this.i18n('{{seconds}} of HD videos', { seconds: this.getApproximateTime(hdSeconds) }),
+ this.i18n('{{seconds}} of average quality videos', { seconds: this.getApproximateTime(normalSeconds) })
+ ]
+
+ this.quotaHelpIndication = lines.join('<br />')
+ }
+}
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { Observable } from 'rxjs'
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { ActivityPubActorType, ActorFollow, FollowState, ResultList } from '@shared/index'
+import { environment } from '../../../environments/environment'
+
+@Injectable()
+export class InstanceFollowService {
+ private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/server'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restService: RestService,
+ private restExtractor: RestExtractor
+ ) {
+ }
+
+ getFollowing (options: {
+ pagination: RestPagination,
+ sort: SortMeta,
+ search?: string,
+ actorType?: ActivityPubActorType,
+ state?: FollowState
+ }): Observable<ResultList<ActorFollow>> {
+ const { pagination, sort, search, state, actorType } = options
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) params = params.append('search', search)
+ if (state) params = params.append('state', state)
+ if (actorType) params = params.append('actorType', actorType)
+
+ return this.authHttp.get<ResultList<ActorFollow>>(InstanceFollowService.BASE_APPLICATION_URL + '/following', { params })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ getFollowers (options: {
+ pagination: RestPagination,
+ sort: SortMeta,
+ search?: string,
+ actorType?: ActivityPubActorType,
+ state?: FollowState
+ }): Observable<ResultList<ActorFollow>> {
+ const { pagination, sort, search, state, actorType } = options
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) params = params.append('search', search)
+ if (state) params = params.append('state', state)
+ if (actorType) params = params.append('actorType', actorType)
+
+ return this.authHttp.get<ResultList<ActorFollow>>(InstanceFollowService.BASE_APPLICATION_URL + '/followers', { params })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ follow (notEmptyHosts: string[]) {
+ const body = {
+ hosts: notEmptyHosts
+ }
+
+ return this.authHttp.post(InstanceFollowService.BASE_APPLICATION_URL + '/following', body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ unfollow (follow: ActorFollow) {
+ return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + follow.following.host)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ acceptFollower (follow: ActorFollow) {
+ const handle = follow.follower.name + '@' + follow.follower.host
+
+ return this.authHttp.post(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}/accept`, {})
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ rejectFollower (follow: ActorFollow) {
+ const handle = follow.follower.name + '@' + follow.follower.host
+
+ return this.authHttp.post(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}/reject`, {})
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ removeFollower (follow: ActorFollow) {
+ const handle = follow.follower.name + '@' + follow.follower.host
+
+ return this.authHttp.delete(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}`)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+}
--- /dev/null
+<p i18n *ngIf="null === serverStats">Loading instance statistics...</p>
+
+<section *ngIf="null !== serverStats">
+ <h3 i18n>Local</h3>
+
+ <div class="row">
+ <div class="col-6 col-lg-4 col-xl-3">
+ <div class="card stat">
+ <div class="card-body">
+ <p class="stat-value">{{ serverStats.totalUsers }}</p>
+ <p class="stat-label" i18n>users</p>
+ </div>
+ <i class="glyphicon glyphicon-user icon-bottom"></i>
+ </div>
+ </div>
+
+ <div class="col-6 col-lg-4 col-xl-3">
+ <div class="card stat">
+ <div class="card-body">
+ <p class="stat-value">{{ serverStats.totalLocalVideos }}</p>
+ <p class="stat-label" i18n>videos</p>
+ </div>
+ <i class="glyphicon glyphicon-facetime-video"></i>
+ </div>
+ </div>
+
+ <div class="col-6 col-lg-4 col-xl-3">
+ <div class="card stat">
+ <div class="card-body">
+ <p class="stat-value">{{ serverStats.totalLocalVideoViews }}</p>
+ <p class="stat-label" i18n>video views</p>
+ </div>
+ <i class="glyphicon glyphicon-eye-open"></i>
+ </div>
+ </div>
+
+ <div class="col-6 col-lg-4 col-xl-3">
+ <div class="card stat">
+ <div class="card-body">
+ <p class="stat-value">{{ serverStats.totalLocalVideoComments }}</p>
+ <p class="stat-label" i18n>video comments</p>
+ </div>
+ <i class="glyphicon glyphicon-comment"></i>
+ </div>
+ </div>
+
+ <div class="col-6 col-lg-4 col-xl-3">
+ <div class="card stat">
+ <div class="card-body">
+ <p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p>
+ <p class="stat-label" i18n>of hosted video</p>
+ </div>
+ <i class="glyphicon glyphicon-hdd"></i>
+ </div>
+ </div>
+ </div>
+
+ <h3 i18n>Federation</h3>
+
+ <div class="row">
+ <div class="col-6 col-lg-4 col-xl-3">
+ <div class="card stat">
+ <div class="card-body">
+ <p class="stat-value">{{ serverStats.totalVideos }}</p>
+ <p class="stat-label" i18n>videos</p>
+ </div>
+ <i class="glyphicon glyphicon-facetime-video"></i>
+ </div>
+ </div>
+
+ <div class="col-6 col-lg-4 col-xl-3">
+ <div class="card stat">
+ <div class="card-body">
+ <p class="stat-value">{{ serverStats.totalVideoComments }}</p>
+ <p class="stat-label" i18n>video comments</p>
+ </div>
+ <i class="glyphicon glyphicon-comment"></i>
+ </div>
+ </div>
+
+ <div class="col-6 col-lg-4 col-xl-3">
+ <div class="card stat">
+ <div class="card-body">
+ <p class="stat-value">{{ serverStats.totalInstanceFollowers }}</p>
+ <p class="stat-label" i18n>followers</p>
+ </div>
+ <i class="glyphicon glyphicon-retweet"></i>
+ </div>
+ </div>
+
+ <div class="col-6 col-lg-4 col-xl-3">
+ <div class="card stat">
+ <div class="card-body">
+ <p class="stat-value">{{ serverStats.totalInstanceFollowing }}</p>
+ <p class="stat-label" i18n>following</p>
+ </div>
+ <i class="glyphicon glyphicon-retweet"></i>
+ </div>
+ </div>
+ </div>
+</section>
--- /dev/null
+
+h3 {
+ font-size: 1.25rem;
+}
+
+.stat {
+ text-align: center;
+ margin-bottom: 1em;
+ overflow: hidden;
+
+ .stat-value {
+ font-size: 2.25em;
+ line-height: 1em;
+ margin: 0;
+ }
+
+ .stat-label {
+ font-size: 1.15em;
+ margin: 0;
+ }
+
+ .glyphicon {
+ opacity: 0.12;
+ position: absolute;
+ left: 16px;
+ top: -24px;
+
+ &.icon-bottom {
+ top: 4px;
+ }
+
+ &::before {
+ font-size: 8em;
+ }
+ }
+
+ .card-body {
+ z-index: 2;
+ }
+}
--- /dev/null
+import { Component, OnInit } from '@angular/core'
+import { ServerStats } from '@shared/models/server'
+import { ServerService } from '@app/core'
+
+@Component({
+ selector: 'my-instance-statistics',
+ templateUrl: './instance-statistics.component.html',
+ styleUrls: [ './instance-statistics.component.scss' ]
+})
+export class InstanceStatisticsComponent implements OnInit {
+ serverStats: ServerStats = null
+
+ constructor (
+ private serverService: ServerService
+ ) {
+ }
+
+ ngOnInit () {
+ this.serverService.getServerStats()
+ .subscribe(res => this.serverStats = res)
+ }
+}
--- /dev/null
+import { forkJoin } from 'rxjs'
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { MarkdownService, RestExtractor, ServerService } from '@app/core'
+import { About, peertubeTranslate } from '@shared/models'
+import { environment } from '../../../environments/environment'
+
+@Injectable()
+export class InstanceService {
+ private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
+ private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private markdownService: MarkdownService,
+ private serverService: ServerService
+ ) {
+ }
+
+ getAbout () {
+ return this.authHttp.get<About>(InstanceService.BASE_CONFIG_URL + '/about')
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+ }
+
+ contactAdministrator (fromEmail: string, fromName: string, subject: string, message: string) {
+ const body = {
+ fromEmail,
+ fromName,
+ subject,
+ body: message
+ }
+
+ return this.authHttp.post(InstanceService.BASE_SERVER_URL + '/contact', body)
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+
+ }
+
+ async buildHtml (about: About) {
+ const html = {
+ description: '',
+ terms: '',
+ codeOfConduct: '',
+ moderationInformation: '',
+ administrator: '',
+ hardwareInformation: ''
+ }
+
+ for (const key of Object.keys(html)) {
+ html[ key ] = await this.markdownService.textMarkdownToHTML(about.instance[ key ])
+ }
+
+ return html
+ }
+
+ buildTranslatedLanguages (about: About) {
+ return forkJoin([
+ this.serverService.getVideoLanguages(),
+ this.serverService.getServerLocale()
+ ]).pipe(
+ map(([ languagesArray, translations ]) => {
+ return about.instance.languages
+ .map(l => {
+ const languageObj = languagesArray.find(la => la.id === l)
+
+ return peertubeTranslate(languageObj.label, translations)
+ })
+ })
+ )
+ }
+
+ buildTranslatedCategories (about: About) {
+ return forkJoin([
+ this.serverService.getVideoCategories(),
+ this.serverService.getServerLocale()
+ ]).pipe(
+ map(([ categoriesArray, translations ]) => {
+ return about.instance.categories
+ .map(c => {
+ const categoryObj = categoriesArray.find(ca => ca.id === c)
+
+ return peertubeTranslate(categoryObj.label, translations)
+ })
+ })
+ )
+ }
+}
--- /dev/null
+
+import { NgModule } from '@angular/core'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import { FeatureBooleanComponent } from './feature-boolean.component'
+import { InstanceFeaturesTableComponent } from './instance-features-table.component'
+import { InstanceFollowService } from './instance-follow.service'
+import { InstanceStatisticsComponent } from './instance-statistics.component'
+import { InstanceService } from './instance.service'
+
+@NgModule({
+ imports: [
+ SharedMainModule
+ ],
+
+ declarations: [
+ FeatureBooleanComponent,
+ InstanceFeaturesTableComponent,
+ InstanceStatisticsComponent
+ ],
+
+ exports: [
+ FeatureBooleanComponent,
+ InstanceFeaturesTableComponent,
+ InstanceStatisticsComponent
+ ],
+
+ providers: [
+ InstanceFollowService,
+ InstanceService
+ ]
+})
+export class SharedInstanceModule { }
--- /dev/null
+import { Account as ServerAccount } from '@shared/models/actors/account.model'
+import { Actor } from './actor.model'
+
+export class Account extends Actor implements ServerAccount {
+ displayName: string
+ description: string
+ nameWithHost: string
+ nameWithHostForced: string
+ mutedByUser: boolean
+ mutedByInstance: boolean
+ mutedServerByUser: boolean
+ mutedServerByInstance: boolean
+
+ userId?: number
+
+ constructor (hash: ServerAccount) {
+ super(hash)
+
+ this.displayName = hash.displayName
+ this.description = hash.description
+ this.userId = hash.userId
+ this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
+ this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
+
+ this.mutedByUser = false
+ this.mutedByInstance = false
+ this.mutedServerByUser = false
+ this.mutedServerByInstance = false
+ }
+}
--- /dev/null
+import { Observable, ReplaySubject } from 'rxjs'
+import { catchError, map, tap } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor } from '@app/core'
+import { Account as ServerAccount } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { Account } from './account.model'
+
+@Injectable()
+export class AccountService {
+ static BASE_ACCOUNT_URL = environment.apiUrl + '/api/v1/accounts/'
+
+ accountLoaded = new ReplaySubject<Account>(1)
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor
+ ) {}
+
+ getAccount (id: number | string): Observable<Account> {
+ return this.authHttp.get<ServerAccount>(AccountService.BASE_ACCOUNT_URL + id)
+ .pipe(
+ map(accountHash => new Account(accountHash)),
+ tap(account => this.accountLoaded.next(account)),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+}
--- /dev/null
+<ng-container *ngIf="actor">
+ <div class="actor">
+ <div class="d-flex">
+ <img [src]="actor.avatarUrl" alt="Avatar" />
+
+ <div class="actor-img-edit-container">
+ <div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body">
+ <my-global-icon iconName="edit"></my-global-icon>
+ <label for="avatarfile" i18n>Change your avatar</label>
+ <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
+ </div>
+ </div>
+ </div>
+
+
+ <div class="actor-info">
+ <div class="actor-info-names">
+ <div class="actor-info-display-name">{{ actor.displayName }}</div>
+ <div class="actor-info-username">{{ actor.name }}</div>
+ </div>
+ <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
+ </div>
+ </div>
+</ng-container>
\ No newline at end of file
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.actor {
+ display: flex;
+
+ img {
+ @include avatar(100px);
+
+ margin-right: 15px;
+ }
+
+ .actor-img-edit-container {
+ position: relative;
+ width: 0;
+
+ .actor-img-edit-button {
+ @include peertube-button-file(21px);
+ @include button-with-icon(19px);
+
+ margin-top: 10px;
+ margin-bottom: 5px;
+ border-radius: 50%;
+ top: 55px;
+ right: 45px;
+ cursor: pointer;
+
+ input {
+ width: 30px;
+ height: 30px;
+ }
+
+ my-global-icon {
+ right: 7px;
+ }
+ }
+ }
+
+ .actor-info {
+ justify-content: center;
+ display: inline-flex;
+ flex-direction: column;
+
+ .actor-info-names {
+ display: flex;
+ align-items: center;
+
+ .actor-info-display-name {
+ font-size: 20px;
+ font-weight: $font-bold;
+
+ @media screen and (max-width: $small-view) {
+ font-size: 16px;
+ }
+ }
+
+ .actor-info-username {
+ margin-left: 7px;
+ position: relative;
+ top: 2px;
+ font-size: 14px;
+ color: $grey-actor-name;
+ }
+ }
+
+ .actor-info-followers {
+ font-size: 15px;
+ padding-bottom: .5rem;
+ }
+ }
+}
--- /dev/null
+import { BytesPipe } from 'ngx-pipes'
+import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { Notifier, ServerService } from '@app/core'
+import { Account, VideoChannel } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig } from '@shared/models'
+
+@Component({
+ selector: 'my-actor-avatar-info',
+ templateUrl: './actor-avatar-info.component.html',
+ styleUrls: [ './actor-avatar-info.component.scss' ]
+})
+export class ActorAvatarInfoComponent implements OnInit {
+ @ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
+
+ @Input() actor: VideoChannel | Account
+
+ @Output() avatarChange = new EventEmitter<FormData>()
+
+ maxSizeText: string
+
+ private serverConfig: ServerConfig
+ private bytesPipe: BytesPipe
+
+ constructor (
+ private serverService: ServerService,
+ private notifier: Notifier,
+ private i18n: I18n
+ ) {
+ this.bytesPipe = new BytesPipe()
+ this.maxSizeText = this.i18n('max size')
+ }
+
+ ngOnInit (): void {
+ this.serverConfig = this.serverService.getTmpConfig()
+ this.serverService.getConfig()
+ .subscribe(config => this.serverConfig = config)
+ }
+
+ onAvatarChange () {
+ const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ]
+ if (avatarfile.size > this.maxAvatarSize) {
+ this.notifier.error('Error', 'This image is too large.')
+ return
+ }
+
+ const formData = new FormData()
+ formData.append('avatarfile', avatarfile)
+
+ this.avatarChange.emit(formData)
+ }
+
+ get maxAvatarSize () {
+ return this.serverConfig.avatar.file.size.max
+ }
+
+ get maxAvatarSizeInBytes () {
+ return this.bytesPipe.transform(this.maxAvatarSize)
+ }
+
+ get avatarExtensions () {
+ return this.serverConfig.avatar.file.extensions.join(', ')
+ }
+}
--- /dev/null
+import { Actor as ActorServer, Avatar } from '@shared/models'
+import { getAbsoluteAPIUrl } from '@app/helpers'
+
+export abstract class Actor implements ActorServer {
+ id: number
+ url: string
+ name: string
+ host: string
+ followingCount: number
+ followersCount: number
+ createdAt: Date | string
+ updatedAt: Date | string
+ avatar: Avatar
+
+ avatarUrl: string
+
+ static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) {
+ if (actor?.avatar?.url) return actor.avatar.url
+
+ if (actor && actor.avatar) {
+ const absoluteAPIUrl = getAbsoluteAPIUrl()
+
+ return absoluteAPIUrl + actor.avatar.path
+ }
+
+ return this.GET_DEFAULT_AVATAR_URL()
+ }
+
+ static GET_DEFAULT_AVATAR_URL () {
+ return window.location.origin + '/client/assets/images/default-avatar.png'
+ }
+
+ static CREATE_BY_STRING (accountName: string, host: string, forceHostname = false) {
+ const absoluteAPIUrl = getAbsoluteAPIUrl()
+ const thisHost = new URL(absoluteAPIUrl).host
+
+ if (host.trim() === thisHost && !forceHostname) return accountName
+
+ return accountName + '@' + host
+ }
+
+ protected constructor (hash: ActorServer) {
+ this.id = hash.id
+ this.url = hash.url
+ this.name = hash.name
+ this.host = hash.host
+ this.followingCount = hash.followingCount
+ this.followersCount = hash.followersCount
+ this.createdAt = new Date(hash.createdAt.toString())
+ this.updatedAt = new Date(hash.updatedAt.toString())
+ this.avatar = hash.avatar
+
+ this.updateComputedAttributes()
+ }
+
+ updateAvatar (newAvatar: Avatar) {
+ this.avatar = newAvatar
+
+ this.updateComputedAttributes()
+ }
+
+ private updateComputedAttributes () {
+ this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this)
+ }
+}
--- /dev/null
+<div class="wrapper" [ngClass]="'avatar-' + size">
+ <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
+ <img [src]="video.videoChannelAvatarUrl" i18n-alt alt="Channel avatar" />
+ </a>
+ <a [routerLink]="[ '/accounts', video.byAccount ]" [title]="accountLinkTitle">
+ <img [src]="video.accountAvatarUrl" i18n-alt alt="Account avatar" />
+ </a>
+</div>
--- /dev/null
+@import '_mixins';
+
+.wrapper {
+ $avatar-size: 35px;
+
+ width: $avatar-size;
+ height: $avatar-size;
+ position: relative;
+ margin-right: 5px;
+ margin-bottom: 5px;
+
+ &.avatar-sm {
+ width: 28px;
+ height: 28px;
+ margin-bottom: 3px;
+ }
+
+ a {
+ @include disable-outline;
+ }
+
+ a img {
+ height: 100%;
+ object-fit: cover;
+ position: absolute;
+ top:50%;
+ left:50%;
+ border-radius: 50%;
+ transform: translate(-50%,-50%)
+ }
+
+ a:nth-of-type(2) img {
+ height: 60%;
+ width: 60%;
+ border: 2px solid pvar(--mainBackgroundColor);
+ transform: translateX(15%);
+ position: relative;
+ background-color: pvar(--mainBackgroundColor);
+ }
+}
--- /dev/null
+import { Component, Input, OnInit } from '@angular/core'
+import { Video } from '../video/video.model'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+ selector: 'avatar-channel',
+ templateUrl: './avatar.component.html',
+ styleUrls: [ './avatar.component.scss' ]
+})
+export class AvatarComponent implements OnInit {
+ @Input() video: Video
+ @Input() size: 'md' | 'sm' = 'md'
+
+ channelLinkTitle = ''
+ accountLinkTitle = ''
+
+ constructor (
+ private i18n: I18n
+ ) {}
+
+ ngOnInit () {
+ this.channelLinkTitle = this.i18n(
+ '{{name}} (channel page)',
+ { name: this.video.channel.name, handle: this.video.byVideoChannel }
+ )
+ this.accountLinkTitle = this.i18n(
+ '{{name}} (account page)',
+ { name: this.video.account.name, handle: this.video.byAccount }
+ )
+ }
+}
--- /dev/null
+export * from './account.model'
+export * from './account.service'
+export * from './actor-avatar-info.component'
+export * from './actor.model'
+export * from './avatar.component'
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
+@Pipe({ name: 'myFromNow' })
+export class FromNowPipe implements PipeTransform {
+
+ constructor (private i18n: I18n) { }
+
+ transform (arg: number | Date | string) {
+ const argDate = new Date(arg)
+ const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
+
+ let interval = Math.floor(seconds / 31536000)
+ if (interval > 1) return this.i18n('{{interval}} years ago', { interval })
+ if (interval === 1) return this.i18n('{{interval}} year ago', { interval })
+
+ interval = Math.floor(seconds / 2592000)
+ if (interval > 1) return this.i18n('{{interval}} months ago', { interval })
+ if (interval === 1) return this.i18n('{{interval}} month ago', { interval })
+
+ interval = Math.floor(seconds / 604800)
+ if (interval > 1) return this.i18n('{{interval}} weeks ago', { interval })
+ if (interval === 1) return this.i18n('{{interval}} week ago', { interval })
+
+ interval = Math.floor(seconds / 86400)
+ if (interval > 1) return this.i18n('{{interval}} days ago', { interval })
+ if (interval === 1) return this.i18n('{{interval}} day ago', { interval })
+
+ interval = Math.floor(seconds / 3600)
+ if (interval > 1) return this.i18n('{{interval}} hours ago', { interval })
+ if (interval === 1) return this.i18n('{{interval}} hour ago', { interval })
+
+ interval = Math.floor(seconds / 60)
+ if (interval >= 1) return this.i18n('{{interval}} min ago', { interval })
+
+ return this.i18n('just now')
+ }
+}
--- /dev/null
+export * from './from-now.pipe'
+export * from './infinite-scroller.directive'
+export * from './number-formatter.pipe'
+export * from './peertube-template.directive'
--- /dev/null
+import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
+import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
+import { fromEvent, Observable, Subscription } from 'rxjs'
+
+@Directive({
+ selector: '[myInfiniteScroller]'
+})
+export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterContentChecked {
+ @Input() percentLimit = 70
+ @Input() autoInit = false
+ @Input() onItself = false
+ @Input() dataObservable: Observable<any[]>
+
+ @Output() nearOfBottom = new EventEmitter<void>()
+
+ private decimalLimit = 0
+ private lastCurrentBottom = -1
+ private scrollDownSub: Subscription
+ private container: HTMLElement
+
+ private checkScroll = false
+
+ constructor (private el: ElementRef) {
+ this.decimalLimit = this.percentLimit / 100
+ }
+
+ ngAfterContentChecked () {
+ if (this.checkScroll) {
+ this.checkScroll = false
+
+ console.log('Checking if the initial state has a scroll.')
+
+ if (this.hasScroll() === false) this.nearOfBottom.emit()
+ }
+ }
+
+ ngOnInit () {
+ if (this.autoInit === true) return this.initialize()
+ }
+
+ ngOnDestroy () {
+ if (this.scrollDownSub) this.scrollDownSub.unsubscribe()
+ }
+
+ initialize () {
+ this.container = this.onItself
+ ? this.el.nativeElement
+ : document.documentElement
+
+ // Emit the last value
+ const throttleOptions = { leading: true, trailing: true }
+
+ const scrollableElement = this.onItself ? this.container : window
+ const scrollObservable = fromEvent(scrollableElement, 'scroll')
+ .pipe(
+ startWith(true),
+ throttleTime(200, undefined, throttleOptions),
+ map(() => this.getScrollInfo()),
+ distinctUntilChanged((o1, o2) => o1.current === o2.current),
+ share()
+ )
+
+ // Scroll Down
+ this.scrollDownSub = scrollObservable
+ .pipe(
+ filter(({ current }) => this.isScrollingDown(current)),
+ filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
+ )
+ .subscribe(() => this.nearOfBottom.emit())
+
+ if (this.dataObservable) {
+ this.dataObservable
+ .pipe(filter(d => d.length !== 0))
+ .subscribe(() => this.checkScroll = true)
+ }
+ }
+
+ private getScrollInfo () {
+ return { current: this.container.scrollTop, maximumScroll: this.getMaximumScroll() }
+ }
+
+ private getMaximumScroll () {
+ return this.container.scrollHeight - window.innerHeight
+ }
+
+ private hasScroll () {
+ return this.getMaximumScroll() > 0
+ }
+
+ private isScrollingDown (current: number) {
+ const result = this.lastCurrentBottom < current
+
+ this.lastCurrentBottom = current
+ return result
+ }
+}
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core'
+
+// Thanks: https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts
+
+@Pipe({ name: 'myNumberFormatter' })
+export class NumberFormatterPipe implements PipeTransform {
+ private dictionary: Array<{max: number, type: string}> = [
+ { max: 1000, type: '' },
+ { max: 1000000, type: 'K' },
+ { max: 1000000000, type: 'M' }
+ ]
+
+ transform (value: number) {
+ const format = this.dictionary.find(d => value < d.max) || this.dictionary[this.dictionary.length - 1]
+ const calc = Math.floor(value / (format.max / 1000))
+
+ return `${calc}${format.type}`
+ }
+}
--- /dev/null
+import { Directive, Input, TemplateRef } from '@angular/core'
+
+@Directive({
+ selector: '[ptTemplate]'
+})
+export class PeerTubeTemplateDirective <T extends string> {
+ @Input('ptTemplate') name: T
+
+ constructor (public template: TemplateRef<any>) {
+ // empty
+ }
+}
--- /dev/null
+import { Observable, throwError as observableThrowError } from 'rxjs'
+import { catchError, switchMap } from 'rxjs/operators'
+import { HTTP_INTERCEPTORS, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'
+import { Injectable, Injector } from '@angular/core'
+import { AuthService } from '@app/core/auth/auth.service'
+
+@Injectable()
+export class AuthInterceptor implements HttpInterceptor {
+ private authService: AuthService
+
+ // https://github.com/angular/angular/issues/18224#issuecomment-316957213
+ constructor (private injector: Injector) {}
+
+ intercept (req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
+ if (this.authService === undefined) {
+ this.authService = this.injector.get(AuthService)
+ }
+
+ const authReq = this.cloneRequestWithAuth(req)
+
+ // Pass on the cloned request instead of the original request
+ // Catch 401 errors (refresh token expired)
+ return next.handle(authReq)
+ .pipe(
+ catchError(err => {
+ if (err.status === 401 && err.error && err.error.code === 'invalid_token') {
+ return this.handleTokenExpired(req, next)
+ }
+
+ return observableThrowError(err)
+ })
+ )
+ }
+
+ private handleTokenExpired (req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
+ return this.authService.refreshAccessToken()
+ .pipe(
+ switchMap(() => {
+ const authReq = this.cloneRequestWithAuth(req)
+
+ return next.handle(authReq)
+ })
+ )
+ }
+
+ private cloneRequestWithAuth (req: HttpRequest<any>) {
+ const authHeaderValue = this.authService.getRequestHeaderValue()
+
+ if (authHeaderValue === null) return req
+
+ // Clone the request to add the new header
+ return req.clone({ headers: req.headers.set('Authorization', authHeaderValue) })
+ }
+}
+
+export const AUTH_INTERCEPTOR_PROVIDER = {
+ provide: HTTP_INTERCEPTORS,
+ useClass: AuthInterceptor,
+ multi: true
+}
--- /dev/null
+export * from './auth-interceptor.service'
--- /dev/null
+<div class="dropdown-root" ngbDropdown [placement]="placement" [container]="container" *ngIf="areActionsDisplayed(actions, entry)">
+ <button
+ class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }"
+ ngbDropdownToggle role="button"
+ >
+ <my-global-icon *ngIf="!label && buttonDirection === 'horizontal'" class="more-icon" iconName="more-horizontal"></my-global-icon>
+ <my-global-icon *ngIf="!label && buttonDirection === 'vertical'" class="more-icon" iconName="more-vertical"></my-global-icon>
+
+ <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
+</button>
+
+ <div ngbDropdownMenu class="dropdown-menu">
+ <ng-container *ngFor="let actions of getActions()">
+
+ <ng-container *ngFor="let action of actions">
+ <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true">
+
+ <ng-template #templateActionLabel let-action>
+ <my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName" aria-hidden="true"></my-global-icon>
+
+ <div class="d-flex flex-column">
+ <span i18n>{{ action.label }}</span>
+ <small class="text-muted" *ngIf="action.description">{{ action.description }}</small>
+ </div>
+ </ng-template>
+
+ <a
+ *ngIf="action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
+ class="dropdown-item" [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"
+ >
+ <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
+ </a>
+
+ <span
+ *ngIf="!action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
+ class="custom-action dropdown-item" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
+ >
+ <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
+ </span>
+
+ <h6
+ *ngIf="!action.linkBuilder && action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
+ class="dropdown-header" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
+ >
+ <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
+ </h6>
+
+ </ng-container>
+ </ng-container>
+
+ <div *ngIf="areActionsDisplayed(actions, entry)" class="dropdown-divider"></div>
+
+ </ng-container>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.dropdown-divider:last-child {
+ display: none;
+}
+
+.action-button {
+ @include peertube-button;
+
+ &.button-styled {
+
+ &.grey {
+ @include grey-button;
+ }
+
+ &.orange {
+ @include orange-button;
+ }
+
+ &:hover, &:active, &:focus {
+ background-color: $grey-background-color;
+ }
+ }
+
+ display: inline-block;
+ padding: 0 10px;
+
+ &::after {
+ display: none;
+ }
+
+ .more-icon {
+ width: 21px;
+
+ ::ng-deep {
+ @include apply-svg-color(pvar(--actionButtonColor));
+ }
+ }
+
+ &.small {
+ font-size: 14px;
+ height: 20px;
+ line-height: 20px;
+ }
+}
+
+.dropdown-toggle::after {
+ position: relative;
+ top: 1px;
+}
+
+.dropdown-menu {
+ .dropdown-header {
+ padding: 0.2rem 1rem;
+ }
+
+ .dropdown-item {
+ display: flex;
+ cursor: pointer;
+ color: #000 !important;
+
+ &.with-icon {
+ @include dropdown-with-icon-item;
+ }
+
+ a, span {
+ display: block;
+ width: 100%;
+ }
+ }
+}
--- /dev/null
+import { Component, Input } from '@angular/core'
+import { GlobalIconName } from '@app/shared/shared-icons'
+
+export type DropdownAction<T> = {
+ label?: string
+ iconName?: GlobalIconName
+ description?: string
+ title?: string
+ handler?: (a: T) => any
+ linkBuilder?: (a: T) => (string | number)[]
+ isDisplayed?: (a: T) => boolean
+ isHeader?: boolean
+}
+
+export type DropdownButtonSize = 'normal' | 'small'
+export type DropdownTheme = 'orange' | 'grey'
+export type DropdownDirection = 'horizontal' | 'vertical'
+
+@Component({
+ selector: 'my-action-dropdown',
+ styleUrls: [ './action-dropdown.component.scss' ],
+ templateUrl: './action-dropdown.component.html'
+})
+
+export class ActionDropdownComponent<T> {
+ @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = []
+ @Input() entry: T
+
+ @Input() placement = 'bottom-left auto'
+ @Input() container: null | 'body'
+
+ @Input() buttonSize: DropdownButtonSize = 'normal'
+ @Input() buttonDirection: DropdownDirection = 'horizontal'
+ @Input() buttonStyled = true
+
+ @Input() label: string
+ @Input() theme: DropdownTheme = 'grey'
+
+ getActions (): DropdownAction<T>[][] {
+ if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction<T>[][]
+
+ return [ this.actions as DropdownAction<T>[] ]
+ }
+
+ areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean {
+ return actions.some(a => {
+ if (Array.isArray(a)) return this.areActionsDisplayed(a, entry)
+
+ return a.isDisplayed === undefined || a.isDisplayed(entry)
+ })
+ }
+}
--- /dev/null
+<span class="action-button" [ngClass]="className" [title]="getTitle()">
+ <my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon>
+ <my-small-loader [loading]="loading"></my-small-loader>
+
+ <span class="button-label">{{ label }}</span>
+</span>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+my-small-loader ::ng-deep .root {
+ display: inline-block;
+ margin: 0 3px 0 0;
+ width: 20px;
+}
+
+.action-button {
+ @include peertube-button-link;
+ @include button-with-icon(21px, 0, -2px);
+}
+
+.orange-button {
+ @include peertube-button;
+ @include orange-button;
+}
+
+.orange-button-link {
+ @include peertube-button-link;
+ @include orange-button;
+}
+
+.grey-button {
+ @include peertube-button;
+ @include grey-button;
+}
+
+.grey-button-link {
+ @include peertube-button-link;
+ @include grey-button;
+}
+
+// In a table, try to minimize the space taken by this button
+@media screen and (max-width: 1400px) {
+ :host-context(td) {
+ .action-button {
+ padding: 0 13px;
+ }
+
+ .button-label {
+ display: none;
+ }
+ }
+}
--- /dev/null
+import { Component, Input } from '@angular/core'
+import { GlobalIconName } from '@app/shared/shared-icons'
+
+@Component({
+ selector: 'my-button',
+ styleUrls: ['./button.component.scss'],
+ templateUrl: './button.component.html'
+})
+
+export class ButtonComponent {
+ @Input() label = ''
+ @Input() className = 'grey-button'
+ @Input() icon: GlobalIconName = undefined
+ @Input() title: string = undefined
+ @Input() loading = false
+
+ getTitle () {
+ return this.title || this.label
+ }
+}
--- /dev/null
+<span class="action-button action-button-delete grey-button" [title]="title" role="button">
+ <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
+
+ <span class="button-label" *ngIf="label">{{ label }}</span>
+ <span class="button-label" i18n *ngIf="!label">Delete</span>
+</span>
--- /dev/null
+import { Component, Input, OnInit } from '@angular/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+ selector: 'my-delete-button',
+ styleUrls: [ './button.component.scss' ],
+ templateUrl: './delete-button.component.html'
+})
+
+export class DeleteButtonComponent implements OnInit {
+ @Input() label: string
+
+ title: string
+
+ constructor (private i18n: I18n) { }
+
+ ngOnInit () {
+ this.title = this.label || this.i18n('Delete')
+ }
+}
--- /dev/null
+<a class="action-button action-button-edit grey-button" [routerLink]="routerLink" i18n-title title="Edit">
+ <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
+
+ <span class="button-label" *ngIf="label">{{ label }}</span>
+ <span i18n class="button-label" *ngIf="!label">Edit</span>
+</a>
--- /dev/null
+import { Component, Input } from '@angular/core'
+
+@Component({
+ selector: 'my-edit-button',
+ styleUrls: [ './button.component.scss' ],
+ templateUrl: './edit-button.component.html'
+})
+
+export class EditButtonComponent {
+ @Input() label: string
+ @Input() routerLink: string[] | string = []
+}
--- /dev/null
+export * from './action-dropdown.component'
+export * from './button.component'
+export * from './delete-button.component'
+export * from './edit-button.component'
--- /dev/null
+<span
+ class="date-toggle"
+ [title]="getTitle()"
+ [innerHtml]="getContent()"
+ (click)="toggle()"
+></span>
--- /dev/null
+.date-toggle {
+ &:hover {
+ cursor: default
+ }
+}
--- /dev/null
+import { DatePipe } from '@angular/common'
+import { Component, Input, OnChanges, OnInit } from '@angular/core'
+import { FromNowPipe } from '../angular/from-now.pipe'
+
+@Component({
+ selector: 'my-date-toggle',
+ templateUrl: './date-toggle.component.html',
+ styleUrls: [ './date-toggle.component.scss' ]
+})
+export class DateToggleComponent implements OnInit, OnChanges {
+ @Input() date: Date
+ @Input() toggled = false
+
+ dateRelative: string
+ dateAbsolute: string
+
+ constructor (
+ private datePipe: DatePipe,
+ private fromNowPipe: FromNowPipe
+ ) { }
+
+ ngOnInit () {
+ this.updateDates()
+ }
+
+ ngOnChanges () {
+ this.updateDates()
+ }
+
+ toggle () {
+ this.toggled = !this.toggled
+ }
+
+ getTitle () {
+ return this.toggled ? this.dateRelative : this.dateAbsolute
+ }
+
+ getContent () {
+ return this.toggled ? this.dateAbsolute : this.dateRelative
+ }
+
+ private updateDates () {
+ this.dateRelative = this.fromNowPipe.transform(this.date)
+ this.dateAbsolute = this.datePipe.transform(this.date, 'long')
+ }
+}
--- /dev/null
+export * from './date-toggle.component'
--- /dev/null
+<div class="video-feed"
+ [ngbTooltip]="'Feeds available'"
+ placement="right auto"
+ container="body"
+>
+ <my-global-icon
+ *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom"
+ class="icon-syndication" role="button" iconName="syndication"
+ >
+ </my-global-icon>
+
+ <ng-template #feedsList>
+ <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a>
+ </ng-template>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.video-feed {
+ width: min-content;
+
+ a {
+ color: black;
+ display: block;
+ }
+
+ my-global-icon {
+ cursor: pointer;
+ width: 12px;
+ position: relative;
+ top: -2px;
+
+ @include apply-svg-color(pvar(--mainForegroundColor))
+ }
+}
--- /dev/null
+import { Component, Input } from '@angular/core'
+import { Syndication } from './syndication.model'
+
+@Component({
+ selector: 'my-feed',
+ styleUrls: [ './feed.component.scss' ],
+ templateUrl: './feed.component.html'
+})
+export class FeedComponent {
+ @Input() syndicationItems: Syndication[]
+}
--- /dev/null
+export * from './feed.component'
+export * from './syndication.model'
--- /dev/null
+import { FeedFormat } from '@shared/models'
+
+export interface Syndication {
+ format: FeedFormat,
+ label: string,
+ url: string
+}
--- /dev/null
+export * from './account'
+export * from './angular'
+export * from './buttons'
+export * from './date'
+export * from './feeds'
+export * from './loaders'
+export * from './misc'
+export * from './users'
+export * from './video'
+export * from './video-caption'
+export * from './video-channel'
+export * from './shared-main.module'
--- /dev/null
+export * from './loader.component'
+export * from './small-loader.component'
--- /dev/null
+<div *ngIf="loading">
+ <div class="loader">
+ <div></div>
+ <div></div>
+ <div></div>
+ <div></div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+// Thanks to https://loading.io/css/ (CC0 License)
+
+.loader {
+ display: inline-block;
+ position: relative;
+ width: 50px;
+ height: 50px;
+}
+
+.loader div {
+ box-sizing: border-box;
+ display: block;
+ position: absolute;
+ width: 44px;
+ height: 44px;
+ margin: 6px;
+ border: 4px solid;
+ border-radius: 50%;
+ animation: loader 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
+ border-color: #999999 transparent transparent transparent;
+}
+
+.loader div:nth-child(1) {
+ animation-delay: -0.45s;
+}
+
+.loader div:nth-child(2) {
+ animation-delay: -0.3s;
+}
+
+.loader div:nth-child(3) {
+ animation-delay: -0.15s;
+}
+
+@keyframes loader {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
--- /dev/null
+import { Component, Input } from '@angular/core'
+
+@Component({
+ selector: 'my-loader',
+ styleUrls: [ './loader.component.scss' ],
+ templateUrl: './loader.component.html'
+})
+export class LoaderComponent {
+ @Input() loading: boolean
+}
--- /dev/null
+<div class="root" *ngIf="loading">
+ <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div>
+</div>
--- /dev/null
+import { Component, Input } from '@angular/core'
+
+@Component({
+ selector: 'my-small-loader',
+ styleUrls: [ ],
+ templateUrl: './small-loader.component.html'
+})
+
+export class SmallLoaderComponent {
+ @Input() loading: boolean
+}
--- /dev/null
+<ng-template #tooltipTemplate>
+ <p *ngIf="preHtmlTemplate">
+ <ng-template *ngTemplateOutlet="preHtmlTemplate"></ng-template>
+ </p>
+
+ <ng-container *ngIf="preHtmlTemplate && (customHtmlTemplate || mainHtml || postHtmlTemplate)">
+ <br /><br />
+ </ng-container>
+
+ <p *ngIf="customHtmlTemplate">
+ <ng-template *ngTemplateOutlet="customHtmlTemplate"></ng-template>
+ </p>
+
+ <p *ngIf="mainHtml" [innerHTML]="mainHtml"></p>
+
+ <ng-container *ngIf="(customHtmlTemplate || mainHtml) && postHtmlTemplate">
+ <br /><br />
+ </ng-container>
+
+ <p *ngIf="postHtmlTemplate">
+ <ng-template *ngTemplateOutlet="postHtmlTemplate"></ng-template>
+ </p>
+</ng-template>
+
+<span
+ role="button"
+ class="help-tooltip-button"
+ container="body"
+ title="Get help"
+ i18n-title
+ popoverClass="help-popover"
+ [attr.aria-pressed]="isPopoverOpened"
+ [ngbPopover]="tooltipTemplate"
+ [placement]="tooltipPlacement"
+ autoClose="outside"
+ (onHidden)="onPopoverHidden()"
+ (onShown)="onPopoverShown()"
+>
+ <my-global-icon iconName="help"></my-global-icon>
+</span>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.help-tooltip-button {
+ cursor: pointer;
+ border: none;
+
+ my-global-icon {
+ width: 17px;
+ position: relative;
+ top: -2px;
+ margin: 5px;
+
+ @include apply-svg-color(pvar(--mainForegroundColor))
+ }
+}
+
+::ng-deep {
+ .help-popover {
+ z-index: z(help-popover) !important;
+ max-width: 300px;
+
+ .popover-body {
+ font-family: $main-fonts;
+ text-align: left;
+ padding: 10px;
+ font-size: 13px;
+ background-color: pvar(--mainBackgroundColor);
+ color: pvar(--mainForegroundColor);
+ border-radius: 3px;
+
+ p {
+ margin-bottom: 0;
+ }
+
+ ul {
+ padding-left: 20px;
+ margin-bottom: 0;
+ }
+ }
+ }
+}
--- /dev/null
+import { AfterContentInit, Component, ContentChildren, Input, OnChanges, OnInit, QueryList, TemplateRef } from '@angular/core'
+import { MarkdownService } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { PeerTubeTemplateDirective } from '../angular'
+
+@Component({
+ selector: 'my-help',
+ styleUrls: [ './help.component.scss' ],
+ templateUrl: './help.component.html'
+})
+
+export class HelpComponent implements OnInit, OnChanges, AfterContentInit {
+ @Input() helpType: 'custom' | 'markdownText' | 'markdownEnhanced' = 'custom'
+ @Input() tooltipPlacement = 'right auto'
+
+ @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'preHtml' | 'customHtml' | 'postHtml'>>
+
+ isPopoverOpened = false
+ mainHtml = ''
+
+ preHtmlTemplate: TemplateRef<any>
+ customHtmlTemplate: TemplateRef<any>
+ postHtmlTemplate: TemplateRef<any>
+
+ constructor (private i18n: I18n) { }
+
+ ngOnInit () {
+ this.init()
+ }
+
+ ngAfterContentInit () {
+ {
+ const t = this.templates.find(t => t.name === 'preHtml')
+ if (t) this.preHtmlTemplate = t.template
+ }
+
+ {
+ const t = this.templates.find(t => t.name === 'customHtml')
+ if (t) this.customHtmlTemplate = t.template
+ }
+
+ {
+ const t = this.templates.find(t => t.name === 'postHtml')
+ if (t) this.postHtmlTemplate = t.template
+ }
+ }
+
+ ngOnChanges () {
+ this.init()
+ }
+
+ onPopoverHidden () {
+ this.isPopoverOpened = false
+ }
+
+ onPopoverShown () {
+ this.isPopoverOpened = true
+ }
+
+ private init () {
+ if (this.helpType === 'markdownText') {
+ this.mainHtml = this.formatMarkdownSupport(MarkdownService.TEXT_RULES)
+ return
+ }
+
+ if (this.helpType === 'markdownEnhanced') {
+ this.mainHtml = this.formatMarkdownSupport(MarkdownService.ENHANCED_RULES)
+ return
+ }
+ }
+
+ private formatMarkdownSupport (rules: string[]) {
+ // tslint:disable:max-line-length
+ return this.i18n('<a href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noopener noreferrer">Markdown</a> compatible that supports:') +
+ this.createMarkdownList(rules)
+ }
+
+ private createMarkdownList (rules: string[]) {
+ const rulesToText = {
+ 'emphasis': this.i18n('Emphasis'),
+ 'link': this.i18n('Links'),
+ 'newline': this.i18n('New lines'),
+ 'list': this.i18n('Lists'),
+ 'image': this.i18n('Images')
+ }
+
+ const bullets = rules.map(r => rulesToText[r])
+ .filter(text => text)
+ .map(text => '<li>' + text + '</li>')
+ .join('')
+
+ return '<ul>' + bullets + '</ul>'
+ }
+}
--- /dev/null
+export * from './help.component'
+export * from './list-overflow.component'
--- /dev/null
+<div #itemsParent class="d-flex align-items-center text-nowrap w-100 list-overflow-parent">
+ <span [id]="getId(id)" #itemsRendered *ngFor="let item of items; index as id">
+ <ng-container *ngTemplateOutlet="itemTemplate; context: {item: item}"></ng-container>
+ </span>
+
+ <ng-container *ngIf="isMenuDisplayed()">
+ <button *ngIf="isInMobileView" class="btn btn-outline-secondary btn-sm list-overflow-menu" (click)="toggleModal()">
+ <span class="glyphicon glyphicon-chevron-down"></span>
+ </button>
+
+ <div *ngIf="!isInMobileView" class="list-overflow-menu" ngbDropdown container="body" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)" (mouseenter)="openDropdownOnHover(dropdown)">
+ <button class="btn btn-outline-secondary btn-sm" [ngClass]="{ routeActive: active }"
+ ngbDropdownAnchor (click)="dropdownAnchorClicked(dropdown)" role="button"
+ >
+ <span class="glyphicon glyphicon-chevron-down"></span>
+ </button>
+
+ <div ngbDropdownMenu>
+ <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
+ [routerLink]="item.routerLink" routerLinkActive="active" class="dropdown-item">
+ {{ item.label }}
+ </a>
+ </div>
+ </div>
+ </ng-container>
+</div >
+
+<ng-template #modal let-close="close" let-dismiss="dismiss">
+ <div class="modal-body">
+ <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
+ [routerLink]="item.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
+ {{ item.label }}
+ </a>
+ </div>
+</ng-template>
--- /dev/null
+@import '_mixins';
+
+:host {
+ width: 100%;
+}
+
+.list-overflow-parent {
+ overflow: hidden;
+}
+
+.list-overflow-menu {
+ position: absolute;
+ right: 25px;
+}
+
+button {
+ width: 30px;
+ border: none;
+
+ &::after {
+ display: none;
+ }
+
+ &.routeActive {
+ &::after {
+ display: inherit;
+ border: 2px solid pvar(--mainColor);
+ position: relative;
+ right: 95%;
+ top: 50%;
+ }
+ }
+}
+
+::ng-deep .dropdown-menu {
+ margin-top: 0 !important;
+ position: static;
+ right: auto;
+ bottom: auto
+}
+
+.modal-body {
+ a {
+ @include disable-default-a-behaviour;
+
+ color: currentColor;
+ box-sizing: border-box;
+ display: block;
+ font-size: 1.2rem;
+ padding: 9px 12px;
+ text-align: initial;
+ text-transform: unset;
+ width: 100%;
+
+ &.active {
+ color: pvar(--mainBackgroundColor) !important;
+ background-color: pvar(--mainHoverColor);
+ opacity: .9;
+ }
+ }
+}
--- /dev/null
+import { lowerFirst, uniqueId } from 'lodash-es'
+import { take } from 'rxjs/operators'
+import {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ HostListener,
+ Input,
+ QueryList,
+ TemplateRef,
+ ViewChild,
+ ViewChildren
+} from '@angular/core'
+import { ScreenService } from '@app/core'
+import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
+
+export interface ListOverflowItem {
+ label: string
+ routerLink: string | any[]
+}
+
+@Component({
+ selector: 'list-overflow',
+ templateUrl: './list-overflow.component.html',
+ styleUrls: [ './list-overflow.component.scss' ],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ListOverflowComponent<T extends ListOverflowItem> implements AfterViewInit {
+ @Input() items: T[]
+ @Input() itemTemplate: TemplateRef<{item: T}>
+
+ @ViewChild('modal', { static: true }) modal: ElementRef
+ @ViewChild('itemsParent', { static: true }) parent: ElementRef<HTMLDivElement>
+ @ViewChildren('itemsRendered') itemsRendered: QueryList<ElementRef>
+
+ showItemsUntilIndexExcluded: number
+ active = false
+ isInTouchScreen = false
+ isInMobileView = false
+
+ private openedOnHover = false
+
+ constructor (
+ private cdr: ChangeDetectorRef,
+ private modalService: NgbModal,
+ private screenService: ScreenService
+ ) {}
+
+ ngAfterViewInit () {
+ setTimeout(() => this.onWindowResize(), 0)
+ }
+
+ isMenuDisplayed () {
+ return !!this.showItemsUntilIndexExcluded
+ }
+
+ @HostListener('window:resize')
+ onWindowResize () {
+ this.isInTouchScreen = !!this.screenService.isInTouchScreen()
+ this.isInMobileView = !!this.screenService.isInMobileView()
+
+ const parentWidth = this.parent.nativeElement.getBoundingClientRect().width
+ let showItemsUntilIndexExcluded: number
+ let accWidth = 0
+
+ for (const [index, el] of this.itemsRendered.toArray().entries()) {
+ accWidth += el.nativeElement.getBoundingClientRect().width
+ if (showItemsUntilIndexExcluded === undefined) {
+ showItemsUntilIndexExcluded = (parentWidth < accWidth) ? index : undefined
+ }
+
+ const e = document.getElementById(this.getId(index))
+ const shouldBeVisible = showItemsUntilIndexExcluded ? index < showItemsUntilIndexExcluded : true
+ e.style.visibility = shouldBeVisible ? 'inherit' : 'hidden'
+ }
+
+ this.showItemsUntilIndexExcluded = showItemsUntilIndexExcluded
+ this.cdr.markForCheck()
+ }
+
+ openDropdownOnHover (dropdown: NgbDropdown) {
+ this.openedOnHover = true
+ dropdown.open()
+
+ // Menu was closed
+ dropdown.openChange
+ .pipe(take(1))
+ .subscribe(() => this.openedOnHover = false)
+ }
+
+ dropdownAnchorClicked (dropdown: NgbDropdown) {
+ if (this.openedOnHover) {
+ this.openedOnHover = false
+ return
+ }
+
+ return dropdown.toggle()
+ }
+
+ closeDropdownIfHovered (dropdown: NgbDropdown) {
+ if (this.openedOnHover === false) return
+
+ dropdown.close()
+ this.openedOnHover = false
+ }
+
+ toggleModal () {
+ this.modalService.open(this.modal, { centered: true })
+ }
+
+ dismissOtherModals () {
+ this.modalService.dismissAll()
+ }
+
+ getId (id: number | string = uniqueId()): string {
+ return lowerFirst(this.constructor.name) + '_' + id
+ }
+}
--- /dev/null
+import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
+import { SharedModule as PrimeSharedModule } from 'primeng/api'
+import { InputMaskModule } from 'primeng/inputmask'
+import { InputSwitchModule } from 'primeng/inputswitch'
+import { MultiSelectModule } from 'primeng/multiselect'
+import { ClipboardModule } from '@angular/cdk/clipboard'
+import { CommonModule, DatePipe } from '@angular/common'
+import { HttpClientModule } from '@angular/common/http'
+import { NgModule } from '@angular/core'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import { RouterModule } from '@angular/router'
+import {
+ NgbCollapseModule,
+ NgbDropdownModule,
+ NgbModalModule,
+ NgbNavModule,
+ NgbPopoverModule,
+ NgbTooltipModule
+} from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { SharedGlobalIconModule } from '../shared-icons'
+import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account'
+import { FromNowPipe, InfiniteScrollerDirective, NumberFormatterPipe, PeerTubeTemplateDirective } from './angular'
+import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
+import { DateToggleComponent } from './date'
+import { FeedComponent } from './feeds'
+import { LoaderComponent, SmallLoaderComponent } from './loaders'
+import { HelpComponent, ListOverflowComponent } from './misc'
+import { UserHistoryService, UserNotificationsComponent, UserNotificationService } from './users'
+import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
+import { VideoCaptionService } from './video-caption'
+import { VideoChannelService } from './video-channel'
+import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
+
+@NgModule({
+ imports: [
+ CommonModule,
+ FormsModule,
+ ReactiveFormsModule,
+ RouterModule,
+ HttpClientModule,
+
+ NgbDropdownModule,
+ NgbModalModule,
+ NgbPopoverModule,
+ NgbNavModule,
+ NgbTooltipModule,
+ NgbCollapseModule,
+
+ ClipboardModule,
+
+ PrimeSharedModule,
+ InputMaskModule,
+ NgPipesModule,
+ MultiSelectModule,
+ InputSwitchModule,
+
+ SharedGlobalIconModule
+ ],
+
+ declarations: [
+ AvatarComponent,
+ ActorAvatarInfoComponent,
+
+ FromNowPipe,
+ InfiniteScrollerDirective,
+ NumberFormatterPipe,
+ PeerTubeTemplateDirective,
+
+ ActionDropdownComponent,
+ ButtonComponent,
+ DeleteButtonComponent,
+ EditButtonComponent,
+
+ DateToggleComponent,
+
+ FeedComponent,
+
+ LoaderComponent,
+ SmallLoaderComponent,
+
+ HelpComponent,
+ ListOverflowComponent,
+
+ UserNotificationsComponent,
+
+ FeedComponent
+ ],
+
+ exports: [
+ CommonModule,
+ FormsModule,
+ ReactiveFormsModule,
+ RouterModule,
+ HttpClientModule,
+
+ NgbDropdownModule,
+ NgbModalModule,
+ NgbPopoverModule,
+ NgbNavModule,
+ NgbTooltipModule,
+ NgbCollapseModule,
+
+ ClipboardModule,
+
+ PrimeSharedModule,
+ InputMaskModule,
+ BytesPipe,
+ KeysPipe,
+ MultiSelectModule,
+
+ AvatarComponent,
+ ActorAvatarInfoComponent,
+
+ FromNowPipe,
+ InfiniteScrollerDirective,
+ NumberFormatterPipe,
+ PeerTubeTemplateDirective,
+
+ ActionDropdownComponent,
+ ButtonComponent,
+ DeleteButtonComponent,
+ EditButtonComponent,
+
+ DateToggleComponent,
+
+ FeedComponent,
+
+ LoaderComponent,
+ SmallLoaderComponent,
+
+ HelpComponent,
+ ListOverflowComponent,
+
+ UserNotificationsComponent,
+
+ FeedComponent
+ ],
+
+ providers: [
+ I18n,
+
+ DatePipe,
+
+ FromNowPipe,
+
+ AUTH_INTERCEPTOR_PROVIDER,
+
+ AccountService,
+
+ UserHistoryService,
+ UserNotificationService,
+
+ RedundancyService,
+ VideoImportService,
+ VideoOwnershipService,
+ VideoService,
+
+ VideoCaptionService,
+
+ VideoChannelService
+ ]
+})
+export class SharedMainModule { }
--- /dev/null
+export * from './user-history.service'
+export * from './user-notification.model'
+export * from './user-notification.service'
+export * from './user-notifications.component'
--- /dev/null
+import { catchError, map, switchMap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
+import { ResultList } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { Video } from '../video/video.model'
+import { VideoService } from '../video/video.service'
+
+@Injectable()
+export class UserHistoryService {
+ static BASE_USER_VIDEOS_HISTORY_URL = environment.apiUrl + '/api/v1/users/me/history/videos'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private restService: RestService,
+ private videoService: VideoService
+ ) {}
+
+ getUserVideosHistory (historyPagination: ComponentPaginationLight) {
+ const pagination = this.restService.componentPaginationToRestPagination(historyPagination)
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination)
+
+ return this.authHttp
+ .get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params })
+ .pipe(
+ switchMap(res => this.videoService.extractVideos(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ deleteUserVideosHistory () {
+ return this.authHttp
+ .post(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL + '/remove', {})
+ .pipe(
+ map(() => this.restExtractor.extractDataBool()),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+}
--- /dev/null
+import { Actor } from '../account/actor.model'
+import { ActorInfo, Avatar, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '@shared/models'
+
+export class UserNotification implements UserNotificationServer {
+ id: number
+ type: UserNotificationType
+ read: boolean
+
+ video?: VideoInfo & {
+ channel: ActorInfo & { avatarUrl?: string }
+ }
+
+ videoImport?: {
+ id: number
+ video?: VideoInfo
+ torrentName?: string
+ magnetUri?: string
+ targetUrl?: string
+ }
+
+ comment?: {
+ id: number
+ threadId: number
+ account: ActorInfo & { avatarUrl?: string }
+ video: VideoInfo
+ }
+
+ videoAbuse?: {
+ id: number
+ video: VideoInfo
+ }
+
+ videoBlacklist?: {
+ id: number
+ video: VideoInfo
+ }
+
+ account?: ActorInfo & { avatarUrl?: string }
+
+ actorFollow?: {
+ id: number
+ state: FollowState
+ follower: ActorInfo & { avatarUrl?: string }
+ following: {
+ type: 'account' | 'channel' | 'instance'
+ name: string
+ displayName: string
+ host: string
+ }
+ }
+
+ createdAt: string
+ updatedAt: string
+
+ // Additional fields
+ videoUrl?: string
+ commentUrl?: any[]
+ videoAbuseUrl?: string
+ videoAutoBlacklistUrl?: string
+ accountUrl?: string
+ videoImportIdentifier?: string
+ videoImportUrl?: string
+ instanceFollowUrl?: string
+
+ constructor (hash: UserNotificationServer) {
+ this.id = hash.id
+ this.type = hash.type
+ this.read = hash.read
+
+ // We assume that some fields exist
+ // To prevent a notification popup crash in case of bug, wrap it inside a try/catch
+ try {
+ this.video = hash.video
+ if (this.video) this.setAvatarUrl(this.video.channel)
+
+ this.videoImport = hash.videoImport
+
+ this.comment = hash.comment
+ if (this.comment) this.setAvatarUrl(this.comment.account)
+
+ this.videoAbuse = hash.videoAbuse
+
+ this.videoBlacklist = hash.videoBlacklist
+
+ this.account = hash.account
+ if (this.account) this.setAvatarUrl(this.account)
+
+ this.actorFollow = hash.actorFollow
+ if (this.actorFollow) this.setAvatarUrl(this.actorFollow.follower)
+
+ this.createdAt = hash.createdAt
+ this.updatedAt = hash.updatedAt
+
+ switch (this.type) {
+ case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION:
+ this.videoUrl = this.buildVideoUrl(this.video)
+ break
+
+ case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO:
+ this.videoUrl = this.buildVideoUrl(this.video)
+ break
+
+ case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO:
+ case UserNotificationType.COMMENT_MENTION:
+ if (!this.comment) break
+ this.accountUrl = this.buildAccountUrl(this.comment.account)
+ this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
+ break
+
+ case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
+ this.videoAbuseUrl = '/admin/moderation/video-abuses/list'
+ this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
+ break
+
+ case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
+ this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list'
+ // Backward compatibility where we did not assign videoBlacklist to this type of notification before
+ if (!this.videoBlacklist) this.videoBlacklist = { id: null, video: this.video }
+
+ this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
+ break
+
+ case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
+ this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
+ break
+
+ case UserNotificationType.MY_VIDEO_PUBLISHED:
+ this.videoUrl = this.buildVideoUrl(this.video)
+ break
+
+ case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS:
+ this.videoImportUrl = this.buildVideoImportUrl()
+ this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
+
+ if (this.videoImport.video) this.videoUrl = this.buildVideoUrl(this.videoImport.video)
+ break
+
+ case UserNotificationType.MY_VIDEO_IMPORT_ERROR:
+ this.videoImportUrl = this.buildVideoImportUrl()
+ this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
+ break
+
+ case UserNotificationType.NEW_USER_REGISTRATION:
+ this.accountUrl = this.buildAccountUrl(this.account)
+ break
+
+ case UserNotificationType.NEW_FOLLOW:
+ this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
+ break
+
+ case UserNotificationType.NEW_INSTANCE_FOLLOWER:
+ this.instanceFollowUrl = '/admin/follows/followers-list'
+ break
+
+ case UserNotificationType.AUTO_INSTANCE_FOLLOWING:
+ this.instanceFollowUrl = '/admin/follows/following-list'
+ break
+ }
+ } catch (err) {
+ this.type = null
+ console.error(err)
+ }
+ }
+
+ private buildVideoUrl (video: { uuid: string }) {
+ return '/videos/watch/' + video.uuid
+ }
+
+ private buildAccountUrl (account: { name: string, host: string }) {
+ return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host)
+ }
+
+ private buildVideoImportUrl () {
+ return '/my-account/video-imports'
+ }
+
+ private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) {
+ return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
+ }
+
+ private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) {
+ actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
+ }
+}
--- /dev/null
+import { catchError, map, tap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { ComponentPaginationLight, RestExtractor, RestService, User, UserNotificationSocket } from '@app/core'
+import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { UserNotification } from './user-notification.model'
+
+@Injectable()
+export class UserNotificationService {
+ static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications'
+ static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private restService: RestService,
+ private userNotificationSocket: UserNotificationSocket
+ ) {}
+
+ listMyNotifications (pagination: ComponentPaginationLight, unread?: boolean, ignoreLoadingBar = false) {
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination))
+
+ if (unread) params = params.append('unread', `${unread}`)
+
+ const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined
+
+ return this.authHttp.get<ResultList<UserNotification>>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ countUnreadNotifications () {
+ return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true)
+ .pipe(map(n => n.total))
+ }
+
+ markAsRead (notification: UserNotification) {
+ const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read'
+
+ const body = { ids: [ notification.id ] }
+ const headers = { ignoreLoadingBar: '' }
+
+ return this.authHttp.post(url, body, { headers })
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => this.userNotificationSocket.dispatch('read')),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ markAllAsRead () {
+ const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all'
+ const headers = { ignoreLoadingBar: '' }
+
+ return this.authHttp.post(url, {}, { headers })
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => this.userNotificationSocket.dispatch('read-all')),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ updateNotificationSettings (user: User, settings: UserNotificationSetting) {
+ const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
+
+ return this.authHttp.put(url, settings)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ private formatNotification (notification: UserNotificationServer) {
+ return new UserNotification(notification)
+ }
+}
--- /dev/null
+<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 *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
+
+ <ng-container [ngSwitch]="notification.type">
+ <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
+ <ng-container *ngIf="notification.video; then hasVideo; else noVideo"></ng-container>
+
+ <ng-template #hasVideo>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
+ <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.video.channel.avatarUrl" />
+ </a>
+
+ <div class="message" i18n>
+ {{ notification.video.channel.displayName }} published a new video: <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a>
+ </div>
+ </ng-template>
+
+ <ng-template #noVideo>
+ <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ The notification concerns a video now unavailable
+ </div>
+ </ng-template>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
+ <my-global-icon iconName="undo" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblocked
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
+ <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blocked
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
+ <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.video.name }}</a>
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS">
+ <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ The recently added video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been <a (click)="markAsRead(notification)" [routerLink]="notification.videoAutoBlacklistUrl">automatically blocked</a>
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
+ <ng-container *ngIf="notification.comment; then hasComment; else noComment"></ng-container>
+
+ <ng-template #hasComment>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
+ <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
+ </a>
+
+ <div class="message" i18n>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
+ </div>
+ </ng-template>
+
+ <ng-template #noComment>
+ <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ The notification concerns a comment now unavailable
+ </div>
+ </ng-template>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
+ <my-global-icon iconName="sparkle" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
+ <my-global-icon iconName="cloud-download" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl || notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
+ <my-global-icon iconName="cloud-error" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
+ <my-global-icon iconName="user-add" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }}</a> registered on your instance
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
+ <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
+ <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.actorFollow.follower.avatarUrl" />
+ </a>
+
+ <div class="message" i18n>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
+
+ <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">your channel {{ notification.actorFollow.following.displayName }}</ng-container>
+ <ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
+ <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
+ <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
+ </a>
+
+ <div class="message" i18n>
+ <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.NEW_INSTANCE_FOLLOWER">
+ <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ Your instance has <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">a new follower</a> ({{ notification.actorFollow?.follower.host }})
+ <ng-container *ngIf="notification.actorFollow?.state === 'pending'"> awaiting your approval</ng-container>
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchCase="UserNotificationType.AUTO_INSTANCE_FOLLOWING">
+ <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ Your instance automatically followed <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">{{ notification.actorFollow.following.host }}</a>
+ </div>
+ </ng-container>
+
+ <ng-container *ngSwitchDefault>
+ <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
+
+ <div class="message" i18n>
+ The notification points to a content now unavailable
+ </div>
+ </ng-container>
+ </ng-container>
+
+ <div [title]="notification.createdAt" class="from-date">{{ notification.createdAt | myFromNow }}</div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.no-notification {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 20px 0;
+}
+
+.notification {
+ display: flex;
+ align-items: center;
+ font-size: inherit;
+ padding: 15px 5px 15px 10px;
+ border-bottom: 1px solid $separator-border-color;
+ word-break: break-word;
+
+ &.unread {
+ background-color: rgba(0, 0, 0, 0.05);
+ }
+
+ my-global-icon {
+ width: 24px;
+ margin-right: 11px;
+ margin-left: 3px;
+
+ @include apply-svg-color(#333);
+ }
+
+ .avatar {
+ @include avatar(30px);
+
+ margin-right: 10px;
+ }
+
+ .message {
+ flex-grow: 1;
+
+ a {
+ font-weight: $font-semibold;
+ }
+ }
+
+ .from-date {
+ font-size: 0.85em;
+ color: pvar(--greyForegroundColor);
+ padding-left: 5px;
+ min-width: 70px;
+ text-align: right;
+ margin-left: auto;
+ }
+}
--- /dev/null
+import { Subject } from 'rxjs'
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { ComponentPagination, hasMoreItems, Notifier } from '@app/core'
+import { UserNotificationType } from '@shared/models'
+import { UserNotification } from './user-notification.model'
+import { UserNotificationService } from './user-notification.service'
+
+@Component({
+ selector: 'my-user-notifications',
+ templateUrl: 'user-notifications.component.html',
+ styleUrls: [ 'user-notifications.component.scss' ]
+})
+export class UserNotificationsComponent implements OnInit {
+ @Input() ignoreLoadingBar = false
+ @Input() infiniteScroll = true
+ @Input() itemsPerPage = 20
+ @Input() markAllAsReadSubject: Subject<boolean>
+
+ @Output() notificationsLoaded = new EventEmitter()
+
+ notifications: UserNotification[] = []
+
+ // So we can access it in the template
+ UserNotificationType = UserNotificationType
+
+ componentPagination: ComponentPagination
+
+ onDataSubject = new Subject<any[]>()
+
+ constructor (
+ private userNotificationService: UserNotificationService,
+ private notifier: Notifier
+ ) { }
+
+ ngOnInit () {
+ this.componentPagination = {
+ currentPage: 1,
+ itemsPerPage: this.itemsPerPage, // Reset items per page, because of the @Input() variable
+ totalItems: null
+ }
+
+ this.loadMoreNotifications()
+
+ if (this.markAllAsReadSubject) {
+ this.markAllAsReadSubject.subscribe(() => this.markAllAsRead())
+ }
+ }
+
+ loadMoreNotifications () {
+ this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar)
+ .subscribe(
+ result => {
+ this.notifications = this.notifications.concat(result.data)
+ this.componentPagination.totalItems = result.total
+
+ this.notificationsLoaded.emit()
+
+ this.onDataSubject.next(result.data)
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ onNearOfBottom () {
+ if (this.infiniteScroll === false) return
+
+ this.componentPagination.currentPage++
+
+ if (hasMoreItems(this.componentPagination)) {
+ this.loadMoreNotifications()
+ }
+ }
+
+ markAsRead (notification: UserNotification) {
+ if (notification.read) return
+
+ this.userNotificationService.markAsRead(notification)
+ .subscribe(
+ () => {
+ notification.read = true
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ markAllAsRead () {
+ this.userNotificationService.markAllAsRead()
+ .subscribe(
+ () => {
+ for (const notification of this.notifications) {
+ notification.read = true
+ }
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+}
--- /dev/null
+export * from './video-caption-edit.model'
+export * from './video-caption.service'
--- /dev/null
+export interface VideoCaptionEdit {
+ language: {
+ id: string
+ label?: string
+ }
+
+ action?: 'CREATE' | 'REMOVE'
+ captionfile?: any
+}
--- /dev/null
+import { Observable, of } from 'rxjs'
+import { catchError, map, switchMap } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, ServerService } from '@app/core'
+import { objectToFormData, sortBy } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main/video'
+import { peertubeTranslate, ResultList, VideoCaption } from '@shared/models'
+import { VideoCaptionEdit } from './video-caption-edit.model'
+
+@Injectable()
+export class VideoCaptionService {
+ constructor (
+ private authHttp: HttpClient,
+ private serverService: ServerService,
+ private restExtractor: RestExtractor
+ ) {}
+
+ listCaptions (videoId: number | string): Observable<ResultList<VideoCaption>> {
+ return this.authHttp.get<ResultList<VideoCaption>>(VideoService.BASE_VIDEO_URL + videoId + '/captions')
+ .pipe(
+ switchMap(captionsResult => {
+ return this.serverService.getServerLocale()
+ .pipe(map(translations => ({ captionsResult, translations })))
+ }),
+ map(({ captionsResult, translations }) => {
+ for (const c of captionsResult.data) {
+ c.language.label = peertubeTranslate(c.language.label, translations)
+ }
+
+ return captionsResult
+ }),
+ map(captionsResult => {
+ sortBy(captionsResult.data, 'language', 'label')
+
+ return captionsResult
+ })
+ )
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+ }
+
+ removeCaption (videoId: number | string, language: string) {
+ return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ addCaption (videoId: number | string, language: string, captionfile: File) {
+ const body = { captionfile }
+ const data = objectToFormData(body)
+
+ return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) {
+ let obs = of(true)
+
+ for (const videoCaption of videoCaptions) {
+ if (videoCaption.action === 'CREATE') {
+ obs = obs.pipe(switchMap(() => this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile)))
+ } else if (videoCaption.action === 'REMOVE') {
+ obs = obs.pipe(switchMap(() => this.removeCaption(videoId, videoCaption.language.id)))
+ }
+ }
+
+ return obs
+ }
+}
--- /dev/null
+export * from './video-channel.model'
+export * from './video-channel.service'
--- /dev/null
+import { VideoChannel as ServerVideoChannel, ViewsPerDate, Account } from '@shared/models'
+import { Actor } from '../account/actor.model'
+
+export class VideoChannel extends Actor implements ServerVideoChannel {
+ displayName: string
+ description: string
+ support: string
+ isLocal: boolean
+ nameWithHost: string
+ nameWithHostForced: string
+
+ ownerAccount?: Account
+ ownerBy?: string
+ ownerAvatarUrl?: string
+
+ videosCount?: number
+
+ viewsPerDay?: ViewsPerDate[]
+
+ constructor (hash: ServerVideoChannel) {
+ super(hash)
+
+ this.displayName = hash.displayName
+ this.description = hash.description
+ this.support = hash.support
+ this.isLocal = hash.isLocal
+ this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
+ this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
+
+ this.videosCount = hash.videosCount
+
+ if (hash.viewsPerDay) {
+ this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) }))
+ }
+
+ if (hash.ownerAccount) {
+ this.ownerAccount = hash.ownerAccount
+ this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
+ this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
+ }
+ }
+}
--- /dev/null
+import { Observable, ReplaySubject } from 'rxjs'
+import { catchError, map, tap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
+import { Avatar, ResultList, VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { Account } from '../account'
+import { AccountService } from '../account/account.service'
+import { VideoChannel } from './video-channel.model'
+
+@Injectable()
+export class VideoChannelService {
+ static BASE_VIDEO_CHANNEL_URL = environment.apiUrl + '/api/v1/video-channels/'
+
+ videoChannelLoaded = new ReplaySubject<VideoChannel>(1)
+
+ static extractVideoChannels (result: ResultList<VideoChannelServer>) {
+ const videoChannels: VideoChannel[] = []
+
+ for (const videoChannelJSON of result.data) {
+ videoChannels.push(new VideoChannel(videoChannelJSON))
+ }
+
+ return { data: videoChannels, total: result.total }
+ }
+
+ constructor (
+ private authHttp: HttpClient,
+ private restService: RestService,
+ private restExtractor: RestExtractor
+ ) { }
+
+ getVideoChannel (videoChannelName: string) {
+ return this.authHttp.get<VideoChannel>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName)
+ .pipe(
+ map(videoChannelHash => new VideoChannel(videoChannelHash)),
+ tap(videoChannel => this.videoChannelLoaded.next(videoChannel)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ listAccountVideoChannels (
+ account: Account,
+ componentPagination?: ComponentPaginationLight,
+ withStats = false
+ ): Observable<ResultList<VideoChannel>> {
+ const pagination = componentPagination
+ ? this.restService.componentPaginationToRestPagination(componentPagination)
+ : { start: 0, count: 20 }
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination)
+ params = params.set('withStats', withStats + '')
+
+ const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels'
+ return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
+ .pipe(
+ map(res => VideoChannelService.extractVideoChannels(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ createVideoChannel (videoChannel: VideoChannelCreate) {
+ return this.authHttp.post(VideoChannelService.BASE_VIDEO_CHANNEL_URL, videoChannel)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ updateVideoChannel (videoChannelName: string, videoChannel: VideoChannelUpdate) {
+ return this.authHttp.put(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName, videoChannel)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ changeVideoChannelAvatar (videoChannelName: string, avatarForm: FormData) {
+ const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar/pick'
+
+ return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ removeVideoChannel (videoChannel: VideoChannel) {
+ return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+}
--- /dev/null
+export * from './redundancy.service'
+export * from './video-details.model'
+export * from './video-edit.model'
+export * from './video-import.service'
+export * from './video-ownership.service'
+export * from './video.model'
+export * from './video.service'
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { concat, Observable } from 'rxjs'
+import { catchError, map, toArray } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+
+@Injectable()
+export class RedundancyService {
+ static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restService: RestService,
+ private restExtractor: RestExtractor
+ ) { }
+
+ updateRedundancy (host: string, redundancyAllowed: boolean) {
+ const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
+
+ const body = { redundancyAllowed }
+
+ return this.authHttp.put(url, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ listVideoRedundancies (options: {
+ pagination: RestPagination,
+ sort: SortMeta,
+ target?: VideoRedundanciesTarget
+ }): Observable<ResultList<VideoRedundancy>> {
+ const { pagination, sort, target } = options
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (target) params = params.append('target', target)
+
+ return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
+ .pipe(
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ addVideoRedundancy (video: Video) {
+ return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
+ .pipe(
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ removeVideoRedundancies (redundancy: VideoRedundancy) {
+ const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
+ .concat(redundancy.redundancies.files.map(r => r.id))
+ .map(id => this.removeRedundancy(id))
+
+ return concat(...observables)
+ .pipe(toArray())
+ }
+
+ private removeRedundancy (redundancyId: number) {
+ return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+}
--- /dev/null
+import { Account } from '@app/shared/shared-main/account/account.model'
+import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
+import {
+ VideoConstant,
+ VideoDetails as VideoDetailsServerModel,
+ VideoFile,
+ VideoState,
+ VideoStreamingPlaylist,
+ VideoStreamingPlaylistType
+} from '@shared/models'
+import { Video } from './video.model'
+
+export class VideoDetails extends Video implements VideoDetailsServerModel {
+ descriptionPath: string
+ support: string
+ channel: VideoChannel
+ tags: string[]
+ files: VideoFile[]
+ account: Account
+ commentsEnabled: boolean
+ downloadEnabled: boolean
+
+ waitTranscoding: boolean
+ state: VideoConstant<VideoState>
+
+ likesPercent: number
+ dislikesPercent: number
+
+ trackerUrls: string[]
+
+ streamingPlaylists: VideoStreamingPlaylist[]
+
+ constructor (hash: VideoDetailsServerModel, translations = {}) {
+ super(hash, translations)
+
+ this.descriptionPath = hash.descriptionPath
+ this.files = hash.files
+ this.channel = new VideoChannel(hash.channel)
+ this.account = new Account(hash.account)
+ this.tags = hash.tags
+ this.support = hash.support
+ this.commentsEnabled = hash.commentsEnabled
+ this.downloadEnabled = hash.downloadEnabled
+
+ this.trackerUrls = hash.trackerUrls
+ this.streamingPlaylists = hash.streamingPlaylists
+
+ this.buildLikeAndDislikePercents()
+ }
+
+ buildLikeAndDislikePercents () {
+ this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100
+ this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100
+ }
+
+ getHlsPlaylist () {
+ return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
+ }
+
+ hasHlsPlaylist () {
+ return !!this.getHlsPlaylist()
+ }
+
+ getFiles () {
+ if (this.files.length === 0) return this.getHlsPlaylist().files
+
+ return this.files
+ }
+}
--- /dev/null
+import { Video, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models'
+
+export class VideoEdit implements VideoUpdate {
+ static readonly SPECIAL_SCHEDULED_PRIVACY = -1
+
+ category: number
+ licence: number
+ language: string
+ description: string
+ name: string
+ tags: string[]
+ nsfw: boolean
+ commentsEnabled: boolean
+ downloadEnabled: boolean
+ waitTranscoding: boolean
+ channelId: number
+ privacy: VideoPrivacy
+ support: string
+ thumbnailfile?: any
+ previewfile?: any
+ thumbnailUrl: string
+ previewUrl: string
+ uuid?: string
+ id?: number
+ scheduleUpdate?: VideoScheduleUpdate
+ originallyPublishedAt?: Date | string
+
+ constructor (
+ video?: Video & {
+ tags: string[],
+ commentsEnabled: boolean,
+ downloadEnabled: boolean,
+ support: string,
+ thumbnailUrl: string,
+ previewUrl: string
+ }) {
+ if (video) {
+ this.id = video.id
+ this.uuid = video.uuid
+ this.category = video.category.id
+ this.licence = video.licence.id
+ this.language = video.language.id
+ this.description = video.description
+ this.name = video.name
+ this.tags = video.tags
+ this.nsfw = video.nsfw
+ this.commentsEnabled = video.commentsEnabled
+ this.downloadEnabled = video.downloadEnabled
+ this.waitTranscoding = video.waitTranscoding
+ this.channelId = video.channel.id
+ this.privacy = video.privacy.id
+ this.support = video.support
+ this.thumbnailUrl = video.thumbnailUrl
+ this.previewUrl = video.previewUrl
+
+ this.scheduleUpdate = video.scheduledUpdate
+ this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null
+ }
+ }
+
+ patch (values: { [ id: string ]: string }) {
+ Object.keys(values).forEach((key) => {
+ this[ key ] = values[ key ]
+ })
+
+ // If schedule publication, the video is private and will be changed to public privacy
+ if (parseInt(values['privacy'], 10) === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) {
+ const updateAt = new Date(values['schedulePublicationAt'])
+ updateAt.setSeconds(0)
+
+ this.privacy = VideoPrivacy.PRIVATE
+ this.scheduleUpdate = {
+ updateAt: updateAt.toISOString(),
+ privacy: VideoPrivacy.PUBLIC
+ }
+ } else {
+ this.scheduleUpdate = null
+ }
+
+ // Convert originallyPublishedAt to string so that function objectToFormData() works correctly
+ if (this.originallyPublishedAt) {
+ const originallyPublishedAt = new Date(values['originallyPublishedAt'])
+ this.originallyPublishedAt = originallyPublishedAt.toISOString()
+ }
+
+ // Use the same file than the preview for the thumbnail
+ if (this.previewfile) {
+ this.thumbnailfile = this.previewfile
+ }
+ }
+
+ toFormPatch () {
+ const json = {
+ category: this.category,
+ licence: this.licence,
+ language: this.language,
+ description: this.description,
+ support: this.support,
+ name: this.name,
+ tags: this.tags,
+ nsfw: this.nsfw,
+ commentsEnabled: this.commentsEnabled,
+ downloadEnabled: this.downloadEnabled,
+ waitTranscoding: this.waitTranscoding,
+ channelId: this.channelId,
+ privacy: this.privacy,
+ originallyPublishedAt: this.originallyPublishedAt
+ }
+
+ // Special case if we scheduled an update
+ if (this.scheduleUpdate) {
+ Object.assign(json, {
+ privacy: VideoEdit.SPECIAL_SCHEDULED_PRIVACY,
+ schedulePublicationAt: new Date(this.scheduleUpdate.updateAt.toString())
+ })
+ }
+
+ return json
+ }
+}
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { Observable } from 'rxjs'
+import { catchError, map, switchMap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService, ServerService, UserService } from '@app/core'
+import { objectToFormData } from '@app/helpers'
+import { peertubeTranslate, ResultList, VideoImport, VideoImportCreate, VideoUpdate } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+
+@Injectable()
+export class VideoImportService {
+ private static BASE_VIDEO_IMPORT_URL = environment.apiUrl + '/api/v1/videos/imports/'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restService: RestService,
+ private restExtractor: RestExtractor,
+ private serverService: ServerService
+ ) {}
+
+ importVideoUrl (targetUrl: string, video: VideoUpdate): Observable<VideoImport> {
+ const url = VideoImportService.BASE_VIDEO_IMPORT_URL
+
+ const body = this.buildImportVideoObject(video)
+ body.targetUrl = targetUrl
+
+ const data = objectToFormData(body)
+ return this.authHttp.post<VideoImport>(url, data)
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+ }
+
+ importVideoTorrent (target: string | Blob, video: VideoUpdate): Observable<VideoImport> {
+ const url = VideoImportService.BASE_VIDEO_IMPORT_URL
+ const body: VideoImportCreate = this.buildImportVideoObject(video)
+
+ if (typeof target === 'string') body.magnetUri = target
+ else body.torrentfile = target
+
+ const data = objectToFormData(body)
+ return this.authHttp.post<VideoImport>(url, data)
+ .pipe(catchError(res => this.restExtractor.handleError(res)))
+ }
+
+ getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoImport>> {
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ return this.authHttp
+ .get<ResultList<VideoImport>>(UserService.BASE_USERS_URL + '/me/videos/imports', { params })
+ .pipe(
+ switchMap(res => this.extractVideoImports(res)),
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ private buildImportVideoObject (video: VideoUpdate): VideoImportCreate {
+ const language = video.language || null
+ const licence = video.licence || null
+ const category = video.category || null
+ const description = video.description || null
+ const support = video.support || null
+ const scheduleUpdate = video.scheduleUpdate || null
+ const originallyPublishedAt = video.originallyPublishedAt || null
+
+ return {
+ name: video.name,
+ category,
+ licence,
+ language,
+ support,
+ description,
+ channelId: video.channelId,
+ privacy: video.privacy,
+ tags: video.tags,
+ nsfw: video.nsfw,
+ waitTranscoding: video.waitTranscoding,
+ commentsEnabled: video.commentsEnabled,
+ downloadEnabled: video.downloadEnabled,
+ thumbnailfile: video.thumbnailfile,
+ previewfile: video.previewfile,
+ scheduleUpdate,
+ originallyPublishedAt
+ }
+ }
+
+ private extractVideoImports (result: ResultList<VideoImport>): Observable<ResultList<VideoImport>> {
+ return this.serverService.getServerLocale()
+ .pipe(
+ map(translations => {
+ result.data.forEach(d =>
+ d.state.label = peertubeTranslate(d.state.label, translations)
+ )
+
+ return result
+ })
+ )
+ }
+}
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { Observable } from 'rxjs'
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { ResultList, VideoChangeOwnership, VideoChangeOwnershipAccept, VideoChangeOwnershipCreate } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+
+@Injectable()
+export class VideoOwnershipService {
+ private static BASE_VIDEO_CHANGE_OWNERSHIP_URL = environment.apiUrl + '/api/v1/videos/'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restService: RestService,
+ private restExtractor: RestExtractor
+ ) {
+ }
+
+ changeOwnership (id: number, username: string) {
+ const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + id + '/give-ownership'
+ const body: VideoChangeOwnershipCreate = {
+ username
+ }
+
+ return this.authHttp.post(url, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ getOwnershipChanges (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoChangeOwnership>> {
+ const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership'
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ return this.authHttp.get<ResultList<VideoChangeOwnership>>(url, { params })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ acceptOwnership (id: number, input: VideoChangeOwnershipAccept) {
+ const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/accept'
+ return this.authHttp.post(url, input)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(this.restExtractor.handleError)
+ )
+ }
+
+ refuseOwnership (id: number) {
+ const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/refuse'
+ return this.authHttp.post(url, {})
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(this.restExtractor.handleError)
+ )
+ }
+}
--- /dev/null
+import { AuthUser } from '@app/core'
+import { User } from '@app/core/users/user.model'
+import { durationToString, getAbsoluteAPIUrl } from '@app/helpers'
+import {
+ Avatar,
+ peertubeTranslate,
+ ServerConfig,
+ UserRight,
+ Video as VideoServerModel,
+ VideoConstant,
+ VideoPrivacy,
+ VideoScheduleUpdate,
+ VideoState
+} from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { Actor } from '../account/actor.model'
+
+export class Video implements VideoServerModel {
+ byVideoChannel: string
+ byAccount: string
+
+ accountAvatarUrl: string
+ videoChannelAvatarUrl: string
+
+ createdAt: Date
+ updatedAt: Date
+ publishedAt: Date
+ originallyPublishedAt: Date | string
+ category: VideoConstant<number>
+ licence: VideoConstant<number>
+ language: VideoConstant<string>
+ privacy: VideoConstant<VideoPrivacy>
+ description: string
+ duration: number
+ durationLabel: string
+ id: number
+ uuid: string
+ isLocal: boolean
+ name: string
+ serverHost: string
+ thumbnailPath: string
+ thumbnailUrl: string
+
+ previewPath: string
+ previewUrl: string
+
+ embedPath: string
+ embedUrl: string
+
+ url?: string
+
+ views: number
+ likes: number
+ dislikes: number
+ nsfw: boolean
+
+ originInstanceUrl: string
+ originInstanceHost: string
+
+ waitTranscoding?: boolean
+ state?: VideoConstant<VideoState>
+ scheduledUpdate?: VideoScheduleUpdate
+ blacklisted?: boolean
+ blockedReason?: string
+
+ account: {
+ id: number
+ name: string
+ displayName: string
+ url: string
+ host: string
+ avatar?: Avatar
+ }
+
+ channel: {
+ id: number
+ name: string
+ displayName: string
+ url: string
+ host: string
+ avatar?: Avatar
+ }
+
+ userHistory?: {
+ currentTime: number
+ }
+
+ static buildClientUrl (videoUUID: string) {
+ return '/videos/watch/' + videoUUID
+ }
+
+ constructor (hash: VideoServerModel, translations = {}) {
+ const absoluteAPIUrl = getAbsoluteAPIUrl()
+
+ this.createdAt = new Date(hash.createdAt.toString())
+ this.publishedAt = new Date(hash.publishedAt.toString())
+ this.category = hash.category
+ this.licence = hash.licence
+ this.language = hash.language
+ this.privacy = hash.privacy
+ this.waitTranscoding = hash.waitTranscoding
+ this.state = hash.state
+ this.description = hash.description
+
+ this.duration = hash.duration
+ this.durationLabel = durationToString(hash.duration)
+
+ this.id = hash.id
+ this.uuid = hash.uuid
+
+ this.isLocal = hash.isLocal
+ this.name = hash.name
+
+ this.thumbnailPath = hash.thumbnailPath
+ this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
+
+ this.previewPath = hash.previewPath
+ this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
+
+ this.embedPath = hash.embedPath
+ this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath)
+
+ this.url = hash.url
+
+ this.views = hash.views
+ this.likes = hash.likes
+ this.dislikes = hash.dislikes
+
+ this.nsfw = hash.nsfw
+
+ this.account = hash.account
+ this.channel = hash.channel
+
+ this.byAccount = Actor.CREATE_BY_STRING(hash.account.name, hash.account.host)
+ this.byVideoChannel = Actor.CREATE_BY_STRING(hash.channel.name, hash.channel.host)
+ this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
+ this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.channel)
+
+ this.category.label = peertubeTranslate(this.category.label, translations)
+ this.licence.label = peertubeTranslate(this.licence.label, translations)
+ this.language.label = peertubeTranslate(this.language.label, translations)
+ this.privacy.label = peertubeTranslate(this.privacy.label, translations)
+
+ this.scheduledUpdate = hash.scheduledUpdate
+ this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null
+
+ if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
+
+ this.blacklisted = hash.blacklisted
+ this.blockedReason = hash.blacklistedReason
+
+ this.userHistory = hash.userHistory
+
+ this.originInstanceHost = this.account.host
+ this.originInstanceUrl = 'https://' + this.originInstanceHost
+ }
+
+ isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
+ // Video is not NSFW, skip
+ if (this.nsfw === false) return false
+
+ // Return user setting if logged in
+ if (user) return user.nsfwPolicy !== 'display'
+
+ // Return default instance config
+ return serverConfig.instance.defaultNSFWPolicy !== 'display'
+ }
+
+ isRemovableBy (user: AuthUser) {
+ return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO))
+ }
+
+ isBlockableBy (user: AuthUser) {
+ return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
+ }
+
+ isUnblockableBy (user: AuthUser) {
+ return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
+ }
+
+ isUpdatableBy (user: AuthUser) {
+ return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
+ }
+
+ canBeDuplicatedBy (user: AuthUser) {
+ return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
+ }
+}
--- /dev/null
+import { FfprobeData } from 'fluent-ffmpeg'
+import { Observable } from 'rxjs'
+import { catchError, map, switchMap } from 'rxjs/operators'
+import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
+import { objectToFormData } from '@app/helpers'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import {
+ FeedFormat,
+ NSFWPolicyType,
+ ResultList,
+ UserVideoRate,
+ UserVideoRateType,
+ UserVideoRateUpdate,
+ Video as VideoServerModel,
+ VideoConstant,
+ VideoDetails as VideoDetailsServerModel,
+ VideoFilter,
+ VideoPrivacy,
+ VideoSortField,
+ VideoUpdate
+} from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { Account, AccountService } from '../account'
+import { VideoChannel, VideoChannelService } from '../video-channel'
+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>>
+}
+
+@Injectable()
+export class VideoService implements VideosProvider {
+ static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
+ static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private restService: RestService,
+ private serverService: ServerService,
+ private i18n: I18n
+ ) {}
+
+ getVideoViewUrl (uuid: string) {
+ return VideoService.BASE_VIDEO_URL + uuid + '/views'
+ }
+
+ getUserWatchingVideoUrl (uuid: string) {
+ return VideoService.BASE_VIDEO_URL + uuid + '/watching'
+ }
+
+ getVideo (options: { videoId: string }): Observable<VideoDetails> {
+ return this.serverService.getServerLocale()
+ .pipe(
+ switchMap(translations => {
+ return this.authHttp.get<VideoDetailsServerModel>(VideoService.BASE_VIDEO_URL + options.videoId)
+ .pipe(map(videoHash => ({ videoHash, translations })))
+ }),
+ map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ updateVideo (video: VideoEdit) {
+ const language = video.language || null
+ const licence = video.licence || null
+ const category = video.category || null
+ const description = video.description || null
+ const support = video.support || null
+ const scheduleUpdate = video.scheduleUpdate || null
+ const originallyPublishedAt = video.originallyPublishedAt || null
+
+ const body: VideoUpdate = {
+ name: video.name,
+ category,
+ licence,
+ language,
+ support,
+ description,
+ channelId: video.channelId,
+ privacy: video.privacy,
+ tags: video.tags,
+ nsfw: video.nsfw,
+ waitTranscoding: video.waitTranscoding,
+ commentsEnabled: video.commentsEnabled,
+ downloadEnabled: video.downloadEnabled,
+ thumbnailfile: video.thumbnailfile,
+ previewfile: video.previewfile,
+ scheduleUpdate,
+ originallyPublishedAt
+ }
+
+ const data = objectToFormData(body)
+
+ return this.authHttp.put(VideoService.BASE_VIDEO_URL + video.id, data)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ uploadVideo (video: FormData) {
+ const req = new HttpRequest('POST', VideoService.BASE_VIDEO_URL + 'upload', video, { reportProgress: true })
+
+ return this.authHttp
+ .request<{ video: { id: number, uuid: string } }>(req)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable<ResultList<Video>> {
+ const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+ params = this.restService.addObjectParams(params, { search })
+
+ return this.authHttp
+ .get<ResultList<Video>>(UserService.BASE_USERS_URL + 'me/videos', { params })
+ .pipe(
+ switchMap(res => this.extractVideos(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ getAccountVideos (
+ account: Account,
+ videoPagination: ComponentPaginationLight,
+ sort: VideoSortField
+ ): Observable<ResultList<Video>> {
+ const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ return this.authHttp
+ .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
+ .pipe(
+ switchMap(res => this.extractVideos(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ getVideoChannelVideos (
+ videoChannel: VideoChannel,
+ videoPagination: ComponentPaginationLight,
+ sort: VideoSortField,
+ nsfwPolicy?: NSFWPolicyType
+ ): Observable<ResultList<Video>> {
+ const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (nsfwPolicy) {
+ params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
+ }
+
+ return this.authHttp
+ .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
+ .pipe(
+ switchMap(res => this.extractVideos(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ getVideos (parameters: {
+ videoPagination: ComponentPaginationLight,
+ sort: VideoSortField,
+ filter?: VideoFilter,
+ categoryOneOf?: number[],
+ languageOneOf?: string[],
+ skipCount?: boolean,
+ nsfwPolicy?: NSFWPolicyType
+ }): Observable<ResultList<Video>> {
+ const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy } = parameters
+
+ const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+
+ 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 (nsfwPolicy) {
+ params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
+ }
+
+ if (languageOneOf) {
+ for (const l of languageOneOf) {
+ params = params.append('languageOneOf[]', l)
+ }
+ }
+
+ if (categoryOneOf) {
+ for (const c of categoryOneOf) {
+ params = params.append('categoryOneOf[]', c + '')
+ }
+ }
+
+ return this.authHttp
+ .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
+ .pipe(
+ switchMap(res => this.extractVideos(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ buildBaseFeedUrls (params: HttpParams) {
+ const feeds = [
+ {
+ format: FeedFormat.RSS,
+ label: 'rss 2.0',
+ url: VideoService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase()
+ },
+ {
+ format: FeedFormat.ATOM,
+ label: 'atom 1.0',
+ url: VideoService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase()
+ },
+ {
+ format: FeedFormat.JSON,
+ label: 'json 1.0',
+ url: VideoService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase()
+ }
+ ]
+
+ if (params && params.keys().length !== 0) {
+ for (const feed of feeds) {
+ feed.url += '?' + params.toString()
+ }
+ }
+
+ return feeds
+ }
+
+ getVideoFeedUrls (sort: VideoSortField, filter?: VideoFilter, categoryOneOf?: number[]) {
+ let params = this.restService.addRestGetParams(new HttpParams(), undefined, sort)
+
+ if (filter) params = params.set('filter', filter)
+
+ if (categoryOneOf) {
+ for (const c of categoryOneOf) {
+ params = params.append('categoryOneOf[]', c + '')
+ }
+ }
+
+ return this.buildBaseFeedUrls(params)
+ }
+
+ getAccountFeedUrls (accountId: number) {
+ let params = this.restService.addRestGetParams(new HttpParams())
+ params = params.set('accountId', accountId.toString())
+
+ return this.buildBaseFeedUrls(params)
+ }
+
+ getVideoChannelFeedUrls (videoChannelId: number) {
+ let params = this.restService.addRestGetParams(new HttpParams())
+ params = params.set('videoChannelId', videoChannelId.toString())
+
+ return this.buildBaseFeedUrls(params)
+ }
+
+ getVideoFileMetadata (metadataUrl: string) {
+ return this.authHttp
+ .get<FfprobeData>(metadataUrl)
+ .pipe(
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ removeVideo (id: number) {
+ return this.authHttp
+ .delete(VideoService.BASE_VIDEO_URL + id)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ loadCompleteDescription (descriptionPath: string) {
+ return this.authHttp
+ .get<{ description: string }>(environment.apiUrl + descriptionPath)
+ .pipe(
+ map(res => res.description),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ setVideoLike (id: number) {
+ return this.setVideoRate(id, 'like')
+ }
+
+ setVideoDislike (id: number) {
+ return this.setVideoRate(id, 'dislike')
+ }
+
+ unsetVideoLike (id: number) {
+ return this.setVideoRate(id, 'none')
+ }
+
+ getUserVideoRating (id: number) {
+ const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating'
+
+ return this.authHttp.get<UserVideoRate>(url)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ extractVideos (result: ResultList<VideoServerModel>) {
+ return this.serverService.getServerLocale()
+ .pipe(
+ map(translations => {
+ const videosJson = result.data
+ const totalVideos = result.total
+ const videos: Video[] = []
+
+ for (const videoJson of videosJson) {
+ videos.push(new Video(videoJson, translations))
+ }
+
+ return { total: totalVideos, data: videos }
+ })
+ )
+ }
+
+ explainedPrivacyLabels (privacies: VideoConstant<VideoPrivacy>[]) {
+ const base = [
+ {
+ id: VideoPrivacy.PRIVATE,
+ label: this.i18n('Only I can see this video')
+ },
+ {
+ id: VideoPrivacy.UNLISTED,
+ label: this.i18n('Only people with the private link can see this video')
+ },
+ {
+ id: VideoPrivacy.PUBLIC,
+ label: this.i18n('Anyone can see this video')
+ },
+ {
+ id: VideoPrivacy.INTERNAL,
+ label: this.i18n('Only users of this instance can see this video')
+ }
+ ]
+
+ return base.filter(o => !!privacies.find(p => p.id === o.id))
+ }
+
+ nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) {
+ return nsfwPolicy === 'do_not_list'
+ ? 'false'
+ : 'both'
+ }
+
+ private setVideoRate (id: number, rateType: UserVideoRateType) {
+ const url = VideoService.BASE_VIDEO_URL + id + '/rate'
+ const body: UserVideoRateUpdate = {
+ rating: rateType
+ }
+
+ return this.authHttp
+ .put(url, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+}
--- /dev/null
+import { AccountBlock as AccountBlockServer } from '@shared/models'
+import { Account } from '@app/shared/shared-main'
+
+export class AccountBlock implements AccountBlockServer {
+ byAccount: Account
+ blockedAccount: Account
+ createdAt: Date | string
+
+ constructor (block: AccountBlockServer) {
+ this.byAccount = new Account(block.byAccount)
+ this.blockedAccount = new Account(block.blockedAccount)
+ this.createdAt = block.createdAt
+ }
+}
--- /dev/null
+<p-table
+ [value]="blockedAccounts" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
+ [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
+ [showCurrentPageReport]="true" i18n-currentPageReportTemplate
+ currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted accounts"
+>
+ <ng-template pTemplate="caption">
+ <div class="caption">
+ <div class="ml-auto has-feedback has-clear">
+ <input
+ type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
+ (keyup)="onSearch($event)"
+ >
+ <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
+ <span class="sr-only" i18n>Clear filters</span>
+ </div>
+ </div>
+ </ng-template>
+
+ <ng-template pTemplate="header">
+ <tr>
+ <th style="width: 100%;" i18n>Account</th>
+ <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
+ <th style="width: 150px;"></th> <!-- column for action buttons -->
+ </tr>
+ </ng-template>
+
+ <ng-template pTemplate="body" let-accountBlock>
+ <tr>
+ <td>
+ <a [href]="accountBlock.blockedAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
+ <div class="chip two-lines">
+ <img
+ class="avatar"
+ [src]="accountBlock.blockedAccount.avatar?.path"
+ (error)="switchToDefaultAvatar($event)"
+ alt="Avatar"
+ >
+ <div>
+ {{ accountBlock.blockedAccount.displayName }}
+ <span class="text-muted">{{ accountBlock.blockedAccount.nameWithHost }}</span>
+ </div>
+ </div>
+ </a>
+ </td>
+
+ <td>{{ accountBlock.createdAt | date: 'short' }}</td>
+ <td class="action-cell">
+ <button class="unblock-button" (click)="unblockAccount(accountBlock)" i18n>Unmute</button>
+ </td>
+ </tr>
+ </ng-template>
+
+ <ng-template pTemplate="emptymessage">
+ <tr>
+ <td colspan="6">
+ <div class="no-results">
+ <ng-container *ngIf="search" i18n>No account found matching current filters.</ng-container>
+ <ng-container *ngIf="!search" i18n>No account found.</ng-container>
+ </div>
+ </td>
+ </tr>
+ </ng-template>
+</p-table>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.caption {
+ justify-content: flex-end;
+
+ input {
+ @include peertube-input-text(250px);
+ flex-grow: 1;
+ }
+}
+
+.unblock-button {
+ @include peertube-button;
+ @include grey-button;
+}
\ No newline at end of file
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { OnInit } from '@angular/core'
+import { Notifier, RestPagination, RestTable } from '@app/core'
+import { Actor } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { AccountBlock } from './account-block.model'
+import { BlocklistComponentType, BlocklistService } from './blocklist.service'
+
+export class GenericAccountBlocklistComponent extends RestTable implements OnInit {
+ // @ts-ignore: "Abstract methods can only appear within an abstract class"
+ abstract mode: BlocklistComponentType
+
+ blockedAccounts: AccountBlock[] = []
+ totalRecords = 0
+ sort: SortMeta = { field: 'createdAt', order: -1 }
+ pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
+
+ constructor (
+ private notifier: Notifier,
+ private blocklistService: BlocklistService,
+ private i18n: I18n
+ ) {
+ super()
+ }
+
+ // @ts-ignore: "Abstract methods can only appear within an abstract class"
+ abstract getIdentifier (): string
+
+ ngOnInit () {
+ this.initialize()
+ }
+
+ switchToDefaultAvatar ($event: Event) {
+ ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
+ }
+
+ unblockAccount (accountBlock: AccountBlock) {
+ const blockedAccount = accountBlock.blockedAccount
+ const operation = this.mode === BlocklistComponentType.Account
+ ? this.blocklistService.unblockAccountByUser(blockedAccount)
+ : this.blocklistService.unblockAccountByInstance(blockedAccount)
+
+ operation.subscribe(
+ () => {
+ this.notifier.success(
+ this.mode === BlocklistComponentType.Account
+ ? this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: blockedAccount.nameWithHost })
+ : this.i18n('Account {{nameWithHost}} unmuted by your instance.', { nameWithHost: blockedAccount.nameWithHost })
+ )
+
+ this.loadData()
+ }
+ )
+ }
+
+ protected loadData () {
+ const operation = this.mode === BlocklistComponentType.Account
+ ? this.blocklistService.getUserAccountBlocklist({
+ pagination: this.pagination,
+ sort: this.sort,
+ search: this.search
+ })
+ : this.blocklistService.getInstanceAccountBlocklist({
+ pagination: this.pagination,
+ sort: this.sort,
+ search: this.search
+ })
+
+ return operation.subscribe(
+ resultList => {
+ this.blockedAccounts = resultList.data
+ this.totalRecords = resultList.total
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+}
--- /dev/null
+<ng-template #modal>
+ <div class="modal-header">
+ <h4 i18n class="modal-title">{{ action }}</h4>
+
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+ <div class="modal-body">
+ <form novalidate [formGroup]="form" (ngSubmit)="submit()">
+ <div class="form-group">
+ <label i18n for="hosts">1 host (without "http://") per line</label>
+
+ <textarea
+ [placeholder]="placeholder" formControlName="domains" type="text" id="hosts" name="hosts"
+ class="form-control" [ngClass]="{ 'input-error': formErrors['domains'] }" ngbAutofocus
+ ></textarea>
+
+ <div *ngIf="formErrors.domains" class="form-error">
+ {{ formErrors.domains }}
+
+ <div *ngIf="form.controls['domains'].errors.validDomains">
+ {{ form.controls['domains'].errors.validDomains.value }}
+ </div>
+ </div>
+ </div>
+
+ <ng-content select="warning"></ng-content>
+
+ <div class="form-group inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+ (click)="hide()" (key.enter)="hide()"
+ >
+
+ <input
+ type="submit" [value]="action" class="action-button-submit"
+ [disabled]="!form.valid"
+ >
+ </div>
+ </form>
+ </div>
+
+</ng-template>
--- /dev/null
+textarea {
+ height: 200px;
+}
--- /dev/null
+import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { BatchDomainsValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Component({
+ selector: 'my-batch-domains-modal',
+ templateUrl: './batch-domains-modal.component.html',
+ styleUrls: [ './batch-domains-modal.component.scss' ]
+})
+export class BatchDomainsModalComponent extends FormReactive implements OnInit {
+ @ViewChild('modal', { static: true }) modal: NgbModal
+ @Input() placeholder = 'example.com'
+ @Input() action: string
+ @Output() domains = new EventEmitter<string[]>()
+
+ private openedModal: NgbModalRef
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private modalService: NgbModal,
+ private batchDomainsValidatorsService: BatchDomainsValidatorsService,
+ private i18n: I18n
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ if (!this.action) this.action = this.i18n('Process domains')
+
+ this.buildForm({
+ domains: this.batchDomainsValidatorsService.DOMAINS
+ })
+ }
+
+ openModal () {
+ this.openedModal = this.modalService.open(this.modal, { centered: true })
+ }
+
+ hide () {
+ this.openedModal.close()
+ }
+
+ submit () {
+ this.domains.emit(
+ this.batchDomainsValidatorsService.getNotEmptyHosts(this.form.controls['domains'].value)
+ )
+ this.form.reset()
+ this.hide()
+ }
+}
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { AccountBlock as AccountBlockServer, ResultList, ServerBlock } from '@shared/models'
+import { environment } from '../../../environments/environment'
+import { Account } from '../shared-main'
+import { AccountBlock } from './account-block.model'
+
+export enum BlocklistComponentType { Account, Instance }
+
+@Injectable()
+export class BlocklistService {
+ static BASE_USER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/users/me/blocklist'
+ static BASE_SERVER_BLOCKLIST_URL = environment.apiUrl + '/api/v1/server/blocklist'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private restService: RestService
+ ) { }
+
+ /*********************** User -> Account blocklist ***********************/
+
+ getUserAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
+ const { pagination, sort, search } = options
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) params = params.append('search', search)
+
+ return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', { params })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ blockAccountByUser (account: Account) {
+ const body = { accountName: account.nameWithHost }
+
+ return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts', body)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ unblockAccountByUser (account: Account) {
+ const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost
+
+ return this.authHttp.delete(path)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ /*********************** User -> Server blocklist ***********************/
+
+ getUserServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
+ const { pagination, sort, search } = options
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) params = params.append('search', search)
+
+ return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', { params })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ blockServerByUser (host: string) {
+ const body = { host }
+
+ return this.authHttp.post(BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers', body)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ unblockServerByUser (host: string) {
+ const path = BlocklistService.BASE_USER_BLOCKLIST_URL + '/servers/' + host
+
+ return this.authHttp.delete(path)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ /*********************** Instance -> Account blocklist ***********************/
+
+ getInstanceAccountBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
+ const { pagination, sort, search } = options
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) params = params.append('search', search)
+
+ return this.authHttp.get<ResultList<AccountBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', { params })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ map(res => this.restExtractor.applyToResultListData(res, this.formatAccountBlock.bind(this))),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ blockAccountByInstance (account: Account) {
+ const body = { accountName: account.nameWithHost }
+
+ return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts', body)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ unblockAccountByInstance (account: Account) {
+ const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/accounts/' + account.nameWithHost
+
+ return this.authHttp.delete(path)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ /*********************** Instance -> Server blocklist ***********************/
+
+ getInstanceServerBlocklist (options: { pagination: RestPagination, sort: SortMeta, search?: string }) {
+ const { pagination, sort, search } = options
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) params = params.append('search', search)
+
+ return this.authHttp.get<ResultList<ServerBlock>>(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', { params })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ blockServerByInstance (host: string) {
+ const body = { host }
+
+ return this.authHttp.post(BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers', body)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ unblockServerByInstance (host: string) {
+ const path = BlocklistService.BASE_SERVER_BLOCKLIST_URL + '/servers/' + host
+
+ return this.authHttp.delete(path)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+
+ private formatAccountBlock (accountBlock: AccountBlockServer) {
+ return new AccountBlock(accountBlock)
+ }
+}
--- /dev/null
+import { catchError } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor } from '@app/core'
+import { BulkRemoveCommentsOfBody } from '@shared/models'
+import { environment } from '../../../environments/environment'
+
+@Injectable()
+export class BulkService {
+ static BASE_BULK_URL = environment.apiUrl + '/api/v1/bulk'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor
+ ) { }
+
+ removeCommentsOf (body: BulkRemoveCommentsOfBody) {
+ const url = BulkService.BASE_BULK_URL + '/remove-comments-of'
+
+ return this.authHttp.post(url, body)
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+}
--- /dev/null
+export * from './account-block.model'
+export * from './account-blocklist.component'
+export * from './batch-domains-modal.component'
+export * from './blocklist.service'
+export * from './bulk.service'
+export * from './server-blocklist.component'
+export * from './user-ban-modal.component'
+export * from './user-moderation-dropdown.component'
+export * from './video-abuse.service'
+export * from './video-block.component'
+export * from './video-block.service'
+export * from './video-report.component'
+export * from './shared-moderation.module'
--- /dev/null
+<p-table
+ [value]="blockedServers" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
+ [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" (onPage)="onPage($event)"
+ [showCurrentPageReport]="true" i18n-currentPageReportTemplate
+ currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} muted instances"
+>
+ <ng-template pTemplate="caption">
+ <div class="caption">
+ <div class="ml-auto has-feedback has-clear">
+ <input
+ type="text" name="table-filter" id="table-filter" i18n-placeholder placeholder="Filter..."
+ (keyup)="onSearch($event)"
+ >
+ <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
+ <span class="sr-only" i18n>Clear filters</span>
+ </div>
+ <a class="ml-2 block-button" (click)="addServersToBlock()" (key.enter)="addServersToBlock()">
+ <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
+ <ng-container i18n>Mute domain</ng-container>
+ </a>
+ </div>
+ </ng-template>
+
+ <ng-template pTemplate="header">
+ <tr>
+ <th style="width: 100%;" i18n>Instance</th>
+ <th style="width: 150px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
+ <th style="width: 150px;"></th> <!-- column for action buttons -->
+ </tr>
+ </ng-template>
+
+ <ng-template pTemplate="body" let-serverBlock>
+ <tr>
+ <td>
+ <a [href]="'https://' + serverBlock.blockedServer.host" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
+ {{ serverBlock.blockedServer.host }}
+ <span class="glyphicon glyphicon-new-window"></span>
+ </a>
+ </td>
+ <td>{{ serverBlock.createdAt | date: 'short' }}</td>
+ <td class="action-cell">
+ <button class="unblock-button" (click)="unblockServer(serverBlock)" i18n>Unmute</button>
+ </td>
+ </tr>
+ </ng-template>
+
+ <ng-template pTemplate="emptymessage">
+ <tr>
+ <td colspan="6">
+ <div class="no-results">
+ <ng-container *ngIf="search" i18n>No server found matching current filters.</ng-container>
+ <ng-container *ngIf="!search" i18n>No server found.</ng-container>
+ </div>
+ </td>
+ </tr>
+ </ng-template>
+</p-table>
+
+<my-batch-domains-modal #batchDomainsModal i18n-action action="Mute domains" (domains)="onDomainsToBlock($event)"></my-batch-domains-modal>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+a {
+ @include disable-default-a-behaviour;
+ display: inline-block;
+
+ &, &:hover {
+ color: pvar(--mainForegroundColor);
+ }
+
+ span {
+ font-size: 80%;
+ color: pvar(--inputPlaceholderColor);
+ }
+}
+
+.caption {
+ justify-content: flex-end;
+
+ input {
+ @include peertube-input-text(250px);
+ flex-grow: 1;
+ }
+}
+
+.unblock-button {
+ @include peertube-button;
+ @include grey-button;
+}
+
+.block-button {
+ @include create-button;
+}
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { OnInit, ViewChild } from '@angular/core'
+import { BatchDomainsModalComponent } from '@app/shared/shared-moderation/batch-domains-modal.component'
+import { Notifier, RestPagination, RestTable } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerBlock } from '@shared/models'
+import { BlocklistComponentType, BlocklistService } from './blocklist.service'
+
+export class GenericServerBlocklistComponent extends RestTable implements OnInit {
+ @ViewChild('batchDomainsModal') batchDomainsModal: BatchDomainsModalComponent
+
+ // @ts-ignore: "Abstract methods can only appear within an abstract class"
+ public abstract mode: BlocklistComponentType
+
+ blockedServers: ServerBlock[] = []
+ totalRecords = 0
+ sort: SortMeta = { field: 'createdAt', order: -1 }
+ pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
+
+ constructor (
+ protected notifier: Notifier,
+ protected blocklistService: BlocklistService,
+ protected i18n: I18n
+ ) {
+ super()
+ }
+
+ // @ts-ignore: "Abstract methods can only appear within an abstract class"
+ public abstract getIdentifier (): string
+
+ ngOnInit () {
+ this.initialize()
+ }
+
+ unblockServer (serverBlock: ServerBlock) {
+ const operation = (host: string) => this.mode === BlocklistComponentType.Account
+ ? this.blocklistService.unblockServerByUser(host)
+ : this.blocklistService.unblockServerByInstance(host)
+ const host = serverBlock.blockedServer.host
+
+ operation(host).subscribe(
+ () => {
+ this.notifier.success(
+ this.mode === BlocklistComponentType.Account
+ ? this.i18n('Instance {{host}} unmuted.', { host })
+ : this.i18n('Instance {{host}} unmuted by your instance.', { host })
+ )
+
+ this.loadData()
+ }
+ )
+ }
+
+ addServersToBlock () {
+ this.batchDomainsModal.openModal()
+ }
+
+ onDomainsToBlock (domains: string[]) {
+ const operation = (domain: string) => this.mode === BlocklistComponentType.Account
+ ? this.blocklistService.blockServerByUser(domain)
+ : this.blocklistService.blockServerByInstance(domain)
+
+ domains.forEach(domain => {
+ operation(domain).subscribe(
+ () => {
+ this.notifier.success(
+ this.mode === BlocklistComponentType.Account
+ ? this.i18n('Instance {{domain}} muted.', { domain })
+ : this.i18n('Instance {{domain}} muted by your instance.', { domain })
+ )
+
+ this.loadData()
+ }
+ )
+ })
+ }
+
+ protected loadData () {
+ const operation = this.mode === BlocklistComponentType.Account
+ ? this.blocklistService.getUserServerBlocklist({
+ pagination: this.pagination,
+ sort: this.sort,
+ search: this.search
+ })
+ : this.blocklistService.getInstanceServerBlocklist({
+ pagination: this.pagination,
+ sort: this.sort,
+ search: this.search
+ })
+
+ return operation.subscribe(
+ resultList => {
+ this.blockedServers = resultList.data
+ this.totalRecords = resultList.total
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+}
--- /dev/null
+
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '../shared-forms/shared-form.module'
+import { SharedGlobalIconModule } from '../shared-icons'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import { BatchDomainsModalComponent } from './batch-domains-modal.component'
+import { BlocklistService } from './blocklist.service'
+import { BulkService } from './bulk.service'
+import { UserBanModalComponent } from './user-ban-modal.component'
+import { UserModerationDropdownComponent } from './user-moderation-dropdown.component'
+import { VideoAbuseService } from './video-abuse.service'
+import { VideoBlockComponent } from './video-block.component'
+import { VideoBlockService } from './video-block.service'
+import { VideoReportComponent } from './video-report.component'
+
+@NgModule({
+ imports: [
+ SharedMainModule,
+ SharedFormModule,
+ SharedGlobalIconModule
+ ],
+
+ declarations: [
+ UserBanModalComponent,
+ UserModerationDropdownComponent,
+ VideoBlockComponent,
+ VideoReportComponent,
+ BatchDomainsModalComponent
+ ],
+
+ exports: [
+ UserBanModalComponent,
+ UserModerationDropdownComponent,
+ VideoBlockComponent,
+ VideoReportComponent,
+ BatchDomainsModalComponent
+ ],
+
+ providers: [
+ BlocklistService,
+ BulkService,
+ VideoAbuseService,
+ VideoBlockService
+ ]
+})
+export class SharedModerationModule { }
--- /dev/null
+<ng-template #modal>
+ <div class="modal-header">
+ <h4 i18n class="modal-title">Ban</h4>
+
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+ <div class="modal-body">
+ <form novalidate [formGroup]="form" (ngSubmit)="banUser()">
+ <div class="form-group">
+ <textarea
+ i18n-placeholder placeholder="Reason..." formControlName="reason"
+ class="form-control" [ngClass]="{ 'input-error': formErrors['reason'] }"
+ ></textarea>
+ <div *ngIf="formErrors.reason" class="form-error">
+ {{ formErrors.reason }}
+ </div>
+ </div>
+
+ <div i18n>
+ A banned user will no longer be able to login.
+ </div>
+
+ <div class="form-group inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+ (click)="hide()" (key.enter)="hide()"
+ >
+
+ <input
+ type="submit" i18n-value value="Ban this user" class="action-button-submit"
+ [disabled]="!form.valid"
+ >
+ </div>
+ </form>
+ </div>
+
+</ng-template>
--- /dev/null
+@import 'variables';
+@import 'mixins';
+
+textarea {
+ @include peertube-textarea(100%, 60px);
+}
--- /dev/null
+import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
+import { Notifier, UserService } from '@app/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { User } from '@shared/models'
+
+@Component({
+ selector: 'my-user-ban-modal',
+ templateUrl: './user-ban-modal.component.html',
+ styleUrls: [ './user-ban-modal.component.scss' ]
+})
+export class UserBanModalComponent extends FormReactive implements OnInit {
+ @ViewChild('modal', { static: true }) modal: NgbModal
+ @Output() userBanned = new EventEmitter<User | User[]>()
+
+ private usersToBan: User | User[]
+ private openedModal: NgbModalRef
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private modalService: NgbModal,
+ private notifier: Notifier,
+ private userService: UserService,
+ private userValidatorsService: UserValidatorsService,
+ private i18n: I18n
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ this.buildForm({
+ reason: this.userValidatorsService.USER_BAN_REASON
+ })
+ }
+
+ openModal (user: User | User[]) {
+ this.usersToBan = user
+ this.openedModal = this.modalService.open(this.modal, { centered: true })
+ }
+
+ hide () {
+ this.usersToBan = undefined
+ this.openedModal.close()
+ }
+
+ async banUser () {
+ const reason = this.form.value['reason'] || undefined
+
+ this.userService.banUsers(this.usersToBan, reason)
+ .subscribe(
+ () => {
+ const message = Array.isArray(this.usersToBan)
+ ? this.i18n('{{num}} users banned.', { num: this.usersToBan.length })
+ : this.i18n('User {{username}} banned.', { username: this.usersToBan.username })
+
+ this.notifier.success(message)
+
+ this.userBanned.emit(this.usersToBan)
+ this.hide()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+}
--- /dev/null
+<ng-container *ngIf="userActions.length !== 0">
+ <my-user-ban-modal #userBanModal (userBanned)="onUserBanned()"></my-user-ban-modal>
+
+ <my-action-dropdown
+ [actions]="userActions" [entry]="{ user: user, account: account }"
+ [buttonSize]="buttonSize" [placement]="placement" [label]="label"
+ [container]="container"
+ ></my-action-dropdown>
+</ng-container>
--- /dev/null
+import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
+import { AuthService, ConfirmService, Notifier, ServerService, UserService } from '@app/core'
+import { Account, DropdownAction } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { BulkRemoveCommentsOfBody, ServerConfig, User, UserRight } from '@shared/models'
+import { BlocklistService } from './blocklist.service'
+import { BulkService } from './bulk.service'
+import { UserBanModalComponent } from './user-ban-modal.component'
+
+@Component({
+ selector: 'my-user-moderation-dropdown',
+ templateUrl: './user-moderation-dropdown.component.html'
+})
+export class UserModerationDropdownComponent implements OnInit, OnChanges {
+ @ViewChild('userBanModal') userBanModal: UserBanModalComponent
+
+ @Input() user: User
+ @Input() account: Account
+
+ @Input() buttonSize: 'normal' | 'small' = 'normal'
+ @Input() placement = 'left-top left-bottom auto'
+ @Input() label: string
+ @Input() container: 'body' | undefined = undefined
+
+ @Output() userChanged = new EventEmitter()
+ @Output() userDeleted = new EventEmitter()
+
+ userActions: DropdownAction<{ user: User, account: Account }>[][] = []
+
+ private serverConfig: ServerConfig
+
+ constructor (
+ private authService: AuthService,
+ private notifier: Notifier,
+ private confirmService: ConfirmService,
+ private serverService: ServerService,
+ private userService: UserService,
+ private blocklistService: BlocklistService,
+ private bulkService: BulkService,
+ private i18n: I18n
+ ) { }
+
+ get requiresEmailVerification () {
+ return this.serverConfig.signup.requiresEmailVerification
+ }
+
+ ngOnInit (): void {
+ this.serverConfig = this.serverService.getTmpConfig()
+ this.serverService.getConfig()
+ .subscribe(config => this.serverConfig = config)
+ }
+
+ ngOnChanges () {
+ this.buildActions()
+ }
+
+ openBanUserModal (user: User) {
+ if (user.username === 'root') {
+ this.notifier.error(this.i18n('You cannot ban root.'))
+ return
+ }
+
+ this.userBanModal.openModal(user)
+ }
+
+ onUserBanned () {
+ this.userChanged.emit()
+ }
+
+ async unbanUser (user: User) {
+ const message = this.i18n('Do you really want to unban {{username}}?', { username: user.username })
+ const res = await this.confirmService.confirm(message, this.i18n('Unban'))
+ if (res === false) return
+
+ this.userService.unbanUsers(user)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('User {{username}} unbanned.', { username: user.username }))
+
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ async removeUser (user: User) {
+ if (user.username === 'root') {
+ this.notifier.error(this.i18n('You cannot delete root.'))
+ return
+ }
+
+ const message = this.i18n('If you remove this user, you will not be able to create another with the same username!')
+ const res = await this.confirmService.confirm(message, this.i18n('Delete'))
+ if (res === false) return
+
+ this.userService.removeUser(user).subscribe(
+ () => {
+ this.notifier.success(this.i18n('User {{username}} deleted.', { username: user.username }))
+ this.userDeleted.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ setEmailAsVerified (user: User) {
+ this.userService.updateUser(user.id, { emailVerified: true }).subscribe(
+ () => {
+ this.notifier.success(this.i18n('User {{username}} email set as verified', { username: user.username }))
+
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ blockAccountByUser (account: Account) {
+ this.blocklistService.blockAccountByUser(account)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Account {{nameWithHost}} muted.', { nameWithHost: account.nameWithHost }))
+
+ this.account.mutedByUser = true
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ unblockAccountByUser (account: Account) {
+ this.blocklistService.unblockAccountByUser(account)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted.', { nameWithHost: account.nameWithHost }))
+
+ this.account.mutedByUser = false
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ blockServerByUser (host: string) {
+ this.blocklistService.blockServerByUser(host)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Instance {{host}} muted.', { host }))
+
+ this.account.mutedServerByUser = true
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ unblockServerByUser (host: string) {
+ this.blocklistService.unblockServerByUser(host)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Instance {{host}} unmuted.', { host }))
+
+ this.account.mutedServerByUser = false
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ blockAccountByInstance (account: Account) {
+ this.blocklistService.blockAccountByInstance(account)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Account {{nameWithHost}} muted by the instance.', { nameWithHost: account.nameWithHost }))
+
+ this.account.mutedByInstance = true
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ unblockAccountByInstance (account: Account) {
+ this.blocklistService.unblockAccountByInstance(account)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Account {{nameWithHost}} unmuted by the instance.', { nameWithHost: account.nameWithHost }))
+
+ this.account.mutedByInstance = false
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ blockServerByInstance (host: string) {
+ this.blocklistService.blockServerByInstance(host)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Instance {{host}} muted by the instance.', { host }))
+
+ this.account.mutedServerByInstance = true
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ unblockServerByInstance (host: string) {
+ this.blocklistService.unblockServerByInstance(host)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Instance {{host}} unmuted by the instance.', { host }))
+
+ this.account.mutedServerByInstance = false
+ this.userChanged.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ async bulkRemoveCommentsOf (body: BulkRemoveCommentsOfBody) {
+ const message = this.i18n('Are you sure you want to remove all the comments of this account?')
+ const res = await this.confirmService.confirm(message, this.i18n('Delete account comments'))
+ if (res === false) return
+
+ this.bulkService.removeCommentsOf(body)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Will remove comments of this account (may take several minutes).'))
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ getRouterUserEditLink (user: User) {
+ return [ '/admin', 'users', 'update', user.id ]
+ }
+
+ private buildActions () {
+ this.userActions = []
+
+ if (this.authService.isLoggedIn()) {
+ const authUser = this.authService.getUser()
+
+ if (this.user && authUser.id === this.user.id) return
+
+ if (this.user && authUser.hasRight(UserRight.MANAGE_USERS) && authUser.canManage(this.user)) {
+ this.userActions.push([
+ {
+ label: this.i18n('Edit user'),
+ description: this.i18n('Change quota, role, and more.'),
+ linkBuilder: ({ user }) => this.getRouterUserEditLink(user)
+ },
+ {
+ label: this.i18n('Delete user'),
+ description: this.i18n('Videos will be deleted, comments will be tombstoned.'),
+ handler: ({ user }) => this.removeUser(user)
+ },
+ {
+ label: this.i18n('Ban'),
+ description: this.i18n('User won\'t be able to login anymore, but videos and comments will be kept as is.'),
+ handler: ({ user }) => this.openBanUserModal(user),
+ isDisplayed: ({ user }) => !user.blocked
+ },
+ {
+ label: this.i18n('Unban user'),
+ description: this.i18n('Allow the user to login and create videos/comments again'),
+ handler: ({ user }) => this.unbanUser(user),
+ isDisplayed: ({ user }) => user.blocked
+ },
+ {
+ label: this.i18n('Set Email as Verified'),
+ handler: ({ user }) => this.setEmailAsVerified(user),
+ isDisplayed: ({ user }) => this.requiresEmailVerification && !user.blocked && user.emailVerified === false
+ }
+ ])
+ }
+
+ // Actions on accounts/servers
+ if (this.account) {
+ // User actions
+ this.userActions.push([
+ {
+ label: this.i18n('Mute this account'),
+ description: this.i18n('Hide any content from that user for you.'),
+ isDisplayed: ({ account }) => account.mutedByUser === false,
+ handler: ({ account }) => this.blockAccountByUser(account)
+ },
+ {
+ label: this.i18n('Unmute this account'),
+ description: this.i18n('Show back content from that user for you.'),
+ isDisplayed: ({ account }) => account.mutedByUser === true,
+ handler: ({ account }) => this.unblockAccountByUser(account)
+ },
+ {
+ label: this.i18n('Mute the instance'),
+ description: this.i18n('Hide any content from that instance for you.'),
+ isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false,
+ handler: ({ account }) => this.blockServerByUser(account.host)
+ },
+ {
+ label: this.i18n('Unmute the instance'),
+ description: this.i18n('Show back content from that instance for you.'),
+ isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true,
+ handler: ({ account }) => this.unblockServerByUser(account.host)
+ },
+ {
+ label: this.i18n('Remove comments from your videos'),
+ description: this.i18n('Remove comments of this account from your videos.'),
+ handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'my-videos' })
+ }
+ ])
+
+ let instanceActions: DropdownAction<{ user: User, account: Account }>[] = []
+
+ // Instance actions on account blocklists
+ if (authUser.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) {
+ instanceActions = instanceActions.concat([
+ {
+ label: this.i18n('Mute this account by your instance'),
+ description: this.i18n('Hide any content from that user for you, your instance and its users.'),
+ isDisplayed: ({ account }) => account.mutedByInstance === false,
+ handler: ({ account }) => this.blockAccountByInstance(account)
+ },
+ {
+ label: this.i18n('Unmute this account by your instance'),
+ description: this.i18n('Show back content from that user for you, your instance and its users.'),
+ isDisplayed: ({ account }) => account.mutedByInstance === true,
+ handler: ({ account }) => this.unblockAccountByInstance(account)
+ }
+ ])
+ }
+
+ // Instance actions on server blocklists
+ if (authUser.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) {
+ instanceActions = instanceActions.concat([
+ {
+ label: this.i18n('Mute the instance by your instance'),
+ description: this.i18n('Hide any content from that instance for you, your instance and its users.'),
+ isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === false,
+ handler: ({ account }) => this.blockServerByInstance(account.host)
+ },
+ {
+ label: this.i18n('Unmute the instance by your instance'),
+ description: this.i18n('Show back content from that instance for you, your instance and its users.'),
+ isDisplayed: ({ account }) => !account.userId && account.mutedServerByInstance === true,
+ handler: ({ account }) => this.unblockServerByInstance(account.host)
+ }
+ ])
+ }
+
+ if (authUser.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)) {
+ instanceActions = instanceActions.concat([
+ {
+ label: this.i18n('Remove comments from your instance'),
+ description: this.i18n('Remove comments of this account from your instance.'),
+ handler: ({ account }) => this.bulkRemoveCommentsOf({ accountName: account.nameWithHost, scope: 'instance' })
+ }
+ ])
+ }
+
+ if (instanceActions.length !== 0) {
+ this.userActions.push(instanceActions)
+ }
+ }
+ }
+ }
+}
--- /dev/null
+import { omit } from 'lodash-es'
+import { SortMeta } from 'primeng/api'
+import { Observable } from 'rxjs'
+import { catchError, map } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '@shared/models'
+import { environment } from '../../../environments/environment'
+
+@Injectable()
+export class VideoAbuseService {
+ private static BASE_VIDEO_ABUSE_URL = environment.apiUrl + '/api/v1/videos/'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restService: RestService,
+ private restExtractor: RestExtractor
+ ) {}
+
+ getVideoAbuses (options: {
+ pagination: RestPagination,
+ sort: SortMeta,
+ search?: string
+ }): Observable<ResultList<VideoAbuse>> {
+ const { pagination, sort, search } = options
+ const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse'
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) {
+ const filters = this.restService.parseQueryStringFilter(search, {
+ id: { prefix: '#' },
+ state: {
+ prefix: 'state:',
+ handler: v => {
+ if (v === 'accepted') return VideoAbuseState.ACCEPTED
+ if (v === 'pending') return VideoAbuseState.PENDING
+ if (v === 'rejected') return VideoAbuseState.REJECTED
+
+ return undefined
+ }
+ },
+ videoIs: {
+ prefix: 'videoIs:',
+ handler: v => {
+ if (v === 'deleted') return v
+ if (v === 'blacklisted') return v
+
+ return undefined
+ }
+ },
+ searchReporter: { prefix: 'reporter:' },
+ searchReportee: { prefix: 'reportee:' },
+ predefinedReason: { prefix: 'tag:' }
+ })
+
+ params = this.restService.addObjectParams(params, filters)
+ }
+
+ return this.authHttp.get<ResultList<VideoAbuse>>(url, { params })
+ .pipe(
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ reportVideo (parameters: { id: number } & VideoAbuseCreate) {
+ const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse'
+
+ const body = omit(parameters, [ 'id' ])
+
+ return this.authHttp.post(url, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ updateVideoAbuse (videoAbuse: VideoAbuse, abuseUpdate: VideoAbuseUpdate) {
+ const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
+
+ return this.authHttp.put(url, abuseUpdate)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ removeVideoAbuse (videoAbuse: VideoAbuse) {
+ const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
+
+ return this.authHttp.delete(url)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }}
--- /dev/null
+<ng-template #modal>
+ <div class="modal-header">
+ <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4>
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+ <div class="modal-body">
+
+ <form novalidate [formGroup]="form" (ngSubmit)="block()">
+ <div class="form-group">
+ <textarea
+ i18n-placeholder placeholder="Please describe the reason..." formControlName="reason"
+ [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
+ ></textarea>
+ <div *ngIf="formErrors.reason" class="form-error">
+ {{ formErrors.reason }}
+ </div>
+ </div>
+
+ <div class="form-group" *ngIf="video.isLocal">
+ <my-peertube-checkbox
+ inputName="unfederate" formControlName="unfederate"
+ i18n-labelText labelText="Unfederate the video"
+ >
+ <ng-container ngProjectAs="description">
+ <span i18n>This will ask remote instances to delete it</span>
+ </ng-container>
+ </my-peertube-checkbox>
+ </div>
+
+ <div class="form-group inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+ (click)="hide()" (key.enter)="hide()"
+ >
+
+ <input
+ type="submit" i18n-value value="Submit" class="action-button-submit"
+ [disabled]="!form.valid"
+ >
+ </div>
+ </form>
+
+ </div>
+</ng-template>
--- /dev/null
+@import 'variables';
+@import 'mixins';
+
+textarea {
+ @include peertube-textarea(100%, 100px);
+}
--- /dev/null
+import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { Notifier } from '@app/core'
+import { FormReactive, FormValidatorService, VideoBlockValidatorsService } from '@app/shared/shared-forms'
+import { Video } from '@app/shared/shared-main'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoBlockService } from './video-block.service'
+
+@Component({
+ selector: 'my-video-block',
+ templateUrl: './video-block.component.html',
+ styleUrls: [ './video-block.component.scss' ]
+})
+export class VideoBlockComponent extends FormReactive implements OnInit {
+ @Input() video: Video = null
+
+ @ViewChild('modal', { static: true }) modal: NgbModal
+
+ @Output() videoBlocked = new EventEmitter()
+
+ error: string = null
+
+ private openedModal: NgbModalRef
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private modalService: NgbModal,
+ private videoBlockValidatorsService: VideoBlockValidatorsService,
+ private videoBlocklistService: VideoBlockService,
+ private notifier: Notifier,
+ private i18n: I18n
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ const defaultValues = { unfederate: 'true' }
+
+ this.buildForm({
+ reason: this.videoBlockValidatorsService.VIDEO_BLOCK_REASON,
+ unfederate: null
+ }, defaultValues)
+ }
+
+ show () {
+ this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
+ }
+
+ hide () {
+ this.openedModal.close()
+ this.openedModal = null
+ }
+
+ block () {
+ const reason = this.form.value[ 'reason' ] || undefined
+ const unfederate = this.video.isLocal ? this.form.value[ 'unfederate' ] : undefined
+
+ this.videoBlocklistService.blockVideo(this.video.id, reason, unfederate)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Video blocked.'))
+ this.hide()
+
+ this.video.blacklisted = true
+ this.video.blockedReason = reason
+
+ this.videoBlocked.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+}
--- /dev/null
+import { SortMeta } from 'primeng/api'
+import { from as observableFrom, Observable } from 'rxjs'
+import { catchError, concatMap, map, toArray } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, RestPagination, RestService } from '@app/core'
+import { ResultList, VideoBlacklist, VideoBlacklistType } from '@shared/models'
+import { environment } from '../../../environments/environment'
+
+@Injectable()
+export class VideoBlockService {
+ private static BASE_VIDEOS_URL = environment.apiUrl + '/api/v1/videos/'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restService: RestService,
+ private restExtractor: RestExtractor
+ ) {}
+
+ listBlocks (options: {
+ pagination: RestPagination
+ sort: SortMeta
+ search?: string
+ type?: VideoBlacklistType
+ }): Observable<ResultList<VideoBlacklist>> {
+ const { pagination, sort, search, type } = options
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (search) {
+ const filters = this.restService.parseQueryStringFilter(search, {
+ type: {
+ prefix: 'type:',
+ handler: v => {
+ if (v === 'manual') return VideoBlacklistType.MANUAL
+ if (v === 'auto') return VideoBlacklistType.AUTO_BEFORE_PUBLISHED
+
+ return undefined
+ }
+ }
+ })
+
+ params = this.restService.addObjectParams(params, filters)
+ }
+ if (type) params = params.append('type', type.toString())
+
+ return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlockService.BASE_VIDEOS_URL + 'blacklist', { params })
+ .pipe(
+ map(res => this.restExtractor.convertResultListDateToHuman(res)),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+
+ unblockVideo (videoIdArgs: number | number[]) {
+ const videoIds = Array.isArray(videoIdArgs) ? videoIdArgs : [ videoIdArgs ]
+
+ return observableFrom(videoIds)
+ .pipe(
+ concatMap(id => this.authHttp.delete(VideoBlockService.BASE_VIDEOS_URL + id + '/blacklist')),
+ toArray(),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ blockVideo (videoId: number, reason: string, unfederate: boolean) {
+ const body = {
+ unfederate,
+ reason
+ }
+
+ return this.authHttp.post(VideoBlockService.BASE_VIDEOS_URL + videoId + '/blacklist', body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(res => this.restExtractor.handleError(res))
+ )
+ }
+}
--- /dev/null
+<ng-template #modal>
+ <div class="modal-header">
+ <h4 i18n class="modal-title">Report video "{{ video.name }}"</h4>
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+ <div class="modal-body">
+ <form novalidate [formGroup]="form" (ngSubmit)="report()">
+
+ <div class="row">
+ <div class="col-5 form-group">
+
+ <label i18n for="reportPredefinedReasons">What is the issue?</label>
+
+ <div class="ml-2 mt-2 d-flex flex-column">
+ <ng-container formGroupName="predefinedReasons">
+ <div class="form-group" *ngFor="let reason of predefinedReasons">
+ <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}">
+ <ng-template *ngIf="reason.help" ptTemplate="help">
+ <div [innerHTML]="reason.help"></div>
+ </ng-template>
+ <ng-container *ngIf="reason.description" ngProjectAs="description">
+ <div [innerHTML]="reason.description"></div>
+ </ng-container>
+ </my-peertube-checkbox>
+ </div>
+ </ng-container>
+ </div>
+
+ </div>
+
+ <div class="col-7">
+ <div class="row justify-content-center">
+ <div class="col-12 col-lg-9 mb-2">
+ <div class="screenratio">
+ <div [innerHTML]="embedHtml"></div>
+ </div>
+ </div>
+ </div>
+
+ <div class="mb-1 start-at" formGroupName="timestamp">
+ <my-peertube-checkbox
+ formControlName="hasStart"
+ i18n-labelText labelText="Start at"
+ ></my-peertube-checkbox>
+
+ <my-timestamp-input
+ [timestamp]="timestamp.startAt"
+ [maxTimestamp]="video.duration"
+ formControlName="startAt"
+ inputName="startAt"
+ >
+ </my-timestamp-input>
+ </div>
+
+ <div class="mb-3 stop-at" formGroupName="timestamp" *ngIf="timestamp.hasStart">
+ <my-peertube-checkbox
+ formControlName="hasEnd"
+ i18n-labelText labelText="Stop at"
+ ></my-peertube-checkbox>
+
+ <my-timestamp-input
+ [timestamp]="timestamp.endAt"
+ [maxTimestamp]="video.duration"
+ formControlName="endAt"
+ inputName="endAt"
+ >
+ </my-timestamp-input>
+ </div>
+
+ <div i18n class="information">
+ Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
+ </div>
+
+ <div class="form-group">
+ <textarea
+ i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
+ [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
+ ></textarea>
+ <div *ngIf="formErrors.reason" class="form-error">
+ {{ formErrors.reason }}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="form-group inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+ (click)="hide()" (key.enter)="hide()"
+ >
+ <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
+ </div>
+
+ </form>
+ </div>
+</ng-template>
--- /dev/null
+@import 'variables';
+@import 'mixins';
+
+.information {
+ margin-bottom: 20px;
+}
+
+textarea {
+ @include peertube-textarea(100%, 100px);
+}
+
+.start-at,
+.stop-at {
+ width: 300px;
+ display: flex;
+ align-items: center;
+
+ my-timestamp-input {
+ margin-left: 10px;
+ }
+}
+
+.screenratio {
+ @include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
+ left: 0;
+ };
+}
--- /dev/null
+import { mapValues, pickBy } from 'lodash-es'
+import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
+import { Component, Input, OnInit, ViewChild } from '@angular/core'
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
+import { Notifier } from '@app/core'
+import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { videoAbusePredefinedReasonsMap, VideoAbusePredefinedReasonsString } from '@shared/models/videos/abuse/video-abuse-reason.model'
+import { Video } from '../shared-main'
+import { VideoAbuseService } from './video-abuse.service'
+
+@Component({
+ selector: 'my-video-report',
+ templateUrl: './video-report.component.html',
+ styleUrls: [ './video-report.component.scss' ]
+})
+export class VideoReportComponent extends FormReactive implements OnInit {
+ @Input() video: Video = null
+
+ @ViewChild('modal', { static: true }) modal: NgbModal
+
+ error: string = null
+ predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
+ embedHtml: SafeHtml
+
+ private openedModal: NgbModalRef
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private modalService: NgbModal,
+ private videoAbuseValidatorsService: VideoAbuseValidatorsService,
+ private videoAbuseService: VideoAbuseService,
+ private notifier: Notifier,
+ private sanitizer: DomSanitizer,
+ private i18n: I18n
+ ) {
+ super()
+ }
+
+ get currentHost () {
+ return window.location.host
+ }
+
+ get originHost () {
+ if (this.isRemoteVideo()) {
+ return this.video.account.host
+ }
+
+ return ''
+ }
+
+ get timestamp () {
+ return this.form.get('timestamp').value
+ }
+
+ getVideoEmbed () {
+ return this.sanitizer.bypassSecurityTrustHtml(
+ buildVideoEmbed(
+ buildVideoLink({
+ baseUrl: this.video.embedUrl,
+ title: false,
+ warningTitle: false
+ })
+ )
+ )
+ }
+
+ ngOnInit () {
+ this.buildForm({
+ reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON,
+ predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null),
+ timestamp: {
+ hasStart: null,
+ startAt: null,
+ hasEnd: null,
+ endAt: null
+ }
+ })
+
+ this.predefinedReasons = [
+ {
+ id: 'violentOrRepulsive',
+ label: this.i18n('Violent or repulsive'),
+ help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
+ },
+ {
+ id: 'hatefulOrAbusive',
+ label: this.i18n('Hateful or abusive'),
+ help: this.i18n('Contains abusive, racist or sexist language or iconography.')
+ },
+ {
+ id: 'spamOrMisleading',
+ label: this.i18n('Spam, ad or false news'),
+ help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
+ },
+ {
+ id: 'privacy',
+ label: this.i18n('Privacy breach or doxxing'),
+ help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).')
+ },
+ {
+ id: 'rights',
+ label: this.i18n('Intellectual property violation'),
+ help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
+ },
+ {
+ id: 'serverRules',
+ label: this.i18n('Breaks server rules'),
+ description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.')
+ },
+ {
+ id: 'thumbnails',
+ label: this.i18n('Thumbnails'),
+ help: this.i18n('The above can only be seen in thumbnails.')
+ },
+ {
+ id: 'captions',
+ label: this.i18n('Captions'),
+ help: this.i18n('The above can only be seen in captions (please describe which).')
+ }
+ ]
+
+ this.embedHtml = this.getVideoEmbed()
+ }
+
+ show () {
+ this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
+ }
+
+ hide () {
+ this.openedModal.close()
+ this.openedModal = null
+ }
+
+ report () {
+ const reason = this.form.get('reason').value
+ const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[]
+ const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
+
+ this.videoAbuseService.reportVideo({
+ id: this.video.id,
+ reason,
+ predefinedReasons,
+ startAt: hasStart && startAt ? startAt : undefined,
+ endAt: hasEnd && endAt ? endAt : undefined
+ }).subscribe(
+ () => {
+ this.notifier.success(this.i18n('Video reported.'))
+ this.hide()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ isRemoteVideo () {
+ return !this.video.isLocal
+ }
+}
--- /dev/null
+export * from './video-thumbnail.component'
+export * from './shared-thumbnail.module'
--- /dev/null
+
+import { NgModule } from '@angular/core'
+import { SharedGlobalIconModule } from '../shared-icons'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import { VideoThumbnailComponent } from './video-thumbnail.component'
+
+@NgModule({
+ imports: [
+ SharedMainModule,
+ SharedGlobalIconModule
+ ],
+
+ declarations: [
+ VideoThumbnailComponent
+ ],
+
+ exports: [
+ VideoThumbnailComponent
+ ],
+
+ providers: [ ]
+})
+export class SharedThumbnailModule { }
--- /dev/null
+<a
+ [routerLink]="getVideoRouterLink()" [queryParams]="queryParams"
+ class="video-thumbnail"
+>
+ <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
+
+ <div *ngIf="displayWatchLaterPlaylist" class="video-thumbnail-actions-overlay">
+ <ng-container *ngIf="inWatchLaterPlaylist !== true">
+ <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
+ <my-global-icon iconName="clock" [attr.aria-label]="addToWatchLaterText" role="button"></my-global-icon>
+ </div>
+ </ng-container>
+
+ <ng-container *ngIf="inWatchLaterPlaylist === true">
+ <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addedToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
+ <my-global-icon iconName="tick" [attr.aria-label]="addedToWatchLaterText" role="button"></my-global-icon>
+ </div>
+ </ng-container>
+ </div>
+
+ <div class="video-thumbnail-label-overlay warning"><ng-content select="label-warning"></ng-content></div>
+ <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div>
+
+ <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
+
+ <div class="play-overlay">
+ <div class="icon"></div>
+ </div>
+
+ <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
+ <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
+ </div>
+</a>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+.video-thumbnail {
+ @include miniature-thumbnail;
+
+ .progress-bar {
+ height: 3px;
+ width: 100%;
+ position: absolute;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.20);
+
+ div {
+ height: 100%;
+ background-color: pvar(--mainColor);
+ }
+ }
+
+ .video-thumbnail-watch-later-overlay,
+ .video-thumbnail-label-overlay,
+ .video-thumbnail-duration-overlay {
+ @include static-thumbnail-overlay;
+
+ border-radius: 3px;
+ font-size: 12px;
+ font-weight: $font-semibold;
+ line-height: 1.2;
+ z-index: z(miniature);
+ }
+
+ .video-thumbnail-label-overlay {
+ position: absolute;
+ padding: 0 5px;
+ left: 5px;
+ top: 5px;
+ font-weight: $font-bold;
+
+ &.warning { background-color: orange; }
+ &.danger { background-color: red; }
+ }
+
+ .video-thumbnail-duration-overlay {
+ position: absolute;
+ padding: 0 3px;
+ right: 5px;
+ bottom: 5px;
+ }
+
+ .video-thumbnail-actions-overlay {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ right: 5px;
+ top: 5px;
+ opacity: 0;
+
+ div:not(:first-child) {
+ margin-top: 2px;
+ }
+
+ .video-thumbnail-watch-later-overlay {
+ padding: 3px;
+
+ my-global-icon {
+ width: 22px;
+ height: 22px;
+
+ @include apply-svg-color(#fff);
+ }
+ }
+ }
+}
--- /dev/null
+import { Component, EventEmitter, Input, Output } from '@angular/core'
+import { ScreenService } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Video } from '../shared-main'
+
+@Component({
+ selector: 'my-video-thumbnail',
+ styleUrls: [ './video-thumbnail.component.scss' ],
+ templateUrl: './video-thumbnail.component.html'
+})
+export class VideoThumbnailComponent {
+ @Input() video: Video
+ @Input() nsfw = false
+ @Input() routerLink: any[]
+ @Input() queryParams: { [ p: string ]: any }
+
+ @Input() displayWatchLaterPlaylist: boolean
+ @Input() inWatchLaterPlaylist: boolean
+
+ @Output() watchLaterClick = new EventEmitter<boolean>()
+
+ addToWatchLaterText: string
+ addedToWatchLaterText: string
+
+ constructor (
+ private screenService: ScreenService,
+ private i18n: I18n
+ ) {
+ this.addToWatchLaterText = this.i18n('Add to watch later')
+ this.addedToWatchLaterText = this.i18n('Remove from watch later')
+ }
+
+ getImageUrl () {
+ if (!this.video) return ''
+
+ if (this.screenService.isInMobileView()) {
+ return this.video.previewUrl
+ }
+
+ return this.video.thumbnailUrl
+ }
+
+ getProgressPercent () {
+ if (!this.video.userHistory) return 0
+
+ const currentTime = this.video.userHistory.currentTime
+
+ return (currentTime / this.video.duration) * 100
+ }
+
+ getVideoRouterLink () {
+ if (this.routerLink) return this.routerLink
+
+ return [ '/videos/watch', this.video.uuid ]
+ }
+
+ onWatchLaterClick (event: Event) {
+ this.watchLaterClick.emit(this.inWatchLaterPlaylist)
+
+ event.stopPropagation()
+ return false
+ }
+}
--- /dev/null
+export * from './user-interface-settings.component'
+export * from './user-video-settings.component'
+
+export * from './shared-user-settings.module'
--- /dev/null
+
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '../shared-forms'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import { UserInterfaceSettingsComponent } from './user-interface-settings.component'
+import { UserVideoSettingsComponent } from './user-video-settings.component'
+
+@NgModule({
+ imports: [
+ SharedMainModule,
+ SharedFormModule
+ ],
+
+ declarations: [
+ UserInterfaceSettingsComponent,
+ UserVideoSettingsComponent
+ ],
+
+ exports: [
+ UserInterfaceSettingsComponent,
+ UserVideoSettingsComponent
+ ],
+
+ providers: [ ]
+})
+export class SharedUserInterfaceSettingsModule { }
--- /dev/null
+<form role="form" (ngSubmit)="updateInterfaceSettings()" [formGroup]="form">
+
+ <div class="form-group">
+ <label i18n for="theme">Theme</label>
+
+ <div class="peertube-select-container">
+ <select formControlName="theme" id="theme" class="form-control">
+ <option i18n value="instance-default">instance default</option>
+ <option i18n value="default">peertube default</option>
+
+ <option *ngFor="let theme of availableThemes" [value]="theme">{{ theme }}</option>
+ </select>
+ </div>
+ </div>
+
+ <input *ngIf="!reactiveUpdate" type="submit" class="mt-0" i18n-value value="Save" [disabled]="!form.valid">
+</form>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+label {
+ font-weight: $font-regular;
+ font-size: 100%;
+}
+
+input[type=submit] {
+ @include peertube-button;
+ @include orange-button;
+
+ display: block;
+ margin-top: 15px;
+}
+
+.peertube-select-container {
+ @include peertube-select-container(340px);
+
+ margin-bottom: 30px;
+}
--- /dev/null
+import { Subject, Subscription } from 'rxjs'
+import { Component, Input, OnDestroy, OnInit } from '@angular/core'
+import { AuthService, Notifier, ServerService, UserService } from '@app/core'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, User, UserUpdateMe } from '@shared/models'
+
+@Component({
+ selector: 'my-user-interface-settings',
+ templateUrl: './user-interface-settings.component.html',
+ styleUrls: [ './user-interface-settings.component.scss' ]
+})
+export class UserInterfaceSettingsComponent extends FormReactive implements OnInit, OnDestroy {
+ @Input() user: User = null
+ @Input() reactiveUpdate = false
+ @Input() notifyOnUpdate = true
+ @Input() userInformationLoaded: Subject<any>
+
+ formValuesWatcher: Subscription
+
+ private serverConfig: ServerConfig
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private authService: AuthService,
+ private notifier: Notifier,
+ private userService: UserService,
+ private serverService: ServerService,
+ private i18n: I18n
+ ) {
+ super()
+ }
+
+ get availableThemes () {
+ return this.serverConfig.theme.registered
+ .map(t => t.name)
+ }
+
+ ngOnInit () {
+ this.serverConfig = this.serverService.getTmpConfig()
+ this.serverService.getConfig()
+ .subscribe(config => this.serverConfig = config)
+
+ this.buildForm({
+ theme: null
+ })
+
+ this.userInformationLoaded
+ .subscribe(() => {
+ this.form.patchValue({
+ theme: this.user.theme
+ })
+
+ if (this.reactiveUpdate) {
+ this.formValuesWatcher = this.form.valueChanges.subscribe(val => this.updateInterfaceSettings())
+ }
+ })
+ }
+
+ ngOnDestroy () {
+ this.formValuesWatcher?.unsubscribe()
+ }
+
+ updateInterfaceSettings () {
+ const theme = this.form.value['theme']
+
+ const details: UserUpdateMe = {
+ theme
+ }
+
+ if (this.authService.isLoggedIn()) {
+ this.userService.updateMyProfile(details).subscribe(
+ () => {
+ this.authService.refreshUserInformation()
+
+ if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.'))
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ } else {
+ this.userService.updateMyAnonymousProfile(details)
+ if (this.notifyOnUpdate) this.notifier.success(this.i18n('Interface settings updated.'))
+ }
+ }
+}
--- /dev/null
+<form role="form" (ngSubmit)="updateDetails()" [formGroup]="form">
+ <div class="form-group form-group-select">
+ <label i18n for="nsfwPolicy">Default policy on videos containing sensitive content</label>
+ <my-help>
+ <ng-template ptTemplate="customHtml">
+ <ng-container i18n>
+ With <strong>Do not list</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
+ </ng-container>
+ </ng-template>
+ </my-help>
+
+ <div class="peertube-select-container">
+ <select id="nsfwPolicy" formControlName="nsfwPolicy" class="form-control">
+ <option i18n value="undefined" disabled>Policy for sensitive videos</option>
+ <option i18n value="do_not_list">Do not list</option>
+ <option i18n value="blur">Blur thumbnails</option>
+ <option i18n value="display">Display</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="form-group form-group-select">
+ <label i18n for="videoLanguages">Only display videos in the following languages/subtitles</label>
+ <my-help>
+ <ng-template ptTemplate="customHtml">
+ <ng-container i18n>In Recently added, Trending, Local, Most liked and Search pages</ng-container>
+ </ng-template>
+ </my-help>
+
+ <div>
+ <p-multiSelect
+ inputId="videoLanguages" [options]="languageItems" formControlName="videoLanguages" [showToggleAll]="true"
+ [defaultLabel]="getDefaultVideoLanguageLabel()" [selectedItemsLabel]="getSelectedVideoLanguageLabel()"
+ emptyFilterMessage="No results found" i18n-emptyFilterMessage
+ ></p-multiSelect>
+ </div>
+ </div>
+
+ <ng-content select="inner-title"></ng-content>
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="webTorrentEnabled" formControlName="webTorrentEnabled" [recommended]="true"
+ i18n-labelText labelText="Help share videos being played"
+ >
+ <ng-container ngProjectAs="description">
+ <span i18n>The <a routerLink="/about/peertube" fragment="privacy">sharing system</a> implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load.</span>
+ </ng-container>
+ </my-peertube-checkbox>
+ </div>
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="autoPlayVideo" formControlName="autoPlayVideo"
+ i18n-labelText labelText="Automatically play videos"
+ >
+ <ng-container ngProjectAs="description">
+ <span i18n>When on a video page, directly start playing the video.</span>
+ </ng-container>
+ </my-peertube-checkbox>
+ </div>
+
+ <div class="form-group">
+ <my-peertube-checkbox
+ inputName="autoPlayNextVideo" formControlName="autoPlayNextVideo"
+ i18n-labelText labelText="Automatically start playing the next video"
+ >
+ <ng-container ngProjectAs="description">
+ <span i18n>When a video ends, follow up with the next suggested video.</span>
+ </ng-container>
+ </my-peertube-checkbox>
+ </div>
+
+ <input *ngIf="!reactiveUpdate" type="submit" i18n-value value="Save" [disabled]="!form.valid">
+</form>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+label {
+ font-weight: $font-regular;
+ font-size: 100%;
+}
+
+input[type=submit] {
+ @include peertube-button;
+ @include orange-button;
+
+ margin-top: 15px;
+}
+
+.peertube-select-container {
+ @include peertube-select-container(340px);
+
+ margin-bottom: 30px;
+}
+
+.form-group-select {
+ margin-bottom: 30px;
+}
--- /dev/null
+import { pick } from 'lodash-es'
+import { SelectItem } from 'primeng/api'
+import { forkJoin, 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 } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserUpdateMe } from '@shared/models'
+import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
+
+@Component({
+ selector: 'my-user-video-settings',
+ templateUrl: './user-video-settings.component.html',
+ styleUrls: [ './user-video-settings.component.scss' ]
+})
+export class UserVideoSettingsComponent extends FormReactive implements OnInit, OnDestroy {
+ @Input() user: User = null
+ @Input() reactiveUpdate = false
+ @Input() notifyOnUpdate = true
+ @Input() userInformationLoaded: Subject<any>
+
+ languageItems: SelectItem[] = []
+ defaultNSFWPolicy: NSFWPolicyType
+ formValuesWatcher: Subscription
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private authService: AuthService,
+ private notifier: Notifier,
+ private userService: UserService,
+ private serverService: ServerService,
+ private i18n: I18n
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ let oldForm: any
+
+ this.buildForm({
+ nsfwPolicy: null,
+ webTorrentEnabled: null,
+ autoPlayVideo: null,
+ autoPlayNextVideo: null,
+ videoLanguages: null
+ })
+
+ forkJoin([
+ this.serverService.getVideoLanguages(),
+ this.serverService.getConfig(),
+ this.userInformationLoaded.pipe(first())
+ ]).subscribe(([ languages, config ]) => {
+ this.languageItems = [ { label: this.i18n('Unknown language'), value: '_unknown' } ]
+ this.languageItems = this.languageItems
+ .concat(languages.map(l => ({ label: l.label, value: l.id })))
+
+ const videoLanguages = this.user.videoLanguages
+ ? this.user.videoLanguages
+ : this.languageItems.map(l => l.value)
+
+ this.defaultNSFWPolicy = config.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) {
+ oldForm = { ...this.form.value }
+ this.formValuesWatcher = this.form.valueChanges.subscribe((formValue: any) => {
+ const updatedKey = Object.keys(formValue).find(k => formValue[k] !== oldForm[k])
+ oldForm = { ...this.form.value }
+ this.updateDetails([updatedKey])
+ })
+ }
+ })
+ }
+
+ ngOnDestroy () {
+ this.formValuesWatcher?.unsubscribe()
+ }
+
+ updateDetails (onlyKeys?: string[]) {
+ const nsfwPolicy = this.form.value[ 'nsfwPolicy' ]
+ const webTorrentEnabled = this.form.value['webTorrentEnabled']
+ const autoPlayVideo = this.form.value['autoPlayVideo']
+ const autoPlayNextVideo = this.form.value['autoPlayNextVideo']
+
+ let videoLanguages: string[] = this.form.value['videoLanguages']
+ if (Array.isArray(videoLanguages)) {
+ if (videoLanguages.length === this.languageItems.length) {
+ videoLanguages = null // null means "All"
+ } else if (videoLanguages.length > 20) {
+ this.notifier.error('Too many languages are enabled. Please enable them all or stay below 20 enabled languages.')
+ return
+ } else if (videoLanguages.length === 0) {
+ this.notifier.error('You need to enabled at least 1 video language.')
+ return
+ }
+ }
+
+ let details: UserUpdateMe = {
+ nsfwPolicy,
+ webTorrentEnabled,
+ autoPlayVideo,
+ autoPlayNextVideo,
+ videoLanguages
+ }
+
+ if (onlyKeys) details = pick(details, onlyKeys)
+
+ if (this.authService.isLoggedIn()) {
+ this.userService.updateMyProfile(details).subscribe(
+ () => {
+ this.authService.refreshUserInformation()
+
+ if (this.notifyOnUpdate) this.notifier.success(this.i18n('Video settings updated.'))
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ } else {
+ this.userService.updateMyAnonymousProfile(details)
+ if (this.notifyOnUpdate) this.notifier.success(this.i18n('Display/Video settings updated.'))
+ }
+ }
+
+ getDefaultVideoLanguageLabel () {
+ return this.i18n('No language')
+ }
+
+ getSelectedVideoLanguageLabel () {
+ return this.i18n('{{\'{0} languages selected')
+ }
+}
--- /dev/null
+export * from './user-subscription.service'
+export * from './subscribe-button.component'
+export * from './remote-subscribe.component'
+
+export * from './shared-user-subscription.module'
--- /dev/null
+<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
+ <div class="form-group mb-2">
+ <input type="email"
+ formControlName="text"
+ class="form-control"
+ (keyup.control.enter)="onValidKey()" (keyup.meta.enter)="onValidKey()"
+ placeholder="jane_doe@example.com">
+ </div>
+
+ <button type="submit" [disabled]="!form.valid" class="btn btn-sm btn-remote-follow" i18n>
+ <span *ngIf="!interact">Remote subscribe</span>
+ <span *ngIf="interact">Remote interact</span>
+ </button>
+
+ <my-help *ngIf="!interact && showHelp">
+ <ng-template ptTemplate="customHtml">
+ <ng-container i18n>
+ You can subscribe to the channel via any ActivityPub-capable fediverse instance.<br /><br />
+ For instance with Mastodon or Pleroma you can type the channel URL in the search box and subscribe there.
+ </ng-container>
+ </ng-template>
+ </my-help>
+
+ <my-help *ngIf="showHelp && interact">
+ <ng-template ptTemplate="customHtml">
+ <ng-container i18n>
+ You can interact with this via any ActivityPub-capable fediverse instance.<br /><br />
+ For instance with Mastodon or Pleroma you can type the current URL in the search box and interact with it there.
+ </ng-container>
+ </ng-template>
+ </my-help>
+</form>
--- /dev/null
+@import '_mixins';
+
+.btn-remote-follow {
+ @include peertube-button;
+ @include orange-button;
+}
\ No newline at end of file
--- /dev/null
+import { Component, Input, OnInit } from '@angular/core'
+import { FormReactive, FormValidatorService, UserValidatorsService } from '@app/shared/shared-forms'
+
+@Component({
+ selector: 'my-remote-subscribe',
+ templateUrl: './remote-subscribe.component.html',
+ styleUrls: ['./remote-subscribe.component.scss']
+})
+export class RemoteSubscribeComponent extends FormReactive implements OnInit {
+ @Input() uri: string
+ @Input() interact = false
+ @Input() showHelp = false
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private userValidatorsService: UserValidatorsService
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ this.buildForm({
+ text: this.userValidatorsService.USER_EMAIL
+ })
+ }
+
+ onValidKey () {
+ this.check()
+ if (!this.form.valid) return
+
+ this.formValidated()
+ }
+
+ formValidated () {
+ const address = this.form.value['text']
+ const [ username, hostname ] = address.split('@')
+
+ // Should not have CORS error because https://tools.ietf.org/html/rfc7033#section-5
+ fetch(`https://${hostname}/.well-known/webfinger?resource=acct:${username}@${hostname}`)
+ .then(response => response.json())
+ .then(data => new Promise((resolve, reject) => {
+ console.log(data)
+
+ if (data && Array.isArray(data.links)) {
+ const link: { template: string } = data.links.find((link: any) => {
+ return link && typeof link.template === 'string' && link.rel === 'http://ostatus.org/schema/1.0/subscribe'
+ })
+
+ if (link && link.template.includes('{uri}')) {
+ resolve(link.template.replace('{uri}', encodeURIComponent(this.uri)))
+ }
+ }
+ reject()
+ }))
+ .then(window.open)
+ .catch(err => console.error(err))
+ }
+}
--- /dev/null
+
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '../shared-forms'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import { RemoteSubscribeComponent } from './remote-subscribe.component'
+import { SubscribeButtonComponent } from './subscribe-button.component'
+import { UserSubscriptionService } from './user-subscription.service'
+
+@NgModule({
+ imports: [
+ SharedMainModule,
+ SharedFormModule
+ ],
+
+ declarations: [
+ RemoteSubscribeComponent,
+ SubscribeButtonComponent
+ ],
+
+ exports: [
+ RemoteSubscribeComponent,
+ SubscribeButtonComponent
+ ],
+
+ providers: [
+ UserSubscriptionService
+ ]
+})
+export class SharedUserSubscriptionModule { }
--- /dev/null
+<div class="btn-group-subscribe btn-group"
+ [ngClass]="{'subscribe-button': !isAllChannelsSubscribed, 'unsubscribe-button': isAllChannelsSubscribed, 'big': isBigButton }">
+
+ <ng-template #userLoggedOut>
+ <span [ngClass]="{ 'extra-text': isAtLeastOneChannelSubscribed }">
+ <ng-container *ngIf="account; then multiple; else single"></ng-container>
+ <ng-template i18n #single>Subscribe</ng-template>
+ <ng-template #multiple>
+ <span i18n>Subscribe to all channels</span>
+ <span *ngIf="isAtLeastOneChannelSubscribed">{{ subscribeStatus(true).length }}/{{ subscribed.size }}
+ <ng-container i18n>channels subscribed</ng-container>
+ </span>
+ </ng-template>
+ </span>
+ <span *ngIf="!isBigButton && displayFollowers && videoChannels.length > 1 && videoChannel.followersCount !== 0" class="followers-count">
+ {{ videoChannels[0].followersCount | myNumberFormatter }}
+ </span>
+ </ng-template>
+
+ <ng-template #userLoggedIn>
+ <button *ngIf="!isAllChannelsSubscribed" type="button"
+ class="btn btn-sm" role="button"
+ (click)="subscribe()">
+ <ng-template [ngTemplateOutlet]="userLoggedOut"></ng-template>
+ </button>
+
+ <button
+ *ngIf="isAllChannelsSubscribed" type="button"
+ class="btn btn-sm" role="button"
+ (click)="unsubscribe()">
+ <ng-container i18n>{account + "", select, undefined {Unsubscribe} other {Unsubscribe from all channels}}</ng-container>
+ </button>
+ </ng-template>
+
+ <ng-container
+ *ngIf="isUserLoggedIn(); then userLoggedIn">
+ </ng-container>
+
+ <div class="btn-group" ngbDropdown autoClose="outside"
+ placement="bottom-right" role="group"
+ aria-label="Multiple ways to subscribe to the current channel">
+ <button class="btn btn-sm dropdown-toggle-split" ngbDropdownToggle>
+ <ng-container
+ *ngIf="!isUserLoggedIn(); then userLoggedOut">
+ </ng-container>
+ </button>
+
+ <div class="dropdown-menu" ngbDropdownMenu>
+
+ <h6 class="dropdown-header" i18n>Using an ActivityPub account</h6>
+
+ <button class="dropdown-item" (click)="subscribe()">
+ <span *ngIf="!isUserLoggedIn()" i18n>Subscribe with an account on this instance</span>
+ <span *ngIf="isUserLoggedIn()" i18n>Subscribe with your local account</span>
+ </button>
+
+ <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button>
+ <my-remote-subscribe [showHelp]="true" [uri]="uri"></my-remote-subscribe>
+
+ <div class="dropdown-divider"></div>
+
+ <h6 class="dropdown-header" i18n>Using a syndication feed</h6>
+ <a [href]="rssUri" target="_blank" class="dropdown-item" i18n>Subscribe via RSS</a>
+
+ </div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.btn-group-subscribe {
+ @include peertube-button;
+ @include disable-default-a-behaviour;
+
+ float: right;
+ padding: 0;
+
+ & > .btn,
+ & > .dropdown > .dropdown-toggle {
+ font-size: 15px;
+ }
+
+ &:not(.big) {
+ white-space: nowrap;
+ }
+
+ &.big {
+ height: 35px;
+
+ & > button:first-child {
+ width: 175px;
+ }
+
+ button .extra-text {
+ span:first-child {
+ line-height: 80%;
+ }
+
+ span:not(:first-child) {
+ font-size: 75%;
+ }
+ }
+ }
+
+ // Unlogged
+ & > .dropdown > .dropdown-toggle span {
+ padding-right: 3px;
+ }
+
+ // Logged
+ & > .btn {
+ padding-right: 4px;
+
+ & + .dropdown > button {
+ padding-left: 2px;
+
+ &::after {
+ position: relative;
+ top: 1px;
+ }
+ }
+ }
+
+ &.subscribe-button {
+ .btn {
+ @include orange-button;
+ font-weight: 600;
+ }
+
+ span.followers-count {
+ padding-left: 5px;
+ }
+ }
+ &.unsubscribe-button {
+ .btn {
+ @include grey-button;
+ font-weight: 600;
+ }
+ }
+
+ .dropdown-menu {
+ cursor: default;
+
+ button {
+ cursor: pointer;
+ }
+
+ .dropdown-item-neutral {
+ cursor: default;
+
+ &:hover,
+ &:focus {
+ background-color: inherit;
+ }
+ }
+ }
+
+ ::ng-deep form {
+ padding: 0.25rem 1rem;
+ }
+
+ input {
+ @include peertube-input-text(100%);
+ }
+}
+
+.extra-text {
+ display: flex;
+ flex-direction: column;
+
+ span:first-child {
+ line-height: 75%;
+ }
+
+ span:not(:first-child) {
+ font-size: 60%;
+ text-align: left;
+ }
+}
--- /dev/null
+import { concat, forkJoin, merge } from 'rxjs'
+import { Component, Input, OnChanges, OnInit } from '@angular/core'
+import { Router } from '@angular/router'
+import { AuthService, Notifier } from '@app/core'
+import { Account, VideoChannel, VideoService } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { FeedFormat } from '@shared/models'
+import { UserSubscriptionService } from './user-subscription.service'
+
+@Component({
+ selector: 'my-subscribe-button',
+ templateUrl: './subscribe-button.component.html',
+ styleUrls: [ './subscribe-button.component.scss' ]
+})
+export class SubscribeButtonComponent implements OnInit, OnChanges {
+ /**
+ * SubscribeButtonComponent can be used with a single VideoChannel passed as [VideoChannel],
+ * or with an account and a full list of that account's videoChannels. The latter is intended
+ * to allow mass un/subscription from an account's page, while keeping the channel-centric
+ * subscription model.
+ */
+ @Input() account: Account
+ @Input() videoChannels: VideoChannel[]
+ @Input() displayFollowers = false
+ @Input() size: 'small' | 'normal' = 'normal'
+
+ subscribed = new Map<string, boolean>()
+
+ constructor (
+ private authService: AuthService,
+ private router: Router,
+ private notifier: Notifier,
+ private userSubscriptionService: UserSubscriptionService,
+ private i18n: I18n,
+ private videoService: VideoService
+ ) { }
+
+ get handle () {
+ return this.account
+ ? this.account.nameWithHost
+ : this.videoChannel.name + '@' + this.videoChannel.host
+ }
+
+ get channelHandle () {
+ return this.getChannelHandler(this.videoChannel)
+ }
+
+ get uri () {
+ return this.account
+ ? this.account.url
+ : this.videoChannels[0].url
+ }
+
+ get rssUri () {
+ const rssFeed = this.account
+ ? this.videoService
+ .getAccountFeedUrls(this.account.id)
+ .find(i => i.format === FeedFormat.RSS)
+ : this.videoService
+ .getVideoChannelFeedUrls(this.videoChannels[0].id)
+ .find(i => i.format === FeedFormat.RSS)
+
+ return rssFeed.url
+ }
+
+ get videoChannel () {
+ return this.videoChannels[0]
+ }
+
+ get isAllChannelsSubscribed () {
+ return this.subscribeStatus(true).length === this.videoChannels.length
+ }
+
+ get isAtLeastOneChannelSubscribed () {
+ return this.subscribeStatus(true).length > 0
+ }
+
+ get isBigButton () {
+ return this.isUserLoggedIn() && this.videoChannels.length > 1 && this.isAtLeastOneChannelSubscribed
+ }
+
+ ngOnInit () {
+ this.loadSubscribedStatus()
+ }
+
+ ngOnChanges () {
+ this.ngOnInit()
+ }
+
+ subscribe () {
+ if (this.isUserLoggedIn()) {
+ return this.localSubscribe()
+ }
+
+ return this.gotoLogin()
+ }
+
+ localSubscribe () {
+ const subscribedStatus = this.subscribeStatus(false)
+
+ const observableBatch = this.videoChannels
+ .map(videoChannel => this.getChannelHandler(videoChannel))
+ .filter(handle => subscribedStatus.includes(handle))
+ .map(handle => this.userSubscriptionService.addSubscription(handle))
+
+ forkJoin(observableBatch)
+ .subscribe(
+ () => {
+ this.notifier.success(
+ this.account
+ ? this.i18n(
+ 'Subscribed to all current channels of {{nameWithHost}}. You will be notified of all their new videos.',
+ { nameWithHost: this.account.displayName }
+ )
+ : this.i18n(
+ 'Subscribed to {{nameWithHost}}. You will be notified of all their new videos.',
+ { nameWithHost: this.videoChannels[0].displayName }
+ )
+ ,
+ this.i18n('Subscribed')
+ )
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ unsubscribe () {
+ if (this.isUserLoggedIn()) {
+ this.localUnsubscribe()
+ }
+ }
+
+ localUnsubscribe () {
+ const subscribeStatus = this.subscribeStatus(true)
+
+ const observableBatch = this.videoChannels
+ .map(videoChannel => this.getChannelHandler(videoChannel))
+ .filter(handle => subscribeStatus.includes(handle))
+ .map(handle => this.userSubscriptionService.deleteSubscription(handle))
+
+ concat(...observableBatch)
+ .subscribe({
+ complete: () => {
+ this.notifier.success(
+ this.account
+ ? this.i18n('Unsubscribed from all channels of {{nameWithHost}}', { nameWithHost: this.account.nameWithHost })
+ : this.i18n('Unsubscribed from {{nameWithHost}}', { nameWithHost: this.videoChannels[ 0 ].nameWithHost })
+ ,
+ this.i18n('Unsubscribed')
+ )
+ },
+
+ error: err => this.notifier.error(err.message)
+ })
+ }
+
+ isUserLoggedIn () {
+ return this.authService.isLoggedIn()
+ }
+
+ gotoLogin () {
+ this.router.navigate([ '/login' ])
+ }
+
+ subscribeStatus (subscribed: boolean) {
+ const accumulator: string[] = []
+ for (const [key, value] of this.subscribed.entries()) {
+ if (value === subscribed) accumulator.push(key)
+ }
+
+ return accumulator
+ }
+
+ private getChannelHandler (videoChannel: VideoChannel) {
+ return videoChannel.name + '@' + videoChannel.host
+ }
+
+ private loadSubscribedStatus () {
+ if (!this.isUserLoggedIn()) return
+
+ for (const videoChannel of this.videoChannels) {
+ const handle = this.getChannelHandler(videoChannel)
+ this.subscribed.set(handle, false)
+
+ merge(
+ this.userSubscriptionService.listenToSubscriptionCacheChange(handle),
+ this.userSubscriptionService.doesSubscriptionExist(handle)
+ ).subscribe(
+ res => this.subscribed.set(handle, res),
+
+ err => this.notifier.error(err.message)
+ )
+ }
+ }
+}
--- /dev/null
+import * as debug from 'debug'
+import { uniq } from 'lodash-es'
+import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
+import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable, NgZone } from '@angular/core'
+import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
+import { enterZone, leaveZone } from '@app/helpers'
+import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
+import { ResultList, VideoChannel as VideoChannelServer, VideoSortField } from '@shared/models'
+import { environment } from '../../../environments/environment'
+
+const logger = debug('peertube:subscriptions:UserSubscriptionService')
+
+type SubscriptionExistResult = { [ uri: string ]: boolean }
+type SubscriptionExistResultObservable = { [ uri: string ]: Observable<boolean> }
+
+@Injectable()
+export class UserSubscriptionService {
+ static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions'
+
+ // Use a replay subject because we "next" a value before subscribing
+ private existsSubject = new ReplaySubject<string>(1)
+ private readonly existsObservable: Observable<SubscriptionExistResult>
+
+ private myAccountSubscriptionCache: SubscriptionExistResult = {}
+ private myAccountSubscriptionCacheObservable: SubscriptionExistResultObservable = {}
+ private myAccountSubscriptionCacheSubject = new Subject<SubscriptionExistResult>()
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private videoService: VideoService,
+ private restService: RestService,
+ private ngZone: NgZone
+ ) {
+ this.existsObservable = merge(
+ this.existsSubject.pipe(
+ // We leave Angular zone so Protractor does not get stuck
+ bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
+ filter(uris => uris.length !== 0),
+ map(uris => uniq(uris)),
+ observeOn(enterZone(this.ngZone, asyncScheduler)),
+ switchMap(uris => this.doSubscriptionsExist(uris)),
+ share()
+ ),
+
+ this.myAccountSubscriptionCacheSubject
+ )
+ }
+
+ getUserSubscriptionVideos (parameters: {
+ videoPagination: ComponentPaginationLight,
+ sort: VideoSortField,
+ skipCount?: boolean
+ }): Observable<ResultList<Video>> {
+ const { videoPagination, sort, skipCount } = parameters
+ const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ if (skipCount) params = params.set('skipCount', skipCount + '')
+
+ return this.authHttp
+ .get<ResultList<Video>>(UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/videos', { params })
+ .pipe(
+ switchMap(res => this.videoService.extractVideos(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ /**
+ * Subscription part
+ */
+
+ deleteSubscription (nameWithHost: string) {
+ const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/' + nameWithHost
+
+ return this.authHttp.delete(url)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => {
+ this.myAccountSubscriptionCache[nameWithHost] = false
+
+ this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache)
+ }),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ addSubscription (nameWithHost: string) {
+ const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
+
+ const body = { uri: nameWithHost }
+ return this.authHttp.post(url, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => {
+ this.myAccountSubscriptionCache[nameWithHost] = true
+
+ this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache)
+ }),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ listSubscriptions (componentPagination: ComponentPaginationLight): Observable<ResultList<VideoChannel>> {
+ const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
+
+ const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination)
+
+ return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
+ .pipe(
+ map(res => VideoChannelService.extractVideoChannels(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ /**
+ * SubscriptionExist part
+ */
+
+ listenToMyAccountSubscriptionCacheSubject () {
+ return this.myAccountSubscriptionCacheSubject.asObservable()
+ }
+
+ listenToSubscriptionCacheChange (nameWithHost: string) {
+ if (nameWithHost in this.myAccountSubscriptionCacheObservable) {
+ return this.myAccountSubscriptionCacheObservable[ nameWithHost ]
+ }
+
+ const obs = this.existsObservable
+ .pipe(
+ filter(existsResult => existsResult[ nameWithHost ] !== undefined),
+ map(existsResult => existsResult[ nameWithHost ])
+ )
+
+ this.myAccountSubscriptionCacheObservable[ nameWithHost ] = obs
+ return obs
+ }
+
+ doesSubscriptionExist (nameWithHost: string) {
+ logger('Running subscription check for %d.', nameWithHost)
+
+ if (nameWithHost in this.myAccountSubscriptionCache) {
+ logger('Found cache for %d.', nameWithHost)
+
+ return of(this.myAccountSubscriptionCache[ nameWithHost ])
+ }
+
+ this.existsSubject.next(nameWithHost)
+
+ logger('Fetching from network for %d.', nameWithHost)
+ return this.existsObservable.pipe(
+ filter(existsResult => existsResult[ nameWithHost ] !== undefined),
+ map(existsResult => existsResult[ nameWithHost ]),
+ tap(result => this.myAccountSubscriptionCache[ nameWithHost ] = result)
+ )
+ }
+
+ private doSubscriptionsExist (uris: string[]): Observable<SubscriptionExistResult> {
+ const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
+ let params = new HttpParams()
+
+ params = this.restService.addObjectParams(params, { uris })
+
+ return this.authHttp.get<SubscriptionExistResult>(url, { params })
+ .pipe(
+ tap(res => {
+ this.myAccountSubscriptionCache = {
+ ...this.myAccountSubscriptionCache,
+ ...res
+ }
+ }),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+}
--- /dev/null
+<div class="margin-content">
+ <div class="videos-header">
+ <h1 *ngIf="titlePage" class="title-page title-page-single">
+ <div placement="bottom" [ngbTooltip]="titleTooltip" container="body">
+ {{ titlePage }}
+ </div>
+ <my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed>
+ </h1>
+
+ <div class="action-block" *ngIf="actions.length > 0">
+ <a [routerLink]="action.routerLink" routerLinkActive="active" *ngFor="let action of actions">
+ <button class="btn">
+ <my-global-icon [iconName]="action.iconName" aria-hidden="true"></my-global-icon>
+ <span>{{ action.label }}</span>
+ </button>
+ </a>
+ </div>
+
+ <div class="moderation-block" *ngIf="displayModerationBlock">
+ <my-peertube-checkbox
+ (change)="toggleModerationDisplay()"
+ inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos"
+ >
+ </my-peertube-checkbox>
+ </div>
+ </div>
+
+ <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div>
+ <div
+ myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
+ class="videos"
+ >
+ <ng-container *ngFor="let video of videos; trackBy: videoById;">
+ <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
+ {{ getCurrentGroupedDateLabel(video) }}
+ </h2>
+
+ <div class="video-wrapper">
+ <my-video-miniature
+ [fitWidth]="true"
+ [video]="video" [user]="userMiniature" [ownerDisplayType]="ownerDisplayType"
+ [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
+ (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
+ >
+ </my-video-miniature>
+ </div>
+ </ng-container>
+ </div>
+</div>
--- /dev/null
+@import '_bootstrap-variables';
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+.videos-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+
+ .title-page.title-page-single {
+ display: flex;
+
+ my-feed {
+ display: inline-block;
+ top: 1px;
+ margin-left: 5px;
+ width: max-content;
+ opacity: 0;
+ transition: ease-in .2s opacity;
+ }
+ &:hover my-feed {
+ opacity: 1;
+ }
+ }
+
+ .action-block {
+ a button {
+ @include peertube-button;
+ @include grey-button;
+ @include button-with-icon(18px, 3px, -1px);
+ }
+ }
+
+ .moderation-block {
+ display: flex;
+ flex-grow: 1;
+ justify-content: flex-end;
+ align-items: center;
+ }
+}
+
+.date-title {
+ font-size: 16px;
+ font-weight: $font-semibold;
+ margin-bottom: 20px;
+ margin-top: -10px;
+
+ // make the element span a full grid row within .videos grid
+ grid-column: 1 / -1;
+
+ &:not(:first-child) {
+ margin-top: .5rem;
+ padding-top: 20px;
+ border-top: 1px solid $separator-border-color;
+ }
+}
+
+.margin-content {
+ @include fluid-videos-miniature-layout;
+}
+
+@media screen and (max-width: $mobile-view) {
+ .videos-header {
+ flex-direction: column;
+ align-items: center;
+ height: auto;
+ margin-bottom: 10px;
+
+ .title-page {
+ margin-bottom: 10px;
+ margin-right: 0px;
+ }
+ }
+}
--- /dev/null
+import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
+import { debounceTime, switchMap, tap } from 'rxjs/operators'
+import { OnDestroy, OnInit } from '@angular/core'
+import { ActivatedRoute, 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 { I18n } from '@ngx-translate/i18n-polyfill'
+import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
+import { ServerConfig, VideoSortField } from '@shared/models'
+import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
+import { Syndication, Video } from '../shared-main'
+import { MiniatureDisplayOptions, OwnerDisplayType } from './video-miniature.component'
+
+enum GroupDate {
+ UNKNOWN = 0,
+ TODAY = 1,
+ YESTERDAY = 2,
+ LAST_WEEK = 3,
+ LAST_MONTH = 4,
+ OLDER = 5
+}
+
+export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook {
+ pagination: ComponentPaginationLight = {
+ currentPage: 1,
+ itemsPerPage: 25
+ }
+ sort: VideoSortField = '-publishedAt'
+
+ categoryOneOf?: number[]
+ languageOneOf?: string[]
+ nsfwPolicy?: NSFWPolicyType
+ defaultSort: VideoSortField = '-publishedAt'
+
+ syndicationItems: Syndication[] = []
+
+ loadOnInit = true
+ useUserVideoPreferences = false
+ ownerDisplayType: OwnerDisplayType = 'account'
+ 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: {
+ routerLink: string
+ iconName: GlobalIconName
+ label: string
+ }[] = []
+
+ onDataSubject = new Subject<any[]>()
+
+ userMiniature: User
+
+ protected serverConfig: ServerConfig
+
+ 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 i18n: I18n
+ 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.getTmpConfig()
+ this.serverService.getConfig()
+ .subscribe(config => this.serverConfig = config)
+
+ this.groupedDateLabels = {
+ [GroupDate.UNKNOWN]: null,
+ [GroupDate.TODAY]: this.i18n('Today'),
+ [GroupDate.YESTERDAY]: this.i18n('Yesterday'),
+ [GroupDate.LAST_WEEK]: this.i18n('Last week'),
+ [GroupDate.LAST_MONTH]: this.i18n('Last month'),
+ [GroupDate.OLDER]: this.i18n('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()
+
+ if (this.loadOnInit === true) {
+ loadUserObservable.subscribe(() => 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()
+ }
+
+ 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(
+ ({ 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 => {
+ const message = this.i18n('Cannot load more videos. Try again later.')
+
+ console.error(message, { error })
+ this.notifier.error(message)
+ }
+ )
+ }
+
+ reloadVideos () {
+ this.pagination.currentPage = 1
+ this.loadMoreVideos(true)
+ }
+
+ toggleModerationDisplay () {
+ throw new Error('toggleModerationDisplay is not implemented')
+ }
+
+ removeVideoFromArray (video: Video) {
+ this.videos = this.videos.filter(v => v.id !== video.id)
+ }
+
+ buildGroupedDateLabels () {
+ let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
+
+ for (const video of this.videos) {
+ const publishedDate = video.publishedAt
+
+ if (currentGroupedDate <= GroupDate.TODAY && isToday(publishedDate)) {
+ if (currentGroupedDate === GroupDate.TODAY) continue
+
+ currentGroupedDate = GroupDate.TODAY
+ this.groupedDates[ video.id ] = currentGroupedDate
+ continue
+ }
+
+ if (currentGroupedDate <= GroupDate.YESTERDAY && isYesterday(publishedDate)) {
+ if (currentGroupedDate === GroupDate.YESTERDAY) continue
+
+ currentGroupedDate = GroupDate.YESTERDAY
+ this.groupedDates[ video.id ] = currentGroupedDate
+ continue
+ }
+
+ if (currentGroupedDate <= GroupDate.LAST_WEEK && isLastWeek(publishedDate)) {
+ if (currentGroupedDate === GroupDate.LAST_WEEK) continue
+
+ currentGroupedDate = GroupDate.LAST_WEEK
+ this.groupedDates[ video.id ] = currentGroupedDate
+ continue
+ }
+
+ if (currentGroupedDate <= GroupDate.LAST_MONTH && isLastMonth(publishedDate)) {
+ if (currentGroupedDate === GroupDate.LAST_MONTH) continue
+
+ currentGroupedDate = GroupDate.LAST_MONTH
+ this.groupedDates[ video.id ] = currentGroupedDate
+ continue
+ }
+
+ if (currentGroupedDate <= GroupDate.OLDER) {
+ if (currentGroupedDate === GroupDate.OLDER) continue
+
+ currentGroupedDate = GroupDate.OLDER
+ this.groupedDates[ video.id ] = currentGroupedDate
+ }
+ }
+ }
+
+ getCurrentGroupedDateLabel (video: Video) {
+ if (this.groupByDate === false) return undefined
+
+ return this.groupedDateLabels[this.groupedDates[video.id]]
+ }
+
+ // On videos hook for children that want to do something
+ protected onMoreVideos () { /* empty */ }
+
+ protected loadRouteParams (routeParams: { [ key: string ]: any }) {
+ this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort
+ this.categoryOneOf = routeParams[ 'categoryOneOf' ]
+ this.angularState = routeParams[ 'a-state' ]
+ }
+
+ 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.router.url
+ 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.useUserVideoPreferences) return
+
+ this.languageOneOf = user.videoLanguages
+ this.nsfwPolicy = user.nsfwPolicy
+ }))
+ }
+}
--- /dev/null
+export * from './abstract-video-list'
+export * from './video-actions-dropdown.component'
+export * from './video-download.component'
+export * from './video-miniature.component'
+export * from './videos-selection.component'
+
+export * from './shared-video-miniature.module'
--- /dev/null
+
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '../shared-forms'
+import { SharedGlobalIconModule } from '../shared-icons'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import { SharedModerationModule } from '../shared-moderation'
+import { SharedThumbnailModule } from '../shared-thumbnail'
+import { SharedVideoPlaylistModule } from '../shared-video-playlist/shared-video-playlist.module'
+import { VideoActionsDropdownComponent } from './video-actions-dropdown.component'
+import { VideoDownloadComponent } from './video-download.component'
+import { VideoMiniatureComponent } from './video-miniature.component'
+import { VideosSelectionComponent } from './videos-selection.component'
+
+@NgModule({
+ imports: [
+ SharedMainModule,
+ SharedFormModule,
+ SharedModerationModule,
+ SharedVideoPlaylistModule,
+ SharedThumbnailModule,
+ SharedGlobalIconModule
+ ],
+
+ declarations: [
+ VideoActionsDropdownComponent,
+ VideoDownloadComponent,
+ VideoMiniatureComponent,
+ VideosSelectionComponent
+ ],
+
+ exports: [
+ VideoActionsDropdownComponent,
+ VideoDownloadComponent,
+ VideoMiniatureComponent,
+ VideosSelectionComponent
+ ],
+
+ providers: [ ]
+})
+export class SharedVideoMiniatureModule { }
--- /dev/null
+<ng-container *ngIf="videoActions.length !== 0">
+
+ <div class="playlist-dropdown" ngbDropdown #playlistDropdown="ngbDropdown" role="button" autoClose="outside" [placement]="getPlaylistDropdownPlacement()"
+ *ngIf="isUserLoggedIn() && displayOptions.playlist" (openChange)="playlistAdd.openChange($event)"
+ >
+ <span class="anchor" ngbDropdownAnchor></span>
+
+ <div ngbDropdownMenu>
+ <my-video-add-to-playlist #playlistAdd [video]="video" [lazyLoad]="true"></my-video-add-to-playlist>
+ </div>
+ </div>
+
+ <my-action-dropdown
+ [actions]="videoActions" [label]="label" [entry]="{ video: video }" (click)="loadDropdownInformation()"
+ [buttonSize]="buttonSize" [placement]="placement" [buttonDirection]="buttonDirection" [buttonStyled]="buttonStyled"
+ ></my-action-dropdown>
+
+ <my-video-download #videoDownloadModal></my-video-download>
+ <my-video-report #videoReportModal [video]="video"></my-video-report>
+ <my-video-block #videoBlockModal [video]="video" (videoBlocked)="onVideoBlocked()"></my-video-block>
+</ng-container>
--- /dev/null
+.playlist-dropdown {
+ position: absolute;
+
+ .anchor {
+ display: block;
+ opacity: 0;
+ }
+}
+
+::ng-deep .icon-playlist-add {
+ left: 2px;
+}
--- /dev/null
+import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
+import { AuthService, ConfirmService, Notifier, ScreenService } from '@app/core'
+import { VideoBlockComponent, VideoBlockService, VideoReportComponent } from '@app/shared/shared-moderation'
+import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoCaption } from '@shared/models'
+import { DropdownAction, DropdownButtonSize, DropdownDirection, RedundancyService, Video, VideoDetails, VideoService } from '../shared-main'
+import { VideoAddToPlaylistComponent } from '../shared-video-playlist'
+import { VideoDownloadComponent } from './video-download.component'
+
+export type VideoActionsDisplayType = {
+ playlist?: boolean
+ download?: boolean
+ update?: boolean
+ blacklist?: boolean
+ delete?: boolean
+ report?: boolean
+ duplicate?: boolean
+}
+
+@Component({
+ selector: 'my-video-actions-dropdown',
+ templateUrl: './video-actions-dropdown.component.html',
+ styleUrls: [ './video-actions-dropdown.component.scss' ]
+})
+export class VideoActionsDropdownComponent implements OnChanges {
+ @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
+ @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
+
+ @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
+ @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
+ @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent
+
+ @Input() video: Video | VideoDetails
+ @Input() videoCaptions: VideoCaption[] = []
+
+ @Input() displayOptions: VideoActionsDisplayType = {
+ playlist: false,
+ download: true,
+ update: true,
+ blacklist: true,
+ delete: true,
+ report: true,
+ duplicate: true
+ }
+ @Input() placement = 'left'
+
+ @Input() label: string
+
+ @Input() buttonStyled = false
+ @Input() buttonSize: DropdownButtonSize = 'normal'
+ @Input() buttonDirection: DropdownDirection = 'vertical'
+
+ @Output() videoRemoved = new EventEmitter()
+ @Output() videoUnblocked = new EventEmitter()
+ @Output() videoBlocked = new EventEmitter()
+ @Output() modalOpened = new EventEmitter()
+
+ videoActions: DropdownAction<{ video: Video }>[][] = []
+
+ private loaded = false
+
+ constructor (
+ private authService: AuthService,
+ private notifier: Notifier,
+ private confirmService: ConfirmService,
+ private videoBlocklistService: VideoBlockService,
+ private screenService: ScreenService,
+ private videoService: VideoService,
+ private redundancyService: RedundancyService,
+ private i18n: I18n
+ ) { }
+
+ get user () {
+ return this.authService.getUser()
+ }
+
+ ngOnChanges () {
+ if (this.loaded) {
+ this.loaded = false
+ this.playlistAdd.reload()
+ }
+
+ this.buildActions()
+ }
+
+ isUserLoggedIn () {
+ return this.authService.isLoggedIn()
+ }
+
+ loadDropdownInformation () {
+ if (!this.isUserLoggedIn() || this.loaded === true) return
+
+ this.loaded = true
+
+ if (this.displayOptions.playlist) this.playlistAdd.load()
+ }
+
+ /* Show modals */
+
+ showDownloadModal () {
+ this.modalOpened.emit()
+
+ this.videoDownloadModal.show(this.video as VideoDetails, this.videoCaptions)
+ }
+
+ showReportModal () {
+ this.modalOpened.emit()
+
+ this.videoReportModal.show()
+ }
+
+ showBlockModal () {
+ this.modalOpened.emit()
+
+ this.videoBlockModal.show()
+ }
+
+ /* Actions checker */
+
+ isVideoUpdatable () {
+ return this.video.isUpdatableBy(this.user)
+ }
+
+ isVideoRemovable () {
+ return this.video.isRemovableBy(this.user)
+ }
+
+ isVideoBlockable () {
+ return this.video.isBlockableBy(this.user)
+ }
+
+ isVideoUnblockable () {
+ return this.video.isUnblockableBy(this.user)
+ }
+
+ isVideoDownloadable () {
+ return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
+ }
+
+ canVideoBeDuplicated () {
+ return this.video.canBeDuplicatedBy(this.user)
+ }
+
+ /* Action handlers */
+
+ async unblockVideo () {
+ const confirmMessage = this.i18n(
+ 'Do you really want to unblock this video? It will be available again in the videos list.'
+ )
+
+ const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblock'))
+ if (res === false) return
+
+ this.videoBlocklistService.unblockVideo(this.video.id).subscribe(
+ () => {
+ this.notifier.success(this.i18n('Video {{name}} unblocked.', { name: this.video.name }))
+
+ this.video.blacklisted = false
+ this.video.blockedReason = null
+
+ this.videoUnblocked.emit()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ async removeVideo () {
+ this.modalOpened.emit()
+
+ const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete'))
+ if (res === false) return
+
+ this.videoService.removeVideo(this.video.id)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name }))
+
+ this.videoRemoved.emit()
+ },
+
+ error => this.notifier.error(error.message)
+ )
+ }
+
+ duplicateVideo () {
+ this.redundancyService.addVideoRedundancy(this.video)
+ .subscribe(
+ () => {
+ const message = this.i18n('This video will be duplicated by your instance.')
+ this.notifier.success(message)
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ onVideoBlocked () {
+ this.videoBlocked.emit()
+ }
+
+ getPlaylistDropdownPlacement () {
+ if (this.screenService.isInSmallView()) {
+ return 'bottom-right'
+ }
+
+ return 'bottom-left bottom-right'
+ }
+
+ private buildActions () {
+ this.videoActions = [
+ [
+ {
+ label: this.i18n('Save to playlist'),
+ handler: () => this.playlistDropdown.toggle(),
+ isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.playlist,
+ iconName: 'playlist-add'
+ }
+ ],
+ [
+ {
+ label: this.i18n('Download'),
+ handler: () => this.showDownloadModal(),
+ isDisplayed: () => this.displayOptions.download && this.isVideoDownloadable(),
+ iconName: 'download'
+ },
+ {
+ label: this.i18n('Update'),
+ linkBuilder: ({ video }) => [ '/videos/update', video.uuid ],
+ iconName: 'edit',
+ isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
+ },
+ {
+ label: this.i18n('Block'),
+ handler: () => this.showBlockModal(),
+ iconName: 'no',
+ isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoBlockable()
+ },
+ {
+ label: this.i18n('Unblock'),
+ handler: () => this.unblockVideo(),
+ iconName: 'undo',
+ isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblockable()
+ },
+ {
+ label: this.i18n('Mirror'),
+ handler: () => this.duplicateVideo(),
+ isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
+ iconName: 'cloud-download'
+ },
+ {
+ label: this.i18n('Delete'),
+ handler: () => this.removeVideo(),
+ isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
+ iconName: 'delete'
+ }
+ ],
+ [
+ {
+ label: this.i18n('Report'),
+ handler: () => this.showReportModal(),
+ isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.report,
+ iconName: 'alert'
+ }
+ ]
+ ]
+ }
+}
--- /dev/null
+<ng-template #modal let-hide="close">
+ <div class="modal-header">
+ <h4 class="modal-title">
+ <ng-container i18n>Download</ng-container>
+
+ <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block">
+ <span id="dropdownDownloadType" ngbDropdownToggle>
+ {{ type }}
+ </span>
+ <div ngbDropdownMenu aria-labelledby="dropdownDownloadType">
+ <button *ngIf="type === 'video'" (click)="switchToType('subtitles')" ngbDropdownItem i18n>subtitles</button>
+ <button *ngIf="type === 'subtitles'" (click)="switchToType('video')" ngbDropdownItem i18n>video</button>
+ </div>
+ </div>
+ </h4>
+ <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
+ </div>
+
+ <div class="modal-body">
+ <div class="form-group">
+ <div class="input-group input-group-sm">
+ <div class="input-group-prepend peertube-select-container">
+ <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()">
+ <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
+ </select>
+
+ <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId">
+ <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
+ </select>
+ </div>
+
+ <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
+ <div class="input-group-append">
+ <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
+ <span class="glyphicon glyphicon-copy"></span>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <ng-container *ngIf="type === 'video' && videoFile?.metadata">
+ <div ngbNav #nav="ngbNav" class="nav-tabs">
+
+ <ng-container ngbNavItem>
+ <a ngbNavLink i18n>Format</a>
+ <ng-template ngbNavContent>
+ <div class="file-metadata">
+ <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue">
+ <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
+ <span class="metadata-attribute-value">{{ item.value.value }}</span>
+ </div>
+ </div>
+ </ng-template>
+ </ng-container>
+
+ <ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
+ <a ngbNavLink i18n>Video stream</a>
+ <ng-template ngbNavContent>
+ <div class="file-metadata">
+ <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue">
+ <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
+ <span class="metadata-attribute-value">{{ item.value.value }}</span>
+ </div>
+ </div>
+ </ng-template>
+ </ng-container>
+
+ <ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
+ <a ngbNavLink i18n>Audio stream</a>
+ <ng-template ngbNavContent>
+ <div class="file-metadata">
+ <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue">
+ <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
+ <span class="metadata-attribute-value">{{ item.value.value }}</span>
+ </div>
+ </div>
+ </ng-template>
+ </ng-container>
+ </div>
+
+ <div [ngbNavOutlet]="nav"></div>
+ </ng-container>
+
+ <div class="download-type" *ngIf="type === 'video'">
+ <div class="peertube-radio-container">
+ <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
+ <label i18n for="download-direct">Direct download</label>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
+ <label i18n for="download-torrent">Torrent (.torrent file)</label>
+ </div>
+ </div>
+ </div>
+
+ <div class="modal-footer inputs">
+ <input
+ type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
+ (click)="hide()" (key.enter)="hide()"
+ >
+
+ <input
+ type="submit" i18n-value value="Download" class="action-button-submit"
+ (click)="download()"
+ >
+ </div>
+</ng-template>
--- /dev/null
+@import 'variables';
+@import 'mixins';
+
+.peertube-select-container {
+ @include peertube-select-container(100px);
+
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ border-right: none;
+
+ select {
+ height: inherit;
+ }
+}
+
+#dropdownDownloadType {
+ cursor: pointer;
+}
+
+.download-type {
+ margin-top: 30px;
+
+ .peertube-radio-container {
+ @include peertube-radio-container;
+
+ display: inline-block;
+ margin-right: 30px;
+ }
+}
+
+.file-metadata {
+ padding: 1rem;
+}
+
+.file-metadata .metadata-attribute {
+ font-size: 13px;
+ display: block;
+ margin-bottom: 12px;
+
+ .metadata-attribute-label {
+ min-width: 142px;
+ padding-right: 5px;
+ display: inline-block;
+ color: pvar(--greyForegroundColor);
+ font-weight: $font-bold;
+ }
+
+ a.metadata-attribute-value {
+ @include disable-default-a-behaviour;
+ color: pvar(--mainForegroundColor);
+
+ &:hover {
+ opacity: 0.9;
+ }
+ }
+
+ &.metadata-attribute-tags {
+ .metadata-attribute-value:not(:nth-child(2)) {
+ &::before {
+ content: ', '
+ }
+ }
+ }
+}
--- /dev/null
+import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg'
+import { mapValues, pick } from 'lodash-es'
+import { BytesPipe } from 'ngx-pipes'
+import { Component, ElementRef, ViewChild } from '@angular/core'
+import { AuthService, Notifier } from '@app/core'
+import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoCaption, VideoFile, VideoPrivacy } from '@shared/models'
+import { NumberFormatterPipe, VideoDetails, VideoService } from '../shared-main'
+
+type DownloadType = 'video' | 'subtitles'
+type FileMetadata = { [key: string]: { label: string, value: string }}
+
+@Component({
+ selector: 'my-video-download',
+ templateUrl: './video-download.component.html',
+ styleUrls: [ './video-download.component.scss' ]
+})
+export class VideoDownloadComponent {
+ @ViewChild('modal', { static: true }) modal: ElementRef
+
+ downloadType: 'direct' | 'torrent' = 'torrent'
+ resolutionId: number | string = -1
+ subtitleLanguageId: string
+
+ video: VideoDetails
+ videoFile: VideoFile
+ videoFileMetadataFormat: FileMetadata
+ videoFileMetadataVideoStream: FileMetadata | undefined
+ videoFileMetadataAudioStream: FileMetadata | undefined
+ videoCaptions: VideoCaption[]
+ activeModal: NgbActiveModal
+
+ type: DownloadType = 'video'
+
+ private bytesPipe: BytesPipe
+ private numbersPipe: NumberFormatterPipe
+
+ constructor (
+ private notifier: Notifier,
+ private modalService: NgbModal,
+ private videoService: VideoService,
+ private auth: AuthService,
+ private i18n: I18n
+ ) {
+ this.bytesPipe = new BytesPipe()
+ this.numbersPipe = new NumberFormatterPipe()
+ }
+
+ get typeText () {
+ return this.type === 'video'
+ ? this.i18n('video')
+ : this.i18n('subtitles')
+ }
+
+ getVideoFiles () {
+ if (!this.video) return []
+
+ return this.video.getFiles()
+ }
+
+ show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
+ this.video = video
+ this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined
+
+ this.activeModal = this.modalService.open(this.modal, { centered: true })
+
+ this.resolutionId = this.getVideoFiles()[0].resolution.id
+ this.onResolutionIdChange()
+ if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
+ }
+
+ onClose () {
+ this.video = undefined
+ this.videoCaptions = undefined
+ }
+
+ download () {
+ window.location.assign(this.getLink())
+ this.activeModal.close()
+ }
+
+ getLink () {
+ return this.type === 'subtitles' && this.videoCaptions
+ ? this.getSubtitlesLink()
+ : this.getVideoFileLink()
+ }
+
+ async onResolutionIdChange () {
+ this.videoFile = this.getVideoFile()
+ if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
+
+ await this.hydrateMetadataFromMetadataUrl(this.videoFile)
+
+ this.videoFileMetadataFormat = this.videoFile
+ ? this.getMetadataFormat(this.videoFile.metadata.format)
+ : undefined
+ this.videoFileMetadataVideoStream = this.videoFile
+ ? this.getMetadataStream(this.videoFile.metadata.streams, 'video')
+ : undefined
+ this.videoFileMetadataAudioStream = this.videoFile
+ ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio')
+ : undefined
+ }
+
+ getVideoFile () {
+ // HTML select send us a string, so convert it to a number
+ this.resolutionId = parseInt(this.resolutionId.toString(), 10)
+
+ const file = this.getVideoFiles().find(f => f.resolution.id === this.resolutionId)
+ if (!file) {
+ console.error('Could not find file with resolution %d.', this.resolutionId)
+ return
+ }
+ return file
+ }
+
+ getVideoFileLink () {
+ const file = this.videoFile
+ if (!file) return
+
+ const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
+ ? '?access_token=' + this.auth.getAccessToken()
+ : ''
+
+ switch (this.downloadType) {
+ case 'direct':
+ return file.fileDownloadUrl + suffix
+
+ case 'torrent':
+ return file.torrentDownloadUrl + suffix
+ }
+ }
+
+ getSubtitlesLink () {
+ return window.location.origin + this.videoCaptions.find(caption => caption.language.id === this.subtitleLanguageId).captionPath
+ }
+
+ activateCopiedMessage () {
+ this.notifier.success(this.i18n('Copied'))
+ }
+
+ switchToType (type: DownloadType) {
+ this.type = type
+ }
+
+ getMetadataFormat (format: FfprobeFormat) {
+ const keyToTranslateFunction = {
+ 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }),
+ 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }),
+ 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }),
+ 'bit_rate': (value: number) => ({
+ label: this.i18n('Bitrate'),
+ value: `${this.numbersPipe.transform(value)}bps`
+ })
+ }
+
+ // flattening format
+ const sanitizedFormat = Object.assign(format, format.tags)
+ delete sanitizedFormat.tags
+
+ return mapValues(
+ pick(sanitizedFormat, Object.keys(keyToTranslateFunction)),
+ (val, key) => keyToTranslateFunction[key](val)
+ )
+ }
+
+ getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') {
+ const stream = streams.find(s => s.codec_type === type)
+ if (!stream) return undefined
+
+ let keyToTranslateFunction = {
+ 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }),
+ 'profile': (value: string) => ({ label: this.i18n('Profile'), value }),
+ 'bit_rate': (value: number) => ({
+ label: this.i18n('Bitrate'),
+ value: `${this.numbersPipe.transform(value)}bps`
+ })
+ }
+
+ if (type === 'video') {
+ keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
+ 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }),
+ 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }),
+ 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }),
+ 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value })
+ })
+ } else {
+ keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
+ 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }),
+ 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value })
+ })
+ }
+
+ return mapValues(
+ pick(stream, Object.keys(keyToTranslateFunction)),
+ (val, key) => keyToTranslateFunction[key](val)
+ )
+ }
+
+ private hydrateMetadataFromMetadataUrl (file: VideoFile) {
+ const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
+ observable.subscribe(res => file.metadata = res)
+ return observable.toPromise()
+ }
+}
--- /dev/null
+<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
+ <my-video-thumbnail
+ [video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
+ [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
+ >
+ <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
+ <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
+ </my-video-thumbnail>
+
+ <div class="video-bottom">
+ <div class="video-miniature-information">
+ <div class="d-inline-flex video-miniature-meta">
+ <a *ngIf="displayOptions.avatar" class="avatar" [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
+ <img [src]="getAvatarUrl()" alt="" />
+ </a>
+
+ <div class="w-100 d-flex flex-column">
+ <a
+ tabindex="-1"
+ class="video-miniature-name"
+ [routerLink]="videoLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
+ >{{ video.name }}</a>
+
+ <span class="video-miniature-created-at-views">
+ <my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>
+
+ <span class="views">
+ <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container>
+ <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container>
+ </span>
+ </span>
+
+ <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]">
+ {{ video.byAccount }}
+ </a>
+ <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]">
+ {{ video.byVideoChannel }}
+ </a>
+
+ <div class="video-info-privacy">
+ <ng-container *ngIf="displayOptions.privacyText">{{ video.privacy.label }}</ng-container>
+ <ng-container *ngIf="displayOptions.privacyText && displayOptions.state && getStateLabel(video)"> - </ng-container>
+ <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
+ </div>
+ </div>
+ </div>
+
+ <div *ngIf="displayOptions.blacklistInfo && video.blacklisted" class="video-info-blocked">
+ <span class="blocked-label" i18n>Blocked</span>
+ <span class="blocked-reason" *ngIf="video.blockedReason">{{ video.blockedReason }}</span>
+ </div>
+
+ <div i18n *ngIf="displayOptions.nsfw && video.nsfw" class="video-info-nsfw">
+ Sensitive
+ </div>
+ </div>
+
+ <div class="video-actions">
+ <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 -->
+ <my-video-actions-dropdown
+ *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left auto"
+ (videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()"
+ ></my-video-actions-dropdown>
+ </div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+$more-button-width: 40px;
+$more-margin-right: 15px;
+
+.video-miniature {
+ display: inline-flex;
+ flex-direction: column;
+ padding-bottom: $video-miniature-margin-bottom;
+ vertical-align: top;
+
+ .video-bottom {
+ display: flex;
+
+ .video-miniature-information {
+ width: $video-miniature-width - $more-button-width - $more-margin-right;
+ line-height: normal;
+
+ .avatar {
+ margin: 10px 10px 0 0;
+
+ img {
+ @include avatar(40px);
+ }
+ }
+
+ .video-miniature-name {
+ @include miniature-name;
+ width: calc(100% - #{$more-button-width});
+ }
+
+ .video-miniature-meta {
+ width: calc(100% + #{$more-button-width});
+ overflow: hidden;
+ }
+
+ .video-miniature-created-at-views {
+ display: block;
+ font-size: 13px;
+ }
+
+ .video-miniature-account,
+ .video-miniature-channel {
+ @include disable-default-a-behaviour;
+ @include ellipsis;
+
+ display: block;
+ font-size: 13px;
+ color: pvar(--greyForegroundColor);
+
+ &:hover {
+ color: $grey-foreground-hover-color;
+ }
+ }
+
+ .video-info-privacy,
+ .video-info-blocked .blocked-label,
+ .video-info-nsfw {
+ font-weight: $font-semibold;
+ }
+
+ .video-info-blocked {
+ color: red;
+
+ .blocked-reason::before {
+ content: ' - ';
+ }
+ }
+
+ .video-info-nsfw {
+ color: red;
+ }
+ }
+
+ .video-actions {
+ margin-top: 3px;
+ width: $more-button-width;
+ height: 30px;
+
+ ::ng-deep .dropdown-root:not(.show) {
+ opacity: 0;
+ }
+
+ ::ng-deep .playlist-dropdown.show + my-action-dropdown .dropdown-root {
+ opacity: 1;
+ }
+
+ ::ng-deep .more-icon {
+ opacity: .6;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+
+ @media screen and (max-width: $small-view) {
+ .video-miniature-information {
+ margin: 0 10px;
+ }
+
+ .video-actions {
+ margin: 0;
+ top: -3px;
+
+ ::ng-deep .dropdown-root {
+ opacity: 1 !important;
+ }
+ }
+ }
+ }
+
+ &:hover ::ng-deep .video-thumbnail .video-thumbnail-actions-overlay,
+ &:hover .video-bottom .video-actions ::ng-deep .dropdown-root {
+ opacity: 1;
+ }
+
+ &.fit-width {
+ width: 100%;
+
+ .video-bottom {
+ width: 100% !important;
+
+ .video-miniature-information {
+ width: calc(100% - #{$more-button-width}) !important;
+ }
+ }
+
+ my-video-thumbnail {
+ @include large-screen-ratio($selector: '::ng-deep .video-thumbnail');
+ }
+ }
+
+ &.display-as-row {
+ flex-direction: row;
+ padding-bottom: 0;
+ height: auto;
+ display: flex;
+ flex-grow: 1;
+
+ my-video-thumbnail {
+ margin-right: 10px;
+ }
+
+ .video-bottom {
+ .video-miniature-information {
+ @media screen and (min-width: $small-view) {
+ width: auto;
+ min-width: 500px;
+ }
+
+ .video-miniature-name {
+ @include ellipsis-multiline(1.3em, 2);
+
+ margin-top: 2px;
+ margin-bottom: 5px;
+ }
+
+ .video-miniature-created-at-views,
+ .video-miniature-account,
+ .video-miniature-channel {
+ font-size: 95%;
+ width: fit-content;
+ }
+
+ .video-miniature-created-at-views + .video-miniature-channel {
+ margin-top: 5px;
+ }
+
+ .video-info-privacy {
+ margin-top: 5px;
+ }
+
+ .video-info-blocked {
+ margin-top: 3px;
+ }
+ }
+
+ .video-actions {
+ margin: 0;
+ top: -3px;
+ }
+ }
+
+ @media screen and (max-width: $small-view) {
+ flex-direction: column;
+ height: auto;
+
+ my-video-thumbnail {
+ margin-right: 0;
+ }
+
+ .video-miniature-information {
+ min-width: initial;
+ }
+ }
+ }
+}
--- /dev/null
+import { switchMap } from 'rxjs/operators'
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ Inject,
+ Input,
+ LOCALE_ID,
+ OnInit,
+ Output
+} from '@angular/core'
+import { AuthService, ScreenService, ServerService, User } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
+import { Video } from '../shared-main'
+import { VideoPlaylistService } from '../shared-video-playlist'
+import { VideoActionsDisplayType } from './video-actions-dropdown.component'
+
+export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
+export type MiniatureDisplayOptions = {
+ date?: boolean
+ views?: boolean
+ by?: boolean
+ avatar?: boolean
+ privacyLabel?: boolean
+ privacyText?: boolean
+ state?: boolean
+ blacklistInfo?: boolean
+ nsfw?: boolean
+}
+
+@Component({
+ selector: 'my-video-miniature',
+ styleUrls: [ './video-miniature.component.scss' ],
+ templateUrl: './video-miniature.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class VideoMiniatureComponent implements OnInit {
+ @Input() user: User
+ @Input() video: Video
+
+ @Input() ownerDisplayType: OwnerDisplayType = 'account'
+ @Input() displayOptions: MiniatureDisplayOptions = {
+ date: true,
+ views: true,
+ by: true,
+ avatar: false,
+ privacyLabel: false,
+ privacyText: false,
+ state: false,
+ blacklistInfo: false
+ }
+ @Input() displayAsRow = false
+ @Input() displayVideoActions = true
+ @Input() fitWidth = false
+
+ @Input() useLazyLoadUrl = false
+
+ @Output() videoBlocked = new EventEmitter()
+ @Output() videoUnblocked = new EventEmitter()
+ @Output() videoRemoved = new EventEmitter()
+
+ videoActionsDisplayOptions: VideoActionsDisplayType = {
+ playlist: true,
+ download: false,
+ update: true,
+ blacklist: true,
+ delete: true,
+ report: true,
+ duplicate: true
+ }
+ showActions = false
+ serverConfig: ServerConfig
+
+ addToWatchLaterText: string
+ addedToWatchLaterText: string
+ inWatchLaterPlaylist: boolean
+ channelLinkTitle = ''
+
+ watchLaterPlaylist: {
+ id: number
+ playlistElementId?: number
+ }
+
+ videoLink: any[] = []
+
+ private ownerDisplayTypeChosen: 'account' | 'videoChannel'
+
+ constructor (
+ private screenService: ScreenService,
+ private serverService: ServerService,
+ private i18n: I18n,
+ private authService: AuthService,
+ private videoPlaylistService: VideoPlaylistService,
+ private cd: ChangeDetectorRef,
+ @Inject(LOCALE_ID) private localeId: string
+ ) {}
+
+ get isVideoBlur () {
+ return this.video.isVideoNSFWForUser(this.user, this.serverConfig)
+ }
+
+ ngOnInit () {
+ this.serverConfig = this.serverService.getTmpConfig()
+ this.serverService.getConfig()
+ .subscribe(config => {
+ this.serverConfig = config
+ this.buildVideoLink()
+ })
+
+ this.setUpBy()
+
+ this.channelLinkTitle = this.i18n(
+ '{{name}} (channel page)',
+ { name: this.video.channel.name, handle: this.video.byVideoChannel }
+ )
+
+ // We rely on mouseenter to lazy load actions
+ if (this.screenService.isInTouchScreen()) {
+ this.loadActions()
+ }
+ }
+
+ buildVideoLink () {
+ if (this.useLazyLoadUrl && this.video.url) {
+ const remoteUriConfig = this.serverConfig.search.remoteUri
+
+ // Redirect on the external instance if not allowed to fetch remote data
+ const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
+ const fromPath = window.location.pathname + window.location.search
+
+ this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ]
+ return
+ }
+
+ this.videoLink = [ '/videos/watch', this.video.uuid ]
+ }
+
+ displayOwnerAccount () {
+ return this.ownerDisplayTypeChosen === 'account'
+ }
+
+ displayOwnerVideoChannel () {
+ return this.ownerDisplayTypeChosen === 'videoChannel'
+ }
+
+ isUnlistedVideo () {
+ return this.video.privacy.id === VideoPrivacy.UNLISTED
+ }
+
+ isPrivateVideo () {
+ return this.video.privacy.id === VideoPrivacy.PRIVATE
+ }
+
+ getStateLabel (video: Video) {
+ if (!video.state) return ''
+
+ if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) {
+ return this.i18n('Published')
+ }
+
+ if (video.scheduledUpdate) {
+ const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
+ return this.i18n('Publication scheduled on ') + updateAt
+ }
+
+ if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
+ return this.i18n('Waiting transcoding')
+ }
+
+ if (video.state.id === VideoState.TO_TRANSCODE) {
+ return this.i18n('To transcode')
+ }
+
+ if (video.state.id === VideoState.TO_IMPORT) {
+ return this.i18n('To import')
+ }
+
+ return ''
+ }
+
+ getAvatarUrl () {
+ if (this.ownerDisplayTypeChosen === 'account') {
+ return this.video.accountAvatarUrl
+ }
+
+ return this.video.videoChannelAvatarUrl
+ }
+
+ loadActions () {
+ if (this.displayVideoActions) this.showActions = true
+
+ this.loadWatchLater()
+ }
+
+ onVideoBlocked () {
+ this.videoBlocked.emit()
+ }
+
+ onVideoUnblocked () {
+ this.videoUnblocked.emit()
+ }
+
+ onVideoRemoved () {
+ this.videoRemoved.emit()
+ }
+
+ isUserLoggedIn () {
+ return this.authService.isLoggedIn()
+ }
+
+ onWatchLaterClick (currentState: boolean) {
+ if (currentState === true) this.removeFromWatchLater()
+ else this.addToWatchLater()
+
+ this.inWatchLaterPlaylist = !currentState
+ }
+
+ addToWatchLater () {
+ const body = { videoId: this.video.id }
+
+ this.videoPlaylistService.addVideoInPlaylist(this.watchLaterPlaylist.id, body).subscribe(
+ res => {
+ this.watchLaterPlaylist.playlistElementId = res.videoPlaylistElement.id
+ }
+ )
+ }
+
+ removeFromWatchLater () {
+ this.videoPlaylistService.removeVideoFromPlaylist(this.watchLaterPlaylist.id, this.watchLaterPlaylist.playlistElementId, this.video.id)
+ .subscribe(
+ _ => { /* empty */ }
+ )
+ }
+
+ isWatchLaterPlaylistDisplayed () {
+ return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
+ }
+
+ private setUpBy () {
+ if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') {
+ this.ownerDisplayTypeChosen = this.ownerDisplayType
+ return
+ }
+
+ // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12)
+ // -> Use the account name
+ if (
+ this.video.channel.name === `${this.video.account.name}_channel` ||
+ this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
+ ) {
+ this.ownerDisplayTypeChosen = 'account'
+ } else {
+ this.ownerDisplayTypeChosen = 'videoChannel'
+ }
+ }
+
+ private loadWatchLater () {
+ if (!this.isUserLoggedIn() || this.inWatchLaterPlaylist !== undefined) return
+
+ this.authService.userInformationLoaded
+ .pipe(switchMap(() => this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)))
+ .subscribe(existResult => {
+ const watchLaterPlaylist = this.authService.getUser().specialPlaylists.find(p => p.type === VideoPlaylistType.WATCH_LATER)
+ const existsInWatchLater = existResult.find(r => r.playlistId === watchLaterPlaylist.id)
+ this.inWatchLaterPlaylist = false
+
+ this.watchLaterPlaylist = {
+ id: watchLaterPlaylist.id
+ }
+
+ if (existsInWatchLater) {
+ this.inWatchLaterPlaylist = true
+ this.watchLaterPlaylist.playlistElementId = existsInWatchLater.playlistElementId
+ }
+
+ this.cd.markForCheck()
+ })
+
+ this.videoPlaylistService.runPlaylistCheck(this.video.id)
+ }
+}
--- /dev/null
+<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
+
+<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos">
+ <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById">
+
+ <div class="checkbox-container">
+ <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="_selection[video.id]"></my-peertube-checkbox>
+ </div>
+
+ <my-video-miniature
+ [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions"
+ [displayVideoActions]="false" [ownerDisplayType]="ownerDisplayType"
+ ></my-video-miniature>
+
+ <!-- Display only once -->
+ <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
+ <div class="action-selection-mode-child">
+ <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
+ Cancel
+ </span>
+
+ <ng-container *ngTemplateOutlet="globalButtonsTemplate"></ng-container>
+ </div>
+ </div>
+
+ <ng-container *ngIf="isInSelectionMode() === false">
+ <ng-container *ngTemplateOutlet="rowButtonsTemplate; context: {$implicit: video}"></ng-container>
+ </ng-container>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.action-selection-mode {
+ display: flex;
+ justify-content: flex-end;
+ flex-grow: 1;
+
+ .action-selection-mode-child {
+ position: fixed;
+
+ .action-button {
+ display: inline-block;
+ }
+
+ .action-button-cancel-selection {
+ @include peertube-button;
+ @include grey-button;
+
+ margin-right: 10px;
+ }
+ }
+}
+
+.video {
+ @include row-blocks;
+
+ &:first-child {
+ margin-top: 47px;
+ }
+
+ .checkbox-container {
+ display: flex;
+ align-items: center;
+ margin-right: 20px;
+ margin-left: 12px;
+ }
+
+ my-video-miniature {
+ flex-grow: 1;
+ }
+}
+
+@media screen and (max-width: $small-view) {
+ .video {
+ flex-direction: column;
+ height: auto;
+
+ .checkbox-container {
+ display: none;
+ }
+
+ my-button {
+ margin-top: 10px;
+ }
+ }
+}
--- /dev/null
+import { Observable } from 'rxjs'
+import {
+ AfterContentInit,
+ Component,
+ ContentChildren,
+ EventEmitter,
+ Input,
+ OnDestroy,
+ OnInit,
+ Output,
+ QueryList,
+ TemplateRef
+} from '@angular/core'
+import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, ComponentPagination, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ResultList, VideoSortField } from '@shared/models'
+import { PeerTubeTemplateDirective, Video } from '../shared-main'
+import { AbstractVideoList } from './abstract-video-list'
+import { MiniatureDisplayOptions, OwnerDisplayType } from './video-miniature.component'
+
+export type SelectionType = { [ id: number ]: boolean }
+
+@Component({
+ selector: 'my-videos-selection',
+ templateUrl: './videos-selection.component.html',
+ styleUrls: [ './videos-selection.component.scss' ]
+})
+export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
+ @Input() pagination: ComponentPagination
+ @Input() titlePage: string
+ @Input() miniatureDisplayOptions: MiniatureDisplayOptions
+ @Input() ownerDisplayType: OwnerDisplayType
+
+ @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
+
+ @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'rowButtons' | 'globalButtons'>>
+
+ @Output() selectionChange = new EventEmitter<SelectionType>()
+ @Output() videosModelChange = new EventEmitter<Video[]>()
+
+ _selection: SelectionType = {}
+
+ rowButtonsTemplate: TemplateRef<any>
+ globalButtonsTemplate: TemplateRef<any>
+
+ constructor (
+ protected i18n: I18n,
+ protected router: Router,
+ protected route: ActivatedRoute,
+ protected notifier: Notifier,
+ protected authService: AuthService,
+ protected userService: UserService,
+ protected screenService: ScreenService,
+ protected storageService: LocalStorageService,
+ protected serverService: ServerService
+ ) {
+ super()
+ }
+
+ @Input() get selection () {
+ return this._selection
+ }
+
+ set selection (selection: SelectionType) {
+ this._selection = selection
+ this.selectionChange.emit(this._selection)
+ }
+
+ @Input() get videosModel () {
+ return this.videos
+ }
+
+ set videosModel (videos: Video[]) {
+ this.videos = videos
+ this.videosModelChange.emit(this.videos)
+ }
+
+ ngOnInit () {
+ super.ngOnInit()
+ }
+
+ ngAfterContentInit () {
+ {
+ const t = this.templates.find(t => t.name === 'rowButtons')
+ if (t) this.rowButtonsTemplate = t.template
+ }
+
+ {
+ const t = this.templates.find(t => t.name === 'globalButtons')
+ if (t) this.globalButtonsTemplate = t.template
+ }
+ }
+
+ ngOnDestroy () {
+ super.ngOnDestroy()
+ }
+
+ getVideosObservable (page: number) {
+ return this.getVideosObservableFunction(page, this.sort)
+ }
+
+ abortSelectionMode () {
+ this._selection = {}
+ }
+
+ isInSelectionMode () {
+ return Object.keys(this._selection).some(k => this._selection[ k ] === true)
+ }
+
+ generateSyndicationList () {
+ throw new Error('Method not implemented.')
+ }
+
+ protected onMoreVideos () {
+ this.videosModel = this.videos
+ }
+}
--- /dev/null
+export * from './video-add-to-playlist.component'
+export * from './video-playlist-element-miniature.component'
+export * from './video-playlist-element.model'
+export * from './video-playlist-miniature.component'
+export * from './video-playlist.model'
+export * from './video-playlist.service'
+
+export * from './shared-video-playlist.module'
--- /dev/null
+
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '../shared-forms'
+import { SharedGlobalIconModule } from '../shared-icons'
+import { SharedMainModule } from '../shared-main/shared-main.module'
+import { SharedThumbnailModule } from '../shared-thumbnail'
+import { VideoAddToPlaylistComponent } from './video-add-to-playlist.component'
+import { VideoPlaylistElementMiniatureComponent } from './video-playlist-element-miniature.component'
+import { VideoPlaylistMiniatureComponent } from './video-playlist-miniature.component'
+import { VideoPlaylistService } from './video-playlist.service'
+
+@NgModule({
+ imports: [
+ SharedMainModule,
+ SharedFormModule,
+ SharedThumbnailModule,
+ SharedGlobalIconModule
+ ],
+
+ declarations: [
+ VideoAddToPlaylistComponent,
+ VideoPlaylistElementMiniatureComponent,
+ VideoPlaylistMiniatureComponent
+ ],
+
+ exports: [
+ VideoAddToPlaylistComponent,
+ VideoPlaylistElementMiniatureComponent,
+ VideoPlaylistMiniatureComponent
+ ],
+
+ providers: [
+ VideoPlaylistService
+ ]
+})
+export class SharedVideoPlaylistModule { }
--- /dev/null
+<div class="root">
+ <div class="header">
+ <div class="first-row">
+ <div i18n class="title">Save to</div>
+
+ <div class="options" (click)="displayOptions = !displayOptions">
+ <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
+
+ <span i18n>Options</span>
+ </div>
+ </div>
+
+ <div class="options-row" *ngIf="displayOptions">
+ <div>
+ <my-peertube-checkbox
+ inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
+ i18n-labelText labelText="Start at"
+ ></my-peertube-checkbox>
+
+ <my-timestamp-input
+ [timestamp]="timestampOptions.startTimestamp"
+ [maxTimestamp]="video.duration"
+ [disabled]="!timestampOptions.startTimestampEnabled"
+ [(ngModel)]="timestampOptions.startTimestamp"
+ ></my-timestamp-input>
+ </div>
+
+ <div>
+ <my-peertube-checkbox
+ inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
+ i18n-labelText labelText="Stop at"
+ ></my-peertube-checkbox>
+
+ <my-timestamp-input
+ [timestamp]="timestampOptions.stopTimestamp"
+ [maxTimestamp]="video.duration"
+ [disabled]="!timestampOptions.stopTimestampEnabled"
+ [(ngModel)]="timestampOptions.stopTimestamp"
+ ></my-timestamp-input>
+ </div>
+ </div>
+ </div>
+
+ <div class="input-container">
+ <input type="text" placeholder="Search playlists" i18n-placeholder [(ngModel)]="videoPlaylistSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" />
+ </div>
+
+ <div class="playlists">
+ <div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)">
+ <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox>
+
+ <div class="display-name">
+ {{ playlist.displayName }}
+
+ <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info">
+ {{ formatTimestamp(playlist) }}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
+ <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
+
+ <span i18n>Create a private playlist</span>
+ </div>
+
+ <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
+ <div class="form-group">
+ <label i18n for="displayName">Display name</label>
+ <input
+ type="text" id="displayName"
+ formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
+ >
+ <div *ngIf="formErrors['displayName']" class="form-error">
+ {{ formErrors['displayName'] }}
+ </div>
+ </div>
+
+ <input type="submit" i18n-value value="Create" [disabled]="!form.valid">
+ </form>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.header,
+.dropdown-item,
+.input-container {
+ padding: 8px 24px;
+}
+
+.header {
+ min-width: 240px;
+ margin-bottom: 10px;
+ border-bottom: 1px solid $separator-border-color;
+
+ .first-row {
+ display: flex;
+ align-items: center;
+
+ .title {
+ font-size: 18px;
+ flex-grow: 1;
+ }
+
+ .options {
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ cursor: pointer;
+
+ my-global-icon {
+ @include apply-svg-color(#333);
+
+ width: 16px;
+ height: 23px;
+ margin-right: 3px;
+ }
+ }
+ }
+
+ .options-row {
+ margin-top: 10px;
+ padding-left: 10px;
+
+ > div {
+ display: flex;
+ align-items: center;
+ }
+ }
+}
+
+.playlists {
+ max-height: 180px;
+ overflow-y: auto;
+}
+
+.playlist {
+ display: inline-flex;
+ cursor: pointer;
+
+ my-peertube-checkbox {
+ margin-right: 10px;
+ align-self: center;
+ }
+
+ .display-name {
+ display: flex;
+ align-items: flex-end;
+
+ .timestamp-info {
+ font-size: 0.9em;
+ color: pvar(--greyForegroundColor);
+ margin-left: 5px;
+ }
+ }
+}
+
+.new-playlist-button,
+.new-playlist-block {
+ padding-top: 10px;
+ border-top: 1px solid $separator-border-color;
+}
+
+.new-playlist-button {
+ cursor: pointer;
+
+ my-global-icon {
+ @include apply-svg-color(#333);
+
+ position: relative;
+ left: -1px;
+ top: -1px;
+ margin-right: 4px;
+ width: 21px;
+ height: 21px;
+ }
+}
+
+input[type=text] {
+ @include peertube-input-text(200px);
+
+ display: block;
+}
+
+input[type=submit] {
+ @include peertube-button;
+ @include orange-button;
+}
--- /dev/null
+import * as debug from 'debug'
+import { Subject, Subscription } from 'rxjs'
+import { debounceTime, filter } from 'rxjs/operators'
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
+import { AuthService, DisableForReuseHook, Notifier } from '@app/core'
+import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/shared-forms'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Video, VideoExistInPlaylist, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
+import { secondsToTime } from '../../../assets/player/utils'
+import { CachedPlaylist, VideoPlaylistService } from './video-playlist.service'
+
+const logger = debug('peertube:playlists:VideoAddToPlaylistComponent')
+
+type PlaylistSummary = {
+ id: number
+ inPlaylist: boolean
+ displayName: string
+
+ playlistElementId?: number
+ startTimestamp?: number
+ stopTimestamp?: number
+}
+
+@Component({
+ selector: 'my-video-add-to-playlist',
+ styleUrls: [ './video-add-to-playlist.component.scss' ],
+ templateUrl: './video-add-to-playlist.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, OnChanges, OnDestroy, DisableForReuseHook {
+ @Input() video: Video
+ @Input() currentVideoTimestamp: number
+ @Input() lazyLoad = false
+
+ isNewPlaylistBlockOpened = false
+ videoPlaylistSearch: string
+ videoPlaylistSearchChanged = new Subject<string>()
+ videoPlaylists: PlaylistSummary[] = []
+ timestampOptions: {
+ startTimestampEnabled: boolean
+ startTimestamp: number
+ stopTimestampEnabled: boolean
+ stopTimestamp: number
+ }
+ displayOptions = false
+
+ private disabled = false
+
+ private listenToPlaylistChangeSub: Subscription
+ private playlistsData: CachedPlaylist[] = []
+
+ constructor (
+ protected formValidatorService: FormValidatorService,
+ private authService: AuthService,
+ private notifier: Notifier,
+ private i18n: I18n,
+ private videoPlaylistService: VideoPlaylistService,
+ private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
+ private cd: ChangeDetectorRef
+ ) {
+ super()
+ }
+
+ get user () {
+ return this.authService.getUser()
+ }
+
+ ngOnInit () {
+ this.buildForm({
+ displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME
+ })
+
+ this.videoPlaylistService.listenToMyAccountPlaylistsChange()
+ .subscribe(result => {
+ this.playlistsData = result.data
+
+ this.videoPlaylistService.runPlaylistCheck(this.video.id)
+ })
+
+ this.videoPlaylistSearchChanged
+ .pipe(debounceTime(500))
+ .subscribe(() => this.load())
+
+ if (this.lazyLoad === false) this.load()
+ }
+
+ ngOnChanges (simpleChanges: SimpleChanges) {
+ if (simpleChanges['video']) {
+ this.reload()
+ }
+ }
+
+ ngOnDestroy () {
+ this.unsubscribePlaylistChanges()
+ }
+
+ disableForReuse () {
+ this.disabled = true
+ }
+
+ enabledForReuse () {
+ this.disabled = false
+ }
+
+ reload () {
+ logger('Reloading component')
+
+ this.videoPlaylists = []
+ this.videoPlaylistSearch = undefined
+
+ this.resetOptions(true)
+ this.load()
+
+ this.cd.markForCheck()
+ }
+
+ load () {
+ logger('Loading component')
+
+ this.listenToPlaylistChanges()
+
+ this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch)
+ .subscribe(playlistsResult => {
+ this.playlistsData = playlistsResult.data
+
+ this.videoPlaylistService.runPlaylistCheck(this.video.id)
+ })
+ }
+
+ openChange (opened: boolean) {
+ if (opened === false) {
+ this.isNewPlaylistBlockOpened = false
+ this.displayOptions = false
+ }
+ }
+
+ openCreateBlock (event: Event) {
+ event.preventDefault()
+
+ this.isNewPlaylistBlockOpened = true
+ }
+
+ togglePlaylist (event: Event, playlist: PlaylistSummary) {
+ event.preventDefault()
+
+ if (playlist.inPlaylist === true) {
+ this.removeVideoFromPlaylist(playlist)
+ } else {
+ this.addVideoInPlaylist(playlist)
+ }
+
+ playlist.inPlaylist = !playlist.inPlaylist
+ this.resetOptions()
+
+ this.cd.markForCheck()
+ }
+
+ createPlaylist () {
+ const displayName = this.form.value[ 'displayName' ]
+
+ const videoPlaylistCreate: VideoPlaylistCreate = {
+ displayName,
+ privacy: VideoPlaylistPrivacy.PRIVATE
+ }
+
+ this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
+ () => {
+ this.isNewPlaylistBlockOpened = false
+
+ this.cd.markForCheck()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+ }
+
+ resetOptions (resetTimestamp = false) {
+ this.displayOptions = false
+
+ this.timestampOptions = {} as any
+ this.timestampOptions.startTimestampEnabled = false
+ this.timestampOptions.stopTimestampEnabled = false
+
+ if (resetTimestamp) {
+ this.timestampOptions.startTimestamp = 0
+ this.timestampOptions.stopTimestamp = this.video.duration
+ }
+ }
+
+ formatTimestamp (playlist: PlaylistSummary) {
+ const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
+ const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
+
+ return `(${start}-${stop})`
+ }
+
+ onVideoPlaylistSearchChanged () {
+ this.videoPlaylistSearchChanged.next()
+ }
+
+ private removeVideoFromPlaylist (playlist: PlaylistSummary) {
+ if (!playlist.playlistElementId) return
+
+ this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
+ },
+
+ err => {
+ this.notifier.error(err.message)
+ },
+
+ () => this.cd.markForCheck()
+ )
+ }
+
+ private listenToPlaylistChanges () {
+ this.unsubscribePlaylistChanges()
+
+ this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)
+ .pipe(filter(() => this.disabled === false))
+ .subscribe(existResult => this.rebuildPlaylists(existResult))
+ }
+
+ private unsubscribePlaylistChanges () {
+ if (this.listenToPlaylistChangeSub) {
+ this.listenToPlaylistChangeSub.unsubscribe()
+ this.listenToPlaylistChangeSub = undefined
+ }
+ }
+
+ private rebuildPlaylists (existResult: VideoExistInPlaylist[]) {
+ logger('Got existing results for %d.', this.video.id, existResult)
+
+ this.videoPlaylists = []
+ for (const playlist of this.playlistsData) {
+ const existingPlaylist = existResult.find(p => p.playlistId === playlist.id)
+
+ this.videoPlaylists.push({
+ id: playlist.id,
+ displayName: playlist.displayName,
+ inPlaylist: !!existingPlaylist,
+ playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined,
+ startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
+ stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
+ })
+ }
+
+ logger('Rebuilt playlist state for video %d.', this.video.id, this.videoPlaylists)
+
+ this.cd.markForCheck()
+ }
+
+ private addVideoInPlaylist (playlist: PlaylistSummary) {
+ const body: VideoPlaylistElementCreate = { videoId: this.video.id }
+
+ if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp
+ if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp
+
+ this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
+ .subscribe(
+ () => {
+ const message = body.startTimestamp || body.stopTimestamp
+ ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) })
+ : this.i18n('Video added in {{n}}', { n: playlist.displayName })
+
+ this.notifier.success(message)
+ },
+
+ err => {
+ this.notifier.error(err.message)
+ },
+
+ () => this.cd.markForCheck()
+ )
+ }
+}
--- /dev/null
+<div class="video" [ngClass]="{ playing: playing }">
+ <a [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()">
+ <div class="position">
+ <my-global-icon *ngIf="playing" iconName="play"></my-global-icon>
+ <ng-container *ngIf="!playing">{{ position }}</ng-container>
+ </div>
+
+ <my-video-thumbnail
+ *ngIf="playlistElement.video"
+ [video]="playlistElement.video" [nsfw]="isVideoBlur(playlistElement.video)"
+ [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
+ ></my-video-thumbnail>
+
+ <div class="fake-thumbnail" *ngIf="!playlistElement.video"></div>
+
+ <div class="video-info">
+ <ng-container *ngIf="playlistElement.video">
+ <a tabindex="-1" class="video-info-name"
+ [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
+ [attr.title]="playlistElement.video.name"
+ >{{ playlistElement.video.name }}</a>
+
+ <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', playlistElement.video.byAccount ]">
+ {{ playlistElement.video.byAccount }}
+ </a>
+ <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ playlistElement.video.byAccount }}</span>
+
+ <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(playlistElement) }}</span>
+ </ng-container>
+
+ <span *ngIf="!playlistElement.video" class="video-info-name">
+ <ng-container i18n *ngIf="isUnavailable(playlistElement)">Unavailable</ng-container>
+ <ng-container i18n *ngIf="isPrivate(playlistElement)">Private</ng-container>
+ <ng-container i18n *ngIf="isDeleted(playlistElement)">Deleted</ng-container>
+ </span>
+ </div>
+ </a>
+
+ <my-edit-button *ngIf="owned && touchScreenEditButton" [routerLink]="[ '/my-account', 'video-playlists', playlist.uuid ]"></my-edit-button>
+
+ <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom auto"
+ (openChange)="onDropdownOpenChange()" autoClose="outside"
+ >
+ <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
+
+ <div ngbDropdownMenu>
+ <ng-container *ngIf="playlistElement.video">
+ <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, playlistElement)">
+ <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
+ <ng-container i18n>Edit starts/stops at</ng-container>
+ </div>
+
+ <div class="timestamp-options" *ngIf="displayTimestampOptions">
+ <div>
+ <my-peertube-checkbox
+ inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
+ i18n-labelText labelText="Start at"
+ ></my-peertube-checkbox>
+
+ <my-timestamp-input
+ [timestamp]="timestampOptions.startTimestamp"
+ [maxTimestamp]="playlistElement.video.duration"
+ [disabled]="!timestampOptions.startTimestampEnabled"
+ [(ngModel)]="timestampOptions.startTimestamp"
+ ></my-timestamp-input>
+ </div>
+
+ <div>
+ <my-peertube-checkbox
+ inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
+ i18n-labelText labelText="Stop at"
+ ></my-peertube-checkbox>
+
+ <my-timestamp-input
+ [timestamp]="timestampOptions.stopTimestamp"
+ [maxTimestamp]="playlistElement.video.duration"
+ [disabled]="!timestampOptions.stopTimestampEnabled"
+ [(ngModel)]="timestampOptions.stopTimestamp"
+ ></my-timestamp-input>
+ </div>
+
+ <input type="submit" i18n-value value="Save" (click)="updateTimestamps(playlistElement)">
+ </div>
+ </ng-container>
+
+ <span class="dropdown-item" (click)="removeFromPlaylist(playlistElement)">
+ <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
+ <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container>
+ </span>
+ </div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+$thumbnail-width: 130px;
+$thumbnail-height: 72px;
+
+my-video-thumbnail {
+ @include thumbnail-size-component($thumbnail-width, $thumbnail-height);
+}
+
+.fake-thumbnail {
+ width: $thumbnail-width;
+ height: $thumbnail-height;
+ background-color: #ececec;
+}
+
+my-video-thumbnail,
+.fake-thumbnail {
+ display: flex; // Avoids an issue with line-height that adds space below the element
+ margin-right: 10px;
+}
+
+.video {
+ display: flex;
+ align-items: center;
+ background-color: pvar(--mainBackgroundColor);
+ padding: 10px;
+ border-bottom: 1px solid $separator-border-color;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+
+ .more {
+ opacity: 1;
+ }
+ }
+
+ @media not all and (hover: hover) and (pointer: fine) {
+ .more {
+ opacity: 1 !important;
+ }
+ }
+
+ &.playing {
+ background-color: rgba(0, 0, 0, 0.02);
+ }
+
+ a {
+ @include disable-default-a-behaviour;
+
+ color: pvar(--mainForegroundColor);
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ cursor: pointer;
+
+ .position {
+ font-weight: $font-semibold;
+ margin-right: 10px;
+ color: pvar(--greyForegroundColor);
+ min-width: 25px;
+
+ my-global-icon {
+ @include apply-svg-color(pvar(--greyForegroundColor));
+
+ width: 17px;
+ position: relative;
+ left: -2px;
+ }
+ }
+
+ .video-info {
+ display: flex;
+ flex-direction: column;
+ align-self: flex-start;
+ min-width: 0;
+
+ a {
+ width: auto;
+ }
+
+ .video-info-account, .video-info-timestamp {
+ color: pvar(--greyForegroundColor);
+ }
+ }
+ }
+
+ .video-info-name {
+ font-size: 18px;
+ font-weight: $font-semibold;
+ display: inline-block;
+
+ @include ellipsis;
+ }
+
+ .more, my-edit-button {
+ justify-self: flex-end;
+ margin-left: auto;
+ cursor: pointer;
+ min-width: 24px;
+ }
+
+ .more {
+ opacity: 0;
+
+ &.show {
+ opacity: 1;
+ }
+
+ .icon-more {
+ @include apply-svg-color(pvar(--greyForegroundColor));
+
+ display: flex;
+
+ &::after {
+ border: none;
+ }
+ }
+
+ .dropdown-item {
+ @include dropdown-with-icon-item;
+ }
+
+ .timestamp-options {
+ padding-top: 0;
+ padding-left: 35px;
+ margin-bottom: 15px;
+
+ > div {
+ display: flex;
+ align-items: center;
+ }
+
+ input {
+ @include peertube-button;
+ @include orange-button;
+
+ margin-top: 10px;
+ }
+ }
+ }
+}
+
+@mixin more-dropdown-control {
+ .video {
+ my-edit-button {
+ display: none;
+
+ + .more {
+ display: inline-flex;
+ }
+ }
+ }
+}
+
+@mixin edit-button-control {
+ .video {
+ my-edit-button {
+ display: none;
+ }
+
+ &.playing {
+ my-edit-button {
+ display: inline-flex;
+ height: max-content;
+ }
+ }
+
+ my-edit-button + .more {
+ display: none;
+ }
+ }
+}
+
+@mixin edit-button-in-mobile-view {
+ .video {
+ my-edit-button {
+ ::ng-deep .action-button-edit {
+ padding: 0 13px;
+
+ .button-label {
+ display: none;
+ }
+ }
+ }
+ }
+}
+
+@media screen and (min-width: $small-view) {
+ :host-context(.expanded) {
+ @include more-dropdown-control();
+ }
+}
+
+@media screen and (max-width: $small-view) {
+ :host-context(.expanded) {
+ @include edit-button-control();
+ }
+}
+
+@media screen and (max-width: $mobile-view) {
+ :host-context(.expanded) {
+ @include edit-button-in-mobile-view();
+ }
+}
+
+@media screen and (min-width: #{$small-view + $menu-width}) {
+ :host-context(.main-col:not(.expanded)) {
+ @include more-dropdown-control();
+ }
+}
+
+@media screen and (max-width: #{$small-view + $menu-width}) {
+ :host-context(.main-col:not(.expanded)) {
+ @include edit-button-control();
+ }
+}
+
+@media screen and (max-width: #{$mobile-view + $menu-width}) {
+ :host-context(.main-col:not(.expanded)) {
+ @include edit-button-in-mobile-view();
+ }
+}
--- /dev/null
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
+import { AuthService, Notifier, ServerService } from '@app/core'
+import { Video } from '@app/shared/shared-main'
+import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { ServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models'
+import { secondsToTime } from '../../../assets/player/utils'
+import { VideoPlaylistElement } from './video-playlist-element.model'
+import { VideoPlaylist } from './video-playlist.model'
+import { VideoPlaylistService } from './video-playlist.service'
+
+@Component({
+ selector: 'my-video-playlist-element-miniature',
+ styleUrls: [ './video-playlist-element-miniature.component.scss' ],
+ templateUrl: './video-playlist-element-miniature.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class VideoPlaylistElementMiniatureComponent implements OnInit {
+ @ViewChild('moreDropdown') moreDropdown: NgbDropdown
+
+ @Input() playlist: VideoPlaylist
+ @Input() playlistElement: VideoPlaylistElement
+ @Input() owned = false
+ @Input() playing = false
+ @Input() rowLink = false
+ @Input() accountLink = true
+ @Input() position: number // Keep this property because we're in the OnPush change detection strategy
+ @Input() touchScreenEditButton = false
+
+ @Output() elementRemoved = new EventEmitter<VideoPlaylistElement>()
+
+ displayTimestampOptions = false
+
+ timestampOptions: {
+ startTimestampEnabled: boolean
+ startTimestamp: number
+ stopTimestampEnabled: boolean
+ stopTimestamp: number
+ } = {} as any
+
+ private serverConfig: ServerConfig
+
+ constructor (
+ private authService: AuthService,
+ private serverService: ServerService,
+ private notifier: Notifier,
+ private i18n: I18n,
+ private videoPlaylistService: VideoPlaylistService,
+ private cdr: ChangeDetectorRef
+ ) {}
+
+ ngOnInit (): void {
+ this.serverConfig = this.serverService.getTmpConfig()
+ this.serverService.getConfig()
+ .subscribe(config => {
+ this.serverConfig = config
+ this.cdr.detectChanges()
+ })
+ }
+
+ isUnavailable (e: VideoPlaylistElement) {
+ return e.type === VideoPlaylistElementType.UNAVAILABLE
+ }
+
+ isPrivate (e: VideoPlaylistElement) {
+ return e.type === VideoPlaylistElementType.PRIVATE
+ }
+
+ isDeleted (e: VideoPlaylistElement) {
+ return e.type === VideoPlaylistElementType.DELETED
+ }
+
+ buildRouterLink () {
+ if (!this.playlist) return null
+
+ return [ '/videos/watch/playlist', this.playlist.uuid ]
+ }
+
+ buildRouterQuery () {
+ if (!this.playlistElement || !this.playlistElement.video) return {}
+
+ return {
+ videoId: this.playlistElement.video.uuid,
+ start: this.playlistElement.startTimestamp,
+ stop: this.playlistElement.stopTimestamp,
+ resume: true
+ }
+ }
+
+ isVideoBlur (video: Video) {
+ return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig)
+ }
+
+ removeFromPlaylist (playlistElement: VideoPlaylistElement) {
+ const videoId = this.playlistElement.video ? this.playlistElement.video.id : undefined
+
+ this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id, videoId)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
+
+ this.elementRemoved.emit(playlistElement)
+ },
+
+ err => this.notifier.error(err.message)
+ )
+
+ this.moreDropdown.close()
+ }
+
+ updateTimestamps (playlistElement: VideoPlaylistElement) {
+ const body: VideoPlaylistElementUpdate = {}
+
+ body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
+ body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
+
+ this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body, this.playlistElement.video.id)
+ .subscribe(
+ () => {
+ this.notifier.success(this.i18n('Timestamps updated'))
+
+ playlistElement.startTimestamp = body.startTimestamp
+ playlistElement.stopTimestamp = body.stopTimestamp
+
+ this.cdr.detectChanges()
+ },
+
+ err => this.notifier.error(err.message)
+ )
+
+ this.moreDropdown.close()
+ }
+
+ formatTimestamp (playlistElement: VideoPlaylistElement) {
+ const start = playlistElement.startTimestamp
+ const stop = playlistElement.stopTimestamp
+
+ const startFormatted = secondsToTime(start, true, ':')
+ const stopFormatted = secondsToTime(stop, true, ':')
+
+ if (start === null && stop === null) return ''
+
+ if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
+ if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
+
+ return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
+ }
+
+ onDropdownOpenChange () {
+ this.displayTimestampOptions = false
+ }
+
+ toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) {
+ event.preventDefault()
+
+ this.displayTimestampOptions = !this.displayTimestampOptions
+
+ if (this.displayTimestampOptions === true) {
+ this.timestampOptions = {
+ startTimestampEnabled: false,
+ stopTimestampEnabled: false,
+ startTimestamp: 0,
+ stopTimestamp: playlistElement.video.duration
+ }
+
+ if (playlistElement.startTimestamp) {
+ this.timestampOptions.startTimestampEnabled = true
+ this.timestampOptions.startTimestamp = playlistElement.startTimestamp
+ }
+
+ if (playlistElement.stopTimestamp) {
+ this.timestampOptions.stopTimestampEnabled = true
+ this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp
+ }
+ }
+
+ // FIXME: why do we have to use setTimeout here?
+ setTimeout(() => {
+ this.cdr.detectChanges()
+ })
+ }
+}
--- /dev/null
+import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos'
+import { Video } from '@app/shared/shared-main'
+
+export class VideoPlaylistElement implements ServerVideoPlaylistElement {
+ id: number
+ position: number
+ startTimestamp: number
+ stopTimestamp: number
+
+ type: VideoPlaylistElementType
+
+ video?: Video
+
+ constructor (hash: ServerVideoPlaylistElement, translations: {}) {
+ this.id = hash.id
+ this.position = hash.position
+ this.startTimestamp = hash.startTimestamp
+ this.stopTimestamp = hash.stopTimestamp
+
+ this.type = hash.type
+
+ if (hash.video) this.video = new Video(hash.video, translations)
+ }
+}
--- /dev/null
+<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
+ <a
+ [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"
+ class="miniature-thumbnail"
+ >
+ <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
+
+ <div class="miniature-playlist-info-overlay">
+ <ng-container i18n>{playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist.videosLength }} videos}}</ng-container>
+ </div>
+
+ <div class="play-overlay">
+ <div class="icon"></div>
+ </div>
+ </a>
+
+ <div class="miniature-info">
+ <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description">
+ {{ playlist.displayName }}
+ </a>
+
+ <a i18n [routerLink]="[ '/video-channels', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
+ {{ playlist.videoChannelBy }}
+ </a>
+
+ <div class="privacy-date">
+ <span class="video-info-privacy" *ngIf="displayPrivacy">{{ playlist.privacy.label }}</span>
+
+ <span i18n class="updated-at">Updated {{ playlist.updatedAt | myFromNow }}</span>
+ </div>
+
+ <div *ngIf="displayDescription" class="video-info-description">{{ playlist.description }}</div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+.miniature {
+ display: inline-block;
+
+ &.no-videos:not(.to-manage){
+ a {
+ cursor: default !important;
+ }
+ }
+
+ &.to-manage,
+ &.no-videos {
+ .play-overlay {
+ display: none;
+ }
+ }
+
+ .miniature-thumbnail {
+ @include miniature-thumbnail;
+
+ .miniature-playlist-info-overlay {
+ @include static-thumbnail-overlay;
+
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ height: $video-thumbnail-height;
+ padding: 0 10px;
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ font-weight: $font-semibold;
+ }
+ }
+
+ .miniature-info {
+ width: 200px;
+ margin-top: 2px;
+ line-height: normal;
+
+ .miniature-name {
+ @include miniature-name;
+
+ @include ellipsis-multiline(1.3em, 2);
+
+ margin: 0;
+ }
+
+ .by {
+ @include disable-default-a-behaviour;
+
+ display: block;
+ color: pvar(--greyForegroundColor);
+ }
+
+ .privacy-date {
+ margin-top: 5px;
+
+ .video-info-privacy {
+ font-size: 14px;
+ font-weight: $font-semibold;
+
+ &::after {
+ content: '-';
+ margin: 0 3px;
+ }
+ }
+ }
+
+ .video-info-description {
+ margin-top: 10px;
+ color: pvar(--greyForegroundColor);
+ }
+ }
+}
--- /dev/null
+import { Component, Input } from '@angular/core'
+import { VideoPlaylist } from './video-playlist.model'
+
+@Component({
+ selector: 'my-video-playlist-miniature',
+ styleUrls: [ './video-playlist-miniature.component.scss' ],
+ templateUrl: './video-playlist-miniature.component.html'
+})
+export class VideoPlaylistMiniatureComponent {
+ @Input() playlist: VideoPlaylist
+ @Input() toManage = false
+ @Input() displayChannel = false
+ @Input() displayDescription = false
+ @Input() displayPrivacy = false
+
+ getPlaylistUrl () {
+ if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
+ if (this.playlist.videosLength === 0) return null
+
+ return [ '/videos/watch/playlist', this.playlist.uuid ]
+ }
+}
--- /dev/null
+import { getAbsoluteAPIUrl } from '@app/helpers'
+import { Actor } from '@app/shared/shared-main'
+import {
+ AccountSummary,
+ peertubeTranslate,
+ VideoChannelSummary,
+ VideoConstant,
+ VideoPlaylist as ServerVideoPlaylist,
+ VideoPlaylistPrivacy,
+ VideoPlaylistType
+} from '@shared/models'
+
+export class VideoPlaylist implements ServerVideoPlaylist {
+ id: number
+ uuid: string
+ isLocal: boolean
+
+ displayName: string
+ description: string
+ privacy: VideoConstant<VideoPlaylistPrivacy>
+
+ thumbnailPath: string
+
+ videosLength: number
+
+ type: VideoConstant<VideoPlaylistType>
+
+ createdAt: Date | string
+ updatedAt: Date | string
+
+ ownerAccount: AccountSummary
+ videoChannel?: VideoChannelSummary
+
+ thumbnailUrl: string
+
+ ownerBy: string
+ ownerAvatarUrl: string
+
+ videoChannelBy?: string
+ videoChannelAvatarUrl?: string
+
+ private thumbnailVersion: number
+ private originThumbnailUrl: string
+
+ constructor (hash: ServerVideoPlaylist, translations: {}) {
+ const absoluteAPIUrl = getAbsoluteAPIUrl()
+
+ this.id = hash.id
+ this.uuid = hash.uuid
+ this.isLocal = hash.isLocal
+
+ this.displayName = hash.displayName
+
+ this.description = hash.description
+ this.privacy = hash.privacy
+
+ this.thumbnailPath = hash.thumbnailPath
+
+ if (this.thumbnailPath) {
+ this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
+ this.originThumbnailUrl = this.thumbnailUrl
+ } else {
+ this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
+ }
+
+ this.videosLength = hash.videosLength
+
+ this.type = hash.type
+
+ this.createdAt = new Date(hash.createdAt)
+ this.updatedAt = new Date(hash.updatedAt)
+
+ this.ownerAccount = hash.ownerAccount
+ this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
+ this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
+
+ if (hash.videoChannel) {
+ this.videoChannel = hash.videoChannel
+ this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
+ this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel)
+ }
+
+ this.privacy.label = peertubeTranslate(this.privacy.label, translations)
+
+ if (this.type.id === VideoPlaylistType.WATCH_LATER) {
+ this.displayName = peertubeTranslate(this.displayName, translations)
+ }
+ }
+
+ refreshThumbnail () {
+ if (!this.originThumbnailUrl) return
+
+ if (!this.thumbnailVersion) this.thumbnailVersion = 0
+ this.thumbnailVersion++
+
+ this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
+ }
+}
--- /dev/null
+import * as debug from 'debug'
+import { uniq } from 'lodash-es'
+import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
+import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable, NgZone } from '@angular/core'
+import { AuthUser, ComponentPaginationLight, RestExtractor, RestService, ServerService } from '@app/core'
+import { enterZone, leaveZone, objectToFormData } from '@app/helpers'
+import { Account, AccountService, VideoChannel, VideoChannelService } from '@app/shared/shared-main'
+import {
+ ResultList,
+ VideoExistInPlaylist,
+ VideoPlaylist as VideoPlaylistServerModel,
+ VideoPlaylistCreate,
+ VideoPlaylistElement as ServerVideoPlaylistElement,
+ VideoPlaylistElementCreate,
+ VideoPlaylistElementUpdate,
+ VideoPlaylistReorder,
+ VideoPlaylistUpdate,
+ VideosExistInPlaylists
+} from '@shared/models'
+import { environment } from '../../../environments/environment'
+import { VideoPlaylistElement } from './video-playlist-element.model'
+import { VideoPlaylist } from './video-playlist.model'
+
+const logger = debug('peertube:playlists:VideoPlaylistService')
+
+export type CachedPlaylist = VideoPlaylist | { id: number, displayName: string }
+
+@Injectable()
+export class VideoPlaylistService {
+ static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
+ static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
+
+ // Use a replay subject because we "next" a value before subscribing
+ private videoExistsInPlaylistNotifier = new ReplaySubject<number>(1)
+ private videoExistsInPlaylistCacheSubject = new Subject<VideosExistInPlaylists>()
+ private readonly videoExistsInPlaylistObservable: Observable<VideosExistInPlaylists>
+
+ private videoExistsObservableCache: { [ id: number ]: Observable<VideoExistInPlaylist[]> } = {}
+ private videoExistsCache: { [ id: number ]: VideoExistInPlaylist[] } = {}
+
+ private myAccountPlaylistCache: ResultList<CachedPlaylist> = undefined
+ private myAccountPlaylistCacheRunning: Observable<ResultList<CachedPlaylist>>
+ private myAccountPlaylistCacheSubject = new Subject<ResultList<CachedPlaylist>>()
+
+ constructor (
+ private authHttp: HttpClient,
+ private serverService: ServerService,
+ private restExtractor: RestExtractor,
+ private restService: RestService,
+ private ngZone: NgZone
+ ) {
+ this.videoExistsInPlaylistObservable = merge(
+ this.videoExistsInPlaylistNotifier.pipe(
+ // We leave Angular zone so Protractor does not get stuck
+ bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
+ filter(videoIds => videoIds.length !== 0),
+ map(videoIds => uniq(videoIds)),
+ observeOn(enterZone(this.ngZone, asyncScheduler)),
+ switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
+ share()
+ ),
+
+ this.videoExistsInPlaylistCacheSubject
+ )
+ }
+
+ listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
+ const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
+ const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination)
+
+ return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
+ .pipe(
+ switchMap(res => this.extractPlaylists(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ listMyPlaylistWithCache (user: AuthUser, search?: string) {
+ if (!search) {
+ if (this.myAccountPlaylistCacheRunning) return this.myAccountPlaylistCacheRunning
+ if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache)
+ }
+
+ const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search)
+ .pipe(
+ tap(result => {
+ if (!search) {
+ this.myAccountPlaylistCacheRunning = undefined
+ this.myAccountPlaylistCache = result
+ }
+ }),
+ share()
+ )
+
+ if (!search) this.myAccountPlaylistCacheRunning = obs
+ return obs
+ }
+
+ listAccountPlaylists (
+ account: Account,
+ componentPagination: ComponentPaginationLight,
+ sort: string,
+ search?: string
+ ): Observable<ResultList<VideoPlaylist>> {
+ const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
+ const pagination = componentPagination
+ ? this.restService.componentPaginationToRestPagination(componentPagination)
+ : undefined
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+ if (search) params = this.restService.addObjectParams(params, { search })
+
+ return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
+ .pipe(
+ switchMap(res => this.extractPlaylists(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ getVideoPlaylist (id: string | number) {
+ const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
+
+ return this.authHttp.get<VideoPlaylist>(url)
+ .pipe(
+ switchMap(res => this.extractPlaylist(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ createVideoPlaylist (body: VideoPlaylistCreate) {
+ const data = objectToFormData(body)
+
+ return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
+ .pipe(
+ tap(res => {
+ if (!this.myAccountPlaylistCache) return
+
+ this.myAccountPlaylistCache.total++
+
+ this.myAccountPlaylistCache.data.push({
+ id: res.videoPlaylist.id,
+ displayName: body.displayName
+ })
+
+ this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
+ }),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) {
+ const data = objectToFormData(body)
+
+ return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => {
+ if (!this.myAccountPlaylistCache) return
+
+ const playlist = this.myAccountPlaylistCache.data.find(p => p.id === videoPlaylist.id)
+ playlist.displayName = body.displayName
+
+ this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
+ }),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ removeVideoPlaylist (videoPlaylist: VideoPlaylist) {
+ return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => {
+ if (!this.myAccountPlaylistCache) return
+
+ this.myAccountPlaylistCache.total--
+ this.myAccountPlaylistCache.data = this.myAccountPlaylistCache.data
+ .filter(p => p.id !== videoPlaylist.id)
+
+ this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
+ }),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
+ const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos'
+
+ return this.authHttp.post<{ videoPlaylistElement: { id: number } }>(url, body)
+ .pipe(
+ tap(res => {
+ const existsResult = this.videoExistsCache[body.videoId]
+ existsResult.push({
+ playlistId,
+ playlistElementId: res.videoPlaylistElement.id,
+ startTimestamp: body.startTimestamp,
+ stopTimestamp: body.stopTimestamp
+ })
+
+ this.runPlaylistCheck(body.videoId)
+ }),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate, videoId: number) {
+ return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => {
+ const existsResult = this.videoExistsCache[videoId]
+ const elem = existsResult.find(e => e.playlistElementId === playlistElementId)
+
+ elem.startTimestamp = body.startTimestamp
+ elem.stopTimestamp = body.stopTimestamp
+
+ this.runPlaylistCheck(videoId)
+ }),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ removeVideoFromPlaylist (playlistId: number, playlistElementId: number, videoId?: number) {
+ return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ tap(() => {
+ if (!videoId) return
+
+ this.videoExistsCache[videoId] = this.videoExistsCache[videoId].filter(e => e.playlistElementId !== playlistElementId)
+ this.runPlaylistCheck(videoId)
+ }),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) {
+ const body: VideoPlaylistReorder = {
+ startPosition: oldPosition,
+ insertAfterPosition: newPosition
+ }
+
+ return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body)
+ .pipe(
+ map(this.restExtractor.extractDataBool),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ getPlaylistVideos (
+ videoPlaylistId: number | string,
+ componentPagination: ComponentPaginationLight
+ ): Observable<ResultList<VideoPlaylistElement>> {
+ const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos'
+ const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination)
+
+ return this.authHttp
+ .get<ResultList<ServerVideoPlaylistElement>>(path, { params })
+ .pipe(
+ switchMap(res => this.extractVideoPlaylistElements(res)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ listenToMyAccountPlaylistsChange () {
+ return this.myAccountPlaylistCacheSubject.asObservable()
+ }
+
+ listenToVideoPlaylistChange (videoId: number) {
+ if (this.videoExistsObservableCache[ videoId ]) {
+ return this.videoExistsObservableCache[ videoId ]
+ }
+
+ const obs = this.videoExistsInPlaylistObservable
+ .pipe(
+ map(existsResult => existsResult[ videoId ]),
+ filter(r => !!r),
+ tap(result => this.videoExistsCache[ videoId ] = result)
+ )
+
+ this.videoExistsObservableCache[ videoId ] = obs
+ return obs
+ }
+
+ runPlaylistCheck (videoId: number) {
+ logger('Running playlist check.')
+
+ if (this.videoExistsCache[videoId]) {
+ logger('Found cache for %d.', videoId)
+
+ return this.videoExistsInPlaylistCacheSubject.next({ [videoId]: this.videoExistsCache[videoId] })
+ }
+
+ logger('Fetching from network for %d.', videoId)
+ return this.videoExistsInPlaylistNotifier.next(videoId)
+ }
+
+ extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
+ return this.serverService.getServerLocale()
+ .pipe(
+ map(translations => {
+ const playlistsJSON = result.data
+ const total = result.total
+ const playlists: VideoPlaylist[] = []
+
+ for (const playlistJSON of playlistsJSON) {
+ playlists.push(new VideoPlaylist(playlistJSON, translations))
+ }
+
+ return { data: playlists, total }
+ })
+ )
+ }
+
+ extractPlaylist (playlist: VideoPlaylistServerModel) {
+ return this.serverService.getServerLocale()
+ .pipe(map(translations => new VideoPlaylist(playlist, translations)))
+ }
+
+ extractVideoPlaylistElements (result: ResultList<ServerVideoPlaylistElement>) {
+ return this.serverService.getServerLocale()
+ .pipe(
+ map(translations => {
+ const elementsJson = result.data
+ const total = result.total
+ const elements: VideoPlaylistElement[] = []
+
+ for (const elementJson of elementsJson) {
+ elements.push(new VideoPlaylistElement(elementJson, translations))
+ }
+
+ return { total, data: elements }
+ })
+ )
+ }
+
+ private doVideosExistInPlaylist (videoIds: number[]): Observable<VideosExistInPlaylists> {
+ const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
+
+ let params = new HttpParams()
+ params = this.restService.addObjectParams(params, { videoIds })
+
+ return this.authHttp.get<VideoExistInPlaylist>(url, { params, headers: { ignoreLoadingBar: '' } })
+ .pipe(catchError(err => this.restExtractor.handleError(err)))
+ }
+}
+++ /dev/null
-import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
-import { SharedModule as PrimeSharedModule } from 'primeng/api'
-import { InputMaskModule } from 'primeng/inputmask'
-import { InputSwitchModule } from 'primeng/inputswitch'
-import { MultiSelectModule } from 'primeng/multiselect'
-import { ClipboardModule } from '@angular/cdk/clipboard'
-import { CommonModule } from '@angular/common'
-import { HttpClientModule } from '@angular/common/http'
-import { NgModule } from '@angular/core'
-import { FormsModule, ReactiveFormsModule } from '@angular/forms'
-import { RouterModule } from '@angular/router'
-import { BatchDomainsValidatorsService } from '@app/+admin/config/shared/batch-domains-validators.service'
-import { BatchDomainsModalComponent } from '@app/+admin/config/shared/batch-domains-modal.component'
-import { MyAccountInterfaceSettingsComponent } from '@app/+my-account/my-account-settings/my-account-interface'
-import { MyAccountVideoSettingsComponent } from '@app/+my-account/my-account-settings/my-account-video-settings'
-import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
-import { AccountService } from '@app/shared/account/account.service'
-import { FromNowPipe } from '@app/shared/angular/from-now.pipe'
-import { HighlightPipe } from '@app/shared/angular/highlight.pipe'
-import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
-import { ObjectLengthPipe } from '@app/shared/angular/object-length.pipe'
-import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
-import { VideoDurationPipe } from '@app/shared/angular/video-duration-formatter.pipe'
-import { BlocklistService } from '@app/shared/blocklist'
-import { ActionDropdownComponent } from '@app/shared/buttons/action-dropdown.component'
-import { AvatarComponent } from '@app/shared/channel/avatar.component'
-import { ConfirmComponent } from '@app/shared/confirm/confirm.component'
-import { DateToggleComponent } from '@app/shared/date/date-toggle.component'
-import {
- CustomConfigValidatorsService,
- InstanceValidatorsService,
- LoginValidatorsService,
- ReactiveFileComponent,
- ResetPasswordValidatorsService,
- TextareaAutoResizeDirective,
- UserValidatorsService,
- VideoAbuseValidatorsService,
- VideoAcceptOwnershipValidatorsService,
- VideoBlockValidatorsService,
- VideoChangeOwnershipValidatorsService,
- VideoChannelValidatorsService,
- VideoCommentValidatorsService,
- VideoPlaylistValidatorsService,
- VideoValidatorsService
-} from '@app/shared/forms'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
-import { InputReadonlyCopyComponent } from '@app/shared/forms/input-readonly-copy.component'
-import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component'
-import { PeertubeCheckboxComponent } from '@app/shared/forms/peertube-checkbox.component'
-import { TimestampInputComponent } from '@app/shared/forms/timestamp-input.component'
-import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
-import { GlobalIconComponent } from '@app/shared/images/global-icon.component'
-import { PreviewUploadComponent } from '@app/shared/images/preview-upload.component'
-import { FeatureBooleanComponent } from '@app/shared/instance/feature-boolean.component'
-import { FollowService } from '@app/shared/instance/follow.service'
-import { InstanceFeaturesTableComponent } from '@app/shared/instance/instance-features-table.component'
-import { InstanceStatisticsComponent } from '@app/shared/instance/instance-statistics.component'
-import { InstanceService } from '@app/shared/instance/instance.service'
-import { TopMenuDropdownComponent } from '@app/shared/menu/top-menu-dropdown.component'
-import { HelpComponent } from '@app/shared/misc/help.component'
-import { ListOverflowComponent } from '@app/shared/misc/list-overflow.component'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { SmallLoaderComponent } from '@app/shared/misc/small-loader.component'
-import { LocalStorageService, SessionStorageService } from '@app/shared/misc/storage.service'
-import { UserBanModalComponent } from '@app/shared/moderation'
-import { UserModerationDropdownComponent } from '@app/shared/moderation/user-moderation-dropdown.component'
-import { OverviewService } from '@app/shared/overview'
-import { HtmlRendererService, LinkifierService, MarkdownService } from '@app/shared/renderer'
-import { RemoteSubscribeComponent, SubscribeButtonComponent, UserSubscriptionService } from '@app/shared/user-subscription'
-import { UserHistoryService } from '@app/shared/users/user-history.service'
-import { UserNotificationService } from '@app/shared/users/user-notification.service'
-import { UserNotificationsComponent } from '@app/shared/users/user-notifications.component'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoImportService } from '@app/shared/video-import/video-import.service'
-import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
-import { VideoPlaylistElementMiniatureComponent } from '@app/shared/video-playlist/video-playlist-element-miniature.component'
-import { VideoPlaylistMiniatureComponent } from '@app/shared/video-playlist/video-playlist-miniature.component'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
-import { VideoBlockComponent } from '@app/shared/video/modals/video-block.component'
-import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
-import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
-import { RedundancyService } from '@app/shared/video/redundancy.service'
-import { VideoActionsDropdownComponent } from '@app/shared/video/video-actions-dropdown.component'
-import { VideosSelectionComponent } from '@app/shared/video/videos-selection.component'
-import {
- NgbCollapseModule,
- NgbDropdownModule,
- NgbModalModule,
- NgbNavModule,
- NgbPopoverModule,
- NgbTooltipModule
-} from '@ng-bootstrap/ng-bootstrap'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
-import { BulkService } from './bulk/bulk.service'
-import { ButtonComponent } from './buttons/button.component'
-import { DeleteButtonComponent } from './buttons/delete-button.component'
-import { EditButtonComponent } from './buttons/edit-button.component'
-import { LoaderComponent } from './misc/loader.component'
-import { RestExtractor, RestService } from './rest'
-import { UserService } from './users'
-import { VideoAbuseService } from './video-abuse'
-import { VideoBlockService } from './video-block'
-import { VideoOwnershipService } from './video-ownership'
-import { FeedComponent } from './video/feed.component'
-import { VideoMiniatureComponent } from './video/video-miniature.component'
-import { VideoThumbnailComponent } from './video/video-thumbnail.component'
-import { VideoService } from './video/video.service'
-
-@NgModule({
- imports: [
- CommonModule,
- FormsModule,
- ReactiveFormsModule,
- RouterModule,
- HttpClientModule,
-
- NgbDropdownModule,
- NgbModalModule,
- NgbPopoverModule,
- NgbNavModule,
- NgbTooltipModule,
- NgbCollapseModule,
-
- ClipboardModule,
-
- PrimeSharedModule,
- InputMaskModule,
- NgPipesModule,
- MultiSelectModule,
- InputSwitchModule
- ],
-
- declarations: [
- LoaderComponent,
- SmallLoaderComponent,
-
- VideoThumbnailComponent,
- VideoMiniatureComponent,
- VideoPlaylistMiniatureComponent,
- VideoAddToPlaylistComponent,
- VideoPlaylistElementMiniatureComponent,
- VideosSelectionComponent,
- VideoActionsDropdownComponent,
-
- VideoDownloadComponent,
- VideoReportComponent,
- VideoBlockComponent,
-
- FeedComponent,
-
- ButtonComponent,
- DeleteButtonComponent,
- EditButtonComponent,
-
- NumberFormatterPipe,
- ObjectLengthPipe,
- FromNowPipe,
- HighlightPipe,
- PeerTubeTemplateDirective,
- VideoDurationPipe,
-
- ActionDropdownComponent,
- MarkdownTextareaComponent,
- InfiniteScrollerDirective,
- TextareaAutoResizeDirective,
- HelpComponent,
- ListOverflowComponent,
-
- ReactiveFileComponent,
- PeertubeCheckboxComponent,
- TimestampInputComponent,
- InputReadonlyCopyComponent,
-
- AvatarComponent,
- SubscribeButtonComponent,
- RemoteSubscribeComponent,
- InstanceFeaturesTableComponent,
- InstanceStatisticsComponent,
- FeatureBooleanComponent,
- UserBanModalComponent,
- UserModerationDropdownComponent,
- TopMenuDropdownComponent,
- UserNotificationsComponent,
- ConfirmComponent,
- DateToggleComponent,
-
- GlobalIconComponent,
- PreviewUploadComponent,
-
- MyAccountVideoSettingsComponent,
- MyAccountInterfaceSettingsComponent,
- ActorAvatarInfoComponent,
- BatchDomainsModalComponent
- ],
-
- exports: [
- CommonModule,
- FormsModule,
- ReactiveFormsModule,
- RouterModule,
- HttpClientModule,
-
- NgbDropdownModule,
- NgbModalModule,
- NgbPopoverModule,
- NgbNavModule,
- NgbTooltipModule,
- NgbCollapseModule,
-
- ClipboardModule,
-
- PrimeSharedModule,
- InputMaskModule,
- BytesPipe,
- KeysPipe,
- MultiSelectModule,
-
- LoaderComponent,
- SmallLoaderComponent,
-
- VideoThumbnailComponent,
- VideoMiniatureComponent,
- VideoPlaylistMiniatureComponent,
- VideoAddToPlaylistComponent,
- VideoPlaylistElementMiniatureComponent,
- VideosSelectionComponent,
- VideoActionsDropdownComponent,
-
- VideoDownloadComponent,
- VideoReportComponent,
- VideoBlockComponent,
-
- FeedComponent,
-
- ButtonComponent,
- DeleteButtonComponent,
- EditButtonComponent,
-
- ActionDropdownComponent,
- MarkdownTextareaComponent,
- InfiniteScrollerDirective,
- TextareaAutoResizeDirective,
- HelpComponent,
- ListOverflowComponent,
- InputReadonlyCopyComponent,
-
- ReactiveFileComponent,
- PeertubeCheckboxComponent,
- TimestampInputComponent,
-
- AvatarComponent,
- SubscribeButtonComponent,
- RemoteSubscribeComponent,
- InstanceFeaturesTableComponent,
- InstanceStatisticsComponent,
- UserBanModalComponent,
- UserModerationDropdownComponent,
- TopMenuDropdownComponent,
- UserNotificationsComponent,
- ConfirmComponent,
- DateToggleComponent,
-
- GlobalIconComponent,
- PreviewUploadComponent,
-
- NumberFormatterPipe,
- ObjectLengthPipe,
- FromNowPipe,
- HighlightPipe,
- PeerTubeTemplateDirective,
- VideoDurationPipe,
-
- MyAccountVideoSettingsComponent,
- MyAccountInterfaceSettingsComponent,
- ActorAvatarInfoComponent,
- BatchDomainsModalComponent
- ],
-
- providers: [
- AUTH_INTERCEPTOR_PROVIDER,
- RestExtractor,
- RestService,
- VideoAbuseService,
- VideoBlockService,
- VideoOwnershipService,
- UserService,
- VideoService,
- AccountService,
- VideoChannelService,
- VideoPlaylistService,
- VideoCaptionService,
- VideoImportService,
- UserSubscriptionService,
-
- FormValidatorService,
- CustomConfigValidatorsService,
- LoginValidatorsService,
- ResetPasswordValidatorsService,
- UserValidatorsService,
- BatchDomainsValidatorsService,
- VideoPlaylistValidatorsService,
- VideoAbuseValidatorsService,
- VideoChannelValidatorsService,
- VideoCommentValidatorsService,
- VideoValidatorsService,
- VideoCaptionsValidatorsService,
- VideoBlockValidatorsService,
- OverviewService,
- VideoChangeOwnershipValidatorsService,
- VideoAcceptOwnershipValidatorsService,
- InstanceValidatorsService,
- BlocklistService,
- UserHistoryService,
- InstanceService,
- BulkService,
-
- MarkdownService,
- LinkifierService,
- HtmlRendererService,
-
- I18nPrimengCalendarService,
- ScreenService,
- LocalStorageService, SessionStorageService,
-
- UserNotificationService,
-
- FollowService,
- RedundancyService,
-
- I18n
- ]
-})
-export class SharedModule { }
+++ /dev/null
-export * from './user-subscription.service'
-export * from './subscribe-button.component'
-export * from './remote-subscribe.component'
+++ /dev/null
-<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
- <div class="form-group mb-2">
- <input type="email"
- formControlName="text"
- class="form-control"
- (keyup.control.enter)="onValidKey()" (keyup.meta.enter)="onValidKey()"
- placeholder="jane_doe@example.com">
- </div>
-
- <button type="submit" [disabled]="!form.valid" class="btn btn-sm btn-remote-follow" i18n>
- <span *ngIf="!interact">Remote subscribe</span>
- <span *ngIf="interact">Remote interact</span>
- </button>
-
- <my-help *ngIf="!interact && showHelp">
- <ng-template ptTemplate="customHtml">
- <ng-container i18n>
- You can subscribe to the channel via any ActivityPub-capable fediverse instance.<br /><br />
- For instance with Mastodon or Pleroma you can type the channel URL in the search box and subscribe there.
- </ng-container>
- </ng-template>
- </my-help>
-
- <my-help *ngIf="showHelp && interact">
- <ng-template ptTemplate="customHtml">
- <ng-container i18n>
- You can interact with this via any ActivityPub-capable fediverse instance.<br /><br />
- For instance with Mastodon or Pleroma you can type the current URL in the search box and interact with it there.
- </ng-container>
- </ng-template>
- </my-help>
-</form>
+++ /dev/null
-@import '_mixins';
-
-.btn-remote-follow {
- @include peertube-button;
- @include orange-button;
-}
\ No newline at end of file
+++ /dev/null
-import { Component, Input, OnInit } from '@angular/core'
-import { FormReactive } from '@app/shared/forms/form-reactive'
-import {
- FormValidatorService,
- UserValidatorsService
-} from '@app/shared/forms/form-validators'
-
-@Component({
- selector: 'my-remote-subscribe',
- templateUrl: './remote-subscribe.component.html',
- styleUrls: ['./remote-subscribe.component.scss']
-})
-export class RemoteSubscribeComponent extends FormReactive implements OnInit {
- @Input() uri: string
- @Input() interact = false
- @Input() showHelp = false
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private userValidatorsService: UserValidatorsService
- ) {
- super()
- }
-
- ngOnInit () {
- this.buildForm({
- text: this.userValidatorsService.USER_EMAIL
- })
- }
-
- onValidKey () {
- this.check()
- if (!this.form.valid) return
-
- this.formValidated()
- }
-
- formValidated () {
- const address = this.form.value['text']
- const [ username, hostname ] = address.split('@')
-
- // Should not have CORS error because https://tools.ietf.org/html/rfc7033#section-5
- fetch(`https://${hostname}/.well-known/webfinger?resource=acct:${username}@${hostname}`)
- .then(response => response.json())
- .then(data => new Promise((resolve, reject) => {
- console.log(data)
-
- if (data && Array.isArray(data.links)) {
- const link: { template: string } = data.links.find((link: any) => {
- return link && typeof link.template === 'string' && link.rel === 'http://ostatus.org/schema/1.0/subscribe'
- })
-
- if (link && link.template.includes('{uri}')) {
- resolve(link.template.replace('{uri}', encodeURIComponent(this.uri)))
- }
- }
- reject()
- }))
- .then(window.open)
- .catch(err => console.error(err))
- }
-}
+++ /dev/null
-<div class="btn-group-subscribe btn-group"
- [ngClass]="{'subscribe-button': !isAllChannelsSubscribed, 'unsubscribe-button': isAllChannelsSubscribed, 'big': isBigButton }">
-
- <ng-template #userLoggedOut>
- <span [ngClass]="{ 'extra-text': isAtLeastOneChannelSubscribed }">
- <ng-container *ngIf="account; then multiple; else single"></ng-container>
- <ng-template i18n #single>Subscribe</ng-template>
- <ng-template #multiple>
- <span i18n>Subscribe to all channels</span>
- <span *ngIf="isAtLeastOneChannelSubscribed">{{ subscribeStatus(true).length }}/{{ subscribed.size }}
- <ng-container i18n>channels subscribed</ng-container>
- </span>
- </ng-template>
- </span>
- <span *ngIf="!isBigButton && displayFollowers && videoChannels.length > 1 && videoChannel.followersCount !== 0" class="followers-count">
- {{ videoChannels[0].followersCount | myNumberFormatter }}
- </span>
- </ng-template>
-
- <ng-template #userLoggedIn>
- <button *ngIf="!isAllChannelsSubscribed" type="button"
- class="btn btn-sm" role="button"
- (click)="subscribe()">
- <ng-template [ngTemplateOutlet]="userLoggedOut"></ng-template>
- </button>
-
- <button
- *ngIf="isAllChannelsSubscribed" type="button"
- class="btn btn-sm" role="button"
- (click)="unsubscribe()">
- <ng-container i18n>{account + "", select, undefined {Unsubscribe} other {Unsubscribe from all channels}}</ng-container>
- </button>
- </ng-template>
-
- <ng-container
- *ngIf="isUserLoggedIn(); then userLoggedIn">
- </ng-container>
-
- <div class="btn-group" ngbDropdown autoClose="outside"
- placement="bottom-right" role="group"
- aria-label="Multiple ways to subscribe to the current channel">
- <button class="btn btn-sm dropdown-toggle-split" ngbDropdownToggle>
- <ng-container
- *ngIf="!isUserLoggedIn(); then userLoggedOut">
- </ng-container>
- </button>
-
- <div class="dropdown-menu" ngbDropdownMenu>
-
- <h6 class="dropdown-header" i18n>Using an ActivityPub account</h6>
-
- <button class="dropdown-item" (click)="subscribe()">
- <span *ngIf="!isUserLoggedIn()" i18n>Subscribe with an account on this instance</span>
- <span *ngIf="isUserLoggedIn()" i18n>Subscribe with your local account</span>
- </button>
-
- <button class="dropdown-item dropdown-item-neutral" i18n>Subscribe with a Mastodon account:</button>
- <my-remote-subscribe [showHelp]="true" [uri]="uri"></my-remote-subscribe>
-
- <div class="dropdown-divider"></div>
-
- <h6 class="dropdown-header" i18n>Using a syndication feed</h6>
- <a [href]="rssUri" target="_blank" class="dropdown-item" i18n>Subscribe via RSS</a>
-
- </div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.btn-group-subscribe {
- @include peertube-button;
- @include disable-default-a-behaviour;
-
- float: right;
- padding: 0;
-
- & > .btn,
- & > .dropdown > .dropdown-toggle {
- font-size: 15px;
- }
-
- &:not(.big) {
- white-space: nowrap;
- }
-
- &.big {
- height: 35px;
-
- & > button:first-child {
- width: 175px;
- }
-
- button .extra-text {
- span:first-child {
- line-height: 80%;
- }
-
- span:not(:first-child) {
- font-size: 75%;
- }
- }
- }
-
- // Unlogged
- & > .dropdown > .dropdown-toggle span {
- padding-right: 3px;
- }
-
- // Logged
- & > .btn {
- padding-right: 4px;
-
- & + .dropdown > button {
- padding-left: 2px;
-
- &::after {
- position: relative;
- top: 1px;
- }
- }
- }
-
- &.subscribe-button {
- .btn {
- @include orange-button;
- font-weight: 600;
- }
-
- span.followers-count {
- padding-left: 5px;
- }
- }
- &.unsubscribe-button {
- .btn {
- @include grey-button;
- font-weight: 600;
- }
- }
-
- .dropdown-menu {
- cursor: default;
-
- button {
- cursor: pointer;
- }
-
- .dropdown-item-neutral {
- cursor: default;
-
- &:hover,
- &:focus {
- background-color: inherit;
- }
- }
- }
-
- ::ng-deep form {
- padding: 0.25rem 1rem;
- }
-
- input {
- @include peertube-input-text(100%);
- }
-}
-
-.extra-text {
- display: flex;
- flex-direction: column;
-
- span:first-child {
- line-height: 75%;
- }
-
- span:not(:first-child) {
- font-size: 60%;
- text-align: left;
- }
-}
+++ /dev/null
-import { Component, Input, OnInit, OnChanges } from '@angular/core'
-import { Router } from '@angular/router'
-import { AuthService, Notifier } from '@app/core'
-import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoService } from '@app/shared/video/video.service'
-import { FeedFormat } from '../../../../../shared/models/feeds'
-import { Account } from '@app/shared/account/account.model'
-import { concat, forkJoin, merge } from 'rxjs'
-
-@Component({
- selector: 'my-subscribe-button',
- templateUrl: './subscribe-button.component.html',
- styleUrls: [ './subscribe-button.component.scss' ]
-})
-export class SubscribeButtonComponent implements OnInit, OnChanges {
- /**
- * SubscribeButtonComponent can be used with a single VideoChannel passed as [VideoChannel],
- * or with an account and a full list of that account's videoChannels. The latter is intended
- * to allow mass un/subscription from an account's page, while keeping the channel-centric
- * subscription model.
- */
- @Input() account: Account
- @Input() videoChannels: VideoChannel[]
- @Input() displayFollowers = false
- @Input() size: 'small' | 'normal' = 'normal'
-
- subscribed = new Map<string, boolean>()
-
- constructor (
- private authService: AuthService,
- private router: Router,
- private notifier: Notifier,
- private userSubscriptionService: UserSubscriptionService,
- private i18n: I18n,
- private videoService: VideoService
- ) { }
-
- get handle () {
- return this.account
- ? this.account.nameWithHost
- : this.videoChannel.name + '@' + this.videoChannel.host
- }
-
- get channelHandle () {
- return this.getChannelHandler(this.videoChannel)
- }
-
- get uri () {
- return this.account
- ? this.account.url
- : this.videoChannels[0].url
- }
-
- get rssUri () {
- const rssFeed = this.account
- ? this.videoService
- .getAccountFeedUrls(this.account.id)
- .find(i => i.format === FeedFormat.RSS)
- : this.videoService
- .getVideoChannelFeedUrls(this.videoChannels[0].id)
- .find(i => i.format === FeedFormat.RSS)
-
- return rssFeed.url
- }
-
- get videoChannel () {
- return this.videoChannels[0]
- }
-
- get isAllChannelsSubscribed () {
- return this.subscribeStatus(true).length === this.videoChannels.length
- }
-
- get isAtLeastOneChannelSubscribed () {
- return this.subscribeStatus(true).length > 0
- }
-
- get isBigButton () {
- return this.isUserLoggedIn() && this.videoChannels.length > 1 && this.isAtLeastOneChannelSubscribed
- }
-
- ngOnInit () {
- this.loadSubscribedStatus()
- }
-
- ngOnChanges () {
- this.ngOnInit()
- }
-
- subscribe () {
- if (this.isUserLoggedIn()) {
- return this.localSubscribe()
- }
-
- return this.gotoLogin()
- }
-
- localSubscribe () {
- const subscribedStatus = this.subscribeStatus(false)
-
- const observableBatch = this.videoChannels
- .map(videoChannel => this.getChannelHandler(videoChannel))
- .filter(handle => subscribedStatus.includes(handle))
- .map(handle => this.userSubscriptionService.addSubscription(handle))
-
- forkJoin(observableBatch)
- .subscribe(
- () => {
- this.notifier.success(
- this.account
- ? this.i18n(
- 'Subscribed to all current channels of {{nameWithHost}}. You will be notified of all their new videos.',
- { nameWithHost: this.account.displayName }
- )
- : this.i18n(
- 'Subscribed to {{nameWithHost}}. You will be notified of all their new videos.',
- { nameWithHost: this.videoChannels[0].displayName }
- )
- ,
- this.i18n('Subscribed')
- )
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- unsubscribe () {
- if (this.isUserLoggedIn()) {
- this.localUnsubscribe()
- }
- }
-
- localUnsubscribe () {
- const subscribeStatus = this.subscribeStatus(true)
-
- const observableBatch = this.videoChannels
- .map(videoChannel => this.getChannelHandler(videoChannel))
- .filter(handle => subscribeStatus.includes(handle))
- .map(handle => this.userSubscriptionService.deleteSubscription(handle))
-
- concat(...observableBatch)
- .subscribe({
- complete: () => {
- this.notifier.success(
- this.account
- ? this.i18n('Unsubscribed from all channels of {{nameWithHost}}', { nameWithHost: this.account.nameWithHost })
- : this.i18n('Unsubscribed from {{nameWithHost}}', { nameWithHost: this.videoChannels[ 0 ].nameWithHost })
- ,
- this.i18n('Unsubscribed')
- )
- },
-
- error: err => this.notifier.error(err.message)
- })
- }
-
- isUserLoggedIn () {
- return this.authService.isLoggedIn()
- }
-
- gotoLogin () {
- this.router.navigate([ '/login' ])
- }
-
- subscribeStatus (subscribed: boolean) {
- const accumulator: string[] = []
- for (const [key, value] of this.subscribed.entries()) {
- if (value === subscribed) accumulator.push(key)
- }
-
- return accumulator
- }
-
- private getChannelHandler (videoChannel: VideoChannel) {
- return videoChannel.name + '@' + videoChannel.host
- }
-
- private loadSubscribedStatus () {
- if (!this.isUserLoggedIn()) return
-
- for (const videoChannel of this.videoChannels) {
- const handle = this.getChannelHandler(videoChannel)
- this.subscribed.set(handle, false)
-
- merge(
- this.userSubscriptionService.listenToSubscriptionCacheChange(handle),
- this.userSubscriptionService.doesSubscriptionExist(handle)
- ).subscribe(
- res => this.subscribed.set(handle, res),
-
- err => this.notifier.error(err.message)
- )
- }
- }
-}
+++ /dev/null
-import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
-import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable, NgZone } from '@angular/core'
-import { ResultList } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestService } from '../rest'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel as VideoChannelServer } from '../../../../../shared/models/videos'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { uniq } from 'lodash-es'
-import * as debug from 'debug'
-import { enterZone, leaveZone } from '@app/shared/rxjs/zone'
-
-const logger = debug('peertube:subscriptions:UserSubscriptionService')
-
-type SubscriptionExistResult = { [ uri: string ]: boolean }
-type SubscriptionExistResultObservable = { [ uri: string ]: Observable<boolean> }
-
-@Injectable()
-export class UserSubscriptionService {
- static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions'
-
- // Use a replay subject because we "next" a value before subscribing
- private existsSubject = new ReplaySubject<string>(1)
- private readonly existsObservable: Observable<SubscriptionExistResult>
-
- private myAccountSubscriptionCache: SubscriptionExistResult = {}
- private myAccountSubscriptionCacheObservable: SubscriptionExistResultObservable = {}
- private myAccountSubscriptionCacheSubject = new Subject<SubscriptionExistResult>()
-
- constructor (
- private authHttp: HttpClient,
- private restExtractor: RestExtractor,
- private restService: RestService,
- private ngZone: NgZone
- ) {
- this.existsObservable = merge(
- this.existsSubject.pipe(
- // We leave Angular zone so Protractor does not get stuck
- bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
- filter(uris => uris.length !== 0),
- map(uris => uniq(uris)),
- observeOn(enterZone(this.ngZone, asyncScheduler)),
- switchMap(uris => this.doSubscriptionsExist(uris)),
- share()
- ),
-
- this.myAccountSubscriptionCacheSubject
- )
- }
-
- /**
- * Subscription part
- */
-
- deleteSubscription (nameWithHost: string) {
- const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/' + nameWithHost
-
- return this.authHttp.delete(url)
- .pipe(
- map(this.restExtractor.extractDataBool),
- tap(() => {
- this.myAccountSubscriptionCache[nameWithHost] = false
-
- this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache)
- }),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- addSubscription (nameWithHost: string) {
- const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
-
- const body = { uri: nameWithHost }
- return this.authHttp.post(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- tap(() => {
- this.myAccountSubscriptionCache[nameWithHost] = true
-
- this.myAccountSubscriptionCacheSubject.next(this.myAccountSubscriptionCache)
- }),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- listSubscriptions (componentPagination: ComponentPaginationLight): Observable<ResultList<VideoChannel>> {
- const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
-
- const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination)
-
- return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
- .pipe(
- map(res => VideoChannelService.extractVideoChannels(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- /**
- * SubscriptionExist part
- */
-
- listenToMyAccountSubscriptionCacheSubject () {
- return this.myAccountSubscriptionCacheSubject.asObservable()
- }
-
- listenToSubscriptionCacheChange (nameWithHost: string) {
- if (nameWithHost in this.myAccountSubscriptionCacheObservable) {
- return this.myAccountSubscriptionCacheObservable[ nameWithHost ]
- }
-
- const obs = this.existsObservable
- .pipe(
- filter(existsResult => existsResult[ nameWithHost ] !== undefined),
- map(existsResult => existsResult[ nameWithHost ])
- )
-
- this.myAccountSubscriptionCacheObservable[ nameWithHost ] = obs
- return obs
- }
-
- doesSubscriptionExist (nameWithHost: string) {
- logger('Running subscription check for %d.', nameWithHost)
-
- if (nameWithHost in this.myAccountSubscriptionCache) {
- logger('Found cache for %d.', nameWithHost)
-
- return of(this.myAccountSubscriptionCache[ nameWithHost ])
- }
-
- this.existsSubject.next(nameWithHost)
-
- logger('Fetching from network for %d.', nameWithHost)
- return this.existsObservable.pipe(
- filter(existsResult => existsResult[ nameWithHost ] !== undefined),
- map(existsResult => existsResult[ nameWithHost ]),
- tap(result => this.myAccountSubscriptionCache[ nameWithHost ] = result)
- )
- }
-
- private doSubscriptionsExist (uris: string[]): Observable<SubscriptionExistResult> {
- const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/exist'
- let params = new HttpParams()
-
- params = this.restService.addObjectParams(params, { uris })
-
- return this.authHttp.get<SubscriptionExistResult>(url, { params })
- .pipe(
- tap(res => {
- this.myAccountSubscriptionCache = {
- ...this.myAccountSubscriptionCache,
- ...res
- }
- }),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-}
+++ /dev/null
-export * from './user.model'
-export * from './user.service'
-export * from './user-notifications.component'
+++ /dev/null
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { environment } from '../../../environments/environment'
-import { RestExtractor } from '../rest/rest-extractor.service'
-import { RestService } from '../rest/rest.service'
-import { Video } from '../video/video.model'
-import { catchError, map, switchMap } from 'rxjs/operators'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { ResultList } from '../../../../../shared'
-
-@Injectable()
-export class UserHistoryService {
- static BASE_USER_VIDEOS_HISTORY_URL = environment.apiUrl + '/api/v1/users/me/history/videos'
-
- constructor (
- private authHttp: HttpClient,
- private restExtractor: RestExtractor,
- private restService: RestService,
- private videoService: VideoService
- ) {}
-
- getUserVideosHistory (historyPagination: ComponentPaginationLight) {
- const pagination = this.restService.componentPaginationToRestPagination(historyPagination)
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination)
-
- return this.authHttp
- .get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params })
- .pipe(
- switchMap(res => this.videoService.extractVideos(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- deleteUserVideosHistory () {
- return this.authHttp
- .post(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL + '/remove', {})
- .pipe(
- map(() => this.restExtractor.extractDataBool()),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-}
+++ /dev/null
-import { ActorInfo, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo, Avatar } from '../../../../../shared'
-import { Actor } from '@app/shared/actor/actor.model'
-
-export class UserNotification implements UserNotificationServer {
- id: number
- type: UserNotificationType
- read: boolean
-
- video?: VideoInfo & {
- channel: ActorInfo & { avatarUrl?: string }
- }
-
- videoImport?: {
- id: number
- video?: VideoInfo
- torrentName?: string
- magnetUri?: string
- targetUrl?: string
- }
-
- comment?: {
- id: number
- threadId: number
- account: ActorInfo & { avatarUrl?: string }
- video: VideoInfo
- }
-
- videoAbuse?: {
- id: number
- video: VideoInfo
- }
-
- videoBlacklist?: {
- id: number
- video: VideoInfo
- }
-
- account?: ActorInfo & { avatarUrl?: string }
-
- actorFollow?: {
- id: number
- state: FollowState
- follower: ActorInfo & { avatarUrl?: string }
- following: {
- type: 'account' | 'channel' | 'instance'
- name: string
- displayName: string
- host: string
- }
- }
-
- createdAt: string
- updatedAt: string
-
- // Additional fields
- videoUrl?: string
- commentUrl?: any[]
- videoAbuseUrl?: string
- videoAutoBlacklistUrl?: string
- accountUrl?: string
- videoImportIdentifier?: string
- videoImportUrl?: string
- instanceFollowUrl?: string
-
- constructor (hash: UserNotificationServer) {
- this.id = hash.id
- this.type = hash.type
- this.read = hash.read
-
- // We assume that some fields exist
- // To prevent a notification popup crash in case of bug, wrap it inside a try/catch
- try {
- this.video = hash.video
- if (this.video) this.setAvatarUrl(this.video.channel)
-
- this.videoImport = hash.videoImport
-
- this.comment = hash.comment
- if (this.comment) this.setAvatarUrl(this.comment.account)
-
- this.videoAbuse = hash.videoAbuse
-
- this.videoBlacklist = hash.videoBlacklist
-
- this.account = hash.account
- if (this.account) this.setAvatarUrl(this.account)
-
- this.actorFollow = hash.actorFollow
- if (this.actorFollow) this.setAvatarUrl(this.actorFollow.follower)
-
- this.createdAt = hash.createdAt
- this.updatedAt = hash.updatedAt
-
- switch (this.type) {
- case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION:
- this.videoUrl = this.buildVideoUrl(this.video)
- break
-
- case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO:
- this.videoUrl = this.buildVideoUrl(this.video)
- break
-
- case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO:
- case UserNotificationType.COMMENT_MENTION:
- if (!this.comment) break
- this.accountUrl = this.buildAccountUrl(this.comment.account)
- this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
- break
-
- case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
- this.videoAbuseUrl = '/admin/moderation/video-abuses/list'
- this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
- break
-
- case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
- this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list'
- // Backward compatibility where we did not assign videoBlacklist to this type of notification before
- if (!this.videoBlacklist) this.videoBlacklist = { id: null, video: this.video }
-
- this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
- break
-
- case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
- this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
- break
-
- case UserNotificationType.MY_VIDEO_PUBLISHED:
- this.videoUrl = this.buildVideoUrl(this.video)
- break
-
- case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS:
- this.videoImportUrl = this.buildVideoImportUrl()
- this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
-
- if (this.videoImport.video) this.videoUrl = this.buildVideoUrl(this.videoImport.video)
- break
-
- case UserNotificationType.MY_VIDEO_IMPORT_ERROR:
- this.videoImportUrl = this.buildVideoImportUrl()
- this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
- break
-
- case UserNotificationType.NEW_USER_REGISTRATION:
- this.accountUrl = this.buildAccountUrl(this.account)
- break
-
- case UserNotificationType.NEW_FOLLOW:
- this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
- break
-
- case UserNotificationType.NEW_INSTANCE_FOLLOWER:
- this.instanceFollowUrl = '/admin/follows/followers-list'
- break
-
- case UserNotificationType.AUTO_INSTANCE_FOLLOWING:
- this.instanceFollowUrl = '/admin/follows/following-list'
- break
- }
- } catch (err) {
- this.type = null
- console.error(err)
- }
- }
-
- private buildVideoUrl (video: { uuid: string }) {
- return '/videos/watch/' + video.uuid
- }
-
- private buildAccountUrl (account: { name: string, host: string }) {
- return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host)
- }
-
- private buildVideoImportUrl () {
- return '/my-account/video-imports'
- }
-
- private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) {
- return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
- }
-
- private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) {
- actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
- }
-}
+++ /dev/null
-import { Injectable } from '@angular/core'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { RestExtractor, RestService } from '../rest'
-import { catchError, map, tap } from 'rxjs/operators'
-import { environment } from '../../../environments/environment'
-import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '../../../../../shared'
-import { UserNotification } from './user-notification.model'
-import { AuthService } from '../../core'
-import { ComponentPaginationLight } from '../rest/component-pagination.model'
-import { User } from '../users/user.model'
-import { UserNotificationSocket } from '@app/core/notification/user-notification-socket.service'
-
-@Injectable()
-export class UserNotificationService {
- static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications'
- static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings'
-
- constructor (
- private auth: AuthService,
- private authHttp: HttpClient,
- private restExtractor: RestExtractor,
- private restService: RestService,
- private userNotificationSocket: UserNotificationSocket
- ) {}
-
- listMyNotifications (pagination: ComponentPaginationLight, unread?: boolean, ignoreLoadingBar = false) {
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination))
-
- if (unread) params = params.append('unread', `${unread}`)
-
- const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined
-
- return this.authHttp.get<ResultList<UserNotification>>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- countUnreadNotifications () {
- return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true)
- .pipe(map(n => n.total))
- }
-
- markAsRead (notification: UserNotification) {
- const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read'
-
- const body = { ids: [ notification.id ] }
- const headers = { ignoreLoadingBar: '' }
-
- return this.authHttp.post(url, body, { headers })
- .pipe(
- map(this.restExtractor.extractDataBool),
- tap(() => this.userNotificationSocket.dispatch('read')),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- markAllAsRead () {
- const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all'
- const headers = { ignoreLoadingBar: '' }
-
- return this.authHttp.post(url, {}, { headers })
- .pipe(
- map(this.restExtractor.extractDataBool),
- tap(() => this.userNotificationSocket.dispatch('read-all')),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- updateNotificationSettings (user: User, settings: UserNotificationSetting) {
- const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
-
- return this.authHttp.put(url, settings)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- private formatNotification (notification: UserNotificationServer) {
- return new UserNotification(notification)
- }
-}
+++ /dev/null
-<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 *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
-
- <ng-container [ngSwitch]="notification.type">
- <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
- <ng-container *ngIf="notification.video; then hasVideo; else noVideo"></ng-container>
-
- <ng-template #hasVideo>
- <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
- <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.video.channel.avatarUrl" />
- </a>
-
- <div class="message" i18n>
- {{ notification.video.channel.displayName }} published a new video: <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a>
- </div>
- </ng-template>
-
- <ng-template #noVideo>
- <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- The notification concerns a video now unavailable
- </div>
- </ng-template>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
- <my-global-icon iconName="undo" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblocked
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
- <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blocked
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
- <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.videoAbuseUrl">A new video abuse</a> has been created on video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoAbuse.video.name }}</a>
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS">
- <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- The recently added video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been <a (click)="markAsRead(notification)" [routerLink]="notification.videoAutoBlacklistUrl">automatically blocked</a>
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
- <ng-container *ngIf="notification.comment; then hasComment; else noComment"></ng-container>
-
- <ng-template #hasComment>
- <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
- <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
- </a>
-
- <div class="message" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> commented your video <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">{{ notification.comment.video.name }}</a>
- </div>
- </ng-template>
-
- <ng-template #noComment>
- <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- The notification concerns a comment now unavailable
- </div>
- </ng-template>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
- <my-global-icon iconName="sparkle" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
- <my-global-icon iconName="cloud-download" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl || notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
- <my-global-icon iconName="cloud-error" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
- <my-global-icon iconName="user-add" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }}</a> registered on your instance
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
- <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
- <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.actorFollow.follower.avatarUrl" />
- </a>
-
- <div class="message" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
-
- <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">your channel {{ notification.actorFollow.following.displayName }}</ng-container>
- <ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
- <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
- <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
- </a>
-
- <div class="message" i18n>
- <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.comment.account.displayName }}</a> mentioned you on <a (click)="markAsRead(notification)" [routerLink]="notification.commentUrl">video {{ notification.comment.video.name }}</a>
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.NEW_INSTANCE_FOLLOWER">
- <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- Your instance has <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">a new follower</a> ({{ notification.actorFollow?.follower.host }})
- <ng-container *ngIf="notification.actorFollow?.state === 'pending'"> awaiting your approval</ng-container>
- </div>
- </ng-container>
-
- <ng-container *ngSwitchCase="UserNotificationType.AUTO_INSTANCE_FOLLOWING">
- <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- Your instance automatically followed <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">{{ notification.actorFollow.following.host }}</a>
- </div>
- </ng-container>
-
- <ng-container *ngSwitchDefault>
- <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
-
- <div class="message" i18n>
- The notification points to a content now unavailable
- </div>
- </ng-container>
- </ng-container>
-
- <div [title]="notification.createdAt" class="from-date">{{ notification.createdAt | myFromNow }}</div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.no-notification {
- display: flex;
- justify-content: center;
- align-items: center;
- padding: 20px 0;
-}
-
-.notification {
- display: flex;
- align-items: center;
- font-size: inherit;
- padding: 15px 5px 15px 10px;
- border-bottom: 1px solid $separator-border-color;
- word-break: break-word;
-
- &.unread {
- background-color: rgba(0, 0, 0, 0.05);
- }
-
- my-global-icon {
- width: 24px;
- margin-right: 11px;
- margin-left: 3px;
-
- @include apply-svg-color(#333);
- }
-
- .avatar {
- @include avatar(30px);
-
- margin-right: 10px;
- }
-
- .message {
- flex-grow: 1;
-
- a {
- font-weight: $font-semibold;
- }
- }
-
- .from-date {
- font-size: 0.85em;
- color: pvar(--greyForegroundColor);
- padding-left: 5px;
- min-width: 70px;
- text-align: right;
- margin-left: auto;
- }
-}
+++ /dev/null
-import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
-import { UserNotificationService } from '@app/shared/users/user-notification.service'
-import { UserNotificationType } from '../../../../../shared'
-import { ComponentPagination, hasMoreItems } from '@app/shared/rest/component-pagination.model'
-import { Notifier } from '@app/core'
-import { UserNotification } from '@app/shared/users/user-notification.model'
-import { Subject } from 'rxjs'
-
-@Component({
- selector: 'my-user-notifications',
- templateUrl: 'user-notifications.component.html',
- styleUrls: [ 'user-notifications.component.scss' ]
-})
-export class UserNotificationsComponent implements OnInit {
- @Input() ignoreLoadingBar = false
- @Input() infiniteScroll = true
- @Input() itemsPerPage = 20
- @Input() markAllAsReadSubject: Subject<boolean>
-
- @Output() notificationsLoaded = new EventEmitter()
-
- notifications: UserNotification[] = []
-
- // So we can access it in the template
- UserNotificationType = UserNotificationType
-
- componentPagination: ComponentPagination
-
- onDataSubject = new Subject<any[]>()
-
- constructor (
- private userNotificationService: UserNotificationService,
- private notifier: Notifier
- ) { }
-
- ngOnInit () {
- this.componentPagination = {
- currentPage: 1,
- itemsPerPage: this.itemsPerPage, // Reset items per page, because of the @Input() variable
- totalItems: null
- }
-
- this.loadMoreNotifications()
-
- if (this.markAllAsReadSubject) {
- this.markAllAsReadSubject.subscribe(() => this.markAllAsRead())
- }
- }
-
- loadMoreNotifications () {
- this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar)
- .subscribe(
- result => {
- this.notifications = this.notifications.concat(result.data)
- this.componentPagination.totalItems = result.total
-
- this.notificationsLoaded.emit()
-
- this.onDataSubject.next(result.data)
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- onNearOfBottom () {
- if (this.infiniteScroll === false) return
-
- this.componentPagination.currentPage++
-
- if (hasMoreItems(this.componentPagination)) {
- this.loadMoreNotifications()
- }
- }
-
- markAsRead (notification: UserNotification) {
- if (notification.read) return
-
- this.userNotificationService.markAsRead(notification)
- .subscribe(
- () => {
- notification.read = true
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- markAllAsRead () {
- this.userNotificationService.markAllAsRead()
- .subscribe(
- () => {
- for (const notification of this.notifications) {
- notification.read = true
- }
- },
-
- err => this.notifier.error(err.message)
- )
- }
-}
+++ /dev/null
-import {
- hasUserRight,
- User as UserServerModel,
- UserNotificationSetting,
- UserRight,
- UserRole
-} from '../../../../../shared/models/users'
-import { VideoChannel } from '../../../../../shared/models/videos'
-import { NSFWPolicyType } from '../../../../../shared/models/videos/nsfw-policy.type'
-import { Account } from '@app/shared/account/account.model'
-import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { UserAdminFlag } from '@shared/models/users/user-flag.model'
-
-export class User implements UserServerModel {
- static KEYS = {
- ID: 'id',
- ROLE: 'role',
- EMAIL: 'email',
- VIDEOS_HISTORY_ENABLED: 'videos-history-enabled',
- USERNAME: 'username',
- NSFW_POLICY: 'nsfw_policy',
- WEBTORRENT_ENABLED: 'peertube-videojs-' + 'webtorrent_enabled',
- AUTO_PLAY_VIDEO: 'auto_play_video',
- SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO: 'auto_play_next_video',
- AUTO_PLAY_VIDEO_PLAYLIST: 'auto_play_video_playlist',
- THEME: 'last_active_theme',
- VIDEO_LANGUAGES: 'video_languages'
- }
-
- id: number
- username: string
- email: string
- pendingEmail: string | null
-
- emailVerified: boolean
- nsfwPolicy: NSFWPolicyType
-
- adminFlags?: UserAdminFlag
-
- autoPlayVideo: boolean
- autoPlayNextVideo: boolean
- autoPlayNextVideoPlaylist: boolean
- webTorrentEnabled: boolean
- videosHistoryEnabled: boolean
- videoLanguages: string[]
-
- role: UserRole
- roleLabel: string
-
- videoQuota: number
- videoQuotaDaily: number
- videoQuotaUsed?: number
- videoQuotaUsedDaily?: number
- videosCount?: number
- videoAbusesCount?: number
- videoAbusesAcceptedCount?: number
- videoAbusesCreatedCount?: number
- videoCommentsCount?: number
-
- theme: string
-
- account: Account
- notificationSettings?: UserNotificationSetting
- videoChannels?: VideoChannel[]
-
- blocked: boolean
- blockedReason?: string
-
- noInstanceConfigWarningModal: boolean
- noWelcomeModal: boolean
-
- pluginAuth: string | null
-
- lastLoginDate: Date | null
-
- createdAt: Date
-
- constructor (hash: Partial<UserServerModel>) {
- this.id = hash.id
- this.username = hash.username
- this.email = hash.email
-
- this.role = hash.role
-
- this.videoChannels = hash.videoChannels
-
- this.videoQuota = hash.videoQuota
- this.videoQuotaDaily = hash.videoQuotaDaily
- this.videoQuotaUsed = hash.videoQuotaUsed
- this.videoQuotaUsedDaily = hash.videoQuotaUsedDaily
- this.videosCount = hash.videosCount
- this.videoAbusesCount = hash.videoAbusesCount
- this.videoAbusesAcceptedCount = hash.videoAbusesAcceptedCount
- this.videoAbusesCreatedCount = hash.videoAbusesCreatedCount
- this.videoCommentsCount = hash.videoCommentsCount
-
- this.nsfwPolicy = hash.nsfwPolicy
- this.webTorrentEnabled = hash.webTorrentEnabled
- this.autoPlayVideo = hash.autoPlayVideo
- this.autoPlayNextVideo = hash.autoPlayNextVideo
- this.autoPlayNextVideoPlaylist = hash.autoPlayNextVideoPlaylist
- this.videosHistoryEnabled = hash.videosHistoryEnabled
- this.videoLanguages = hash.videoLanguages
-
- this.theme = hash.theme
-
- this.adminFlags = hash.adminFlags
-
- this.blocked = hash.blocked
- this.blockedReason = hash.blockedReason
-
- this.noInstanceConfigWarningModal = hash.noInstanceConfigWarningModal
- this.noWelcomeModal = hash.noWelcomeModal
-
- this.notificationSettings = hash.notificationSettings
-
- this.createdAt = hash.createdAt
-
- this.pluginAuth = hash.pluginAuth
- this.lastLoginDate = hash.lastLoginDate
-
- if (hash.account !== undefined) {
- this.account = new Account(hash.account)
- }
- }
-
- get accountAvatarUrl () {
- if (!this.account) return ''
-
- return this.account.avatarUrl
- }
-
- hasRight (right: UserRight) {
- return hasUserRight(this.role, right)
- }
-
- patch (obj: UserServerModel) {
- for (const key of Object.keys(obj)) {
- this[key] = obj[key]
- }
-
- if (obj.account !== undefined) {
- this.account = new Account(obj.account)
- }
- }
-
- updateAccountAvatar (newAccountAvatar: Avatar) {
- this.account.updateAvatar(newAccountAvatar)
- }
-}
+++ /dev/null
-import { has } from 'lodash-es'
-import { BytesPipe } from 'ngx-pipes'
-import { SortMeta } from 'primeng/api'
-import { from, Observable, of } from 'rxjs'
-import { catchError, concatMap, first, map, shareReplay, toArray, throttleTime, filter } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { AuthService } from '@app/core/auth'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { UserRegister } from '@shared/models/users/user-register.model'
-import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { ResultList, User as UserServerModel, UserCreate, UserRole, UserUpdate, UserUpdateMe, UserVideoQuota } from '../../../../../shared'
-import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { environment } from '../../../environments/environment'
-import { LocalStorageService, SessionStorageService } from '../misc/storage.service'
-import { RestExtractor, RestPagination, RestService } from '../rest'
-import { User } from './user.model'
-
-@Injectable()
-export class UserService {
- static BASE_USERS_URL = environment.apiUrl + '/api/v1/users/'
-
- private bytesPipe = new BytesPipe()
-
- private userCache: { [ id: number ]: Observable<UserServerModel> } = {}
-
- constructor (
- private authHttp: HttpClient,
- private authService: AuthService,
- private restExtractor: RestExtractor,
- private restService: RestService,
- private localStorageService: LocalStorageService,
- private sessionStorageService: SessionStorageService,
- private i18n: I18n
- ) { }
-
- changePassword (currentPassword: string, newPassword: string) {
- const url = UserService.BASE_USERS_URL + 'me'
- const body: UserUpdateMe = {
- currentPassword,
- password: newPassword
- }
-
- return this.authHttp.put(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- changeEmail (password: string, newEmail: string) {
- const url = UserService.BASE_USERS_URL + 'me'
- const body: UserUpdateMe = {
- currentPassword: password,
- email: newEmail
- }
-
- return this.authHttp.put(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- updateMyProfile (profile: UserUpdateMe) {
- const url = UserService.BASE_USERS_URL + 'me'
-
- return this.authHttp.put(url, profile)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- updateMyAnonymousProfile (profile: UserUpdateMe) {
- const supportedKeys = {
- // local storage keys
- nsfwPolicy: (val: NSFWPolicyType) => this.localStorageService.setItem(User.KEYS.NSFW_POLICY, val),
- webTorrentEnabled: (val: boolean) => this.localStorageService.setItem(User.KEYS.WEBTORRENT_ENABLED, String(val)),
- autoPlayVideo: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO, String(val)),
- autoPlayNextVideoPlaylist: (val: boolean) => this.localStorageService.setItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST, String(val)),
- theme: (val: string) => this.localStorageService.setItem(User.KEYS.THEME, val),
- videoLanguages: (val: string[]) => this.localStorageService.setItem(User.KEYS.VIDEO_LANGUAGES, JSON.stringify(val)),
-
- // session storage keys
- autoPlayNextVideo: (val: boolean) =>
- this.sessionStorageService.setItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, String(val))
- }
-
- for (const key of Object.keys(profile)) {
- try {
- if (has(supportedKeys, key)) supportedKeys[key](profile[key])
- } catch (err) {
- console.error(`Cannot set item ${key} in localStorage. Likely due to a value impossible to stringify.`, err)
- }
- }
- }
-
- listenAnonymousUpdate () {
- return this.localStorageService.watch([
- User.KEYS.NSFW_POLICY,
- User.KEYS.WEBTORRENT_ENABLED,
- User.KEYS.AUTO_PLAY_VIDEO,
- User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST,
- User.KEYS.THEME,
- User.KEYS.VIDEO_LANGUAGES
- ]).pipe(
- throttleTime(200),
- filter(() => this.authService.isLoggedIn() !== true),
- map(() => this.getAnonymousUser())
- )
- }
-
- deleteMe () {
- const url = UserService.BASE_USERS_URL + 'me'
-
- return this.authHttp.delete(url)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- changeAvatar (avatarForm: FormData) {
- const url = UserService.BASE_USERS_URL + 'me/avatar/pick'
-
- return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- signup (userCreate: UserRegister) {
- return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getMyVideoQuotaUsed () {
- const url = UserService.BASE_USERS_URL + 'me/video-quota-used'
-
- return this.authHttp.get<UserVideoQuota>(url)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- askResetPassword (email: string) {
- const url = UserService.BASE_USERS_URL + '/ask-reset-password'
-
- return this.authHttp.post(url, { email })
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- resetPassword (userId: number, verificationString: string, password: string) {
- const url = `${UserService.BASE_USERS_URL}/${userId}/reset-password`
- const body = {
- verificationString,
- password
- }
-
- return this.authHttp.post(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) {
- const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email`
- const body = {
- verificationString,
- isPendingEmail
- }
-
- return this.authHttp.post(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- askSendVerifyEmail (email: string) {
- const url = UserService.BASE_USERS_URL + '/ask-send-verify-email'
-
- return this.authHttp.post(url, { email })
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- autocomplete (search: string): Observable<string[]> {
- const url = UserService.BASE_USERS_URL + 'autocomplete'
- const params = new HttpParams().append('search', search)
-
- return this.authHttp
- .get<string[]>(url, { params })
- .pipe(catchError(res => this.restExtractor.handleError(res)))
- }
-
- getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {
- // Don't update display name, the user seems to have changed it
- if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername
-
- return this.displayNameToUsername(newDisplayName)
- }
-
- displayNameToUsername (displayName: string) {
- if (!displayName) return ''
-
- return displayName
- .toLowerCase()
- .replace(/\s/g, '_')
- .replace(/[^a-z0-9_.]/g, '')
- }
-
- /* ###### Admin methods ###### */
-
- addUser (userCreate: UserCreate) {
- return this.authHttp.post(UserService.BASE_USERS_URL, userCreate)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- updateUser (userId: number, userUpdate: UserUpdate) {
- return this.authHttp.put(UserService.BASE_USERS_URL + userId, userUpdate)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- updateUsers (users: UserServerModel[], userUpdate: UserUpdate) {
- return from(users)
- .pipe(
- concatMap(u => this.authHttp.put(UserService.BASE_USERS_URL + u.id, userUpdate)),
- toArray(),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getUserWithCache (userId: number) {
- if (!this.userCache[userId]) {
- this.userCache[ userId ] = this.getUser(userId).pipe(shareReplay())
- }
-
- return this.userCache[userId]
- }
-
- getUser (userId: number, withStats = false) {
- const params = new HttpParams().append('withStats', withStats + '')
- return this.authHttp.get<UserServerModel>(UserService.BASE_USERS_URL + userId, { params })
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- getAnonymousUser () {
- let videoLanguages: string[]
-
- try {
- videoLanguages = JSON.parse(this.localStorageService.getItem(User.KEYS.VIDEO_LANGUAGES))
- } catch (err) {
- videoLanguages = null
- console.error('Cannot parse desired video languages from localStorage.', err)
- }
-
- return new User({
- // local storage keys
- nsfwPolicy: this.localStorageService.getItem(User.KEYS.NSFW_POLICY) as NSFWPolicyType,
- webTorrentEnabled: this.localStorageService.getItem(User.KEYS.WEBTORRENT_ENABLED) !== 'false',
- theme: this.localStorageService.getItem(User.KEYS.THEME) || 'instance-default',
- videoLanguages,
-
- autoPlayNextVideoPlaylist: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO_PLAYLIST) !== 'false',
- autoPlayVideo: this.localStorageService.getItem(User.KEYS.AUTO_PLAY_VIDEO) === 'true',
-
- // session storage keys
- autoPlayNextVideo: this.sessionStorageService.getItem(User.KEYS.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
- })
- }
-
- getUsers (pagination: RestPagination, sort: SortMeta, search?: string): Observable<ResultList<UserServerModel>> {
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (search) params = params.append('search', search)
-
- return this.authHttp.get<ResultList<UserServerModel>>(UserService.BASE_USERS_URL, { params })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- map(res => this.restExtractor.applyToResultListData(res, this.formatUser.bind(this))),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- removeUser (usersArg: UserServerModel | UserServerModel[]) {
- const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
-
- return from(users)
- .pipe(
- concatMap(u => this.authHttp.delete(UserService.BASE_USERS_URL + u.id)),
- toArray(),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- banUsers (usersArg: UserServerModel | UserServerModel[], reason?: string) {
- const body = reason ? { reason } : {}
- const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
-
- return from(users)
- .pipe(
- concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/block', body)),
- toArray(),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- unbanUsers (usersArg: UserServerModel | UserServerModel[]) {
- const users = Array.isArray(usersArg) ? usersArg : [ usersArg ]
-
- return from(users)
- .pipe(
- concatMap(u => this.authHttp.post(UserService.BASE_USERS_URL + u.id + '/unblock', {})),
- toArray(),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getAnonymousOrLoggedUser () {
- if (!this.authService.isLoggedIn()) {
- return of(this.getAnonymousUser())
- }
-
- return this.authService.userInformationLoaded
- .pipe(
- first(),
- map(() => this.authService.getUser())
- )
- }
-
- private formatUser (user: UserServerModel) {
- let videoQuota
- if (user.videoQuota === -1) {
- videoQuota = this.i18n('Unlimited')
- } else {
- videoQuota = this.bytesPipe.transform(user.videoQuota, 0)
- }
-
- const videoQuotaUsed = this.bytesPipe.transform(user.videoQuotaUsed, 0)
-
- const roleLabels: { [ id in UserRole ]: string } = {
- [UserRole.USER]: this.i18n('User'),
- [UserRole.ADMINISTRATOR]: this.i18n('Administrator'),
- [UserRole.MODERATOR]: this.i18n('Moderator')
- }
-
- return Object.assign(user, {
- roleLabel: roleLabels[user.role],
- videoQuota,
- videoQuotaUsed
- })
- }
-}
+++ /dev/null
-export * from './video-abuse.service'
+++ /dev/null
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { SortMeta } from 'primeng/api'
-import { Observable } from 'rxjs'
-import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestPagination, RestService } from '../rest'
-import { omit } from 'lodash-es'
-
-@Injectable()
-export class VideoAbuseService {
- private static BASE_VIDEO_ABUSE_URL = environment.apiUrl + '/api/v1/videos/'
-
- constructor (
- private authHttp: HttpClient,
- private restService: RestService,
- private restExtractor: RestExtractor
- ) {}
-
- getVideoAbuses (options: {
- pagination: RestPagination,
- sort: SortMeta,
- search?: string
- }): Observable<ResultList<VideoAbuse>> {
- const { pagination, sort, search } = options
- const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse'
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (search) {
- const filters = this.restService.parseQueryStringFilter(search, {
- id: { prefix: '#' },
- state: {
- prefix: 'state:',
- handler: v => {
- if (v === 'accepted') return VideoAbuseState.ACCEPTED
- if (v === 'pending') return VideoAbuseState.PENDING
- if (v === 'rejected') return VideoAbuseState.REJECTED
-
- return undefined
- }
- },
- videoIs: {
- prefix: 'videoIs:',
- handler: v => {
- if (v === 'deleted') return v
- if (v === 'blacklisted') return v
-
- return undefined
- }
- },
- searchReporter: { prefix: 'reporter:' },
- searchReportee: { prefix: 'reportee:' },
- predefinedReason: { prefix: 'tag:' }
- })
-
- params = this.restService.addObjectParams(params, filters)
- }
-
- return this.authHttp.get<ResultList<VideoAbuse>>(url, { params })
- .pipe(
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- reportVideo (parameters: { id: number } & VideoAbuseCreate) {
- const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse'
-
- const body = omit(parameters, [ 'id' ])
-
- return this.authHttp.post(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- updateVideoAbuse (videoAbuse: VideoAbuse, abuseUpdate: VideoAbuseUpdate) {
- const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
-
- return this.authHttp.put(url, abuseUpdate)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- removeVideoAbuse (videoAbuse: VideoAbuse) {
- const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id
-
- return this.authHttp.delete(url)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }}
+++ /dev/null
-export * from './video-block.service'
+++ /dev/null
-import { catchError, map, concatMap, toArray } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { SortMeta } from 'primeng/api'
-import { from as observableFrom, Observable } from 'rxjs'
-import { VideoBlacklist, VideoBlacklistType, ResultList } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestPagination, RestService } from '../rest'
-
-@Injectable()
-export class VideoBlockService {
- private static BASE_VIDEOS_URL = environment.apiUrl + '/api/v1/videos/'
-
- constructor (
- private authHttp: HttpClient,
- private restService: RestService,
- private restExtractor: RestExtractor
- ) {}
-
- listBlocks (options: {
- pagination: RestPagination
- sort: SortMeta
- search?: string
- type?: VideoBlacklistType
- }): Observable<ResultList<VideoBlacklist>> {
- const { pagination, sort, search, type } = options
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (search) {
- const filters = this.restService.parseQueryStringFilter(search, {
- type: {
- prefix: 'type:',
- handler: v => {
- if (v === 'manual') return VideoBlacklistType.MANUAL
- if (v === 'auto') return VideoBlacklistType.AUTO_BEFORE_PUBLISHED
-
- return undefined
- }
- }
- })
-
- params = this.restService.addObjectParams(params, filters)
- }
- if (type) params = params.append('type', type.toString())
-
- return this.authHttp.get<ResultList<VideoBlacklist>>(VideoBlockService.BASE_VIDEOS_URL + 'blacklist', { params })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- unblockVideo (videoIdArgs: number | number[]) {
- const videoIds = Array.isArray(videoIdArgs) ? videoIdArgs : [ videoIdArgs ]
-
- return observableFrom(videoIds)
- .pipe(
- concatMap(id => this.authHttp.delete(VideoBlockService.BASE_VIDEOS_URL + id + '/blacklist')),
- toArray(),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- blockVideo (videoId: number, reason: string, unfederate: boolean) {
- const body = {
- unfederate,
- reason
- }
-
- return this.authHttp.post(VideoBlockService.BASE_VIDEOS_URL + videoId + '/blacklist', body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-}
+++ /dev/null
-export * from './video-caption.service'
+++ /dev/null
-export interface VideoCaptionEdit {
- language: {
- id: string
- label?: string
- }
-
- action?: 'CREATE' | 'REMOVE'
- captionfile?: any
-}
+++ /dev/null
-import { catchError, map, switchMap } from 'rxjs/operators'
-import { HttpClient } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { Observable, of } from 'rxjs'
-import { peertubeTranslate, ResultList } from '../../../../../shared'
-import { RestExtractor } from '../rest'
-import { VideoService } from '@app/shared/video/video.service'
-import { objectToFormData, sortBy } from '@app/shared/misc/utils'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
-import { VideoCaption } from '../../../../../shared/models/videos/caption/video-caption.model'
-import { ServerService } from '@app/core'
-
-@Injectable()
-export class VideoCaptionService {
- constructor (
- private authHttp: HttpClient,
- private serverService: ServerService,
- private restExtractor: RestExtractor
- ) {}
-
- listCaptions (videoId: number | string): Observable<ResultList<VideoCaption>> {
- return this.authHttp.get<ResultList<VideoCaption>>(VideoService.BASE_VIDEO_URL + videoId + '/captions')
- .pipe(
- switchMap(captionsResult => {
- return this.serverService.getServerLocale()
- .pipe(map(translations => ({ captionsResult, translations })))
- }),
- map(({ captionsResult, translations }) => {
- for (const c of captionsResult.data) {
- c.language.label = peertubeTranslate(c.language.label, translations)
- }
-
- return captionsResult
- }),
- map(captionsResult => {
- sortBy(captionsResult.data, 'language', 'label')
-
- return captionsResult
- })
- )
- .pipe(catchError(res => this.restExtractor.handleError(res)))
- }
-
- removeCaption (videoId: number | string, language: string) {
- return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- addCaption (videoId: number | string, language: string, captionfile: File) {
- const body = { captionfile }
- const data = objectToFormData(body)
-
- return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) {
- let obs = of(true)
-
- for (const videoCaption of videoCaptions) {
- if (videoCaption.action === 'CREATE') {
- obs = obs.pipe(switchMap(() => this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile)))
- } else if (videoCaption.action === 'REMOVE') {
- obs = obs.pipe(switchMap(() => this.removeCaption(videoId, videoCaption.language.id)))
- }
- }
-
- return obs
- }
-}
+++ /dev/null
-import { VideoChannel as ServerVideoChannel, ViewsPerDate } from '../../../../../shared/models/videos'
-import { Actor } from '../actor/actor.model'
-import { Account } from '../../../../../shared/models/actors'
-
-export class VideoChannel extends Actor implements ServerVideoChannel {
- displayName: string
- description: string
- support: string
- isLocal: boolean
- nameWithHost: string
- nameWithHostForced: string
-
- ownerAccount?: Account
- ownerBy?: string
- ownerAvatarUrl?: string
-
- videosCount?: number
-
- viewsPerDay?: ViewsPerDate[]
-
- constructor (hash: ServerVideoChannel) {
- super(hash)
-
- this.displayName = hash.displayName
- this.description = hash.description
- this.support = hash.support
- this.isLocal = hash.isLocal
- this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
- this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
-
- this.videosCount = hash.videosCount
-
- if (hash.viewsPerDay) {
- this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) }))
- }
-
- if (hash.ownerAccount) {
- this.ownerAccount = hash.ownerAccount
- this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
- this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
- }
- }
-}
+++ /dev/null
-import { catchError, map, tap } from 'rxjs/operators'
-import { Injectable } from '@angular/core'
-import { Observable, ReplaySubject } from 'rxjs'
-import { RestExtractor } from '../rest/rest-extractor.service'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '../../../../../shared/models/videos'
-import { AccountService } from '../account/account.service'
-import { ResultList } from '../../../../../shared'
-import { VideoChannel } from './video-channel.model'
-import { environment } from '../../../environments/environment'
-import { Account } from '@app/shared/account/account.model'
-import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { RestService } from '@app/shared/rest'
-
-@Injectable()
-export class VideoChannelService {
- static BASE_VIDEO_CHANNEL_URL = environment.apiUrl + '/api/v1/video-channels/'
-
- videoChannelLoaded = new ReplaySubject<VideoChannel>(1)
-
- static extractVideoChannels (result: ResultList<VideoChannelServer>) {
- const videoChannels: VideoChannel[] = []
-
- for (const videoChannelJSON of result.data) {
- videoChannels.push(new VideoChannel(videoChannelJSON))
- }
-
- return { data: videoChannels, total: result.total }
- }
-
- constructor (
- private authHttp: HttpClient,
- private restService: RestService,
- private restExtractor: RestExtractor
- ) { }
-
- getVideoChannel (videoChannelName: string) {
- return this.authHttp.get<VideoChannel>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName)
- .pipe(
- map(videoChannelHash => new VideoChannel(videoChannelHash)),
- tap(videoChannel => this.videoChannelLoaded.next(videoChannel)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- listAccountVideoChannels (
- account: Account,
- componentPagination?: ComponentPaginationLight,
- withStats = false
- ): Observable<ResultList<VideoChannel>> {
- const pagination = componentPagination
- ? this.restService.componentPaginationToRestPagination(componentPagination)
- : { start: 0, count: 20 }
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination)
- params = params.set('withStats', withStats + '')
-
- const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels'
- return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
- .pipe(
- map(res => VideoChannelService.extractVideoChannels(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- createVideoChannel (videoChannel: VideoChannelCreate) {
- return this.authHttp.post(VideoChannelService.BASE_VIDEO_CHANNEL_URL, videoChannel)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- updateVideoChannel (videoChannelName: string, videoChannel: VideoChannelUpdate) {
- return this.authHttp.put(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName, videoChannel)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- changeVideoChannelAvatar (videoChannelName: string, avatarForm: FormData) {
- const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar/pick'
-
- return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- removeVideoChannel (videoChannel: VideoChannel) {
- return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-}
+++ /dev/null
-export * from './video-import.service'
+++ /dev/null
-import { catchError, map, switchMap } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
-import { peertubeTranslate, VideoImport } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestService } from '../rest'
-import { VideoImportCreate, VideoUpdate } from '../../../../../shared/models/videos'
-import { objectToFormData } from '@app/shared/misc/utils'
-import { ResultList } from '../../../../../shared/models/result-list.model'
-import { UserService } from '@app/shared/users/user.service'
-import { SortMeta } from 'primeng/api'
-import { RestPagination } from '@app/shared/rest'
-import { ServerService } from '@app/core'
-
-@Injectable()
-export class VideoImportService {
- private static BASE_VIDEO_IMPORT_URL = environment.apiUrl + '/api/v1/videos/imports/'
-
- constructor (
- private authHttp: HttpClient,
- private restService: RestService,
- private restExtractor: RestExtractor,
- private serverService: ServerService
- ) {}
-
- importVideoUrl (targetUrl: string, video: VideoUpdate): Observable<VideoImport> {
- const url = VideoImportService.BASE_VIDEO_IMPORT_URL
-
- const body = this.buildImportVideoObject(video)
- body.targetUrl = targetUrl
-
- const data = objectToFormData(body)
- return this.authHttp.post<VideoImport>(url, data)
- .pipe(catchError(res => this.restExtractor.handleError(res)))
- }
-
- importVideoTorrent (target: string | Blob, video: VideoUpdate): Observable<VideoImport> {
- const url = VideoImportService.BASE_VIDEO_IMPORT_URL
- const body: VideoImportCreate = this.buildImportVideoObject(video)
-
- if (typeof target === 'string') body.magnetUri = target
- else body.torrentfile = target
-
- const data = objectToFormData(body)
- return this.authHttp.post<VideoImport>(url, data)
- .pipe(catchError(res => this.restExtractor.handleError(res)))
- }
-
- getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoImport>> {
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- return this.authHttp
- .get<ResultList<VideoImport>>(UserService.BASE_USERS_URL + '/me/videos/imports', { params })
- .pipe(
- switchMap(res => this.extractVideoImports(res)),
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- private buildImportVideoObject (video: VideoUpdate): VideoImportCreate {
- const language = video.language || null
- const licence = video.licence || null
- const category = video.category || null
- const description = video.description || null
- const support = video.support || null
- const scheduleUpdate = video.scheduleUpdate || null
- const originallyPublishedAt = video.originallyPublishedAt || null
-
- return {
- name: video.name,
- category,
- licence,
- language,
- support,
- description,
- channelId: video.channelId,
- privacy: video.privacy,
- tags: video.tags,
- nsfw: video.nsfw,
- waitTranscoding: video.waitTranscoding,
- commentsEnabled: video.commentsEnabled,
- downloadEnabled: video.downloadEnabled,
- thumbnailfile: video.thumbnailfile,
- previewfile: video.previewfile,
- scheduleUpdate,
- originallyPublishedAt
- }
- }
-
- private extractVideoImports (result: ResultList<VideoImport>): Observable<ResultList<VideoImport>> {
- return this.serverService.getServerLocale()
- .pipe(
- map(translations => {
- result.data.forEach(d =>
- d.state.label = peertubeTranslate(d.state.label, translations)
- )
-
- return result
- })
- )
- }
-}
+++ /dev/null
-export * from './video-ownership.service'
+++ /dev/null
-import { catchError, map } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { environment } from '../../../environments/environment'
-import { RestExtractor, RestService } from '../rest'
-import { VideoChangeOwnershipCreate } from '../../../../../shared/models/videos'
-import { Observable } from 'rxjs/index'
-import { SortMeta } from 'primeng/api'
-import { ResultList, VideoChangeOwnership } from '../../../../../shared'
-import { RestPagination } from '@app/shared/rest'
-import { VideoChangeOwnershipAccept } from '../../../../../shared/models/videos/video-change-ownership-accept.model'
-
-@Injectable()
-export class VideoOwnershipService {
- private static BASE_VIDEO_CHANGE_OWNERSHIP_URL = environment.apiUrl + '/api/v1/videos/'
-
- constructor (
- private authHttp: HttpClient,
- private restService: RestService,
- private restExtractor: RestExtractor
- ) {
- }
-
- changeOwnership (id: number, username: string) {
- const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + id + '/give-ownership'
- const body: VideoChangeOwnershipCreate = {
- username
- }
-
- return this.authHttp.post(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- getOwnershipChanges (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoChangeOwnership>> {
- const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership'
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- return this.authHttp.get<ResultList<VideoChangeOwnership>>(url, { params })
- .pipe(
- map(res => this.restExtractor.convertResultListDateToHuman(res)),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- acceptOwnership (id: number, input: VideoChangeOwnershipAccept) {
- const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/accept'
- return this.authHttp.post(url, input)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(this.restExtractor.handleError)
- )
- }
-
- refuseOwnership (id: number) {
- const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/refuse'
- return this.authHttp.post(url, {})
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(this.restExtractor.handleError)
- )
- }
-}
+++ /dev/null
-<div class="root">
- <div class="header">
- <div class="first-row">
- <div i18n class="title">Save to</div>
-
- <div class="options" (click)="displayOptions = !displayOptions">
- <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
-
- <span i18n>Options</span>
- </div>
- </div>
-
- <div class="options-row" *ngIf="displayOptions">
- <div>
- <my-peertube-checkbox
- inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
- i18n-labelText labelText="Start at"
- ></my-peertube-checkbox>
-
- <my-timestamp-input
- [timestamp]="timestampOptions.startTimestamp"
- [maxTimestamp]="video.duration"
- [disabled]="!timestampOptions.startTimestampEnabled"
- [(ngModel)]="timestampOptions.startTimestamp"
- ></my-timestamp-input>
- </div>
-
- <div>
- <my-peertube-checkbox
- inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
- i18n-labelText labelText="Stop at"
- ></my-peertube-checkbox>
-
- <my-timestamp-input
- [timestamp]="timestampOptions.stopTimestamp"
- [maxTimestamp]="video.duration"
- [disabled]="!timestampOptions.stopTimestampEnabled"
- [(ngModel)]="timestampOptions.stopTimestamp"
- ></my-timestamp-input>
- </div>
- </div>
- </div>
-
- <div class="input-container">
- <input type="text" placeholder="Search playlists" i18n-placeholder [(ngModel)]="videoPlaylistSearch" (ngModelChange)="onVideoPlaylistSearchChanged()" />
- </div>
-
- <div class="playlists">
- <div class="playlist dropdown-item" *ngFor="let playlist of videoPlaylists" (click)="togglePlaylist($event, playlist)">
- <my-peertube-checkbox [inputName]="'in-playlist-' + playlist.id" [(ngModel)]="playlist.inPlaylist" [onPushWorkaround]="true"></my-peertube-checkbox>
-
- <div class="display-name">
- {{ playlist.displayName }}
-
- <div *ngIf="playlist.inPlaylist && (playlist.startTimestamp || playlist.stopTimestamp)" class="timestamp-info">
- {{ formatTimestamp(playlist) }}
- </div>
- </div>
- </div>
- </div>
-
- <div class="new-playlist-button dropdown-item" (click)="openCreateBlock($event)" [hidden]="isNewPlaylistBlockOpened">
- <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
-
- <span i18n>Create a private playlist</span>
- </div>
-
- <form class="new-playlist-block dropdown-item" *ngIf="isNewPlaylistBlockOpened" (ngSubmit)="createPlaylist()" [formGroup]="form">
- <div class="form-group">
- <label i18n for="displayName">Display name</label>
- <input
- type="text" id="displayName"
- formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
- >
- <div *ngIf="formErrors['displayName']" class="form-error">
- {{ formErrors['displayName'] }}
- </div>
- </div>
-
- <input type="submit" i18n-value value="Create" [disabled]="!form.valid">
- </form>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.header,
-.dropdown-item,
-.input-container {
- padding: 8px 24px;
-}
-
-.header {
- min-width: 240px;
- margin-bottom: 10px;
- border-bottom: 1px solid $separator-border-color;
-
- .first-row {
- display: flex;
- align-items: center;
-
- .title {
- font-size: 18px;
- flex-grow: 1;
- }
-
- .options {
- display: flex;
- align-items: center;
- font-size: 14px;
- cursor: pointer;
-
- my-global-icon {
- @include apply-svg-color(#333);
-
- width: 16px;
- height: 23px;
- margin-right: 3px;
- }
- }
- }
-
- .options-row {
- margin-top: 10px;
- padding-left: 10px;
-
- > div {
- display: flex;
- align-items: center;
- }
- }
-}
-
-.playlists {
- max-height: 180px;
- overflow-y: auto;
-}
-
-.playlist {
- display: inline-flex;
- cursor: pointer;
-
- my-peertube-checkbox {
- margin-right: 10px;
- align-self: center;
- }
-
- .display-name {
- display: flex;
- align-items: flex-end;
-
- .timestamp-info {
- font-size: 0.9em;
- color: pvar(--greyForegroundColor);
- margin-left: 5px;
- }
- }
-}
-
-.new-playlist-button,
-.new-playlist-block {
- padding-top: 10px;
- border-top: 1px solid $separator-border-color;
-}
-
-.new-playlist-button {
- cursor: pointer;
-
- my-global-icon {
- @include apply-svg-color(#333);
-
- position: relative;
- left: -1px;
- top: -1px;
- margin-right: 4px;
- width: 21px;
- height: 21px;
- }
-}
-
-input[type=text] {
- @include peertube-input-text(200px);
-
- display: block;
-}
-
-input[type=submit] {
- @include peertube-button;
- @include orange-button;
-}
+++ /dev/null
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
-import { CachedPlaylist, VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { AuthService, Notifier } from '@app/core'
-import { Subject, Subscription } from 'rxjs'
-import { debounceTime, filter } from 'rxjs/operators'
-import { Video, VideoPlaylistCreate, VideoPlaylistElementCreate, VideoPlaylistPrivacy } from '@shared/models'
-import { FormReactive, FormValidatorService, VideoPlaylistValidatorsService } from '@app/shared/forms'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { secondsToTime } from '../../../assets/player/utils'
-import * as debug from 'debug'
-import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
-import { VideoExistInPlaylist } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
-
-const logger = debug('peertube:playlists:VideoAddToPlaylistComponent')
-
-type PlaylistSummary = {
- id: number
- inPlaylist: boolean
- displayName: string
-
- playlistElementId?: number
- startTimestamp?: number
- stopTimestamp?: number
-}
-
-@Component({
- selector: 'my-video-add-to-playlist',
- styleUrls: [ './video-add-to-playlist.component.scss' ],
- templateUrl: './video-add-to-playlist.component.html',
- changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class VideoAddToPlaylistComponent extends FormReactive implements OnInit, OnChanges, OnDestroy, DisableForReuseHook {
- @Input() video: Video
- @Input() currentVideoTimestamp: number
- @Input() lazyLoad = false
-
- isNewPlaylistBlockOpened = false
- videoPlaylistSearch: string
- videoPlaylistSearchChanged = new Subject<string>()
- videoPlaylists: PlaylistSummary[] = []
- timestampOptions: {
- startTimestampEnabled: boolean
- startTimestamp: number
- stopTimestampEnabled: boolean
- stopTimestamp: number
- }
- displayOptions = false
-
- private disabled = false
-
- private listenToPlaylistChangeSub: Subscription
- private playlistsData: CachedPlaylist[] = []
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private authService: AuthService,
- private notifier: Notifier,
- private i18n: I18n,
- private videoPlaylistService: VideoPlaylistService,
- private videoPlaylistValidatorsService: VideoPlaylistValidatorsService,
- private cd: ChangeDetectorRef
- ) {
- super()
- }
-
- get user () {
- return this.authService.getUser()
- }
-
- ngOnInit () {
- this.buildForm({
- displayName: this.videoPlaylistValidatorsService.VIDEO_PLAYLIST_DISPLAY_NAME
- })
-
- this.videoPlaylistService.listenToMyAccountPlaylistsChange()
- .subscribe(result => {
- this.playlistsData = result.data
-
- this.videoPlaylistService.runPlaylistCheck(this.video.id)
- })
-
- this.videoPlaylistSearchChanged
- .pipe(debounceTime(500))
- .subscribe(() => this.load())
-
- if (this.lazyLoad === false) this.load()
- }
-
- ngOnChanges (simpleChanges: SimpleChanges) {
- if (simpleChanges['video']) {
- this.reload()
- }
- }
-
- ngOnDestroy () {
- this.unsubscribePlaylistChanges()
- }
-
- disableForReuse () {
- this.disabled = true
- }
-
- enabledForReuse () {
- this.disabled = false
- }
-
- reload () {
- logger('Reloading component')
-
- this.videoPlaylists = []
- this.videoPlaylistSearch = undefined
-
- this.resetOptions(true)
- this.load()
-
- this.cd.markForCheck()
- }
-
- load () {
- logger('Loading component')
-
- this.listenToPlaylistChanges()
-
- this.videoPlaylistService.listMyPlaylistWithCache(this.user, this.videoPlaylistSearch)
- .subscribe(playlistsResult => {
- this.playlistsData = playlistsResult.data
-
- this.videoPlaylistService.runPlaylistCheck(this.video.id)
- })
- }
-
- openChange (opened: boolean) {
- if (opened === false) {
- this.isNewPlaylistBlockOpened = false
- this.displayOptions = false
- }
- }
-
- openCreateBlock (event: Event) {
- event.preventDefault()
-
- this.isNewPlaylistBlockOpened = true
- }
-
- togglePlaylist (event: Event, playlist: PlaylistSummary) {
- event.preventDefault()
-
- if (playlist.inPlaylist === true) {
- this.removeVideoFromPlaylist(playlist)
- } else {
- this.addVideoInPlaylist(playlist)
- }
-
- playlist.inPlaylist = !playlist.inPlaylist
- this.resetOptions()
-
- this.cd.markForCheck()
- }
-
- createPlaylist () {
- const displayName = this.form.value[ 'displayName' ]
-
- const videoPlaylistCreate: VideoPlaylistCreate = {
- displayName,
- privacy: VideoPlaylistPrivacy.PRIVATE
- }
-
- this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
- () => {
- this.isNewPlaylistBlockOpened = false
-
- this.cd.markForCheck()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- resetOptions (resetTimestamp = false) {
- this.displayOptions = false
-
- this.timestampOptions = {} as any
- this.timestampOptions.startTimestampEnabled = false
- this.timestampOptions.stopTimestampEnabled = false
-
- if (resetTimestamp) {
- this.timestampOptions.startTimestamp = 0
- this.timestampOptions.stopTimestamp = this.video.duration
- }
- }
-
- formatTimestamp (playlist: PlaylistSummary) {
- const start = playlist.startTimestamp ? secondsToTime(playlist.startTimestamp) : ''
- const stop = playlist.stopTimestamp ? secondsToTime(playlist.stopTimestamp) : ''
-
- return `(${start}-${stop})`
- }
-
- onVideoPlaylistSearchChanged () {
- this.videoPlaylistSearchChanged.next()
- }
-
- private removeVideoFromPlaylist (playlist: PlaylistSummary) {
- if (!playlist.playlistElementId) return
-
- this.videoPlaylistService.removeVideoFromPlaylist(playlist.id, playlist.playlistElementId, this.video.id)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Video removed from {{name}}', { name: playlist.displayName }))
- },
-
- err => {
- this.notifier.error(err.message)
- },
-
- () => this.cd.markForCheck()
- )
- }
-
- private listenToPlaylistChanges () {
- this.unsubscribePlaylistChanges()
-
- this.listenToPlaylistChangeSub = this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)
- .pipe(filter(() => this.disabled === false))
- .subscribe(existResult => this.rebuildPlaylists(existResult))
- }
-
- private unsubscribePlaylistChanges () {
- if (this.listenToPlaylistChangeSub) {
- this.listenToPlaylistChangeSub.unsubscribe()
- this.listenToPlaylistChangeSub = undefined
- }
- }
-
- private rebuildPlaylists (existResult: VideoExistInPlaylist[]) {
- logger('Got existing results for %d.', this.video.id, existResult)
-
- this.videoPlaylists = []
- for (const playlist of this.playlistsData) {
- const existingPlaylist = existResult.find(p => p.playlistId === playlist.id)
-
- this.videoPlaylists.push({
- id: playlist.id,
- displayName: playlist.displayName,
- inPlaylist: !!existingPlaylist,
- playlistElementId: existingPlaylist ? existingPlaylist.playlistElementId : undefined,
- startTimestamp: existingPlaylist ? existingPlaylist.startTimestamp : undefined,
- stopTimestamp: existingPlaylist ? existingPlaylist.stopTimestamp : undefined
- })
- }
-
- logger('Rebuilt playlist state for video %d.', this.video.id, this.videoPlaylists)
-
- this.cd.markForCheck()
- }
-
- private addVideoInPlaylist (playlist: PlaylistSummary) {
- const body: VideoPlaylistElementCreate = { videoId: this.video.id }
-
- if (this.timestampOptions.startTimestampEnabled) body.startTimestamp = this.timestampOptions.startTimestamp
- if (this.timestampOptions.stopTimestampEnabled) body.stopTimestamp = this.timestampOptions.stopTimestamp
-
- this.videoPlaylistService.addVideoInPlaylist(playlist.id, body)
- .subscribe(
- () => {
- const message = body.startTimestamp || body.stopTimestamp
- ? this.i18n('Video added in {{n}} at timestamps {{t}}', { n: playlist.displayName, t: this.formatTimestamp(playlist) })
- : this.i18n('Video added in {{n}}', { n: playlist.displayName })
-
- this.notifier.success(message)
- },
-
- err => {
- this.notifier.error(err.message)
- },
-
- () => this.cd.markForCheck()
- )
- }
-}
+++ /dev/null
-<div class="video" [ngClass]="{ playing: playing }">
- <a [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()">
- <div class="position">
- <my-global-icon *ngIf="playing" iconName="play"></my-global-icon>
- <ng-container *ngIf="!playing">{{ position }}</ng-container>
- </div>
-
- <my-video-thumbnail
- *ngIf="playlistElement.video"
- [video]="playlistElement.video" [nsfw]="isVideoBlur(playlistElement.video)"
- [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
- ></my-video-thumbnail>
-
- <div class="fake-thumbnail" *ngIf="!playlistElement.video"></div>
-
- <div class="video-info">
- <ng-container *ngIf="playlistElement.video">
- <a tabindex="-1" class="video-info-name"
- [routerLink]="buildRouterLink()" [queryParams]="buildRouterQuery()"
- [attr.title]="playlistElement.video.name"
- >{{ playlistElement.video.name }}</a>
-
- <a *ngIf="accountLink" tabindex="-1" class="video-info-account" [routerLink]="[ '/accounts', playlistElement.video.byAccount ]">
- {{ playlistElement.video.byAccount }}
- </a>
- <span *ngIf="!accountLink" tabindex="-1" class="video-info-account">{{ playlistElement.video.byAccount }}</span>
-
- <span tabindex="-1" class="video-info-timestamp">{{ formatTimestamp(playlistElement) }}</span>
- </ng-container>
-
- <span *ngIf="!playlistElement.video" class="video-info-name">
- <ng-container i18n *ngIf="isUnavailable(playlistElement)">Unavailable</ng-container>
- <ng-container i18n *ngIf="isPrivate(playlistElement)">Private</ng-container>
- <ng-container i18n *ngIf="isDeleted(playlistElement)">Deleted</ng-container>
- </span>
- </div>
- </a>
-
- <my-edit-button *ngIf="owned && touchScreenEditButton" [routerLink]="[ '/my-account', 'video-playlists', playlist.uuid ]"></my-edit-button>
-
- <div *ngIf="owned" class="more" ngbDropdown #moreDropdown="ngbDropdown" placement="bottom auto"
- (openChange)="onDropdownOpenChange()" autoClose="outside"
- >
- <my-global-icon iconName="more-vertical" ngbDropdownToggle role="button" class="icon-more" (click)="$event.preventDefault()"></my-global-icon>
-
- <div ngbDropdownMenu>
- <ng-container *ngIf="playlistElement.video">
- <div class="dropdown-item" (click)="toggleDisplayTimestampsOptions($event, playlistElement)">
- <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
- <ng-container i18n>Edit starts/stops at</ng-container>
- </div>
-
- <div class="timestamp-options" *ngIf="displayTimestampOptions">
- <div>
- <my-peertube-checkbox
- inputName="startAt" [(ngModel)]="timestampOptions.startTimestampEnabled"
- i18n-labelText labelText="Start at"
- ></my-peertube-checkbox>
-
- <my-timestamp-input
- [timestamp]="timestampOptions.startTimestamp"
- [maxTimestamp]="playlistElement.video.duration"
- [disabled]="!timestampOptions.startTimestampEnabled"
- [(ngModel)]="timestampOptions.startTimestamp"
- ></my-timestamp-input>
- </div>
-
- <div>
- <my-peertube-checkbox
- inputName="stopAt" [(ngModel)]="timestampOptions.stopTimestampEnabled"
- i18n-labelText labelText="Stop at"
- ></my-peertube-checkbox>
-
- <my-timestamp-input
- [timestamp]="timestampOptions.stopTimestamp"
- [maxTimestamp]="playlistElement.video.duration"
- [disabled]="!timestampOptions.stopTimestampEnabled"
- [(ngModel)]="timestampOptions.stopTimestamp"
- ></my-timestamp-input>
- </div>
-
- <input type="submit" i18n-value value="Save" (click)="updateTimestamps(playlistElement)">
- </div>
- </ng-container>
-
- <span class="dropdown-item" (click)="removeFromPlaylist(playlistElement)">
- <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
- <ng-container i18n>Delete from {{ playlist?.displayName }}</ng-container>
- </span>
- </div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-$thumbnail-width: 130px;
-$thumbnail-height: 72px;
-
-my-video-thumbnail {
- @include thumbnail-size-component($thumbnail-width, $thumbnail-height);
-}
-
-.fake-thumbnail {
- width: $thumbnail-width;
- height: $thumbnail-height;
- background-color: #ececec;
-}
-
-my-video-thumbnail,
-.fake-thumbnail {
- display: flex; // Avoids an issue with line-height that adds space below the element
- margin-right: 10px;
-}
-
-.video {
- display: flex;
- align-items: center;
- background-color: pvar(--mainBackgroundColor);
- padding: 10px;
- border-bottom: 1px solid $separator-border-color;
-
- &:hover {
- background-color: rgba(0, 0, 0, 0.05);
-
- .more {
- opacity: 1;
- }
- }
-
- @media not all and (hover: hover) and (pointer: fine) {
- .more {
- opacity: 1 !important;
- }
- }
-
- &.playing {
- background-color: rgba(0, 0, 0, 0.02);
- }
-
- a {
- @include disable-default-a-behaviour;
-
- color: pvar(--mainForegroundColor);
- display: flex;
- min-width: 0;
- align-items: center;
- cursor: pointer;
-
- .position {
- font-weight: $font-semibold;
- margin-right: 10px;
- color: pvar(--greyForegroundColor);
- min-width: 25px;
-
- my-global-icon {
- @include apply-svg-color(pvar(--greyForegroundColor));
-
- width: 17px;
- position: relative;
- left: -2px;
- }
- }
-
- .video-info {
- display: flex;
- flex-direction: column;
- align-self: flex-start;
- min-width: 0;
-
- a {
- width: auto;
- }
-
- .video-info-account, .video-info-timestamp {
- color: pvar(--greyForegroundColor);
- }
- }
- }
-
- .video-info-name {
- font-size: 18px;
- font-weight: $font-semibold;
- display: inline-block;
-
- @include ellipsis;
- }
-
- .more, my-edit-button {
- justify-self: flex-end;
- margin-left: auto;
- cursor: pointer;
- min-width: 24px;
- }
-
- .more {
- opacity: 0;
-
- &.show {
- opacity: 1;
- }
-
- .icon-more {
- @include apply-svg-color(pvar(--greyForegroundColor));
-
- display: flex;
-
- &::after {
- border: none;
- }
- }
-
- .dropdown-item {
- @include dropdown-with-icon-item;
- }
-
- .timestamp-options {
- padding-top: 0;
- padding-left: 35px;
- margin-bottom: 15px;
-
- > div {
- display: flex;
- align-items: center;
- }
-
- input {
- @include peertube-button;
- @include orange-button;
-
- margin-top: 10px;
- }
- }
- }
-}
-
-@mixin more-dropdown-control {
- .video {
- my-edit-button {
- display: none;
-
- + .more {
- display: inline-flex;
- }
- }
- }
-}
-
-@mixin edit-button-control {
- .video {
- my-edit-button {
- display: none;
- }
-
- &.playing {
- my-edit-button {
- display: inline-flex;
- height: max-content;
- }
- }
-
- my-edit-button + .more {
- display: none;
- }
- }
-}
-
-@mixin edit-button-in-mobile-view {
- .video {
- my-edit-button {
- ::ng-deep .action-button-edit {
- padding: 0 13px;
-
- .button-label {
- display: none;
- }
- }
- }
- }
-}
-
-@media screen and (min-width: $small-view) {
- :host-context(.expanded) {
- @include more-dropdown-control();
- }
-}
-
-@media screen and (max-width: $small-view) {
- :host-context(.expanded) {
- @include edit-button-control();
- }
-}
-
-@media screen and (max-width: $mobile-view) {
- :host-context(.expanded) {
- @include edit-button-in-mobile-view();
- }
-}
-
-@media screen and (min-width: #{$small-view + $menu-width}) {
- :host-context(.main-col:not(.expanded)) {
- @include more-dropdown-control();
- }
-}
-
-@media screen and (max-width: #{$small-view + $menu-width}) {
- :host-context(.main-col:not(.expanded)) {
- @include edit-button-control();
- }
-}
-
-@media screen and (max-width: #{$mobile-view + $menu-width}) {
- :host-context(.main-col:not(.expanded)) {
- @include edit-button-in-mobile-view();
- }
-}
+++ /dev/null
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { Video } from '@app/shared/video/video.model'
-import { ServerConfig, VideoPlaylistElementType, VideoPlaylistElementUpdate } from '@shared/models'
-import { AuthService, ConfirmService, Notifier, ServerService } from '@app/core'
-import { ActivatedRoute } from '@angular/router'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoService } from '@app/shared/video/video.service'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { secondsToTime } from '../../../assets/player/utils'
-import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
-
-@Component({
- selector: 'my-video-playlist-element-miniature',
- styleUrls: [ './video-playlist-element-miniature.component.scss' ],
- templateUrl: './video-playlist-element-miniature.component.html',
- changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class VideoPlaylistElementMiniatureComponent implements OnInit {
- @ViewChild('moreDropdown') moreDropdown: NgbDropdown
-
- @Input() playlist: VideoPlaylist
- @Input() playlistElement: VideoPlaylistElement
- @Input() owned = false
- @Input() playing = false
- @Input() rowLink = false
- @Input() accountLink = true
- @Input() position: number // Keep this property because we're in the OnPush change detection strategy
- @Input() touchScreenEditButton = false
-
- @Output() elementRemoved = new EventEmitter<VideoPlaylistElement>()
-
- displayTimestampOptions = false
-
- timestampOptions: {
- startTimestampEnabled: boolean
- startTimestamp: number
- stopTimestampEnabled: boolean
- stopTimestamp: number
- } = {} as any
-
- private serverConfig: ServerConfig
-
- constructor (
- private authService: AuthService,
- private serverService: ServerService,
- private notifier: Notifier,
- private confirmService: ConfirmService,
- private route: ActivatedRoute,
- private i18n: I18n,
- private videoService: VideoService,
- private videoPlaylistService: VideoPlaylistService,
- private cdr: ChangeDetectorRef
- ) {}
-
- ngOnInit (): void {
- this.serverConfig = this.serverService.getTmpConfig()
- this.serverService.getConfig()
- .subscribe(config => {
- this.serverConfig = config
- this.cdr.detectChanges()
- })
- }
-
- isUnavailable (e: VideoPlaylistElement) {
- return e.type === VideoPlaylistElementType.UNAVAILABLE
- }
-
- isPrivate (e: VideoPlaylistElement) {
- return e.type === VideoPlaylistElementType.PRIVATE
- }
-
- isDeleted (e: VideoPlaylistElement) {
- return e.type === VideoPlaylistElementType.DELETED
- }
-
- buildRouterLink () {
- if (!this.playlist) return null
-
- return [ '/videos/watch/playlist', this.playlist.uuid ]
- }
-
- buildRouterQuery () {
- if (!this.playlistElement || !this.playlistElement.video) return {}
-
- return {
- videoId: this.playlistElement.video.uuid,
- start: this.playlistElement.startTimestamp,
- stop: this.playlistElement.stopTimestamp,
- resume: true
- }
- }
-
- isVideoBlur (video: Video) {
- return video.isVideoNSFWForUser(this.authService.getUser(), this.serverConfig)
- }
-
- removeFromPlaylist (playlistElement: VideoPlaylistElement) {
- const videoId = this.playlistElement.video ? this.playlistElement.video.id : undefined
-
- this.videoPlaylistService.removeVideoFromPlaylist(this.playlist.id, playlistElement.id, videoId)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Video removed from {{name}}', { name: this.playlist.displayName }))
-
- this.elementRemoved.emit(playlistElement)
- },
-
- err => this.notifier.error(err.message)
- )
-
- this.moreDropdown.close()
- }
-
- updateTimestamps (playlistElement: VideoPlaylistElement) {
- const body: VideoPlaylistElementUpdate = {}
-
- body.startTimestamp = this.timestampOptions.startTimestampEnabled ? this.timestampOptions.startTimestamp : null
- body.stopTimestamp = this.timestampOptions.stopTimestampEnabled ? this.timestampOptions.stopTimestamp : null
-
- this.videoPlaylistService.updateVideoOfPlaylist(this.playlist.id, playlistElement.id, body, this.playlistElement.video.id)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Timestamps updated'))
-
- playlistElement.startTimestamp = body.startTimestamp
- playlistElement.stopTimestamp = body.stopTimestamp
-
- this.cdr.detectChanges()
- },
-
- err => this.notifier.error(err.message)
- )
-
- this.moreDropdown.close()
- }
-
- formatTimestamp (playlistElement: VideoPlaylistElement) {
- const start = playlistElement.startTimestamp
- const stop = playlistElement.stopTimestamp
-
- const startFormatted = secondsToTime(start, true, ':')
- const stopFormatted = secondsToTime(stop, true, ':')
-
- if (start === null && stop === null) return ''
-
- if (start !== null && stop === null) return this.i18n('Starts at ') + startFormatted
- if (start === null && stop !== null) return this.i18n('Stops at ') + stopFormatted
-
- return this.i18n('Starts at ') + startFormatted + this.i18n(' and stops at ') + stopFormatted
- }
-
- onDropdownOpenChange () {
- this.displayTimestampOptions = false
- }
-
- toggleDisplayTimestampsOptions (event: Event, playlistElement: VideoPlaylistElement) {
- event.preventDefault()
-
- this.displayTimestampOptions = !this.displayTimestampOptions
-
- if (this.displayTimestampOptions === true) {
- this.timestampOptions = {
- startTimestampEnabled: false,
- stopTimestampEnabled: false,
- startTimestamp: 0,
- stopTimestamp: playlistElement.video.duration
- }
-
- if (playlistElement.startTimestamp) {
- this.timestampOptions.startTimestampEnabled = true
- this.timestampOptions.startTimestamp = playlistElement.startTimestamp
- }
-
- if (playlistElement.stopTimestamp) {
- this.timestampOptions.stopTimestampEnabled = true
- this.timestampOptions.stopTimestamp = playlistElement.stopTimestamp
- }
- }
-
- // FIXME: why do we have to use setTimeout here?
- setTimeout(() => {
- this.cdr.detectChanges()
- })
- }
-}
+++ /dev/null
-import { VideoPlaylistElement as ServerVideoPlaylistElement, VideoPlaylistElementType } from '../../../../../shared/models/videos'
-import { Video } from '@app/shared/video/video.model'
-
-export class VideoPlaylistElement implements ServerVideoPlaylistElement {
- id: number
- position: number
- startTimestamp: number
- stopTimestamp: number
-
- type: VideoPlaylistElementType
-
- video?: Video
-
- constructor (hash: ServerVideoPlaylistElement, translations: {}) {
- this.id = hash.id
- this.position = hash.position
- this.startTimestamp = hash.startTimestamp
- this.stopTimestamp = hash.stopTimestamp
-
- this.type = hash.type
-
- if (hash.video) this.video = new Video(hash.video, translations)
- }
-}
+++ /dev/null
-<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage }">
- <a
- [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description"
- class="miniature-thumbnail"
- >
- <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
-
- <div class="miniature-playlist-info-overlay">
- <ng-container i18n>{playlist.videosLength, plural, =0 {No videos} =1 {1 video} other {{{ playlist.videosLength }} videos}}</ng-container>
- </div>
-
- <div class="play-overlay">
- <div class="icon"></div>
- </div>
- </a>
-
- <div class="miniature-info">
- <a tabindex="-1" class="miniature-name" [routerLink]="getPlaylistUrl()" [attr.title]="playlist.description">
- {{ playlist.displayName }}
- </a>
-
- <a i18n [routerLink]="[ '/video-channels', playlist.videoChannelBy ]" class="by" *ngIf="displayChannel && playlist.videoChannelBy">
- {{ playlist.videoChannelBy }}
- </a>
-
- <div class="privacy-date">
- <span class="video-info-privacy" *ngIf="displayPrivacy">{{ playlist.privacy.label }}</span>
-
- <span i18n class="updated-at">Updated {{ playlist.updatedAt | myFromNow }}</span>
- </div>
-
- <div *ngIf="displayDescription" class="video-info-description">{{ playlist.description }}</div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-.miniature {
- display: inline-block;
-
- &.no-videos:not(.to-manage){
- a {
- cursor: default !important;
- }
- }
-
- &.to-manage,
- &.no-videos {
- .play-overlay {
- display: none;
- }
- }
-
- .miniature-thumbnail {
- @include miniature-thumbnail;
-
- .miniature-playlist-info-overlay {
- @include static-thumbnail-overlay;
-
- position: absolute;
- right: 0;
- bottom: 0;
- height: $video-thumbnail-height;
- padding: 0 10px;
- display: flex;
- align-items: center;
- font-size: 14px;
- font-weight: $font-semibold;
- }
- }
-
- .miniature-info {
- width: 200px;
- margin-top: 2px;
- line-height: normal;
-
- .miniature-name {
- @include miniature-name;
-
- @include ellipsis-multiline(1.3em, 2);
-
- margin: 0;
- }
-
- .by {
- @include disable-default-a-behaviour;
-
- display: block;
- color: pvar(--greyForegroundColor);
- }
-
- .privacy-date {
- margin-top: 5px;
-
- .video-info-privacy {
- font-size: 14px;
- font-weight: $font-semibold;
-
- &::after {
- content: '-';
- margin: 0 3px;
- }
- }
- }
-
- .video-info-description {
- margin-top: 10px;
- color: pvar(--greyForegroundColor);
- }
- }
-}
+++ /dev/null
-import { Component, Input } from '@angular/core'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-
-@Component({
- selector: 'my-video-playlist-miniature',
- styleUrls: [ './video-playlist-miniature.component.scss' ],
- templateUrl: './video-playlist-miniature.component.html'
-})
-export class VideoPlaylistMiniatureComponent {
- @Input() playlist: VideoPlaylist
- @Input() toManage = false
- @Input() displayChannel = false
- @Input() displayDescription = false
- @Input() displayPrivacy = false
-
- getPlaylistUrl () {
- if (this.toManage) return [ '/my-account/video-playlists', this.playlist.uuid ]
- if (this.playlist.videosLength === 0) return null
-
- return [ '/videos/watch/playlist', this.playlist.uuid ]
- }
-}
+++ /dev/null
-import {
- VideoChannelSummary,
- VideoConstant,
- VideoPlaylist as ServerVideoPlaylist,
- VideoPlaylistPrivacy,
- VideoPlaylistType
-} from '../../../../../shared/models/videos'
-import { AccountSummary, peertubeTranslate } from '@shared/models'
-import { Actor } from '@app/shared/actor/actor.model'
-import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
-
-export class VideoPlaylist implements ServerVideoPlaylist {
- id: number
- uuid: string
- isLocal: boolean
-
- displayName: string
- description: string
- privacy: VideoConstant<VideoPlaylistPrivacy>
-
- thumbnailPath: string
-
- videosLength: number
-
- type: VideoConstant<VideoPlaylistType>
-
- createdAt: Date | string
- updatedAt: Date | string
-
- ownerAccount: AccountSummary
- videoChannel?: VideoChannelSummary
-
- thumbnailUrl: string
-
- ownerBy: string
- ownerAvatarUrl: string
-
- videoChannelBy?: string
- videoChannelAvatarUrl?: string
-
- private thumbnailVersion: number
- private originThumbnailUrl: string
-
- constructor (hash: ServerVideoPlaylist, translations: {}) {
- const absoluteAPIUrl = getAbsoluteAPIUrl()
-
- this.id = hash.id
- this.uuid = hash.uuid
- this.isLocal = hash.isLocal
-
- this.displayName = hash.displayName
-
- this.description = hash.description
- this.privacy = hash.privacy
-
- this.thumbnailPath = hash.thumbnailPath
-
- if (this.thumbnailPath) {
- this.thumbnailUrl = absoluteAPIUrl + hash.thumbnailPath
- this.originThumbnailUrl = this.thumbnailUrl
- } else {
- this.thumbnailUrl = window.location.origin + '/client/assets/images/default-playlist.jpg'
- }
-
- this.videosLength = hash.videosLength
-
- this.type = hash.type
-
- this.createdAt = new Date(hash.createdAt)
- this.updatedAt = new Date(hash.updatedAt)
-
- this.ownerAccount = hash.ownerAccount
- this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
- this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
-
- if (hash.videoChannel) {
- this.videoChannel = hash.videoChannel
- this.videoChannelBy = Actor.CREATE_BY_STRING(hash.videoChannel.name, hash.videoChannel.host)
- this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.videoChannel)
- }
-
- this.privacy.label = peertubeTranslate(this.privacy.label, translations)
-
- if (this.type.id === VideoPlaylistType.WATCH_LATER) {
- this.displayName = peertubeTranslate(this.displayName, translations)
- }
- }
-
- refreshThumbnail () {
- if (!this.originThumbnailUrl) return
-
- if (!this.thumbnailVersion) this.thumbnailVersion = 0
- this.thumbnailVersion++
-
- this.thumbnailUrl = this.originThumbnailUrl + '?v' + this.thumbnailVersion
- }
-}
+++ /dev/null
-import { bufferTime, catchError, filter, map, observeOn, share, switchMap, tap } from 'rxjs/operators'
-import { Injectable, NgZone } from '@angular/core'
-import { asyncScheduler, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'
-import { RestExtractor } from '../rest/rest-extractor.service'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { ResultList, VideoPlaylistElementCreate, VideoPlaylistElementUpdate } from '../../../../../shared'
-import { environment } from '../../../environments/environment'
-import { VideoPlaylist as VideoPlaylistServerModel } from '@shared/models/videos/playlist/video-playlist.model'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
-import { VideoPlaylistUpdate } from '@shared/models/videos/playlist/video-playlist-update.model'
-import { objectToFormData } from '@app/shared/misc/utils'
-import { AuthUser, ServerService } from '@app/core'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { AccountService } from '@app/shared/account/account.service'
-import { Account } from '@app/shared/account/account.model'
-import { RestService } from '@app/shared/rest'
-import { VideoExistInPlaylist, VideosExistInPlaylists } from '@shared/models/videos/playlist/video-exist-in-playlist.model'
-import { VideoPlaylistReorder } from '@shared/models/videos/playlist/video-playlist-reorder.model'
-import { ComponentPaginationLight } from '@app/shared/rest/component-pagination.model'
-import { VideoPlaylistElement as ServerVideoPlaylistElement } from '@shared/models/videos/playlist/video-playlist-element.model'
-import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
-import { uniq } from 'lodash-es'
-import * as debug from 'debug'
-import { enterZone, leaveZone } from '@app/shared/rxjs/zone'
-
-const logger = debug('peertube:playlists:VideoPlaylistService')
-
-export type CachedPlaylist = VideoPlaylist | { id: number, displayName: string }
-
-@Injectable()
-export class VideoPlaylistService {
- static BASE_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/video-playlists/'
- static MY_VIDEO_PLAYLIST_URL = environment.apiUrl + '/api/v1/users/me/video-playlists/'
-
- // Use a replay subject because we "next" a value before subscribing
- private videoExistsInPlaylistNotifier = new ReplaySubject<number>(1)
- private videoExistsInPlaylistCacheSubject = new Subject<VideosExistInPlaylists>()
- private readonly videoExistsInPlaylistObservable: Observable<VideosExistInPlaylists>
-
- private videoExistsObservableCache: { [ id: number ]: Observable<VideoExistInPlaylist[]> } = {}
- private videoExistsCache: { [ id: number ]: VideoExistInPlaylist[] } = {}
-
- private myAccountPlaylistCache: ResultList<CachedPlaylist> = undefined
- private myAccountPlaylistCacheRunning: Observable<ResultList<CachedPlaylist>>
- private myAccountPlaylistCacheSubject = new Subject<ResultList<CachedPlaylist>>()
-
- constructor (
- private authHttp: HttpClient,
- private serverService: ServerService,
- private restExtractor: RestExtractor,
- private restService: RestService,
- private ngZone: NgZone
- ) {
- this.videoExistsInPlaylistObservable = merge(
- this.videoExistsInPlaylistNotifier.pipe(
- // We leave Angular zone so Protractor does not get stuck
- bufferTime(500, leaveZone(this.ngZone, asyncScheduler)),
- filter(videoIds => videoIds.length !== 0),
- map(videoIds => uniq(videoIds)),
- observeOn(enterZone(this.ngZone, asyncScheduler)),
- switchMap(videoIds => this.doVideosExistInPlaylist(videoIds)),
- share()
- ),
-
- this.videoExistsInPlaylistCacheSubject
- )
- }
-
- listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
- const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
- const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination)
-
- return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
- .pipe(
- switchMap(res => this.extractPlaylists(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- listMyPlaylistWithCache (user: AuthUser, search?: string) {
- if (!search) {
- if (this.myAccountPlaylistCacheRunning) return this.myAccountPlaylistCacheRunning
- if (this.myAccountPlaylistCache) return of(this.myAccountPlaylistCache)
- }
-
- const obs = this.listAccountPlaylists(user.account, undefined, '-updatedAt', search)
- .pipe(
- tap(result => {
- if (!search) {
- this.myAccountPlaylistCacheRunning = undefined
- this.myAccountPlaylistCache = result
- }
- }),
- share()
- )
-
- if (!search) this.myAccountPlaylistCacheRunning = obs
- return obs
- }
-
- listAccountPlaylists (
- account: Account,
- componentPagination: ComponentPaginationLight,
- sort: string,
- search?: string
- ): Observable<ResultList<VideoPlaylist>> {
- const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
- const pagination = componentPagination
- ? this.restService.componentPaginationToRestPagination(componentPagination)
- : undefined
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
- if (search) params = this.restService.addObjectParams(params, { search })
-
- return this.authHttp.get<ResultList<VideoPlaylist>>(url, { params })
- .pipe(
- switchMap(res => this.extractPlaylists(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getVideoPlaylist (id: string | number) {
- const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + id
-
- return this.authHttp.get<VideoPlaylist>(url)
- .pipe(
- switchMap(res => this.extractPlaylist(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- createVideoPlaylist (body: VideoPlaylistCreate) {
- const data = objectToFormData(body)
-
- return this.authHttp.post<{ videoPlaylist: { id: number } }>(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL, data)
- .pipe(
- tap(res => {
- if (!this.myAccountPlaylistCache) return
-
- this.myAccountPlaylistCache.total++
-
- this.myAccountPlaylistCache.data.push({
- id: res.videoPlaylist.id,
- displayName: body.displayName
- })
-
- this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
- }),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- updateVideoPlaylist (videoPlaylist: VideoPlaylist, body: VideoPlaylistUpdate) {
- const data = objectToFormData(body)
-
- return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id, data)
- .pipe(
- map(this.restExtractor.extractDataBool),
- tap(() => {
- if (!this.myAccountPlaylistCache) return
-
- const playlist = this.myAccountPlaylistCache.data.find(p => p.id === videoPlaylist.id)
- playlist.displayName = body.displayName
-
- this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
- }),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- removeVideoPlaylist (videoPlaylist: VideoPlaylist) {
- return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylist.id)
- .pipe(
- map(this.restExtractor.extractDataBool),
- tap(() => {
- if (!this.myAccountPlaylistCache) return
-
- this.myAccountPlaylistCache.total--
- this.myAccountPlaylistCache.data = this.myAccountPlaylistCache.data
- .filter(p => p.id !== videoPlaylist.id)
-
- this.myAccountPlaylistCacheSubject.next(this.myAccountPlaylistCache)
- }),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- addVideoInPlaylist (playlistId: number, body: VideoPlaylistElementCreate) {
- const url = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos'
-
- return this.authHttp.post<{ videoPlaylistElement: { id: number } }>(url, body)
- .pipe(
- tap(res => {
- const existsResult = this.videoExistsCache[body.videoId]
- existsResult.push({
- playlistId,
- playlistElementId: res.videoPlaylistElement.id,
- startTimestamp: body.startTimestamp,
- stopTimestamp: body.stopTimestamp
- })
-
- this.runPlaylistCheck(body.videoId)
- }),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- updateVideoOfPlaylist (playlistId: number, playlistElementId: number, body: VideoPlaylistElementUpdate, videoId: number) {
- return this.authHttp.put(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- tap(() => {
- const existsResult = this.videoExistsCache[videoId]
- const elem = existsResult.find(e => e.playlistElementId === playlistElementId)
-
- elem.startTimestamp = body.startTimestamp
- elem.stopTimestamp = body.stopTimestamp
-
- this.runPlaylistCheck(videoId)
- }),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- removeVideoFromPlaylist (playlistId: number, playlistElementId: number, videoId?: number) {
- return this.authHttp.delete(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/' + playlistElementId)
- .pipe(
- map(this.restExtractor.extractDataBool),
- tap(() => {
- if (!videoId) return
-
- this.videoExistsCache[videoId] = this.videoExistsCache[videoId].filter(e => e.playlistElementId !== playlistElementId)
- this.runPlaylistCheck(videoId)
- }),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- reorderPlaylist (playlistId: number, oldPosition: number, newPosition: number) {
- const body: VideoPlaylistReorder = {
- startPosition: oldPosition,
- insertAfterPosition: newPosition
- }
-
- return this.authHttp.post(VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + playlistId + '/videos/reorder', body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getPlaylistVideos (
- videoPlaylistId: number | string,
- componentPagination: ComponentPaginationLight
- ): Observable<ResultList<VideoPlaylistElement>> {
- const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + videoPlaylistId + '/videos'
- const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination)
-
- return this.authHttp
- .get<ResultList<ServerVideoPlaylistElement>>(path, { params })
- .pipe(
- switchMap(res => this.extractVideoPlaylistElements(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- listenToMyAccountPlaylistsChange () {
- return this.myAccountPlaylistCacheSubject.asObservable()
- }
-
- listenToVideoPlaylistChange (videoId: number) {
- if (this.videoExistsObservableCache[ videoId ]) {
- return this.videoExistsObservableCache[ videoId ]
- }
-
- const obs = this.videoExistsInPlaylistObservable
- .pipe(
- map(existsResult => existsResult[ videoId ]),
- filter(r => !!r),
- tap(result => this.videoExistsCache[ videoId ] = result)
- )
-
- this.videoExistsObservableCache[ videoId ] = obs
- return obs
- }
-
- runPlaylistCheck (videoId: number) {
- logger('Running playlist check.')
-
- if (this.videoExistsCache[videoId]) {
- logger('Found cache for %d.', videoId)
-
- return this.videoExistsInPlaylistCacheSubject.next({ [videoId]: this.videoExistsCache[videoId] })
- }
-
- logger('Fetching from network for %d.', videoId)
- return this.videoExistsInPlaylistNotifier.next(videoId)
- }
-
- extractPlaylists (result: ResultList<VideoPlaylistServerModel>) {
- return this.serverService.getServerLocale()
- .pipe(
- map(translations => {
- const playlistsJSON = result.data
- const total = result.total
- const playlists: VideoPlaylist[] = []
-
- for (const playlistJSON of playlistsJSON) {
- playlists.push(new VideoPlaylist(playlistJSON, translations))
- }
-
- return { data: playlists, total }
- })
- )
- }
-
- extractPlaylist (playlist: VideoPlaylistServerModel) {
- return this.serverService.getServerLocale()
- .pipe(map(translations => new VideoPlaylist(playlist, translations)))
- }
-
- extractVideoPlaylistElements (result: ResultList<ServerVideoPlaylistElement>) {
- return this.serverService.getServerLocale()
- .pipe(
- map(translations => {
- const elementsJson = result.data
- const total = result.total
- const elements: VideoPlaylistElement[] = []
-
- for (const elementJson of elementsJson) {
- elements.push(new VideoPlaylistElement(elementJson, translations))
- }
-
- return { total, data: elements }
- })
- )
- }
-
- private doVideosExistInPlaylist (videoIds: number[]): Observable<VideosExistInPlaylists> {
- const url = VideoPlaylistService.MY_VIDEO_PLAYLIST_URL + 'videos-exist'
-
- let params = new HttpParams()
- params = this.restService.addObjectParams(params, { videoIds })
-
- return this.authHttp.get<VideoExistInPlaylist>(url, { params, headers: { ignoreLoadingBar: '' } })
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-}
+++ /dev/null
-<div class="margin-content">
- <div class="videos-header">
- <h1 *ngIf="titlePage" class="title-page title-page-single">
- <div placement="bottom" [ngbTooltip]="titleTooltip" container="body">
- {{ titlePage }}
- </div>
- <my-feed *ngIf="titlePage" [syndicationItems]="syndicationItems"></my-feed>
- </h1>
-
- <div class="action-block" *ngIf="actions.length > 0">
- <a [routerLink]="action.routerLink" routerLinkActive="active" *ngFor="let action of actions">
- <button class="btn">
- <my-global-icon [iconName]="action.iconName" aria-hidden="true"></my-global-icon>
- <span>{{ action.label }}</span>
- </button>
- </a>
- </div>
-
- <div class="moderation-block" *ngIf="displayModerationBlock">
- <my-peertube-checkbox
- (change)="toggleModerationDisplay()"
- inputName="display-unlisted-private" i18n-labelText labelText="Display unlisted and private videos"
- >
- </my-peertube-checkbox>
- </div>
- </div>
-
- <div class="no-results" i18n *ngIf="hasDoneFirstQuery && videos.length === 0">No results.</div>
- <div
- myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
- class="videos"
- >
- <ng-container *ngFor="let video of videos; trackBy: videoById;">
- <h2 class="date-title" *ngIf="getCurrentGroupedDateLabel(video)">
- {{ getCurrentGroupedDateLabel(video) }}
- </h2>
-
- <div class="video-wrapper">
- <my-video-miniature
- [fitWidth]="true"
- [video]="video" [user]="userMiniature" [ownerDisplayType]="ownerDisplayType"
- [displayVideoActions]="displayVideoActions" [displayOptions]="displayOptions"
- (videoBlocked)="removeVideoFromArray(video)" (videoRemoved)="removeVideoFromArray(video)"
- >
- </my-video-miniature>
- </div>
- </ng-container>
- </div>
-</div>
+++ /dev/null
-@import '_bootstrap-variables';
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-.videos-header {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
-
- .title-page.title-page-single {
- display: flex;
-
- my-feed {
- display: inline-block;
- top: 1px;
- margin-left: 5px;
- width: max-content;
- opacity: 0;
- transition: ease-in .2s opacity;
- }
- &:hover my-feed {
- opacity: 1;
- }
- }
-
- .action-block {
- a button {
- @include peertube-button;
- @include grey-button;
- @include button-with-icon(18px, 3px, -1px);
- }
- }
-
- .moderation-block {
- display: flex;
- flex-grow: 1;
- justify-content: flex-end;
- align-items: center;
- }
-}
-
-.date-title {
- font-size: 16px;
- font-weight: $font-semibold;
- margin-bottom: 20px;
- margin-top: -10px;
-
- // make the element span a full grid row within .videos grid
- grid-column: 1 / -1;
-
- &:not(:first-child) {
- margin-top: .5rem;
- padding-top: 20px;
- border-top: 1px solid $separator-border-color;
- }
-}
-
-.margin-content {
- @include fluid-videos-miniature-layout;
-}
-
-@media screen and (max-width: $mobile-view) {
- .videos-header {
- flex-direction: column;
- align-items: center;
- height: auto;
- margin-bottom: 10px;
-
- .title-page {
- margin-bottom: 10px;
- margin-right: 0px;
- }
- }
-}
+++ /dev/null
-import { fromEvent, Observable, of, Subject, Subscription } from 'rxjs'
-import { debounceTime, tap, throttleTime, switchMap } from 'rxjs/operators'
-import { OnDestroy, OnInit } from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { Notifier, ServerService } from '@app/core'
-import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
-import { GlobalIconName } from '@app/shared/images/global-icon.component'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Syndication } from '@app/shared/video/syndication.model'
-import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { isLastMonth, isLastWeek, isToday, isYesterday } from '@shared/core-utils/miscs/date'
-import { ServerConfig } from '@shared/models'
-import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { AuthService } from '../../core/auth'
-import { LocalStorageService } from '../misc/storage.service'
-import { ComponentPaginationLight } from '../rest/component-pagination.model'
-import { User, UserService } from '../users'
-import { VideoSortField } from './sort-field.type'
-import { Video } from './video.model'
-
-enum GroupDate {
- UNKNOWN = 0,
- TODAY = 1,
- YESTERDAY = 2,
- LAST_WEEK = 3,
- LAST_MONTH = 4,
- OLDER = 5
-}
-
-export abstract class AbstractVideoList implements OnInit, OnDestroy, DisableForReuseHook {
- pagination: ComponentPaginationLight = {
- currentPage: 1,
- itemsPerPage: 25
- }
- sort: VideoSortField = '-publishedAt'
-
- categoryOneOf?: number[]
- languageOneOf?: string[]
- nsfwPolicy?: NSFWPolicyType
- defaultSort: VideoSortField = '-publishedAt'
-
- syndicationItems: Syndication[] = []
-
- loadOnInit = true
- useUserVideoPreferences = false
- ownerDisplayType: OwnerDisplayType = 'account'
- 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: {
- routerLink: string
- iconName: GlobalIconName
- label: string
- }[] = []
-
- onDataSubject = new Subject<any[]>()
-
- userMiniature: User
-
- protected serverConfig: ServerConfig
-
- 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 i18n: I18n
- 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.getTmpConfig()
- this.serverService.getConfig()
- .subscribe(config => this.serverConfig = config)
-
- this.groupedDateLabels = {
- [GroupDate.UNKNOWN]: null,
- [GroupDate.TODAY]: this.i18n('Today'),
- [GroupDate.YESTERDAY]: this.i18n('Yesterday'),
- [GroupDate.LAST_WEEK]: this.i18n('Last week'),
- [GroupDate.LAST_MONTH]: this.i18n('Last month'),
- [GroupDate.OLDER]: this.i18n('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()
-
- if (this.loadOnInit === true) {
- loadUserObservable.subscribe(() => 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()
- }
-
- 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(
- ({ 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 => {
- const message = this.i18n('Cannot load more videos. Try again later.')
-
- console.error(message, { error })
- this.notifier.error(message)
- }
- )
- }
-
- reloadVideos () {
- this.pagination.currentPage = 1
- this.loadMoreVideos(true)
- }
-
- toggleModerationDisplay () {
- throw new Error('toggleModerationDisplay is not implemented')
- }
-
- removeVideoFromArray (video: Video) {
- this.videos = this.videos.filter(v => v.id !== video.id)
- }
-
- buildGroupedDateLabels () {
- let currentGroupedDate: GroupDate = GroupDate.UNKNOWN
-
- for (const video of this.videos) {
- const publishedDate = video.publishedAt
-
- if (currentGroupedDate <= GroupDate.TODAY && isToday(publishedDate)) {
- if (currentGroupedDate === GroupDate.TODAY) continue
-
- currentGroupedDate = GroupDate.TODAY
- this.groupedDates[ video.id ] = currentGroupedDate
- continue
- }
-
- if (currentGroupedDate <= GroupDate.YESTERDAY && isYesterday(publishedDate)) {
- if (currentGroupedDate === GroupDate.YESTERDAY) continue
-
- currentGroupedDate = GroupDate.YESTERDAY
- this.groupedDates[ video.id ] = currentGroupedDate
- continue
- }
-
- if (currentGroupedDate <= GroupDate.LAST_WEEK && isLastWeek(publishedDate)) {
- if (currentGroupedDate === GroupDate.LAST_WEEK) continue
-
- currentGroupedDate = GroupDate.LAST_WEEK
- this.groupedDates[ video.id ] = currentGroupedDate
- continue
- }
-
- if (currentGroupedDate <= GroupDate.LAST_MONTH && isLastMonth(publishedDate)) {
- if (currentGroupedDate === GroupDate.LAST_MONTH) continue
-
- currentGroupedDate = GroupDate.LAST_MONTH
- this.groupedDates[ video.id ] = currentGroupedDate
- continue
- }
-
- if (currentGroupedDate <= GroupDate.OLDER) {
- if (currentGroupedDate === GroupDate.OLDER) continue
-
- currentGroupedDate = GroupDate.OLDER
- this.groupedDates[ video.id ] = currentGroupedDate
- }
- }
- }
-
- getCurrentGroupedDateLabel (video: Video) {
- if (this.groupByDate === false) return undefined
-
- return this.groupedDateLabels[this.groupedDates[video.id]]
- }
-
- // On videos hook for children that want to do something
- protected onMoreVideos () { /* empty */ }
-
- protected loadRouteParams (routeParams: { [ key: string ]: any }) {
- this.sort = routeParams[ 'sort' ] as VideoSortField || this.defaultSort
- this.categoryOneOf = routeParams[ 'categoryOneOf' ]
- this.angularState = routeParams[ 'a-state' ]
- }
-
- 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.router.url
- 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.useUserVideoPreferences) return
-
- this.languageOneOf = user.videoLanguages
- this.nsfwPolicy = user.nsfwPolicy
- }))
- }
-}
+++ /dev/null
-<div class="video-feed"
- [ngbTooltip]="'Feeds available'"
- placement="right auto"
- container="body"
->
- <my-global-icon
- *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom"
- class="icon-syndication" role="button" iconName="syndication"
- >
- </my-global-icon>
-
- <ng-template #feedsList>
- <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a>
- </ng-template>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.video-feed {
- width: min-content;
-
- a {
- color: black;
- display: block;
- }
-
- my-global-icon {
- cursor: pointer;
- width: 12px;
- position: relative;
- top: -2px;
-
- @include apply-svg-color(pvar(--mainForegroundColor))
- }
-}
+++ /dev/null
-import { Component, Input } from '@angular/core'
-import { Syndication } from '@app/shared/video/syndication.model'
-
-@Component({
- selector: 'my-feed',
- styleUrls: [ './feed.component.scss' ],
- templateUrl: './feed.component.html'
-})
-export class FeedComponent {
- @Input() syndicationItems: Syndication[]
-}
+++ /dev/null
-import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
-import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
-import { fromEvent, Observable, Subscription } from 'rxjs'
-
-@Directive({
- selector: '[myInfiniteScroller]'
-})
-export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterContentChecked {
- @Input() percentLimit = 70
- @Input() autoInit = false
- @Input() onItself = false
- @Input() dataObservable: Observable<any[]>
-
- @Output() nearOfBottom = new EventEmitter<void>()
-
- private decimalLimit = 0
- private lastCurrentBottom = -1
- private scrollDownSub: Subscription
- private container: HTMLElement
-
- private checkScroll = false
-
- constructor (private el: ElementRef) {
- this.decimalLimit = this.percentLimit / 100
- }
-
- ngAfterContentChecked () {
- if (this.checkScroll) {
- this.checkScroll = false
-
- console.log('Checking if the initial state has a scroll.')
-
- if (this.hasScroll() === false) this.nearOfBottom.emit()
- }
- }
-
- ngOnInit () {
- if (this.autoInit === true) return this.initialize()
- }
-
- ngOnDestroy () {
- if (this.scrollDownSub) this.scrollDownSub.unsubscribe()
- }
-
- initialize () {
- this.container = this.onItself
- ? this.el.nativeElement
- : document.documentElement
-
- // Emit the last value
- const throttleOptions = { leading: true, trailing: true }
-
- const scrollableElement = this.onItself ? this.container : window
- const scrollObservable = fromEvent(scrollableElement, 'scroll')
- .pipe(
- startWith(true),
- throttleTime(200, undefined, throttleOptions),
- map(() => this.getScrollInfo()),
- distinctUntilChanged((o1, o2) => o1.current === o2.current),
- share()
- )
-
- // Scroll Down
- this.scrollDownSub = scrollObservable
- .pipe(
- filter(({ current }) => this.isScrollingDown(current)),
- filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
- )
- .subscribe(() => this.nearOfBottom.emit())
-
- if (this.dataObservable) {
- this.dataObservable
- .pipe(filter(d => d.length !== 0))
- .subscribe(() => this.checkScroll = true)
- }
- }
-
- private getScrollInfo () {
- return { current: this.container.scrollTop, maximumScroll: this.getMaximumScroll() }
- }
-
- private getMaximumScroll () {
- return this.container.scrollHeight - window.innerHeight
- }
-
- private hasScroll () {
- return this.getMaximumScroll() > 0
- }
-
- private isScrollingDown (current: number) {
- const result = this.lastCurrentBottom < current
-
- this.lastCurrentBottom = current
- return result
- }
-}
+++ /dev/null
-<ng-template #modal>
- <div class="modal-header">
- <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4>
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
- </div>
-
- <div class="modal-body">
-
- <form novalidate [formGroup]="form" (ngSubmit)="block()">
- <div class="form-group">
- <textarea
- i18n-placeholder placeholder="Please describe the reason..." formControlName="reason"
- [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
- ></textarea>
- <div *ngIf="formErrors.reason" class="form-error">
- {{ formErrors.reason }}
- </div>
- </div>
-
- <div class="form-group" *ngIf="video.isLocal">
- <my-peertube-checkbox
- inputName="unfederate" formControlName="unfederate"
- i18n-labelText labelText="Unfederate the video"
- >
- <ng-container ngProjectAs="description">
- <span i18n>This will ask remote instances to delete it</span>
- </ng-container>
- </my-peertube-checkbox>
- </div>
-
- <div class="form-group inputs">
- <input
- type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
- (click)="hide()" (key.enter)="hide()"
- >
-
- <input
- type="submit" i18n-value value="Submit" class="action-button-submit"
- [disabled]="!form.valid"
- >
- </div>
- </form>
-
- </div>
-</ng-template>
+++ /dev/null
-@import 'variables';
-@import 'mixins';
-
-textarea {
- @include peertube-textarea(100%, 100px);
-}
+++ /dev/null
-import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { Notifier, RedirectService } from '@app/core'
-import { VideoBlockService } from '../../video-block'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { FormReactive, VideoBlockValidatorsService } from '@app/shared/forms'
-import { Video } from '@app/shared/video/video.model'
-
-@Component({
- selector: 'my-video-block',
- templateUrl: './video-block.component.html',
- styleUrls: [ './video-block.component.scss' ]
-})
-export class VideoBlockComponent extends FormReactive implements OnInit {
- @Input() video: Video = null
-
- @ViewChild('modal', { static: true }) modal: NgbModal
-
- @Output() videoBlocked = new EventEmitter()
-
- error: string = null
-
- private openedModal: NgbModalRef
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private modalService: NgbModal,
- private videoBlockValidatorsService: VideoBlockValidatorsService,
- private videoBlocklistService: VideoBlockService,
- private notifier: Notifier,
- private i18n: I18n
- ) {
- super()
- }
-
- ngOnInit () {
- const defaultValues = { unfederate: 'true' }
-
- this.buildForm({
- reason: this.videoBlockValidatorsService.VIDEO_BLOCK_REASON,
- unfederate: null
- }, defaultValues)
- }
-
- show () {
- this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
- }
-
- hide () {
- this.openedModal.close()
- this.openedModal = null
- }
-
- block () {
- const reason = this.form.value[ 'reason' ] || undefined
- const unfederate = this.video.isLocal ? this.form.value[ 'unfederate' ] : undefined
-
- this.videoBlocklistService.blockVideo(this.video.id, reason, unfederate)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Video blocked.'))
- this.hide()
-
- this.video.blacklisted = true
- this.video.blockedReason = reason
-
- this.videoBlocked.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-}
+++ /dev/null
-<ng-template #modal let-hide="close">
- <div class="modal-header">
- <h4 class="modal-title">
- <ng-container i18n>Download</ng-container>
-
- <div *ngIf="videoCaptions" ngbDropdown class="d-inline-block">
- <span id="dropdownDownloadType" ngbDropdownToggle>
- {{ type }}
- </span>
- <div ngbDropdownMenu aria-labelledby="dropdownDownloadType">
- <button *ngIf="type === 'video'" (click)="switchToType('subtitles')" ngbDropdownItem i18n>subtitles</button>
- <button *ngIf="type === 'subtitles'" (click)="switchToType('video')" ngbDropdownItem i18n>video</button>
- </div>
- </div>
- </h4>
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
- </div>
-
- <div class="modal-body">
- <div class="form-group">
- <div class="input-group input-group-sm">
- <div class="input-group-prepend peertube-select-container">
- <select *ngIf="type === 'video'" [(ngModel)]="resolutionId" (ngModelChange)="onResolutionIdChange()">
- <option *ngFor="let file of getVideoFiles()" [value]="file.resolution.id">{{ file.resolution.label }}</option>
- </select>
-
- <select *ngIf="type === 'subtitles'" [(ngModel)]="subtitleLanguageId">
- <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
- </select>
- </div>
-
- <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getLink()" />
- <div class="input-group-append">
- <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
- <span class="glyphicon glyphicon-copy"></span>
- </button>
- </div>
- </div>
- </div>
-
- <ng-container *ngIf="type === 'video' && videoFile?.metadata">
- <div ngbNav #nav="ngbNav" class="nav-tabs">
-
- <ng-container ngbNavItem>
- <a ngbNavLink i18n>Format</a>
- <ng-template ngbNavContent>
- <div class="file-metadata">
- <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataFormat | keyvalue">
- <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
- <span class="metadata-attribute-value">{{ item.value.value }}</span>
- </div>
- </div>
- </ng-template>
- </ng-container>
-
- <ng-container ngbNavItem [disabled]="videoFileMetadataVideoStream === undefined">
- <a ngbNavLink i18n>Video stream</a>
- <ng-template ngbNavContent>
- <div class="file-metadata">
- <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataVideoStream | keyvalue">
- <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
- <span class="metadata-attribute-value">{{ item.value.value }}</span>
- </div>
- </div>
- </ng-template>
- </ng-container>
-
- <ng-container ngbNavItem [disabled]="videoFileMetadataAudioStream === undefined">
- <a ngbNavLink i18n>Audio stream</a>
- <ng-template ngbNavContent>
- <div class="file-metadata">
- <div class="metadata-attribute metadata-attribute-tags" *ngFor="let item of videoFileMetadataAudioStream | keyvalue">
- <span i18n class="metadata-attribute-label">{{ item.value.label }}</span>
- <span class="metadata-attribute-value">{{ item.value.value }}</span>
- </div>
- </div>
- </ng-template>
- </ng-container>
- </div>
-
- <div [ngbNavOutlet]="nav"></div>
- </ng-container>
-
- <div class="download-type" *ngIf="type === 'video'">
- <div class="peertube-radio-container">
- <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
- <label i18n for="download-direct">Direct download</label>
- </div>
-
- <div class="peertube-radio-container">
- <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
- <label i18n for="download-torrent">Torrent (.torrent file)</label>
- </div>
- </div>
- </div>
-
- <div class="modal-footer inputs">
- <input
- type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
- (click)="hide()" (key.enter)="hide()"
- >
-
- <input
- type="submit" i18n-value value="Download" class="action-button-submit"
- (click)="download()"
- >
- </div>
-</ng-template>
+++ /dev/null
-@import 'variables';
-@import 'mixins';
-
-.peertube-select-container {
- @include peertube-select-container(100px);
-
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- border-right: none;
-
- select {
- height: inherit;
- }
-}
-
-#dropdownDownloadType {
- cursor: pointer;
-}
-
-.download-type {
- margin-top: 30px;
-
- .peertube-radio-container {
- @include peertube-radio-container;
-
- display: inline-block;
- margin-right: 30px;
- }
-}
-
-.file-metadata {
- padding: 1rem;
-}
-
-.file-metadata .metadata-attribute {
- font-size: 13px;
- display: block;
- margin-bottom: 12px;
-
- .metadata-attribute-label {
- min-width: 142px;
- padding-right: 5px;
- display: inline-block;
- color: pvar(--greyForegroundColor);
- font-weight: $font-bold;
- }
-
- a.metadata-attribute-value {
- @include disable-default-a-behaviour;
- color: pvar(--mainForegroundColor);
-
- &:hover {
- opacity: 0.9;
- }
- }
-
- &.metadata-attribute-tags {
- .metadata-attribute-value:not(:nth-child(2)) {
- &::before {
- content: ', '
- }
- }
- }
-}
+++ /dev/null
-import { Component, ElementRef, ViewChild } from '@angular/core'
-import { VideoDetails } from '../../../shared/video/video-details.model'
-import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { AuthService, Notifier } from '@app/core'
-import { VideoPrivacy, VideoCaption, VideoFile } from '@shared/models'
-import { FfprobeFormat, FfprobeStream } from 'fluent-ffmpeg'
-import { mapValues, pick } from 'lodash-es'
-import { NumberFormatterPipe } from '@app/shared/angular/number-formatter.pipe'
-import { BytesPipe } from 'ngx-pipes'
-import { VideoService } from '../video.service'
-
-type DownloadType = 'video' | 'subtitles'
-type FileMetadata = { [key: string]: { label: string, value: string }}
-
-@Component({
- selector: 'my-video-download',
- templateUrl: './video-download.component.html',
- styleUrls: [ './video-download.component.scss' ]
-})
-export class VideoDownloadComponent {
- @ViewChild('modal', { static: true }) modal: ElementRef
-
- downloadType: 'direct' | 'torrent' = 'torrent'
- resolutionId: number | string = -1
- subtitleLanguageId: string
-
- video: VideoDetails
- videoFile: VideoFile
- videoFileMetadataFormat: FileMetadata
- videoFileMetadataVideoStream: FileMetadata | undefined
- videoFileMetadataAudioStream: FileMetadata | undefined
- videoCaptions: VideoCaption[]
- activeModal: NgbActiveModal
-
- type: DownloadType = 'video'
-
- private bytesPipe: BytesPipe
- private numbersPipe: NumberFormatterPipe
-
- constructor (
- private notifier: Notifier,
- private modalService: NgbModal,
- private videoService: VideoService,
- private auth: AuthService,
- private i18n: I18n
- ) {
- this.bytesPipe = new BytesPipe()
- this.numbersPipe = new NumberFormatterPipe()
- }
-
- get typeText () {
- return this.type === 'video'
- ? this.i18n('video')
- : this.i18n('subtitles')
- }
-
- getVideoFiles () {
- if (!this.video) return []
-
- return this.video.getFiles()
- }
-
- show (video: VideoDetails, videoCaptions?: VideoCaption[]) {
- this.video = video
- this.videoCaptions = videoCaptions && videoCaptions.length ? videoCaptions : undefined
-
- this.activeModal = this.modalService.open(this.modal, { centered: true })
-
- this.resolutionId = this.getVideoFiles()[0].resolution.id
- this.onResolutionIdChange()
- if (this.videoCaptions) this.subtitleLanguageId = this.videoCaptions[0].language.id
- }
-
- onClose () {
- this.video = undefined
- this.videoCaptions = undefined
- }
-
- download () {
- window.location.assign(this.getLink())
- this.activeModal.close()
- }
-
- getLink () {
- return this.type === 'subtitles' && this.videoCaptions
- ? this.getSubtitlesLink()
- : this.getVideoFileLink()
- }
-
- async onResolutionIdChange () {
- this.videoFile = this.getVideoFile()
- if (this.videoFile.metadata || !this.videoFile.metadataUrl) return
-
- await this.hydrateMetadataFromMetadataUrl(this.videoFile)
-
- this.videoFileMetadataFormat = this.videoFile
- ? this.getMetadataFormat(this.videoFile.metadata.format)
- : undefined
- this.videoFileMetadataVideoStream = this.videoFile
- ? this.getMetadataStream(this.videoFile.metadata.streams, 'video')
- : undefined
- this.videoFileMetadataAudioStream = this.videoFile
- ? this.getMetadataStream(this.videoFile.metadata.streams, 'audio')
- : undefined
- }
-
- getVideoFile () {
- // HTML select send us a string, so convert it to a number
- this.resolutionId = parseInt(this.resolutionId.toString(), 10)
-
- const file = this.getVideoFiles().find(f => f.resolution.id === this.resolutionId)
- if (!file) {
- console.error('Could not find file with resolution %d.', this.resolutionId)
- return
- }
- return file
- }
-
- getVideoFileLink () {
- const file = this.videoFile
- if (!file) return
-
- const suffix = this.video.privacy.id === VideoPrivacy.PRIVATE || this.video.privacy.id === VideoPrivacy.INTERNAL
- ? '?access_token=' + this.auth.getAccessToken()
- : ''
-
- switch (this.downloadType) {
- case 'direct':
- return file.fileDownloadUrl + suffix
-
- case 'torrent':
- return file.torrentDownloadUrl + suffix
- }
- }
-
- getSubtitlesLink () {
- return window.location.origin + this.videoCaptions.find(caption => caption.language.id === this.subtitleLanguageId).captionPath
- }
-
- activateCopiedMessage () {
- this.notifier.success(this.i18n('Copied'))
- }
-
- switchToType (type: DownloadType) {
- this.type = type
- }
-
- getMetadataFormat (format: FfprobeFormat) {
- const keyToTranslateFunction = {
- 'encoder': (value: string) => ({ label: this.i18n('Encoder'), value }),
- 'format_long_name': (value: string) => ({ label: this.i18n('Format name'), value }),
- 'size': (value: number) => ({ label: this.i18n('Size'), value: this.bytesPipe.transform(value, 2) }),
- 'bit_rate': (value: number) => ({
- label: this.i18n('Bitrate'),
- value: `${this.numbersPipe.transform(value)}bps`
- })
- }
-
- // flattening format
- const sanitizedFormat = Object.assign(format, format.tags)
- delete sanitizedFormat.tags
-
- return mapValues(
- pick(sanitizedFormat, Object.keys(keyToTranslateFunction)),
- (val, key) => keyToTranslateFunction[key](val)
- )
- }
-
- getMetadataStream (streams: FfprobeStream[], type: 'video' | 'audio') {
- const stream = streams.find(s => s.codec_type === type)
- if (!stream) return undefined
-
- let keyToTranslateFunction = {
- 'codec_long_name': (value: string) => ({ label: this.i18n('Codec'), value }),
- 'profile': (value: string) => ({ label: this.i18n('Profile'), value }),
- 'bit_rate': (value: number) => ({
- label: this.i18n('Bitrate'),
- value: `${this.numbersPipe.transform(value)}bps`
- })
- }
-
- if (type === 'video') {
- keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
- 'width': (value: number) => ({ label: this.i18n('Resolution'), value: `${value}x${stream.height}` }),
- 'display_aspect_ratio': (value: string) => ({ label: this.i18n('Aspect ratio'), value }),
- 'avg_frame_rate': (value: string) => ({ label: this.i18n('Average frame rate'), value }),
- 'pix_fmt': (value: string) => ({ label: this.i18n('Pixel format'), value })
- })
- } else {
- keyToTranslateFunction = Object.assign(keyToTranslateFunction, {
- 'sample_rate': (value: number) => ({ label: this.i18n('Sample rate'), value }),
- 'channel_layout': (value: number) => ({ label: this.i18n('Channel Layout'), value })
- })
- }
-
- return mapValues(
- pick(stream, Object.keys(keyToTranslateFunction)),
- (val, key) => keyToTranslateFunction[key](val)
- )
- }
-
- private hydrateMetadataFromMetadataUrl (file: VideoFile) {
- const observable = this.videoService.getVideoFileMetadata(file.metadataUrl)
- observable.subscribe(res => file.metadata = res)
- return observable.toPromise()
- }
-}
+++ /dev/null
-<ng-template #modal>
- <div class="modal-header">
- <h4 i18n class="modal-title">Report video "{{ video.name }}"</h4>
- <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
- </div>
-
- <div class="modal-body">
- <form novalidate [formGroup]="form" (ngSubmit)="report()">
-
- <div class="row">
- <div class="col-5 form-group">
-
- <label i18n for="reportPredefinedReasons">What is the issue?</label>
-
- <div class="ml-2 mt-2 d-flex flex-column">
- <ng-container formGroupName="predefinedReasons">
- <div class="form-group" *ngFor="let reason of predefinedReasons">
- <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}">
- <ng-template *ngIf="reason.help" ptTemplate="help">
- <div [innerHTML]="reason.help"></div>
- </ng-template>
- <ng-container *ngIf="reason.description" ngProjectAs="description">
- <div [innerHTML]="reason.description"></div>
- </ng-container>
- </my-peertube-checkbox>
- </div>
- </ng-container>
- </div>
-
- </div>
-
- <div class="col-7">
- <div class="row justify-content-center">
- <div class="col-12 col-lg-9 mb-2">
- <div class="screenratio">
- <div [innerHTML]="embedHtml"></div>
- </div>
- </div>
- </div>
-
- <div class="mb-1 start-at" formGroupName="timestamp">
- <my-peertube-checkbox
- formControlName="hasStart"
- i18n-labelText labelText="Start at"
- ></my-peertube-checkbox>
-
- <my-timestamp-input
- [timestamp]="timestamp.startAt"
- [maxTimestamp]="video.duration"
- formControlName="startAt"
- inputName="startAt"
- >
- </my-timestamp-input>
- </div>
-
- <div class="mb-3 stop-at" formGroupName="timestamp" *ngIf="timestamp.hasStart">
- <my-peertube-checkbox
- formControlName="hasEnd"
- i18n-labelText labelText="Stop at"
- ></my-peertube-checkbox>
-
- <my-timestamp-input
- [timestamp]="timestamp.endAt"
- [maxTimestamp]="video.duration"
- formControlName="endAt"
- inputName="endAt"
- >
- </my-timestamp-input>
- </div>
-
- <div i18n class="information">
- Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>.
- </div>
-
- <div class="form-group">
- <textarea
- i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus
- [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control"
- ></textarea>
- <div *ngIf="formErrors.reason" class="form-error">
- {{ formErrors.reason }}
- </div>
- </div>
- </div>
- </div>
-
- <div class="form-group inputs">
- <input
- type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
- (click)="hide()" (key.enter)="hide()"
- >
- <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid">
- </div>
-
- </form>
- </div>
-</ng-template>
+++ /dev/null
-@import 'variables';
-@import 'mixins';
-
-.information {
- margin-bottom: 20px;
-}
-
-textarea {
- @include peertube-textarea(100%, 100px);
-}
-
-.start-at,
-.stop-at {
- width: 300px;
- display: flex;
- align-items: center;
-
- my-timestamp-input {
- margin-left: 10px;
- }
-}
-
-.screenratio {
- @include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
- left: 0;
- };
-}
+++ /dev/null
-import { Component, Input, OnInit, ViewChild } from '@angular/core'
-import { Notifier } from '@app/core'
-import { FormReactive } from '../../../shared/forms'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoAbuseValidatorsService } from '@app/shared/forms/form-validators/video-abuse-validators.service'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
-import { VideoAbuseService } from '@app/shared/video-abuse'
-import { Video } from '@app/shared/video/video.model'
-import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
-import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
-import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
-import { mapValues, pickBy } from 'lodash-es'
-
-@Component({
- selector: 'my-video-report',
- templateUrl: './video-report.component.html',
- styleUrls: [ './video-report.component.scss' ]
-})
-export class VideoReportComponent extends FormReactive implements OnInit {
- @Input() video: Video = null
-
- @ViewChild('modal', { static: true }) modal: NgbModal
-
- error: string = null
- predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
- embedHtml: SafeHtml
-
- private openedModal: NgbModalRef
-
- constructor (
- protected formValidatorService: FormValidatorService,
- private modalService: NgbModal,
- private videoAbuseValidatorsService: VideoAbuseValidatorsService,
- private videoAbuseService: VideoAbuseService,
- private notifier: Notifier,
- private sanitizer: DomSanitizer,
- private i18n: I18n
- ) {
- super()
- }
-
- get currentHost () {
- return window.location.host
- }
-
- get originHost () {
- if (this.isRemoteVideo()) {
- return this.video.account.host
- }
-
- return ''
- }
-
- get timestamp () {
- return this.form.get('timestamp').value
- }
-
- getVideoEmbed () {
- return this.sanitizer.bypassSecurityTrustHtml(
- buildVideoEmbed(
- buildVideoLink({
- baseUrl: this.video.embedUrl,
- title: false,
- warningTitle: false
- })
- )
- )
- }
-
- ngOnInit () {
- this.buildForm({
- reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON,
- predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null),
- timestamp: {
- hasStart: null,
- startAt: null,
- hasEnd: null,
- endAt: null
- }
- })
-
- this.predefinedReasons = [
- {
- id: 'violentOrRepulsive',
- label: this.i18n('Violent or repulsive'),
- help: this.i18n('Contains offensive, violent, or coarse language or iconography.')
- },
- {
- id: 'hatefulOrAbusive',
- label: this.i18n('Hateful or abusive'),
- help: this.i18n('Contains abusive, racist or sexist language or iconography.')
- },
- {
- id: 'spamOrMisleading',
- label: this.i18n('Spam, ad or false news'),
- help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.')
- },
- {
- id: 'privacy',
- label: this.i18n('Privacy breach or doxxing'),
- help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).')
- },
- {
- id: 'rights',
- label: this.i18n('Intellectual property violation'),
- help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.')
- },
- {
- id: 'serverRules',
- label: this.i18n('Breaks server rules'),
- description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.')
- },
- {
- id: 'thumbnails',
- label: this.i18n('Thumbnails'),
- help: this.i18n('The above can only be seen in thumbnails.')
- },
- {
- id: 'captions',
- label: this.i18n('Captions'),
- help: this.i18n('The above can only be seen in captions (please describe which).')
- }
- ]
-
- this.embedHtml = this.getVideoEmbed()
- }
-
- show () {
- this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' })
- }
-
- hide () {
- this.openedModal.close()
- this.openedModal = null
- }
-
- report () {
- const reason = this.form.get('reason').value
- const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[]
- const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
-
- this.videoAbuseService.reportVideo({
- id: this.video.id,
- reason,
- predefinedReasons,
- startAt: hasStart && startAt ? startAt : undefined,
- endAt: hasEnd && endAt ? endAt : undefined
- }).subscribe(
- () => {
- this.notifier.success(this.i18n('Video reported.'))
- this.hide()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- isRemoteVideo () {
- return !this.video.isLocal
- }
-}
+++ /dev/null
-export interface RecommendationInfo {
- uuid: string
- tags?: string[]
-}
+++ /dev/null
-import { catchError, map, toArray } from 'rxjs/operators'
-import { HttpClient, HttpParams } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { RestExtractor, RestPagination, RestService } from '@app/shared/rest'
-import { SortMeta } from 'primeng/api'
-import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
-import { concat, Observable } from 'rxjs'
-import { environment } from '../../../environments/environment'
-
-@Injectable()
-export class RedundancyService {
- static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
-
- constructor (
- private authHttp: HttpClient,
- private restService: RestService,
- private restExtractor: RestExtractor
- ) { }
-
- updateRedundancy (host: string, redundancyAllowed: boolean) {
- const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
-
- const body = { redundancyAllowed }
-
- return this.authHttp.put(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- listVideoRedundancies (options: {
- pagination: RestPagination,
- sort: SortMeta,
- target?: VideoRedundanciesTarget
- }): Observable<ResultList<VideoRedundancy>> {
- const { pagination, sort, target } = options
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (target) params = params.append('target', target)
-
- return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
- .pipe(
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- addVideoRedundancy (video: Video) {
- return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
- .pipe(
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-
- removeVideoRedundancies (redundancy: VideoRedundancy) {
- const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
- .concat(redundancy.redundancies.files.map(r => r.id))
- .map(id => this.removeRedundancy(id))
-
- return concat(...observables)
- .pipe(toArray())
- }
-
- private removeRedundancy (redundancyId: number) {
- return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(res => this.restExtractor.handleError(res))
- )
- }
-}
+++ /dev/null
-export type VideoSortField = 'name' | '-name'
- | 'duration' | '-duration'
- | 'publishedAt' | '-publishedAt'
- | 'createdAt' | '-createdAt'
- | 'views' | '-views'
- | 'likes' | '-likes'
- | 'trending' | '-trending'
-
-export type CommentSortField = 'createdAt' | '-createdAt'
- | 'totalReplies' | '-totalReplies'
+++ /dev/null
-import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
-
-export interface Syndication {
- format: FeedFormat,
- label: string,
- url: string
-}
+++ /dev/null
-<ng-container *ngIf="videoActions.length !== 0">
-
- <div class="playlist-dropdown" ngbDropdown #playlistDropdown="ngbDropdown" role="button" autoClose="outside" [placement]="getPlaylistDropdownPlacement()"
- *ngIf="isUserLoggedIn() && displayOptions.playlist" (openChange)="playlistAdd.openChange($event)"
- >
- <span class="anchor" ngbDropdownAnchor></span>
-
- <div ngbDropdownMenu>
- <my-video-add-to-playlist #playlistAdd [video]="video" [lazyLoad]="true"></my-video-add-to-playlist>
- </div>
- </div>
-
- <my-action-dropdown
- [actions]="videoActions" [label]="label" [entry]="{ video: video }" (click)="loadDropdownInformation()"
- [buttonSize]="buttonSize" [placement]="placement" [buttonDirection]="buttonDirection" [buttonStyled]="buttonStyled"
- ></my-action-dropdown>
-
- <my-video-download #videoDownloadModal></my-video-download>
- <my-video-report #videoReportModal [video]="video"></my-video-report>
- <my-video-block #videoBlockModal [video]="video" (videoBlocked)="onVideoBlocked()"></my-video-block>
-</ng-container>
+++ /dev/null
-.playlist-dropdown {
- position: absolute;
-
- .anchor {
- display: block;
- opacity: 0;
- }
-}
-
-::ng-deep .icon-playlist-add {
- left: 2px;
-}
+++ /dev/null
-import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { DropdownAction, DropdownButtonSize, DropdownDirection } from '@app/shared/buttons/action-dropdown.component'
-import { AuthService, ConfirmService, Notifier } from '@app/core'
-import { Video } from '@app/shared/video/video.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { VideoDetails } from '@app/shared/video/video-details.model'
-import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
-import { VideoAddToPlaylistComponent } from '@app/shared/video-playlist/video-add-to-playlist.component'
-import { VideoDownloadComponent } from '@app/shared/video/modals/video-download.component'
-import { VideoReportComponent } from '@app/shared/video/modals/video-report.component'
-import { VideoBlockComponent } from '@app/shared/video/modals/video-block.component'
-import { VideoBlockService } from '@app/shared/video-block'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { VideoCaption } from '@shared/models'
-import { RedundancyService } from '@app/shared/video/redundancy.service'
-
-export type VideoActionsDisplayType = {
- playlist?: boolean
- download?: boolean
- update?: boolean
- blacklist?: boolean
- delete?: boolean
- report?: boolean
- duplicate?: boolean
-}
-
-@Component({
- selector: 'my-video-actions-dropdown',
- templateUrl: './video-actions-dropdown.component.html',
- styleUrls: [ './video-actions-dropdown.component.scss' ]
-})
-export class VideoActionsDropdownComponent implements OnChanges {
- @ViewChild('playlistDropdown') playlistDropdown: NgbDropdown
- @ViewChild('playlistAdd') playlistAdd: VideoAddToPlaylistComponent
-
- @ViewChild('videoDownloadModal') videoDownloadModal: VideoDownloadComponent
- @ViewChild('videoReportModal') videoReportModal: VideoReportComponent
- @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent
-
- @Input() video: Video | VideoDetails
- @Input() videoCaptions: VideoCaption[] = []
-
- @Input() displayOptions: VideoActionsDisplayType = {
- playlist: false,
- download: true,
- update: true,
- blacklist: true,
- delete: true,
- report: true,
- duplicate: true
- }
- @Input() placement = 'left'
-
- @Input() label: string
-
- @Input() buttonStyled = false
- @Input() buttonSize: DropdownButtonSize = 'normal'
- @Input() buttonDirection: DropdownDirection = 'vertical'
-
- @Output() videoRemoved = new EventEmitter()
- @Output() videoUnblocked = new EventEmitter()
- @Output() videoBlocked = new EventEmitter()
- @Output() modalOpened = new EventEmitter()
-
- videoActions: DropdownAction<{ video: Video }>[][] = []
-
- private loaded = false
-
- constructor (
- private authService: AuthService,
- private notifier: Notifier,
- private confirmService: ConfirmService,
- private videoBlocklistService: VideoBlockService,
- private screenService: ScreenService,
- private videoService: VideoService,
- private redundancyService: RedundancyService,
- private i18n: I18n
- ) { }
-
- get user () {
- return this.authService.getUser()
- }
-
- ngOnChanges () {
- if (this.loaded) {
- this.loaded = false
- this.playlistAdd.reload()
- }
-
- this.buildActions()
- }
-
- isUserLoggedIn () {
- return this.authService.isLoggedIn()
- }
-
- loadDropdownInformation () {
- if (!this.isUserLoggedIn() || this.loaded === true) return
-
- this.loaded = true
-
- if (this.displayOptions.playlist) this.playlistAdd.load()
- }
-
- /* Show modals */
-
- showDownloadModal () {
- this.modalOpened.emit()
-
- this.videoDownloadModal.show(this.video as VideoDetails, this.videoCaptions)
- }
-
- showReportModal () {
- this.modalOpened.emit()
-
- this.videoReportModal.show()
- }
-
- showBlockModal () {
- this.modalOpened.emit()
-
- this.videoBlockModal.show()
- }
-
- /* Actions checker */
-
- isVideoUpdatable () {
- return this.video.isUpdatableBy(this.user)
- }
-
- isVideoRemovable () {
- return this.video.isRemovableBy(this.user)
- }
-
- isVideoBlockable () {
- return this.video.isBlockableBy(this.user)
- }
-
- isVideoUnblockable () {
- return this.video.isUnblockableBy(this.user)
- }
-
- isVideoDownloadable () {
- return this.video && this.video instanceof VideoDetails && this.video.downloadEnabled
- }
-
- canVideoBeDuplicated () {
- return this.video.canBeDuplicatedBy(this.user)
- }
-
- /* Action handlers */
-
- async unblockVideo () {
- const confirmMessage = this.i18n(
- 'Do you really want to unblock this video? It will be available again in the videos list.'
- )
-
- const res = await this.confirmService.confirm(confirmMessage, this.i18n('Unblock'))
- if (res === false) return
-
- this.videoBlocklistService.unblockVideo(this.video.id).subscribe(
- () => {
- this.notifier.success(this.i18n('Video {{name}} unblocked.', { name: this.video.name }))
-
- this.video.blacklisted = false
- this.video.blockedReason = null
-
- this.videoUnblocked.emit()
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- async removeVideo () {
- this.modalOpened.emit()
-
- const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this video?'), this.i18n('Delete'))
- if (res === false) return
-
- this.videoService.removeVideo(this.video.id)
- .subscribe(
- () => {
- this.notifier.success(this.i18n('Video {{videoName}} deleted.', { videoName: this.video.name }))
-
- this.videoRemoved.emit()
- },
-
- error => this.notifier.error(error.message)
- )
- }
-
- duplicateVideo () {
- this.redundancyService.addVideoRedundancy(this.video)
- .subscribe(
- () => {
- const message = this.i18n('This video will be duplicated by your instance.')
- this.notifier.success(message)
- },
-
- err => this.notifier.error(err.message)
- )
- }
-
- onVideoBlocked () {
- this.videoBlocked.emit()
- }
-
- getPlaylistDropdownPlacement () {
- if (this.screenService.isInSmallView()) {
- return 'bottom-right'
- }
-
- return 'bottom-left bottom-right'
- }
-
- private buildActions () {
- this.videoActions = [
- [
- {
- label: this.i18n('Save to playlist'),
- handler: () => this.playlistDropdown.toggle(),
- isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.playlist,
- iconName: 'playlist-add'
- }
- ],
- [
- {
- label: this.i18n('Download'),
- handler: () => this.showDownloadModal(),
- isDisplayed: () => this.displayOptions.download && this.isVideoDownloadable(),
- iconName: 'download'
- },
- {
- label: this.i18n('Update'),
- linkBuilder: ({ video }) => [ '/videos/update', video.uuid ],
- iconName: 'edit',
- isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.update && this.isVideoUpdatable()
- },
- {
- label: this.i18n('Block'),
- handler: () => this.showBlockModal(),
- iconName: 'no',
- isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoBlockable()
- },
- {
- label: this.i18n('Unblock'),
- handler: () => this.unblockVideo(),
- iconName: 'undo',
- isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.blacklist && this.isVideoUnblockable()
- },
- {
- label: this.i18n('Mirror'),
- handler: () => this.duplicateVideo(),
- isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.duplicate && this.canVideoBeDuplicated(),
- iconName: 'cloud-download'
- },
- {
- label: this.i18n('Delete'),
- handler: () => this.removeVideo(),
- isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.delete && this.isVideoRemovable(),
- iconName: 'delete'
- }
- ],
- [
- {
- label: this.i18n('Report'),
- handler: () => this.showReportModal(),
- isDisplayed: () => this.authService.isLoggedIn() && this.displayOptions.report,
- iconName: 'alert'
- }
- ]
- ]
- }
-}
+++ /dev/null
-import { VideoConstant, VideoDetails as VideoDetailsServerModel, VideoFile, VideoState } from '../../../../../shared'
-import { Video } from '../../shared/video/video.model'
-import { Account } from '@app/shared/account/account.model'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { VideoStreamingPlaylist } from '../../../../../shared/models/videos/video-streaming-playlist.model'
-import { VideoStreamingPlaylistType } from '../../../../../shared/models/videos/video-streaming-playlist.type'
-
-export class VideoDetails extends Video implements VideoDetailsServerModel {
- descriptionPath: string
- support: string
- channel: VideoChannel
- tags: string[]
- files: VideoFile[]
- account: Account
- commentsEnabled: boolean
- downloadEnabled: boolean
-
- waitTranscoding: boolean
- state: VideoConstant<VideoState>
-
- likesPercent: number
- dislikesPercent: number
-
- trackerUrls: string[]
-
- streamingPlaylists: VideoStreamingPlaylist[]
-
- constructor (hash: VideoDetailsServerModel, translations = {}) {
- super(hash, translations)
-
- this.descriptionPath = hash.descriptionPath
- this.files = hash.files
- this.channel = new VideoChannel(hash.channel)
- this.account = new Account(hash.account)
- this.tags = hash.tags
- this.support = hash.support
- this.commentsEnabled = hash.commentsEnabled
- this.downloadEnabled = hash.downloadEnabled
-
- this.trackerUrls = hash.trackerUrls
- this.streamingPlaylists = hash.streamingPlaylists
-
- this.buildLikeAndDislikePercents()
- }
-
- buildLikeAndDislikePercents () {
- this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100
- this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100
- }
-
- getHlsPlaylist () {
- return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
- }
-
- hasHlsPlaylist () {
- return !!this.getHlsPlaylist()
- }
-
- getFiles () {
- if (this.files.length === 0) return this.getHlsPlaylist().files
-
- return this.files
- }
-}
+++ /dev/null
-import { VideoPrivacy } from '../../../../../shared/models/videos/video-privacy.enum'
-import { VideoUpdate } from '../../../../../shared/models/videos'
-import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
-import { Video } from '../../../../../shared/models/videos/video.model'
-
-export class VideoEdit implements VideoUpdate {
- static readonly SPECIAL_SCHEDULED_PRIVACY = -1
-
- category: number
- licence: number
- language: string
- description: string
- name: string
- tags: string[]
- nsfw: boolean
- commentsEnabled: boolean
- downloadEnabled: boolean
- waitTranscoding: boolean
- channelId: number
- privacy: VideoPrivacy
- support: string
- thumbnailfile?: any
- previewfile?: any
- thumbnailUrl: string
- previewUrl: string
- uuid?: string
- id?: number
- scheduleUpdate?: VideoScheduleUpdate
- originallyPublishedAt?: Date | string
-
- constructor (
- video?: Video & {
- tags: string[],
- commentsEnabled: boolean,
- downloadEnabled: boolean,
- support: string,
- thumbnailUrl: string,
- previewUrl: string
- }) {
- if (video) {
- this.id = video.id
- this.uuid = video.uuid
- this.category = video.category.id
- this.licence = video.licence.id
- this.language = video.language.id
- this.description = video.description
- this.name = video.name
- this.tags = video.tags
- this.nsfw = video.nsfw
- this.commentsEnabled = video.commentsEnabled
- this.downloadEnabled = video.downloadEnabled
- this.waitTranscoding = video.waitTranscoding
- this.channelId = video.channel.id
- this.privacy = video.privacy.id
- this.support = video.support
- this.thumbnailUrl = video.thumbnailUrl
- this.previewUrl = video.previewUrl
-
- this.scheduleUpdate = video.scheduledUpdate
- this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null
- }
- }
-
- patch (values: { [ id: string ]: string }) {
- Object.keys(values).forEach((key) => {
- this[ key ] = values[ key ]
- })
-
- // If schedule publication, the video is private and will be changed to public privacy
- if (parseInt(values['privacy'], 10) === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) {
- const updateAt = new Date(values['schedulePublicationAt'])
- updateAt.setSeconds(0)
-
- this.privacy = VideoPrivacy.PRIVATE
- this.scheduleUpdate = {
- updateAt: updateAt.toISOString(),
- privacy: VideoPrivacy.PUBLIC
- }
- } else {
- this.scheduleUpdate = null
- }
-
- // Convert originallyPublishedAt to string so that function objectToFormData() works correctly
- if (this.originallyPublishedAt) {
- const originallyPublishedAt = new Date(values['originallyPublishedAt'])
- this.originallyPublishedAt = originallyPublishedAt.toISOString()
- }
-
- // Use the same file than the preview for the thumbnail
- if (this.previewfile) {
- this.thumbnailfile = this.previewfile
- }
- }
-
- toFormPatch () {
- const json = {
- category: this.category,
- licence: this.licence,
- language: this.language,
- description: this.description,
- support: this.support,
- name: this.name,
- tags: this.tags,
- nsfw: this.nsfw,
- commentsEnabled: this.commentsEnabled,
- downloadEnabled: this.downloadEnabled,
- waitTranscoding: this.waitTranscoding,
- channelId: this.channelId,
- privacy: this.privacy,
- originallyPublishedAt: this.originallyPublishedAt
- }
-
- // Special case if we scheduled an update
- if (this.scheduleUpdate) {
- Object.assign(json, {
- privacy: VideoEdit.SPECIAL_SCHEDULED_PRIVACY,
- schedulePublicationAt: new Date(this.scheduleUpdate.updateAt.toString())
- })
- }
-
- return json
- }
-}
+++ /dev/null
-<div class="video-miniature" [ngClass]="{ 'display-as-row': displayAsRow, 'fit-width': fitWidth }" (mouseenter)="loadActions()">
- <my-video-thumbnail
- [video]="video" [nsfw]="isVideoBlur" [routerLink]="videoLink"
- [displayWatchLaterPlaylist]="isWatchLaterPlaylistDisplayed()" [inWatchLaterPlaylist]="inWatchLaterPlaylist" (watchLaterClick)="onWatchLaterClick($event)"
- >
- <ng-container ngProjectAs="label-warning" *ngIf="displayOptions.privacyLabel && isUnlistedVideo()" i18n>Unlisted</ng-container>
- <ng-container ngProjectAs="label-danger" *ngIf="displayOptions.privacyLabel && isPrivateVideo()" i18n>Private</ng-container>
- </my-video-thumbnail>
-
- <div class="video-bottom">
- <div class="video-miniature-information">
- <div class="d-inline-flex video-miniature-meta">
- <a *ngIf="displayOptions.avatar" class="avatar" [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
- <img [src]="getAvatarUrl()" alt="" />
- </a>
-
- <div class="w-100 d-flex flex-column">
- <a
- tabindex="-1"
- class="video-miniature-name"
- [routerLink]="videoLink" [attr.title]="video.name" [ngClass]="{ 'blur-filter': isVideoBlur }"
- >{{ video.name }}</a>
-
- <span class="video-miniature-created-at-views">
- <my-date-toggle *ngIf="displayOptions.date" [date]="video.publishedAt"></my-date-toggle>
-
- <span class="views">
- <ng-container *ngIf="displayOptions.date && displayOptions.views"> • </ng-container>
- <ng-container i18n *ngIf="displayOptions.views">{video.views, plural, =1 {1 view} other {{{ video.views | myNumberFormatter }} views}}</ng-container>
- </span>
- </span>
-
- <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerAccount()" class="video-miniature-account" [routerLink]="[ '/accounts', video.byAccount ]">
- {{ video.byAccount }}
- </a>
- <a tabindex="-1" *ngIf="displayOptions.by && displayOwnerVideoChannel()" class="video-miniature-channel" [routerLink]="[ '/video-channels', video.byVideoChannel ]">
- {{ video.byVideoChannel }}
- </a>
-
- <div class="video-info-privacy">
- <ng-container *ngIf="displayOptions.privacyText">{{ video.privacy.label }}</ng-container>
- <ng-container *ngIf="displayOptions.privacyText && displayOptions.state && getStateLabel(video)"> - </ng-container>
- <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
- </div>
- </div>
- </div>
-
- <div *ngIf="displayOptions.blacklistInfo && video.blacklisted" class="video-info-blocked">
- <span class="blocked-label" i18n>Blocked</span>
- <span class="blocked-reason" *ngIf="video.blockedReason">{{ video.blockedReason }}</span>
- </div>
-
- <div i18n *ngIf="displayOptions.nsfw && video.nsfw" class="video-info-nsfw">
- Sensitive
- </div>
- </div>
-
- <div class="video-actions">
- <!-- FIXME: remove bottom placement when overflow is fixed in bootstrap dropdown: https://github.com/ng-bootstrap/ng-bootstrap/issues/3495 -->
- <my-video-actions-dropdown
- *ngIf="showActions" [video]="video" [displayOptions]="videoActionsDisplayOptions" placement="bottom-left bottom-right left auto"
- (videoRemoved)="onVideoRemoved()" (videoBlocked)="onVideoBlocked()" (videoUnblocked)="onVideoUnblocked()"
- ></my-video-actions-dropdown>
- </div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-$more-button-width: 40px;
-$more-margin-right: 15px;
-
-.video-miniature {
- display: inline-flex;
- flex-direction: column;
- padding-bottom: $video-miniature-margin-bottom;
- vertical-align: top;
-
- .video-bottom {
- display: flex;
-
- .video-miniature-information {
- width: $video-miniature-width - $more-button-width - $more-margin-right;
- line-height: normal;
-
- .avatar {
- margin: 10px 10px 0 0;
-
- img {
- @include avatar(40px);
- }
- }
-
- .video-miniature-name {
- @include miniature-name;
- width: calc(100% - #{$more-button-width});
- }
-
- .video-miniature-meta {
- width: calc(100% + #{$more-button-width});
- overflow: hidden;
- }
-
- .video-miniature-created-at-views {
- display: block;
- font-size: 13px;
- }
-
- .video-miniature-account,
- .video-miniature-channel {
- @include disable-default-a-behaviour;
- @include ellipsis;
-
- display: block;
- font-size: 13px;
- color: pvar(--greyForegroundColor);
-
- &:hover {
- color: $grey-foreground-hover-color;
- }
- }
-
- .video-info-privacy,
- .video-info-blocked .blocked-label,
- .video-info-nsfw {
- font-weight: $font-semibold;
- }
-
- .video-info-blocked {
- color: red;
-
- .blocked-reason::before {
- content: ' - ';
- }
- }
-
- .video-info-nsfw {
- color: red;
- }
- }
-
- .video-actions {
- margin-top: 3px;
- width: $more-button-width;
- height: 30px;
-
- ::ng-deep .dropdown-root:not(.show) {
- opacity: 0;
- }
-
- ::ng-deep .playlist-dropdown.show + my-action-dropdown .dropdown-root {
- opacity: 1;
- }
-
- ::ng-deep .more-icon {
- opacity: .6;
-
- &:hover {
- opacity: 1;
- }
- }
- }
-
- @media screen and (max-width: $small-view) {
- .video-miniature-information {
- margin: 0 10px;
- }
-
- .video-actions {
- margin: 0;
- top: -3px;
-
- ::ng-deep .dropdown-root {
- opacity: 1 !important;
- }
- }
- }
- }
-
- &:hover ::ng-deep .video-thumbnail .video-thumbnail-actions-overlay,
- &:hover .video-bottom .video-actions ::ng-deep .dropdown-root {
- opacity: 1;
- }
-
- &.fit-width {
- width: 100%;
-
- .video-bottom {
- width: 100% !important;
-
- .video-miniature-information {
- width: calc(100% - #{$more-button-width}) !important;
- }
- }
-
- my-video-thumbnail {
- @include large-screen-ratio($selector: '::ng-deep .video-thumbnail');
- }
- }
-
- &.display-as-row {
- flex-direction: row;
- padding-bottom: 0;
- height: auto;
- display: flex;
- flex-grow: 1;
-
- my-video-thumbnail {
- margin-right: 10px;
- }
-
- .video-bottom {
- .video-miniature-information {
- @media screen and (min-width: $small-view) {
- width: auto;
- min-width: 500px;
- }
-
- .video-miniature-name {
- @include ellipsis-multiline(1.3em, 2);
-
- margin-top: 2px;
- margin-bottom: 5px;
- }
-
- .video-miniature-created-at-views,
- .video-miniature-account,
- .video-miniature-channel {
- font-size: 95%;
- width: fit-content;
- }
-
- .video-miniature-created-at-views + .video-miniature-channel {
- margin-top: 5px;
- }
-
- .video-info-privacy {
- margin-top: 5px;
- }
-
- .video-info-blocked {
- margin-top: 3px;
- }
- }
-
- .video-actions {
- margin: 0;
- top: -3px;
- }
- }
-
- @media screen and (max-width: $small-view) {
- flex-direction: column;
- height: auto;
-
- my-video-thumbnail {
- margin-right: 0;
- }
-
- .video-miniature-information {
- min-width: initial;
- }
- }
- }
-}
+++ /dev/null
-import { switchMap } from 'rxjs/operators'
-import {
- ChangeDetectionStrategy,
- ChangeDetectorRef,
- Component,
- EventEmitter,
- Inject,
- Input,
- LOCALE_ID,
- OnInit,
- Output
-} from '@angular/core'
-import { AuthService, ServerService } from '@app/core'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { VideoActionsDisplayType } from '@app/shared/video/video-actions-dropdown.component'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ServerConfig, VideoPlaylistType, VideoPrivacy, VideoState } from '../../../../../shared'
-import { User } from '../users'
-import { Video } from './video.model'
-
-export type OwnerDisplayType = 'account' | 'videoChannel' | 'auto'
-export type MiniatureDisplayOptions = {
- date?: boolean
- views?: boolean
- by?: boolean
- avatar?: boolean
- privacyLabel?: boolean
- privacyText?: boolean
- state?: boolean
- blacklistInfo?: boolean
- nsfw?: boolean
-}
-
-@Component({
- selector: 'my-video-miniature',
- styleUrls: [ './video-miniature.component.scss' ],
- templateUrl: './video-miniature.component.html',
- changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class VideoMiniatureComponent implements OnInit {
- @Input() user: User
- @Input() video: Video
-
- @Input() ownerDisplayType: OwnerDisplayType = 'account'
- @Input() displayOptions: MiniatureDisplayOptions = {
- date: true,
- views: true,
- by: true,
- avatar: false,
- privacyLabel: false,
- privacyText: false,
- state: false,
- blacklistInfo: false
- }
- @Input() displayAsRow = false
- @Input() displayVideoActions = true
- @Input() fitWidth = false
-
- @Input() useLazyLoadUrl = false
-
- @Output() videoBlocked = new EventEmitter()
- @Output() videoUnblocked = new EventEmitter()
- @Output() videoRemoved = new EventEmitter()
-
- videoActionsDisplayOptions: VideoActionsDisplayType = {
- playlist: true,
- download: false,
- update: true,
- blacklist: true,
- delete: true,
- report: true,
- duplicate: true
- }
- showActions = false
- serverConfig: ServerConfig
-
- addToWatchLaterText: string
- addedToWatchLaterText: string
- inWatchLaterPlaylist: boolean
- channelLinkTitle = ''
-
- watchLaterPlaylist: {
- id: number
- playlistElementId?: number
- }
-
- videoLink: any[] = []
-
- private ownerDisplayTypeChosen: 'account' | 'videoChannel'
-
- constructor (
- private screenService: ScreenService,
- private serverService: ServerService,
- private i18n: I18n,
- private authService: AuthService,
- private videoPlaylistService: VideoPlaylistService,
- private cd: ChangeDetectorRef,
- @Inject(LOCALE_ID) private localeId: string
- ) {}
-
- get isVideoBlur () {
- return this.video.isVideoNSFWForUser(this.user, this.serverConfig)
- }
-
- ngOnInit () {
- this.serverConfig = this.serverService.getTmpConfig()
- this.serverService.getConfig()
- .subscribe(config => {
- this.serverConfig = config
- this.buildVideoLink()
- })
-
- this.setUpBy()
-
- this.channelLinkTitle = this.i18n(
- '{{name}} (channel page)',
- { name: this.video.channel.name, handle: this.video.byVideoChannel }
- )
-
- // We rely on mouseenter to lazy load actions
- if (this.screenService.isInTouchScreen()) {
- this.loadActions()
- }
- }
-
- buildVideoLink () {
- if (this.useLazyLoadUrl && this.video.url) {
- const remoteUriConfig = this.serverConfig.search.remoteUri
-
- // Redirect on the external instance if not allowed to fetch remote data
- const externalRedirect = (!this.authService.isLoggedIn() && !remoteUriConfig.anonymous) || !remoteUriConfig.users
- const fromPath = window.location.pathname + window.location.search
-
- this.videoLink = [ '/search/lazy-load-video', { url: this.video.url, externalRedirect, fromPath } ]
- return
- }
-
- this.videoLink = [ '/videos/watch', this.video.uuid ]
- }
-
- displayOwnerAccount () {
- return this.ownerDisplayTypeChosen === 'account'
- }
-
- displayOwnerVideoChannel () {
- return this.ownerDisplayTypeChosen === 'videoChannel'
- }
-
- isUnlistedVideo () {
- return this.video.privacy.id === VideoPrivacy.UNLISTED
- }
-
- isPrivateVideo () {
- return this.video.privacy.id === VideoPrivacy.PRIVATE
- }
-
- getStateLabel (video: Video) {
- if (!video.state) return ''
-
- if (video.privacy.id !== VideoPrivacy.PRIVATE && video.state.id === VideoState.PUBLISHED) {
- return this.i18n('Published')
- }
-
- if (video.scheduledUpdate) {
- const updateAt = new Date(video.scheduledUpdate.updateAt.toString()).toLocaleString(this.localeId)
- return this.i18n('Publication scheduled on ') + updateAt
- }
-
- if (video.state.id === VideoState.TO_TRANSCODE && video.waitTranscoding === true) {
- return this.i18n('Waiting transcoding')
- }
-
- if (video.state.id === VideoState.TO_TRANSCODE) {
- return this.i18n('To transcode')
- }
-
- if (video.state.id === VideoState.TO_IMPORT) {
- return this.i18n('To import')
- }
-
- return ''
- }
-
- getAvatarUrl () {
- if (this.ownerDisplayTypeChosen === 'account') {
- return this.video.accountAvatarUrl
- }
-
- return this.video.videoChannelAvatarUrl
- }
-
- loadActions () {
- if (this.displayVideoActions) this.showActions = true
-
- this.loadWatchLater()
- }
-
- onVideoBlocked () {
- this.videoBlocked.emit()
- }
-
- onVideoUnblocked () {
- this.videoUnblocked.emit()
- }
-
- onVideoRemoved () {
- this.videoRemoved.emit()
- }
-
- isUserLoggedIn () {
- return this.authService.isLoggedIn()
- }
-
- onWatchLaterClick (currentState: boolean) {
- if (currentState === true) this.removeFromWatchLater()
- else this.addToWatchLater()
-
- this.inWatchLaterPlaylist = !currentState
- }
-
- addToWatchLater () {
- const body = { videoId: this.video.id }
-
- this.videoPlaylistService.addVideoInPlaylist(this.watchLaterPlaylist.id, body).subscribe(
- res => {
- this.watchLaterPlaylist.playlistElementId = res.videoPlaylistElement.id
- }
- )
- }
-
- removeFromWatchLater () {
- this.videoPlaylistService.removeVideoFromPlaylist(this.watchLaterPlaylist.id, this.watchLaterPlaylist.playlistElementId, this.video.id)
- .subscribe(
- _ => { /* empty */ }
- )
- }
-
- isWatchLaterPlaylistDisplayed () {
- return this.displayVideoActions && this.isUserLoggedIn() && this.inWatchLaterPlaylist !== undefined
- }
-
- private setUpBy () {
- if (this.ownerDisplayType === 'account' || this.ownerDisplayType === 'videoChannel') {
- this.ownerDisplayTypeChosen = this.ownerDisplayType
- return
- }
-
- // If the video channel name an UUID (not really displayable, we changed this behaviour in v1.0.0-beta.12)
- // -> Use the account name
- if (
- this.video.channel.name === `${this.video.account.name}_channel` ||
- this.video.channel.name.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
- ) {
- this.ownerDisplayTypeChosen = 'account'
- } else {
- this.ownerDisplayTypeChosen = 'videoChannel'
- }
- }
-
- private loadWatchLater () {
- if (!this.isUserLoggedIn() || this.inWatchLaterPlaylist !== undefined) return
-
- this.authService.userInformationLoaded
- .pipe(switchMap(() => this.videoPlaylistService.listenToVideoPlaylistChange(this.video.id)))
- .subscribe(existResult => {
- const watchLaterPlaylist = this.authService.getUser().specialPlaylists.find(p => p.type === VideoPlaylistType.WATCH_LATER)
- const existsInWatchLater = existResult.find(r => r.playlistId === watchLaterPlaylist.id)
- this.inWatchLaterPlaylist = false
-
- this.watchLaterPlaylist = {
- id: watchLaterPlaylist.id
- }
-
- if (existsInWatchLater) {
- this.inWatchLaterPlaylist = true
- this.watchLaterPlaylist.playlistElementId = existsInWatchLater.playlistElementId
- }
-
- this.cd.markForCheck()
- })
-
- this.videoPlaylistService.runPlaylistCheck(this.video.id)
- }
-}
+++ /dev/null
-<a
- [routerLink]="getVideoRouterLink()" [queryParams]="queryParams"
- class="video-thumbnail"
->
- <img alt="" [attr.aria-label]="video.name" [attr.src]="getImageUrl()" [ngClass]="{ 'blur-filter': nsfw }" />
-
- <div *ngIf="displayWatchLaterPlaylist" class="video-thumbnail-actions-overlay">
- <ng-container *ngIf="inWatchLaterPlaylist !== true">
- <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
- <my-global-icon iconName="clock" [attr.aria-label]="addToWatchLaterText" role="button"></my-global-icon>
- </div>
- </ng-container>
-
- <ng-container *ngIf="inWatchLaterPlaylist === true">
- <div class="video-thumbnail-watch-later-overlay" placement="left" [ngbTooltip]="addedToWatchLaterText" container="body" (click)="onWatchLaterClick($event)">
- <my-global-icon iconName="tick" [attr.aria-label]="addedToWatchLaterText" role="button"></my-global-icon>
- </div>
- </ng-container>
- </div>
-
- <div class="video-thumbnail-label-overlay warning"><ng-content select="label-warning"></ng-content></div>
- <div class="video-thumbnail-label-overlay danger"><ng-content select="label-danger"></ng-content></div>
-
- <div class="video-thumbnail-duration-overlay">{{ video.durationLabel }}</div>
-
- <div class="play-overlay">
- <div class="icon"></div>
- </div>
-
- <div class="progress-bar" *ngIf="video.userHistory?.currentTime">
- <div [ngStyle]="{ 'width.%': getProgressPercent() }"></div>
- </div>
-</a>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-.video-thumbnail {
- @include miniature-thumbnail;
-
- .progress-bar {
- height: 3px;
- width: 100%;
- position: absolute;
- bottom: 0;
- background-color: rgba(0, 0, 0, 0.20);
-
- div {
- height: 100%;
- background-color: pvar(--mainColor);
- }
- }
-
- .video-thumbnail-watch-later-overlay,
- .video-thumbnail-label-overlay,
- .video-thumbnail-duration-overlay {
- @include static-thumbnail-overlay;
-
- border-radius: 3px;
- font-size: 12px;
- font-weight: $font-semibold;
- line-height: 1.2;
- z-index: z(miniature);
- }
-
- .video-thumbnail-label-overlay {
- position: absolute;
- padding: 0 5px;
- left: 5px;
- top: 5px;
- font-weight: $font-bold;
-
- &.warning { background-color: orange; }
- &.danger { background-color: red; }
- }
-
- .video-thumbnail-duration-overlay {
- position: absolute;
- padding: 0 3px;
- right: 5px;
- bottom: 5px;
- }
-
- .video-thumbnail-actions-overlay {
- position: absolute;
- display: flex;
- flex-direction: column;
- right: 5px;
- top: 5px;
- opacity: 0;
-
- div:not(:first-child) {
- margin-top: 2px;
- }
-
- .video-thumbnail-watch-later-overlay {
- padding: 3px;
-
- my-global-icon {
- width: 22px;
- height: 22px;
-
- @include apply-svg-color(#fff);
- }
- }
- }
-}
+++ /dev/null
-import { Component, EventEmitter, Input, Output } from '@angular/core'
-import { Video } from './video.model'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-
-@Component({
- selector: 'my-video-thumbnail',
- styleUrls: [ './video-thumbnail.component.scss' ],
- templateUrl: './video-thumbnail.component.html'
-})
-export class VideoThumbnailComponent {
- @Input() video: Video
- @Input() nsfw = false
- @Input() routerLink: any[]
- @Input() queryParams: { [ p: string ]: any }
-
- @Input() displayWatchLaterPlaylist: boolean
- @Input() inWatchLaterPlaylist: boolean
-
- @Output() watchLaterClick = new EventEmitter<boolean>()
-
- addToWatchLaterText: string
- addedToWatchLaterText: string
-
- constructor (
- private screenService: ScreenService,
- private i18n: I18n
- ) {
- this.addToWatchLaterText = this.i18n('Add to watch later')
- this.addedToWatchLaterText = this.i18n('Remove from watch later')
- }
-
- getImageUrl () {
- if (!this.video) return ''
-
- if (this.screenService.isInMobileView()) {
- return this.video.previewUrl
- }
-
- return this.video.thumbnailUrl
- }
-
- getProgressPercent () {
- if (!this.video.userHistory) return 0
-
- const currentTime = this.video.userHistory.currentTime
-
- return (currentTime / this.video.duration) * 100
- }
-
- getVideoRouterLink () {
- if (this.routerLink) return this.routerLink
-
- return [ '/videos/watch', this.video.uuid ]
- }
-
- onWatchLaterClick (event: Event) {
- this.watchLaterClick.emit(this.inWatchLaterPlaylist)
-
- event.stopPropagation()
- return false
- }
-}
+++ /dev/null
-import { User } from '../'
-import { UserRight, Video as VideoServerModel, VideoPrivacy, VideoState } from '../../../../../shared'
-import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
-import { VideoConstant } from '../../../../../shared/models/videos/video-constant.model'
-import { durationToString, getAbsoluteAPIUrl } from '../misc/utils'
-import { peertubeTranslate, ServerConfig } from '../../../../../shared/models'
-import { Actor } from '@app/shared/actor/actor.model'
-import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
-import { AuthUser } from '@app/core'
-import { environment } from '../../../environments/environment'
-
-export class Video implements VideoServerModel {
- byVideoChannel: string
- byAccount: string
-
- accountAvatarUrl: string
- videoChannelAvatarUrl: string
-
- createdAt: Date
- updatedAt: Date
- publishedAt: Date
- originallyPublishedAt: Date | string
- category: VideoConstant<number>
- licence: VideoConstant<number>
- language: VideoConstant<string>
- privacy: VideoConstant<VideoPrivacy>
- description: string
- duration: number
- durationLabel: string
- id: number
- uuid: string
- isLocal: boolean
- name: string
- serverHost: string
- thumbnailPath: string
- thumbnailUrl: string
-
- previewPath: string
- previewUrl: string
-
- embedPath: string
- embedUrl: string
-
- url?: string
-
- views: number
- likes: number
- dislikes: number
- nsfw: boolean
-
- originInstanceUrl: string
- originInstanceHost: string
-
- waitTranscoding?: boolean
- state?: VideoConstant<VideoState>
- scheduledUpdate?: VideoScheduleUpdate
- blacklisted?: boolean
- blockedReason?: string
-
- account: {
- id: number
- name: string
- displayName: string
- url: string
- host: string
- avatar?: Avatar
- }
-
- channel: {
- id: number
- name: string
- displayName: string
- url: string
- host: string
- avatar?: Avatar
- }
-
- userHistory?: {
- currentTime: number
- }
-
- static buildClientUrl (videoUUID: string) {
- return '/videos/watch/' + videoUUID
- }
-
- constructor (hash: VideoServerModel, translations = {}) {
- const absoluteAPIUrl = getAbsoluteAPIUrl()
-
- this.createdAt = new Date(hash.createdAt.toString())
- this.publishedAt = new Date(hash.publishedAt.toString())
- this.category = hash.category
- this.licence = hash.licence
- this.language = hash.language
- this.privacy = hash.privacy
- this.waitTranscoding = hash.waitTranscoding
- this.state = hash.state
- this.description = hash.description
-
- this.duration = hash.duration
- this.durationLabel = durationToString(hash.duration)
-
- this.id = hash.id
- this.uuid = hash.uuid
-
- this.isLocal = hash.isLocal
- this.name = hash.name
-
- this.thumbnailPath = hash.thumbnailPath
- this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
-
- this.previewPath = hash.previewPath
- this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
-
- this.embedPath = hash.embedPath
- this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath)
-
- this.url = hash.url
-
- this.views = hash.views
- this.likes = hash.likes
- this.dislikes = hash.dislikes
-
- this.nsfw = hash.nsfw
-
- this.account = hash.account
- this.channel = hash.channel
-
- this.byAccount = Actor.CREATE_BY_STRING(hash.account.name, hash.account.host)
- this.byVideoChannel = Actor.CREATE_BY_STRING(hash.channel.name, hash.channel.host)
- this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
- this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.channel)
-
- this.category.label = peertubeTranslate(this.category.label, translations)
- this.licence.label = peertubeTranslate(this.licence.label, translations)
- this.language.label = peertubeTranslate(this.language.label, translations)
- this.privacy.label = peertubeTranslate(this.privacy.label, translations)
-
- this.scheduledUpdate = hash.scheduledUpdate
- this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null
-
- if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
-
- this.blacklisted = hash.blacklisted
- this.blockedReason = hash.blacklistedReason
-
- this.userHistory = hash.userHistory
-
- this.originInstanceHost = this.account.host
- this.originInstanceUrl = 'https://' + this.originInstanceHost
- }
-
- isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
- // Video is not NSFW, skip
- if (this.nsfw === false) return false
-
- // Return user setting if logged in
- if (user) return user.nsfwPolicy !== 'display'
-
- // Return default instance config
- return serverConfig.instance.defaultNSFWPolicy !== 'display'
- }
-
- isRemovableBy (user: AuthUser) {
- return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO))
- }
-
- isBlockableBy (user: AuthUser) {
- return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
- }
-
- isUnblockableBy (user: AuthUser) {
- return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
- }
-
- isUpdatableBy (user: AuthUser) {
- return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
- }
-
- canBeDuplicatedBy (user: AuthUser) {
- return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
- }
-}
+++ /dev/null
-import { catchError, map, switchMap } from 'rxjs/operators'
-import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
-import { Injectable } from '@angular/core'
-import { Observable } from 'rxjs'
-import { Video as VideoServerModel, VideoDetails as VideoDetailsServerModel } from '../../../../../shared'
-import { ResultList } from '../../../../../shared/models/result-list.model'
-import {
- UserVideoRate,
- UserVideoRateType,
- UserVideoRateUpdate,
- VideoConstant,
- VideoFilter,
- VideoPrivacy,
- VideoUpdate
-} from '../../../../../shared/models/videos'
-import { FeedFormat } from '../../../../../shared/models/feeds/feed-format.enum'
-import { environment } from '../../../environments/environment'
-import { ComponentPaginationLight } from '../rest/component-pagination.model'
-import { RestExtractor } from '../rest/rest-extractor.service'
-import { RestService } from '../rest/rest.service'
-import { UserService } from '../users/user.service'
-import { VideoSortField } from './sort-field.type'
-import { VideoDetails } from './video-details.model'
-import { VideoEdit } from './video-edit.model'
-import { Video } from './video.model'
-import { objectToFormData } from '@app/shared/misc/utils'
-import { Account } from '@app/shared/account/account.model'
-import { AccountService } from '@app/shared/account/account.service'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { ServerService, AuthService } from '@app/core'
-import { UserSubscriptionService } from '@app/shared/user-subscription/user-subscription.service'
-import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
-import { FfprobeData } from 'fluent-ffmpeg'
-
-export interface VideosProvider {
- getVideos (parameters: {
- videoPagination: ComponentPaginationLight,
- sort: VideoSortField,
- filter?: VideoFilter,
- categoryOneOf?: number[],
- languageOneOf?: string[]
- nsfwPolicy: NSFWPolicyType
- }): Observable<ResultList<Video>>
-}
-
-@Injectable()
-export class VideoService implements VideosProvider {
- static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
- static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
-
- constructor (
- private authHttp: HttpClient,
- private authService: AuthService,
- private userService: UserService,
- private restExtractor: RestExtractor,
- private restService: RestService,
- private serverService: ServerService,
- private i18n: I18n
- ) {}
-
- getVideoViewUrl (uuid: string) {
- return VideoService.BASE_VIDEO_URL + uuid + '/views'
- }
-
- getUserWatchingVideoUrl (uuid: string) {
- return VideoService.BASE_VIDEO_URL + uuid + '/watching'
- }
-
- getVideo (options: { videoId: string }): Observable<VideoDetails> {
- return this.serverService.getServerLocale()
- .pipe(
- switchMap(translations => {
- return this.authHttp.get<VideoDetailsServerModel>(VideoService.BASE_VIDEO_URL + options.videoId)
- .pipe(map(videoHash => ({ videoHash, translations })))
- }),
- map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- updateVideo (video: VideoEdit) {
- const language = video.language || null
- const licence = video.licence || null
- const category = video.category || null
- const description = video.description || null
- const support = video.support || null
- const scheduleUpdate = video.scheduleUpdate || null
- const originallyPublishedAt = video.originallyPublishedAt || null
-
- const body: VideoUpdate = {
- name: video.name,
- category,
- licence,
- language,
- support,
- description,
- channelId: video.channelId,
- privacy: video.privacy,
- tags: video.tags,
- nsfw: video.nsfw,
- waitTranscoding: video.waitTranscoding,
- commentsEnabled: video.commentsEnabled,
- downloadEnabled: video.downloadEnabled,
- thumbnailfile: video.thumbnailfile,
- previewfile: video.previewfile,
- scheduleUpdate,
- originallyPublishedAt
- }
-
- const data = objectToFormData(body)
-
- return this.authHttp.put(VideoService.BASE_VIDEO_URL + video.id, data)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- uploadVideo (video: FormData) {
- const req = new HttpRequest('POST', VideoService.BASE_VIDEO_URL + 'upload', video, { reportProgress: true })
-
- return this.authHttp
- .request<{ video: { id: number, uuid: string } }>(req)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable<ResultList<Video>> {
- const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
- params = this.restService.addObjectParams(params, { search })
-
- return this.authHttp
- .get<ResultList<Video>>(UserService.BASE_USERS_URL + 'me/videos', { params })
- .pipe(
- switchMap(res => this.extractVideos(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getAccountVideos (
- account: Account,
- videoPagination: ComponentPaginationLight,
- sort: VideoSortField
- ): Observable<ResultList<Video>> {
- const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- return this.authHttp
- .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
- .pipe(
- switchMap(res => this.extractVideos(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getVideoChannelVideos (
- videoChannel: VideoChannel,
- videoPagination: ComponentPaginationLight,
- sort: VideoSortField,
- nsfwPolicy?: NSFWPolicyType
- ): Observable<ResultList<Video>> {
- const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (nsfwPolicy) {
- params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
- }
-
- return this.authHttp
- .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
- .pipe(
- switchMap(res => this.extractVideos(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getUserSubscriptionVideos (parameters: {
- videoPagination: ComponentPaginationLight,
- sort: VideoSortField,
- skipCount?: boolean
- }): Observable<ResultList<Video>> {
- const { videoPagination, sort, skipCount } = parameters
- const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
- let params = new HttpParams()
- params = this.restService.addRestGetParams(params, pagination, sort)
-
- if (skipCount) params = params.set('skipCount', skipCount + '')
-
- return this.authHttp
- .get<ResultList<Video>>(UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL + '/videos', { params })
- .pipe(
- switchMap(res => this.extractVideos(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- getVideos (parameters: {
- videoPagination: ComponentPaginationLight,
- sort: VideoSortField,
- filter?: VideoFilter,
- categoryOneOf?: number[],
- languageOneOf?: string[],
- skipCount?: boolean,
- nsfwPolicy?: NSFWPolicyType
- }): Observable<ResultList<Video>> {
- const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy } = parameters
-
- const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
-
- 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 (nsfwPolicy) {
- params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
- }
-
- if (languageOneOf) {
- for (const l of languageOneOf) {
- params = params.append('languageOneOf[]', l)
- }
- }
-
- if (categoryOneOf) {
- for (const c of categoryOneOf) {
- params = params.append('categoryOneOf[]', c + '')
- }
- }
-
- return this.authHttp
- .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
- .pipe(
- switchMap(res => this.extractVideos(res)),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- buildBaseFeedUrls (params: HttpParams) {
- const feeds = [
- {
- format: FeedFormat.RSS,
- label: 'rss 2.0',
- url: VideoService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase()
- },
- {
- format: FeedFormat.ATOM,
- label: 'atom 1.0',
- url: VideoService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase()
- },
- {
- format: FeedFormat.JSON,
- label: 'json 1.0',
- url: VideoService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase()
- }
- ]
-
- if (params && params.keys().length !== 0) {
- for (const feed of feeds) {
- feed.url += '?' + params.toString()
- }
- }
-
- return feeds
- }
-
- getVideoFeedUrls (sort: VideoSortField, filter?: VideoFilter, categoryOneOf?: number[]) {
- let params = this.restService.addRestGetParams(new HttpParams(), undefined, sort)
-
- if (filter) params = params.set('filter', filter)
-
- if (categoryOneOf) {
- for (const c of categoryOneOf) {
- params = params.append('categoryOneOf[]', c + '')
- }
- }
-
- return this.buildBaseFeedUrls(params)
- }
-
- getAccountFeedUrls (accountId: number) {
- let params = this.restService.addRestGetParams(new HttpParams())
- params = params.set('accountId', accountId.toString())
-
- return this.buildBaseFeedUrls(params)
- }
-
- getVideoChannelFeedUrls (videoChannelId: number) {
- let params = this.restService.addRestGetParams(new HttpParams())
- params = params.set('videoChannelId', videoChannelId.toString())
-
- return this.buildBaseFeedUrls(params)
- }
-
- getVideoFileMetadata (metadataUrl: string) {
- return this.authHttp
- .get<FfprobeData>(metadataUrl)
- .pipe(
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- removeVideo (id: number) {
- return this.authHttp
- .delete(VideoService.BASE_VIDEO_URL + id)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- loadCompleteDescription (descriptionPath: string) {
- return this.authHttp
- .get<{ description: string }>(environment.apiUrl + descriptionPath)
- .pipe(
- map(res => res.description),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-
- setVideoLike (id: number) {
- return this.setVideoRate(id, 'like')
- }
-
- setVideoDislike (id: number) {
- return this.setVideoRate(id, 'dislike')
- }
-
- unsetVideoLike (id: number) {
- return this.setVideoRate(id, 'none')
- }
-
- getUserVideoRating (id: number) {
- const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating'
-
- return this.authHttp.get<UserVideoRate>(url)
- .pipe(catchError(err => this.restExtractor.handleError(err)))
- }
-
- extractVideos (result: ResultList<VideoServerModel>) {
- return this.serverService.getServerLocale()
- .pipe(
- map(translations => {
- const videosJson = result.data
- const totalVideos = result.total
- const videos: Video[] = []
-
- for (const videoJson of videosJson) {
- videos.push(new Video(videoJson, translations))
- }
-
- return { total: totalVideos, data: videos }
- })
- )
- }
-
- explainedPrivacyLabels (privacies: VideoConstant<VideoPrivacy>[]) {
- const base = [
- {
- id: VideoPrivacy.PRIVATE,
- label: this.i18n('Only I can see this video')
- },
- {
- id: VideoPrivacy.UNLISTED,
- label: this.i18n('Only people with the private link can see this video')
- },
- {
- id: VideoPrivacy.PUBLIC,
- label: this.i18n('Anyone can see this video')
- },
- {
- id: VideoPrivacy.INTERNAL,
- label: this.i18n('Only users of this instance can see this video')
- }
- ]
-
- return base.filter(o => !!privacies.find(p => p.id === o.id))
- }
-
- nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) {
- return nsfwPolicy === 'do_not_list'
- ? 'false'
- : 'both'
- }
-
- private setVideoRate (id: number, rateType: UserVideoRateType) {
- const url = VideoService.BASE_VIDEO_URL + id + '/rate'
- const body: UserVideoRateUpdate = {
- rating: rateType
- }
-
- return this.authHttp
- .put(url, body)
- .pipe(
- map(this.restExtractor.extractDataBool),
- catchError(err => this.restExtractor.handleError(err))
- )
- }
-}
+++ /dev/null
-<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No results.</div>
-
-<div myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()" class="videos">
- <div class="video" *ngFor="let video of videos; let i = index; trackBy: videoById">
-
- <div class="checkbox-container">
- <my-peertube-checkbox [inputName]="'video-check-' + video.id" [(ngModel)]="_selection[video.id]"></my-peertube-checkbox>
- </div>
-
- <my-video-miniature
- [video]="video" [displayAsRow]="true" [displayOptions]="miniatureDisplayOptions"
- [displayVideoActions]="false" [ownerDisplayType]="ownerDisplayType"
- ></my-video-miniature>
-
- <!-- Display only once -->
- <div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
- <div class="action-selection-mode-child">
- <span i18n class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
- Cancel
- </span>
-
- <ng-container *ngTemplateOutlet="globalButtonsTemplate"></ng-container>
- </div>
- </div>
-
- <ng-container *ngIf="isInSelectionMode() === false">
- <ng-container *ngTemplateOutlet="rowButtonsTemplate; context: {$implicit: video}"></ng-container>
- </ng-container>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.action-selection-mode {
- display: flex;
- justify-content: flex-end;
- flex-grow: 1;
-
- .action-selection-mode-child {
- position: fixed;
-
- .action-button {
- display: inline-block;
- }
-
- .action-button-cancel-selection {
- @include peertube-button;
- @include grey-button;
-
- margin-right: 10px;
- }
- }
-}
-
-.video {
- @include row-blocks;
-
- &:first-child {
- margin-top: 47px;
- }
-
- .checkbox-container {
- display: flex;
- align-items: center;
- margin-right: 20px;
- margin-left: 12px;
- }
-
- my-video-miniature {
- flex-grow: 1;
- }
-}
-
-@media screen and (max-width: $small-view) {
- .video {
- flex-direction: column;
- height: auto;
-
- .checkbox-container {
- display: none;
- }
-
- my-button {
- margin-top: 10px;
- }
- }
-}
+++ /dev/null
-import {
- AfterContentInit,
- Component,
- ContentChildren,
- EventEmitter,
- Input,
- OnDestroy,
- OnInit,
- Output,
- QueryList,
- TemplateRef
-} from '@angular/core'
-import { ActivatedRoute, Router } from '@angular/router'
-import { AbstractVideoList } from '@app/shared/video/abstract-video-list'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { MiniatureDisplayOptions, OwnerDisplayType } from '@app/shared/video/video-miniature.component'
-import { Observable } from 'rxjs'
-import { Video } from '@app/shared/video/video.model'
-import { PeerTubeTemplateDirective } from '@app/shared/angular/peertube-template.directive'
-import { VideoSortField } from '@app/shared/video/sort-field.type'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ResultList } from '@shared/models'
-import { UserService } from '../users'
-import { LocalStorageService } from '../misc/storage.service'
-
-export type SelectionType = { [ id: number ]: boolean }
-
-@Component({
- selector: 'my-videos-selection',
- templateUrl: './videos-selection.component.html',
- styleUrls: [ './videos-selection.component.scss' ]
-})
-export class VideosSelectionComponent extends AbstractVideoList implements OnInit, OnDestroy, AfterContentInit {
- @Input() pagination: ComponentPagination
- @Input() titlePage: string
- @Input() miniatureDisplayOptions: MiniatureDisplayOptions
- @Input() ownerDisplayType: OwnerDisplayType
-
- @Input() getVideosObservableFunction: (page: number, sort?: VideoSortField) => Observable<ResultList<Video>>
-
- @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'rowButtons' | 'globalButtons'>>
-
- @Output() selectionChange = new EventEmitter<SelectionType>()
- @Output() videosModelChange = new EventEmitter<Video[]>()
-
- _selection: SelectionType = {}
-
- rowButtonsTemplate: TemplateRef<any>
- globalButtonsTemplate: TemplateRef<any>
-
- constructor (
- protected i18n: I18n,
- protected router: Router,
- protected route: ActivatedRoute,
- protected notifier: Notifier,
- protected authService: AuthService,
- protected userService: UserService,
- protected screenService: ScreenService,
- protected storageService: LocalStorageService,
- protected serverService: ServerService
- ) {
- super()
- }
-
- @Input() get selection () {
- return this._selection
- }
-
- set selection (selection: SelectionType) {
- this._selection = selection
- this.selectionChange.emit(this._selection)
- }
-
- @Input() get videosModel () {
- return this.videos
- }
-
- set videosModel (videos: Video[]) {
- this.videos = videos
- this.videosModelChange.emit(this.videos)
- }
-
- ngOnInit () {
- super.ngOnInit()
- }
-
- ngAfterContentInit () {
- {
- const t = this.templates.find(t => t.name === 'rowButtons')
- if (t) this.rowButtonsTemplate = t.template
- }
-
- {
- const t = this.templates.find(t => t.name === 'globalButtons')
- if (t) this.globalButtonsTemplate = t.template
- }
- }
-
- ngOnDestroy () {
- super.ngOnDestroy()
- }
-
- getVideosObservable (page: number) {
- return this.getVideosObservableFunction(page, this.sort)
- }
-
- abortSelectionMode () {
- this._selection = {}
- }
-
- isInSelectionMode () {
- return Object.keys(this._selection).some(k => this._selection[ k ] === true)
- }
-
- generateSyndicationList () {
- throw new Error('Method not implemented.')
- }
-
- protected onMoreVideos () {
- this.videosModel = this.videos
- }
-}
--- /dev/null
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { Injectable } from '@angular/core'
+
+@Injectable()
+export class I18nPrimengCalendarService {
+ private readonly calendarLocale: any = {}
+
+ constructor (private i18n: I18n) {
+ this.calendarLocale = {
+ firstDayOfWeek: 0,
+ dayNames: [
+ this.i18n('Sunday'),
+ this.i18n('Monday'),
+ this.i18n('Tuesday'),
+ this.i18n('Wednesday'),
+ this.i18n('Thursday'),
+ this.i18n('Friday'),
+ this.i18n('Saturday')
+ ],
+
+ dayNamesShort: [
+ this.i18n({ value: 'Sun', description: 'Day name short' }),
+ this.i18n({ value: 'Mon', description: 'Day name short' }),
+ this.i18n({ value: 'Tue', description: 'Day name short' }),
+ this.i18n({ value: 'Wed', description: 'Day name short' }),
+ this.i18n({ value: 'Thu', description: 'Day name short' }),
+ this.i18n({ value: 'Fri', description: 'Day name short' }),
+ this.i18n({ value: 'Sat', description: 'Day name short' })
+ ],
+
+ dayNamesMin: [
+ this.i18n({ value: 'Su', description: 'Day name min' }),
+ this.i18n({ value: 'Mo', description: 'Day name min' }),
+ this.i18n({ value: 'Tu', description: 'Day name min' }),
+ this.i18n({ value: 'We', description: 'Day name min' }),
+ this.i18n({ value: 'Th', description: 'Day name min' }),
+ this.i18n({ value: 'Fr', description: 'Day name min' }),
+ this.i18n({ value: 'Sa', description: 'Day name min' })
+ ],
+
+ monthNames: [
+ this.i18n('January'),
+ this.i18n('February'),
+ this.i18n('March'),
+ this.i18n('April'),
+ this.i18n('May'),
+ this.i18n('June'),
+ this.i18n('July'),
+ this.i18n('August'),
+ this.i18n('September'),
+ this.i18n('October'),
+ this.i18n('November'),
+ this.i18n('December')
+ ],
+
+ monthNamesShort: [
+ this.i18n({ value: 'Jan', description: 'Month name short' }),
+ this.i18n({ value: 'Feb', description: 'Month name short' }),
+ this.i18n({ value: 'Mar', description: 'Month name short' }),
+ this.i18n({ value: 'Apr', description: 'Month name short' }),
+ this.i18n({ value: 'May', description: 'Month name short' }),
+ this.i18n({ value: 'Jun', description: 'Month name short' }),
+ this.i18n({ value: 'Jul', description: 'Month name short' }),
+ this.i18n({ value: 'Aug', description: 'Month name short' }),
+ this.i18n({ value: 'Sep', description: 'Month name short' }),
+ this.i18n({ value: 'Oct', description: 'Month name short' }),
+ this.i18n({ value: 'Nov', description: 'Month name short' }),
+ this.i18n({ value: 'Dec', description: 'Month name short' })
+ ],
+
+ today: this.i18n('Today'),
+
+ clear: this.i18n('Clear')
+ }
+ }
+
+ getCalendarLocale () {
+ return this.calendarLocale
+ }
+
+ getTimezone () {
+ const gmt = new Date().toString().match(/([A-Z]+[\+-][0-9]+)/)[1]
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+
+ return `${timezone} - ${gmt}`
+ }
+
+ getDateFormat () {
+ return this.i18n({
+ value: 'yy-mm-dd ',
+ description: 'Date format in this locale.'
+ })
+ }
+}
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { FormReactive } from '@app/shared'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoCaptionsValidatorsService } from '@app/shared/forms/form-validators/video-captions-validators.service'
import { ServerService } from '@app/core'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
+import { FormReactive, FormValidatorService, VideoCaptionsValidatorsService } from '@app/shared/shared-forms'
+import { VideoCaptionEdit } from '@app/shared/shared-main'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
-import { ServerConfig, VideoConstant } from '../../../../../../shared'
+import { ServerConfig, VideoConstant } from '@shared/models'
@Component({
selector: 'my-video-caption-add-modal',
+import { map } from 'rxjs/operators'
import { Component, Input, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'
-import { ActivatedRoute, Router } from '@angular/router'
-import { FormReactiveValidationMessages, VideoValidatorsService } from '@app/shared'
-import { Notifier } from '@app/core'
-import { ServerService } from '../../../core/server'
-import { VideoEdit } from '../../../shared/video/video-edit.model'
-import { map } from 'rxjs/operators'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { I18nPrimengCalendarService } from '@app/shared/i18n/i18n-primeng-calendar'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoCaptionAddModalComponent } from '@app/videos/+video-edit/shared/video-caption-add-modal.component'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
-import { removeElementFromArray } from '@app/shared/misc/utils'
-import { ServerConfig, VideoConstant, VideoPrivacy } from '../../../../../../shared'
-import { VideoService } from '@app/shared/video/video.service'
+import { ServerService } from '@app/core'
+import { removeElementFromArray } from '@app/helpers'
+import { FormReactiveValidationMessages, FormValidatorService, VideoValidatorsService } from '@app/shared/shared-forms'
+import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
+import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
+import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
@Component({
selector: 'my-video-edit',
constructor (
private formValidatorService: FormValidatorService,
private videoValidatorsService: VideoValidatorsService,
- private videoCaptionService: VideoCaptionService,
private videoService: VideoService,
- private route: ActivatedRoute,
- private router: Router,
- private notifier: Notifier,
private serverService: ServerService,
private i18nPrimengCalendarService: I18nPrimengCalendarService,
private ngZone: NgZone
-import { NgModule } from '@angular/core'
import { TagInputModule } from 'ngx-chips'
-import { SharedModule } from '../../../shared/'
-import { VideoEditComponent } from './video-edit.component'
import { CalendarModule } from 'primeng/calendar'
+import { NgModule } from '@angular/core'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+import { SharedMainModule } from '@app/shared/shared-main'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
+import { VideoEditComponent } from './video-edit.component'
@NgModule({
imports: [
TagInputModule,
CalendarModule,
- SharedModule
+ SharedMainModule,
+ SharedFormModule,
+ SharedGlobalIconModule
],
declarations: [
TagInputModule,
CalendarModule,
+ SharedMainModule,
+ SharedFormModule,
+ SharedGlobalIconModule,
+
VideoEditComponent
],
import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Router } from '@angular/router'
-import { VideoPrivacy, VideoUpdate } from '../../../../../../shared/models/videos'
-import { AuthService, Notifier, ServerService } from '../../../core'
-import { VideoService } from '../../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { LoadingBarService } from '@ngx-loading-bar/core'
+import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
+import { scrollToTop } from '@app/helpers'
+import { FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
-import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
-import { VideoEdit } from '@app/shared/video/video-edit.model'
-import { FormValidatorService } from '@app/shared'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoImportService } from '@app/shared/video-import'
-import { scrollToTop } from '@app/shared/misc/utils'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPrivacy, VideoUpdate } from '@shared/models'
@Component({
selector: 'my-video-import-torrent',
+import { map, switchMap } from 'rxjs/operators'
import { Component, EventEmitter, OnInit, Output } from '@angular/core'
import { Router } from '@angular/router'
-import { VideoPrivacy, VideoUpdate } from '../../../../../../shared/models/videos'
-import { AuthService, Notifier, ServerService } from '../../../core'
-import { VideoService } from '../../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { LoadingBarService } from '@ngx-loading-bar/core'
+import { AuthService, CanComponentDeactivate, Notifier, ServerService } from '@app/core'
+import { getAbsoluteAPIUrl, scrollToTop } from '@app/helpers'
+import { FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
-import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
-import { VideoEdit } from '@app/shared/video/video-edit.model'
-import { FormValidatorService } from '@app/shared'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoImportService } from '@app/shared/video-import'
-import { scrollToTop, getAbsoluteAPIUrl } from '@app/shared/misc/utils'
-import { switchMap, map } from 'rxjs/operators'
+import { LoadingBarService } from '@ngx-loading-bar/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoPrivacy, VideoUpdate } from '@shared/models'
@Component({
selector: 'my-video-import-url',
+import { catchError, switchMap, tap } from 'rxjs/operators'
import { EventEmitter, OnInit } from '@angular/core'
+import { AuthService, CanComponentDeactivateResult, Notifier, ServerService } from '@app/core'
+import { populateAsyncUserVideoChannels } from '@app/helpers'
+import { FormReactive } from '@app/shared/shared-forms'
+import { VideoCaptionEdit, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
-import { AuthService, Notifier, ServerService } from '@app/core'
-import { catchError, switchMap, tap } from 'rxjs/operators'
-import { FormReactive } from '@app/shared'
-import { ServerConfig, VideoConstant, VideoPrivacy } from '../../../../../../shared'
-import { VideoService } from '@app/shared/video/video.service'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoEdit } from '@app/shared/video/video-edit.model'
-import { populateAsyncUserVideoChannels } from '@app/shared/misc/utils'
-import { CanComponentDeactivateResult } from '@app/shared/guards/can-deactivate-guard.service'
+import { ServerConfig, VideoConstant, VideoPrivacy } from '@shared/models'
export abstract class VideoSend extends FormReactive implements OnInit {
userVideoChannels: { id: number, label: string, support: string }[] = []
+import { BytesPipe } from 'ngx-pipes'
+import { Subscription } from 'rxjs'
import { HttpEventType, HttpResponse } from '@angular/common/http'
import { Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
import { Router } from '@angular/router'
+import { AuthService, CanComponentDeactivate, Notifier, ServerService, UserService } from '@app/core'
+import { scrollToTop } from '@app/helpers'
+import { FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
import { LoadingBarService } from '@ngx-loading-bar/core'
-import { BytesPipe } from 'ngx-pipes'
-import { Subscription } from 'rxjs'
-import { VideoPrivacy } from '../../../../../../shared/models/videos'
-import { AuthService, Notifier, ServerService } from '../../../core'
-import { VideoEdit } from '../../../shared/video/video-edit.model'
-import { VideoService } from '../../../shared/video/video.service'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { VideoSend } from '@app/videos/+video-edit/video-add-components/video-send'
-import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
-import { FormValidatorService, UserService } from '@app/shared'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { scrollToTop } from '@app/shared/misc/utils'
+import { VideoPrivacy } from '@shared/models'
@Component({
selector: 'my-video-upload',
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
-
+import { CanDeactivateGuard, LoginGuard } from '@app/core'
import { MetaGuard } from '@ngx-meta/core'
-
-import { LoginGuard } from '../../core'
-import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service'
import { VideoAddComponent } from './video-add.component'
const videoAddRoutes: Routes = [
import { Component, HostListener, OnInit, ViewChild } from '@angular/core'
-import { CanComponentDeactivate } from '@app/shared/guards/can-deactivate-guard.service'
-import { VideoImportUrlComponent } from '@app/videos/+video-edit/video-add-components/video-import-url.component'
-import { VideoUploadComponent } from '@app/videos/+video-edit/video-add-components/video-upload.component'
-import { AuthService, ServerService } from '@app/core'
-import { VideoImportTorrentComponent } from '@app/videos/+video-edit/video-add-components/video-import-torrent.component'
+import { AuthService, CanComponentDeactivate, ServerService } from '@app/core'
import { ServerConfig } from '@shared/models'
+import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
+import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
+import { VideoUploadComponent } from './video-add-components/video-upload.component'
@Component({
selector: 'my-videos-add',
import { NgModule } from '@angular/core'
-import { SharedModule } from '../../shared'
+import { CanDeactivateGuard } from '@app/core'
import { VideoEditModule } from './shared/video-edit.module'
+import { DragDropDirective } from './video-add-components/drag-drop.directive'
+import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
+import { VideoImportUrlComponent } from './video-add-components/video-import-url.component'
+import { VideoUploadComponent } from './video-add-components/video-upload.component'
import { VideoAddRoutingModule } from './video-add-routing.module'
import { VideoAddComponent } from './video-add.component'
-import { DragDropDirective } from './video-add-components/drag-drop.directive'
-import { CanDeactivateGuard } from '../../shared/guards/can-deactivate-guard.service'
-import { VideoUploadComponent } from '@app/videos/+video-edit/video-add-components/video-upload.component'
-import { VideoImportUrlComponent } from '@app/videos/+video-edit/video-add-components/video-import-url.component'
-import { VideoImportTorrentComponent } from '@app/videos/+video-edit/video-add-components/video-import-torrent.component'
@NgModule({
imports: [
VideoAddRoutingModule,
- VideoEditModule,
- SharedModule
+
+ VideoEditModule
],
+
declarations: [
VideoAddComponent,
VideoUploadComponent,
VideoImportTorrentComponent,
DragDropDirective
],
- exports: [
- VideoAddComponent,
- DragDropDirective
- ],
+
+ exports: [ ],
+
providers: [
CanDeactivateGuard
]
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
-
+import { CanDeactivateGuard, LoginGuard } from '@app/core'
import { MetaGuard } from '@ngx-meta/core'
-
-import { LoginGuard } from '../../core'
import { VideoUpdateComponent } from './video-update.component'
-import { VideoUpdateResolver } from '@app/videos/+video-edit/video-update.resolver'
-import { CanDeactivateGuard } from '@app/shared/guards/can-deactivate-guard.service'
+import { VideoUpdateResolver } from './video-update.resolver'
const videoUpdateRoutes: Routes = [
{
import { map, switchMap } from 'rxjs/operators'
import { Component, HostListener, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { LoadingBarService } from '@ngx-loading-bar/core'
import { Notifier } from '@app/core'
-import { ServerService } from '../../core'
-import { FormReactive } from '../../shared'
-import { VideoEdit } from '../../shared/video/video-edit.model'
-import { VideoService } from '../../shared/video/video.service'
+import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
+import { LoadingBarService } from '@ngx-loading-bar/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { VideoCaptionEdit } from '@app/shared/video-caption/video-caption-edit.model'
-import { VideoDetails } from '@app/shared/video/video-details.model'
import { VideoPrivacy } from '@shared/models'
@Component({
import { NgModule } from '@angular/core'
-import { SharedModule } from '../../shared'
+import { CanDeactivateGuard } from '@app/core'
+import { VideoUpdateResolver } from '@app/videos/+video-edit/video-update.resolver'
import { VideoEditModule } from './shared/video-edit.module'
import { VideoUpdateRoutingModule } from './video-update-routing.module'
import { VideoUpdateComponent } from './video-update.component'
-import { VideoUpdateResolver } from '@app/videos/+video-edit/video-update.resolver'
-import { CanDeactivateGuard } from '@app/shared/guards/can-deactivate-guard.service'
@NgModule({
imports: [
VideoUpdateRoutingModule,
- VideoEditModule,
- SharedModule
+
+ VideoEditModule
],
declarations: [
VideoUpdateComponent
],
- exports: [
- VideoUpdateComponent
- ],
+ exports: [ ],
providers: [
VideoUpdateResolver,
+import { forkJoin } from 'rxjs'
+import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
-import { VideoService } from '@app/shared/video/video.service'
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
-import { map, switchMap } from 'rxjs/operators'
-import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { forkJoin } from 'rxjs'
+import { VideoCaptionService, VideoChannelService, VideoService } from '@app/shared/shared-main'
@Injectable()
export class VideoUpdateResolver implements Resolve<any> {
+import { Observable } from 'rxjs'
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { Router } from '@angular/router'
-import { Notifier } from '@app/core'
-import { Observable } from 'rxjs'
-import { VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model'
-import { FormReactive } from '../../../shared'
-import { User } from '../../../shared/users'
-import { Video } from '../../../shared/video/video.model'
+import { Notifier, User } from '@app/core'
+import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms'
+import { Video } from '@app/shared/shared-main'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { VideoCommentCreate } from '@shared/models'
import { VideoComment } from './video-comment.model'
import { VideoCommentService } from './video-comment.service'
-import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
-import { VideoCommentValidatorsService } from '@app/shared/forms/form-validators/video-comment-validators.service'
-import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@Component({
selector: 'my-video-comment-add',
-import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '../../../../../../shared/models/videos/video-comment.model'
-import { VideoComment } from '@app/videos/+video-watch/comment/video-comment.model'
+import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models'
+import { VideoComment } from './video-comment.model'
export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
comment: VideoComment
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
-import { User, UserRight } from '../../../../../../shared/models/users'
+import { MarkdownService, Notifier, UserService } from '@app/core'
import { AuthService } from '@app/core/auth'
-import { AccountService } from '@app/shared/account/account.service'
-import { Video } from '@app/shared/video/video.model'
+import { Account, Actor, Video } from '@app/shared/shared-main'
+import { User, UserRight } from '@shared/models'
+import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
import { VideoComment } from './video-comment.model'
-import { MarkdownService } from '@app/shared/renderer'
-import { Account } from '@app/shared/account/account.model'
-import { Notifier } from '@app/core'
-import { UserService } from '@app/shared'
-import { Actor } from '@app/shared/actor/actor.model'
-import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
@Component({
selector: 'my-video-comment',
constructor (
private markdownService: MarkdownService,
private authService: AuthService,
- private accountService: AccountService,
private userService: UserService,
private notifier: Notifier
) {}
-import { Account as AccountInterface } from '../../../../../../shared/models/actors'
-import { VideoComment as VideoCommentServerModel, VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model'
-import { Actor } from '@app/shared/actor/actor.model'
-import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
+import { getAbsoluteAPIUrl } from '@app/helpers'
+import { Actor } from '@app/shared/shared-main'
+import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models'
export class VideoComment implements VideoCommentServerModel {
id: number
+import { Observable } from 'rxjs'
import { catchError, map } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { objectLineFeedToHtml } from '@app/shared/misc/utils'
-import { Observable } from 'rxjs'
-import { FeedFormat, ResultList } from '../../../../../../shared/models'
+import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
+import { objectLineFeedToHtml } from '@app/helpers'
import {
+ FeedFormat,
+ ResultList,
VideoComment as VideoCommentServerModel,
VideoCommentCreate,
VideoCommentThreadTree as VideoCommentThreadTreeServerModel
-} from '../../../../../../shared/models/videos/video-comment.model'
+} from '@shared/models'
import { environment } from '../../../../environments/environment'
-import { RestExtractor, RestService } from '../../../shared/rest'
-import { ComponentPaginationLight } from '../../../shared/rest/component-pagination.model'
-import { CommentSortField } from '../../../shared/video/sort-field.type'
+import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
import { VideoComment } from './video-comment.model'
-import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
@Injectable()
export class VideoCommentService {
getVideoCommentThreads (parameters: {
videoId: number | string,
componentPagination: ComponentPaginationLight,
- sort: CommentSortField
+ sort: string
}): Observable<ResultList<VideoComment>> {
const { videoId, componentPagination, sort } = parameters
+import { Subject, Subscription } from 'rxjs'
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
-import { ConfirmService, Notifier } from '@app/core'
-import { Subject, Subscription } from 'rxjs'
-import { AuthService } from '../../../core/auth'
-import { ComponentPagination, hasMoreItems } from '../../../shared/rest/component-pagination.model'
-import { User } from '../../../shared/users'
-import { CommentSortField } from '../../../shared/video/sort-field.type'
-import { VideoDetails } from '../../../shared/video/video-details.model'
+import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
+import { Syndication, VideoDetails } from '@app/shared/shared-main'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
import { VideoComment } from './video-comment.model'
import { VideoCommentService } from './video-comment.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { Syndication } from '@app/shared/video/syndication.model'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { VideoCommentThreadTree } from '@app/videos/+video-watch/comment/video-comment-thread-tree.model'
@Component({
selector: 'my-video-comments',
comments: VideoComment[] = []
highlightedThread: VideoComment
- sort: CommentSortField = '-createdAt'
+ sort = '-createdAt'
componentPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 10,
this.viewReplies(commentTree.comment.id)
}
- handleSortChange (sort: CommentSortField) {
+ handleSortChange (sort: string) {
if (this.sort === sort) return
this.sort = sort
import { Component, ElementRef, Input, ViewChild } from '@angular/core'
-import { VideoDetails } from '../../../shared/video/video-details.model'
import { buildVideoEmbed, buildVideoLink } from '../../../../assets/player/utils'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { VideoCaption } from '@shared/models'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
+import { VideoDetails } from '@app/shared/shared-main'
+import { VideoPlaylist } from '@app/shared/shared-video-playlist'
type Customizations = {
startAtCheckbox: boolean
isAdvancedCustomizationCollapsed = true
includeVideoInPlaylist = false
- private currentVideoTimestamp: number
-
constructor (private modalService: NgbModal) { }
show (currentVideoTimestamp?: number) {
- this.currentVideoTimestamp = currentVideoTimestamp
-
let subtitle: string
if (this.videoCaptions.length !== 0) {
subtitle = this.videoCaptions[0].language.id
import { Component, Input, ViewChild } from '@angular/core'
-import { VideoDetails } from '../../../shared/video/video-details.model'
+import { MarkdownService } from '@app/core'
+import { VideoDetails } from '@app/shared/shared-main'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
-import { MarkdownService } from '@app/shared/renderer'
@Component({
selector: 'my-video-support',
--- /dev/null
+import { Directive, EventEmitter, HostListener, Output } from '@angular/core'
+
+@Directive({
+ selector: '[timestampRouteTransformer]'
+})
+export class TimestampRouteTransformerDirective {
+ @Output() timestampClicked = new EventEmitter<number>()
+
+ @HostListener('click', ['$event'])
+ public onClick ($event: Event) {
+ const target = $event.target as HTMLLinkElement
+
+ if (target.hasAttribute('href') !== true) return
+
+ const ngxLink = document.createElement('a')
+ ngxLink.href = target.getAttribute('href')
+
+ // we only care about reflective links
+ if (ngxLink.host !== window.location.host) return
+
+ const ngxLinkParams = new URLSearchParams(ngxLink.search)
+ if (ngxLinkParams.has('start') !== true) return
+
+ const separators = ['h', 'm', 's']
+ const start = ngxLinkParams
+ .get('start')
+ .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator
+ .map(t => {
+ if (t.includes('h')) return parseInt(t, 10) * 3600
+ if (t.includes('m')) return parseInt(t, 10) * 60
+ return parseInt(t, 10)
+ })
+ .reduce((acc, t) => acc + t)
+
+ this.timestampClicked.emit(start)
+
+ $event.preventDefault()
+ }
+}
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+
+@Pipe({
+ name: 'myVideoDurationFormatter'
+})
+export class VideoDurationPipe implements PipeTransform {
+
+ constructor (private i18n: I18n) {
+
+ }
+
+ transform (value: number): string {
+ const hours = Math.floor(value / 3600)
+ const minutes = Math.floor((value % 3600) / 60)
+ const seconds = value % 60
+
+ if (hours > 0) {
+ return this.i18n('{{hours}} h {{minutes}} min {{seconds}} sec', { hours, minutes, seconds })
+ }
+
+ if (minutes > 0) {
+ return this.i18n('{{minutes}} min {{seconds}} sec', { minutes, seconds })
+ }
+
+ return this.i18n('{{seconds}} sec', { seconds })
+ }
+}
import { Component, Input } from '@angular/core'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
-import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models'
import { Router } from '@angular/router'
-import { UserService } from '@app/shared'
-import { AuthService, Notifier } from '@app/core'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { VideoPlaylistElement } from '@app/shared/video-playlist/video-playlist-element.model'
-import { peertubeLocalStorage, peertubeSessionStorage } from '@app/shared/misc/peertube-web-storage'
+import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core'
+import { peertubeLocalStorage, peertubeSessionStorage } from '@app/helpers/peertube-web-storage'
+import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { SessionStorageService, LocalStorageService } from '@app/shared/misc/storage.service'
+import { VideoDetails, VideoPlaylistPrivacy } from '@shared/models'
@Component({
selector: 'my-video-watch-playlist',
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
-
import { MetaGuard } from '@ngx-meta/core'
-
import { VideoWatchComponent } from './video-watch.component'
const videoWatchRoutes: Routes = [
+import { Hotkey, HotkeysService } from 'angular2-hotkeys'
+import { forkJoin, Observable, Subscription } from 'rxjs'
import { catchError } from 'rxjs/operators'
+import { PlatformLocation } from '@angular/common'
import { ChangeDetectorRef, Component, ElementRef, Inject, LOCALE_ID, NgZone, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
+import { AuthService, AuthUser, ConfirmService, MarkdownService, Notifier, RestExtractor, ServerService, UserService } from '@app/core'
+import { HooksService } from '@app/core/plugins/hooks.service'
import { RedirectService } from '@app/core/routing/redirect.service'
-import { peertubeLocalStorage } from '@app/shared/misc/peertube-web-storage'
-import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
+import { isXPercentInViewport, peertubeLocalStorage, scrollToTop } from '@app/helpers'
+import { Video, VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
+import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
+import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { MetaService } from '@ngx-meta/core'
-import { AuthUser, Notifier, ServerService } from '@app/core'
-import { forkJoin, Observable, Subscription } from 'rxjs'
-import { Hotkey, HotkeysService } from 'angular2-hotkeys'
-import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '../../../../../shared'
-import { AuthService, ConfirmService } from '../../core'
-import { RestExtractor, UserService } from '../../shared'
-import { VideoDetails } from '../../shared/video/video-details.model'
-import { VideoService } from '../../shared/video/video.service'
-import { VideoShareComponent } from './modal/video-share.component'
-import { SubscribeButtonComponent } from '@app/shared/user-subscription/subscribe-button.component'
import { I18n } from '@ngx-translate/i18n-polyfill'
-import { environment } from '../../../environments/environment'
-import { VideoCaptionService } from '@app/shared/video-caption'
-import { MarkdownService } from '@app/shared/renderer'
+import { ServerConfig, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models'
+import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
import {
- videojs,
CustomizationOptions,
P2PMediaLoaderOptions,
PeertubePlayerManager,
PeertubePlayerManagerOptions,
- PlayerMode
+ PlayerMode,
+ videojs
} from '../../../assets/player/peertube-player-manager'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { VideoPlaylistService } from '@app/shared/video-playlist/video-playlist.service'
-import { Video } from '@app/shared/video/video.model'
import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils'
-import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
-import { getStoredP2PEnabled, getStoredTheater } from '../../../assets/player/peertube-player-local-storage'
-import { HooksService } from '@app/core/plugins/hooks.service'
-import { PlatformLocation } from '@angular/common'
-import { scrollToTop, isXPercentInViewport } from '@app/shared/misc/utils'
+import { environment } from '../../../environments/environment'
+import { VideoShareComponent } from './modal/video-share.component'
+import { VideoSupportComponent } from './modal/video-support.component'
+import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
@Component({
selector: 'my-video-watch',
+import { QRCodeModule } from 'angularx-qrcode'
import { NgModule } from '@angular/core'
-import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
-import { SharedModule } from '../../shared'
+import { SharedFormModule } from '@app/shared/shared-forms'
+import { SharedGlobalIconModule } from '@app/shared/shared-icons'
+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 { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
+import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module'
+import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { VideoCommentAddComponent } from './comment/video-comment-add.component'
import { VideoCommentComponent } from './comment/video-comment.component'
import { VideoCommentService } from './comment/video-comment.service'
import { VideoCommentsComponent } from './comment/video-comments.component'
import { VideoShareComponent } from './modal/video-share.component'
+import { VideoSupportComponent } from './modal/video-support.component'
+import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
+import { VideoDurationPipe } from './video-duration-formatter.pipe'
+import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
import { VideoWatchRoutingModule } from './video-watch-routing.module'
import { VideoWatchComponent } from './video-watch.component'
-import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
-import { RecommendationsModule } from '@app/videos/recommendations/recommendations.module'
-import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
-import { QRCodeModule } from 'angularx-qrcode'
-import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestamp-route-transformer.directive'
@NgModule({
imports: [
VideoWatchRoutingModule,
- SharedModule,
NgbTooltipModule,
QRCodeModule,
- RecommendationsModule
+ RecommendationsModule,
+
+ SharedMainModule,
+ SharedFormModule,
+ SharedVideoMiniatureModule,
+ SharedVideoPlaylistModule,
+ SharedUserSubscriptionModule,
+ SharedModerationModule,
+ SharedGlobalIconModule
],
declarations: [
VideoCommentAddComponent,
VideoCommentComponent,
+ TimestampRouteTransformerDirective,
+ VideoDurationPipe,
TimestampRouteTransformerDirective
],
import { Observable, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
-import { ServerService } from '@app/core'
+import { ServerService, UserService } from '@app/core'
import { AdvancedSearch } from '@app/search/advanced-search.model'
import { SearchService } from '@app/search/search.service'
-import { UserService } from '@app/shared'
-import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
-import { Video } from '@app/shared/video/video.model'
-import { VideoService } from '@app/shared/video/video.service'
-import { RecommendationService } from '@app/videos/recommendations/recommendations.service'
+import { Video, VideoService } from '@app/shared/shared-main'
import { ServerConfig } from '@shared/models'
+import { RecommendationInfo } from './recommendation-info.model'
+import { RecommendationService } from './recommendations.service'
/**
* Provides "recommendations" by providing the most recently uploaded videos.
--- /dev/null
+export interface RecommendationInfo {
+ uuid: string
+ tags?: string[]
+}
-import { NgModule } from '@angular/core'
import { InputSwitchModule } from 'primeng/inputswitch'
-import { RecommendedVideosComponent } from '@app/videos/recommendations/recommended-videos.component'
-import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store'
import { CommonModule } from '@angular/common'
-import { SharedModule } from '@app/shared'
-import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service'
+import { NgModule } from '@angular/core'
+import { SharedMainModule } from '@app/shared/shared-main'
+import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
+import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
+import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
+import { RecommendedVideosComponent } from './recommended-videos.component'
+import { RecommendedVideosStore } from './recommended-videos.store'
@NgModule({
imports: [
+ CommonModule,
InputSwitchModule,
- SharedModule,
- CommonModule
+
+ SharedMainModule,
+ SharedVideoPlaylistModule,
+ SharedVideoMiniatureModule
],
declarations: [
RecommendedVideosComponent
-import { Video } from '@app/shared/video/video.model'
-import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
import { Observable } from 'rxjs'
+import { Video } from '@app/shared/shared-main'
+import { RecommendationInfo } from './recommendation-info.model'
export interface RecommendationService {
getRecommendations (recommendation: RecommendationInfo): Observable<Video[]>
import { Observable } from 'rxjs'
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
-import { AuthService, Notifier } from '@app/core'
-import { User } from '@app/shared'
-import { SessionStorageService } from '@app/shared/misc/storage.service'
-import { UserService } from '@app/shared/users/user.service'
-import { VideoPlaylist } from '@app/shared/video-playlist/video-playlist.model'
-import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
-import { MiniatureDisplayOptions } from '@app/shared/video/video-miniature.component'
-import { Video } from '@app/shared/video/video.model'
-import { RecommendedVideosStore } from '@app/videos/recommendations/recommended-videos.store'
+import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core'
+import { Video } from '@app/shared/shared-main'
+import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
+import { VideoPlaylist } from '@app/shared/shared-video-playlist'
import { I18n } from '@ngx-translate/i18n-polyfill'
+import { RecommendationInfo } from './recommendation-info.model'
+import { RecommendedVideosStore } from './recommended-videos.store'
@Component({
selector: 'my-recommended-videos',
-import { Inject, Injectable } from '@angular/core'
import { Observable, ReplaySubject } from 'rxjs'
-import { Video } from '@app/shared/video/video.model'
-import { RecommendationInfo } from '@app/shared/video/recommendation-info.model'
-import { RecentVideosRecommendationService } from '@app/videos/recommendations/recent-videos-recommendation.service'
-import { RecommendationService } from '@app/videos/recommendations/recommendations.service'
import { map, shareReplay, switchMap, take } from 'rxjs/operators'
+import { Inject, Injectable } from '@angular/core'
+import { Video } from '@app/shared/shared-main'
+import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
+import { RecommendationInfo } from './recommendation-info.model'
+import { RecommendationService } from './recommendations.service'
/**
* This store is intended to provide data for the RecommendedVideosComponent.
) {
this.recommendations$ = this.requestsForLoad$$.pipe(
switchMap(requestedRecommendation => {
- return recommendations.getRecommendations(requestedRecommendation)
+ return this.recommendations.getRecommendations(requestedRecommendation)
.pipe(take(1))
}),
shareReplay()
+export * from './overview'
export * from './video-local.component'
export * from './video-recently-added.component'
export * from './video-trending.component'
--- /dev/null
+export * from './overview.service'
+export * from './video-overview.component'
+export * from './videos-overview.model'
--- /dev/null
+import { forkJoin, Observable, of } from 'rxjs'
+import { catchError, map, switchMap, tap } from 'rxjs/operators'
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, ServerService } from '@app/core'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { peertubeTranslate, VideosOverview as VideosOverviewServer } from '@shared/models'
+import { environment } from '../../../../environments/environment'
+import { VideosOverview } from './videos-overview.model'
+
+@Injectable()
+export class OverviewService {
+ static BASE_OVERVIEW_URL = environment.apiUrl + '/api/v1/overviews/'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private videosService: VideoService,
+ private serverService: ServerService
+ ) {}
+
+ getVideosOverview (page: number): Observable<VideosOverview> {
+ let params = new HttpParams()
+ params = params.append('page', page + '')
+
+ return this.authHttp
+ .get<VideosOverviewServer>(OverviewService.BASE_OVERVIEW_URL + 'videos', { params })
+ .pipe(
+ switchMap(serverVideosOverview => this.updateVideosOverview(serverVideosOverview)),
+ catchError(err => this.restExtractor.handleError(err))
+ )
+ }
+
+ private updateVideosOverview (serverVideosOverview: VideosOverviewServer): Observable<VideosOverview> {
+ const observables: Observable<any>[] = []
+ const videosOverviewResult: VideosOverview = {
+ tags: [],
+ categories: [],
+ channels: []
+ }
+
+ // Build videos objects
+ for (const key of Object.keys(serverVideosOverview)) {
+ for (const object of serverVideosOverview[ key ]) {
+ observables.push(
+ of(object.videos)
+ .pipe(
+ switchMap(videos => this.videosService.extractVideos({ total: 0, data: videos })),
+ map(result => result.data),
+ tap(videos => {
+ videosOverviewResult[key].push(immutableAssign(object, { videos }))
+ })
+ )
+ )
+ }
+ }
+
+ if (observables.length === 0) return of(videosOverviewResult)
+
+ return forkJoin(observables)
+ .pipe(
+ // Translate categories
+ switchMap(() => {
+ return this.serverService.getServerLocale()
+ .pipe(
+ tap(translations => {
+ for (const c of videosOverviewResult.categories) {
+ c.category.label = peertubeTranslate(c.category.label, translations)
+ }
+ })
+ )
+ }),
+ map(() => videosOverviewResult)
+ )
+ }
+
+}
--- /dev/null
+<h1 class="sr-only" i18n>Discover</h1>
+<div class="margin-content">
+
+ <div class="no-results" i18n *ngIf="notResults">No results.</div>
+
+ <div
+ myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
+ >
+ <ng-container *ngFor="let overview of overviews">
+
+ <div class="section videos" *ngFor="let object of overview.categories">
+ <h1 class="section-title">
+ <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a>
+ </h1>
+
+ <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
+ <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
+ </my-video-miniature>
+ </div>
+ </div>
+
+ <div class="section videos" *ngFor="let object of overview.tags">
+ <h2 class="section-title">
+ <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a>
+ </h2>
+
+ <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
+ <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
+ </my-video-miniature>
+ </div>
+ </div>
+
+ <div class="section channel videos" *ngFor="let object of overview.channels">
+ <div class="section-title">
+ <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">
+ <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" />
+
+ <h2 class="section-title">{{ object.channel.displayName }}</h2>
+ </a>
+ </div>
+
+ <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
+ <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
+ </my-video-miniature>
+ </div>
+ </div>
+
+ </ng-container>
+
+ </div>
+
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+@import '_miniature';
+
+.section-title {
+ // make the element span a full grid row within .videos grid
+ grid-column: 1 / -1;
+}
+
+.margin-content {
+ @include fluid-videos-miniature-layout;
+}
+
+.section {
+ @include miniature-rows;
+}
--- /dev/null
+import { Subject } from 'rxjs'
+import { Component, OnInit } from '@angular/core'
+import { Notifier, ScreenService, User, UserService } from '@app/core'
+import { Video } from '@app/shared/shared-main'
+import { OverviewService } from './overview.service'
+import { VideosOverview } from './videos-overview.model'
+
+@Component({
+ selector: 'my-video-overview',
+ templateUrl: './video-overview.component.html',
+ styleUrls: [ './video-overview.component.scss' ]
+})
+export class VideoOverviewComponent implements OnInit {
+ onDataSubject = new Subject<any>()
+
+ overviews: VideosOverview[] = []
+ notResults = false
+
+ userMiniature: User
+
+ private loaded = false
+ private currentPage = 1
+ private maxPage = 20
+ private lastWasEmpty = false
+ private isLoading = false
+
+ constructor (
+ private notifier: Notifier,
+ private userService: UserService,
+ private overviewService: OverviewService,
+ private screenService: ScreenService
+ ) { }
+
+ ngOnInit () {
+ this.loadMoreResults()
+
+ this.userService.getAnonymousOrLoggedUser()
+ .subscribe(user => this.userMiniature = user)
+
+ this.userService.listenAnonymousUpdate()
+ .subscribe(user => this.userMiniature = user)
+ }
+
+ buildVideoChannelBy (object: { videos: Video[] }) {
+ return object.videos[0].byVideoChannel
+ }
+
+ buildVideoChannelAvatarUrl (object: { videos: Video[] }) {
+ return object.videos[0].videoChannelAvatarUrl
+ }
+
+ buildVideos (videos: Video[]) {
+ const numberOfVideos = this.screenService.getNumberOfAvailableMiniatures()
+
+ return videos.slice(0, numberOfVideos * 2)
+ }
+
+ onNearOfBottom () {
+ if (this.currentPage >= this.maxPage) return
+ if (this.lastWasEmpty) return
+ if (this.isLoading) return
+
+ this.currentPage++
+ this.loadMoreResults()
+ }
+
+ private loadMoreResults () {
+ this.isLoading = true
+
+ this.overviewService.getVideosOverview(this.currentPage)
+ .subscribe(
+ overview => {
+ this.isLoading = false
+
+ if (overview.tags.length === 0 && overview.channels.length === 0 && overview.categories.length === 0) {
+ this.lastWasEmpty = true
+ if (this.loaded === false) this.notResults = true
+
+ return
+ }
+
+ this.loaded = true
+ this.onDataSubject.next(overview)
+
+ this.overviews.push(overview)
+ },
+
+ err => {
+ this.notifier.error(err.message)
+ this.isLoading = false
+ }
+ )
+ }
+}
--- /dev/null
+import { Video } from '@app/shared/shared-main'
+import { VideoChannelSummary, VideoConstant, VideosOverview as VideosOverviewServer } from '@shared/models'
+
+export class VideosOverview implements VideosOverviewServer {
+ channels: {
+ channel: VideoChannelSummary
+ videos: Video[]
+ }[]
+
+ categories: {
+ category: VideoConstant<number>
+ videos: Video[]
+ }[]
+
+ tags: {
+ tag: string
+ videos: Video[]
+ }[]
+ [key: string]: any
+}
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { VideoFilter } from '../../../../../shared/models/videos/video-query.type'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { UserRight } from '../../../../../shared/models/users'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { UserRight, VideoFilter, VideoSortField } from '@shared/models'
@Component({
selector: 'my-videos-local',
- styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
- templateUrl: '../../shared/video/abstract-video-list.html'
+ 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
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-videos-most-liked',
- styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
- templateUrl: '../../shared/video/abstract-video-list.html'
+ styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+ templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
})
export class VideoMostLikedComponent extends AbstractVideoList implements OnInit {
titlePage: string
+++ /dev/null
-<h1 class="sr-only" i18n>Discover</h1>
-<div class="margin-content">
-
- <div class="no-results" i18n *ngIf="notResults">No results.</div>
-
- <div
- myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()"
- >
- <ng-container *ngFor="let overview of overviews">
-
- <div class="section videos" *ngFor="let object of overview.categories">
- <h1 class="section-title">
- <a routerLink="/search" [queryParams]="{ categoryOneOf: [ object.category.id ] }">{{ object.category.label }}</a>
- </h1>
-
- <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
- <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
- </my-video-miniature>
- </div>
- </div>
-
- <div class="section videos" *ngFor="let object of overview.tags">
- <h2 class="section-title">
- <a routerLink="/search" [queryParams]="{ tagsOneOf: [ object.tag ] }">#{{ object.tag }}</a>
- </h2>
-
- <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
- <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
- </my-video-miniature>
- </div>
- </div>
-
- <div class="section channel videos" *ngFor="let object of overview.channels">
- <div class="section-title">
- <a [routerLink]="[ '/video-channels', buildVideoChannelBy(object) ]">
- <img [src]="buildVideoChannelAvatarUrl(object)" alt="Avatar" />
-
- <h2 class="section-title">{{ object.channel.displayName }}</h2>
- </a>
- </div>
-
- <div class="video-wrapper" *ngFor="let video of buildVideos(object.videos)">
- <my-video-miniature [video]="video" [fitWidth]="true" [user]="userMiniature" [displayVideoActions]="true">
- </my-video-miniature>
- </div>
- </div>
-
- </ng-container>
-
- </div>
-
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-@import '_miniature';
-
-.section-title {
- // make the element span a full grid row within .videos grid
- grid-column: 1 / -1;
-}
-
-.margin-content {
- @include fluid-videos-miniature-layout;
-}
-
-.section {
- @include miniature-rows;
-}
+++ /dev/null
-import { Subject } from 'rxjs'
-import { Component, OnInit } from '@angular/core'
-import { Notifier } from '@app/core'
-import { User, UserService } from '@app/shared'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { OverviewService } from '@app/shared/overview'
-import { VideosOverview } from '@app/shared/overview/videos-overview.model'
-import { Video } from '@app/shared/video/video.model'
-
-@Component({
- selector: 'my-video-overview',
- templateUrl: './video-overview.component.html',
- styleUrls: [ './video-overview.component.scss' ]
-})
-export class VideoOverviewComponent implements OnInit {
- onDataSubject = new Subject<any>()
-
- overviews: VideosOverview[] = []
- notResults = false
-
- userMiniature: User
-
- private loaded = false
- private currentPage = 1
- private maxPage = 20
- private lastWasEmpty = false
- private isLoading = false
-
- constructor (
- private notifier: Notifier,
- private userService: UserService,
- private overviewService: OverviewService,
- private screenService: ScreenService
- ) { }
-
- ngOnInit () {
- this.loadMoreResults()
-
- this.userService.getAnonymousOrLoggedUser()
- .subscribe(user => this.userMiniature = user)
-
- this.userService.listenAnonymousUpdate()
- .subscribe(user => this.userMiniature = user)
- }
-
- buildVideoChannelBy (object: { videos: Video[] }) {
- return object.videos[0].byVideoChannel
- }
-
- buildVideoChannelAvatarUrl (object: { videos: Video[] }) {
- return object.videos[0].videoChannelAvatarUrl
- }
-
- buildVideos (videos: Video[]) {
- const numberOfVideos = this.screenService.getNumberOfAvailableMiniatures()
-
- return videos.slice(0, numberOfVideos * 2)
- }
-
- onNearOfBottom () {
- if (this.currentPage >= this.maxPage) return
- if (this.lastWasEmpty) return
- if (this.isLoading) return
-
- this.currentPage++
- this.loadMoreResults()
- }
-
- private loadMoreResults () {
- this.isLoading = true
-
- this.overviewService.getVideosOverview(this.currentPage)
- .subscribe(
- overview => {
- this.isLoading = false
-
- if (overview.tags.length === 0 && overview.channels.length === 0 && overview.categories.length === 0) {
- this.lastWasEmpty = true
- if (this.loaded === false) this.notResults = true
-
- return
- }
-
- this.loaded = true
- this.onDataSubject.next(overview)
-
- this.overviews.push(overview)
- },
-
- err => {
- this.notifier.error(err.message)
- this.isLoading = false
- }
- )
- }
-}
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-videos-recently-added',
- styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
- templateUrl: '../../shared/video/abstract-video-list.html'
+ 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
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { AbstractVideoList } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-videos-trending',
- styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
- templateUrl: '../../shared/video/abstract-video-list.html'
+ 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 {
titlePage: string
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
-import { immutableAssign } from '@app/shared/misc/utils'
-import { AuthService } from '../../core/auth'
-import { AbstractVideoList } from '../../shared/video/abstract-video-list'
-import { VideoSortField } from '../../shared/video/sort-field.type'
-import { VideoService } from '../../shared/video/video.service'
-import { I18n } from '@ngx-translate/i18n-polyfill'
-import { ScreenService } from '@app/shared/misc/screen.service'
-import { OwnerDisplayType } from '@app/shared/video/video-miniature.component'
-import { Notifier, ServerService } from '@app/core'
+import { AuthService, LocalStorageService, Notifier, ScreenService, ServerService, UserService } from '@app/core'
import { HooksService } from '@app/core/plugins/hooks.service'
-import { UserService } from '@app/shared'
-import { LocalStorageService } from '@app/shared/misc/storage.service'
+import { immutableAssign } from '@app/helpers'
+import { VideoService } from '@app/shared/shared-main'
+import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
+import { AbstractVideoList, OwnerDisplayType } from '@app/shared/shared-video-miniature'
+import { I18n } from '@ngx-translate/i18n-polyfill'
+import { VideoSortField } from '@shared/models'
@Component({
selector: 'my-videos-user-subscriptions',
- styleUrls: [ '../../shared/video/abstract-video-list.scss' ],
- templateUrl: '../../shared/video/abstract-video-list.html'
+ styleUrls: [ '../../shared/shared-video-miniature/abstract-video-list.scss' ],
+ templateUrl: '../../shared/shared-video-miniature/abstract-video-list.html'
})
export class VideoUserSubscriptionsComponent extends AbstractVideoList implements OnInit, OnDestroy {
titlePage: string
protected userService: UserService,
protected screenService: ScreenService,
protected storageService: LocalStorageService,
+ private userSubscription: UserSubscriptionService,
private videoService: VideoService,
private hooks: HooksService
) {
}
return this.hooks.wrapObsFun(
- this.videoService.getUserSubscriptionVideos.bind(this.videoService),
+ this.userSubscription.getUserSubscriptionVideos.bind(this.userSubscription),
params,
'common',
'filter:api.user-subscriptions-videos.videos.list.params',
import { VideoTrendingComponent } from './video-list/video-trending.component'
import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
import { VideosComponent } from './videos.component'
-import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component'
-import { VideoOverviewComponent } from '@app/videos/video-list/video-overview.component'
+import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
+import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
const videosRoutes: Routes = [
{
import { NgModule } from '@angular/core'
-import { VideoLocalComponent } from '@app/videos/video-list/video-local.component'
-import { SharedModule } from '../shared'
+import { SharedFormModule } from '@app/shared/shared-forms'
+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 { VideoOverviewComponent } from './video-list/overview/video-overview.component'
+import { VideoLocalComponent } from './video-list/video-local.component'
+import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
import { VideoRecentlyAddedComponent } from './video-list/video-recently-added.component'
import { VideoTrendingComponent } from './video-list/video-trending.component'
-import { VideoMostLikedComponent } from './video-list/video-most-liked.component'
+import { VideoUserSubscriptionsComponent } from './video-list/video-user-subscriptions.component'
import { VideosRoutingModule } from './videos-routing.module'
import { VideosComponent } from './videos.component'
-import { VideoUserSubscriptionsComponent } from '@app/videos/video-list/video-user-subscriptions.component'
-import { VideoOverviewComponent } from '@app/videos/video-list/video-overview.component'
@NgModule({
imports: [
VideosRoutingModule,
- SharedModule
+
+ SharedMainModule,
+ SharedFormModule,
+ SharedVideoMiniatureModule,
+ SharedUserSubscriptionModule,
+ SharedGlobalIconModule
],
declarations: [
-import * as express from 'express'
import 'multer'
-import { UserUpdateMe, UserVideoRate as FormattedUserVideoRate } from '../../../../shared'
+import * as express from 'express'
+import { UserUpdateMe, UserVideoRate as FormattedUserVideoRate, VideoSortField } from '../../../../shared'
+import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
+import { createReqFiles } from '../../../helpers/express-utils'
import { getFormattedObjects } from '../../../helpers/utils'
+import { CONFIG } from '../../../initializers/config'
import { MIMETYPES } from '../../../initializers/constants'
+import { sequelizeTypescript } from '../../../initializers/database'
import { sendUpdateActor } from '../../../lib/activitypub/send'
+import { updateActorAvatarFile } from '../../../lib/avatar'
+import { sendVerifyUserEmail } from '../../../lib/user'
import {
asyncMiddleware,
asyncRetryTransactionMiddleware,
usersVideoRatingValidator
} from '../../../middlewares'
import { deleteMeValidator, videoImportsSortValidator, videosSortValidator } from '../../../middlewares/validators'
+import { updateAvatarValidator } from '../../../middlewares/validators/avatar'
+import { AccountModel } from '../../../models/account/account'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
import { UserModel } from '../../../models/account/user'
import { VideoModel } from '../../../models/video/video'
-import { VideoSortField } from '../../../../client/src/app/shared/video/sort-field.type'
-import { createReqFiles } from '../../../helpers/express-utils'
-import { UserVideoQuota } from '../../../../shared/models/users/user-video-quota.model'
-import { updateAvatarValidator } from '../../../middlewares/validators/avatar'
-import { updateActorAvatarFile } from '../../../lib/avatar'
import { VideoImportModel } from '../../../models/video/video-import'
-import { AccountModel } from '../../../models/account/account'
-import { CONFIG } from '../../../initializers/config'
-import { sequelizeTypescript } from '../../../initializers/database'
-import { sendVerifyUserEmail } from '../../../lib/user'
const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { avatarfile: CONFIG.STORAGE.TMP_DIR })
RegisterServerAuthenticatedResult,
RegisterServerAuthPassOptions,
RegisterServerExternalAuthenticatedResult
-} from '@shared/models/plugins/register-server-auth.model'
+} from '@server/types/plugins/register-server-auth.model'
import * as express from 'express'
import * as OAuthServer from 'express-oauth-server'
-import { PeerTubeHelpers } from '@server/typings/plugins'
+import { PeerTubeHelpers } from '@server/types/plugins'
import { sequelizeTypescript } from '@server/initializers/database'
import { buildLogger } from '@server/helpers/logger'
import { VideoModel } from '@server/models/video/video'
-import { PluginModel } from '../../models/server/plugin'
-import { logger } from '../../helpers/logger'
+import { createReadStream, createWriteStream } from 'fs'
+import { outputFile, readJSON } from 'fs-extra'
import { basename, join } from 'path'
-import { CONFIG } from '../../initializers/config'
-import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
+import { MOAuthTokenUser, MUser } from '@server/types/models'
+import { RegisterServerHookOptions } from '@shared/models/plugins/register-server-hook.model'
+import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks'
import {
ClientScript,
PluginPackageJson,
PluginTranslationPaths as PackagePluginTranslations
} from '../../../shared/models/plugins/plugin-package-json.model'
-import { createReadStream, createWriteStream } from 'fs'
-import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
+import { PluginTranslation } from '../../../shared/models/plugins/plugin-translation.model'
import { PluginType } from '../../../shared/models/plugins/plugin.type'
-import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
-import { outputFile, readJSON } from 'fs-extra'
import { ServerHook, ServerHookName } from '../../../shared/models/plugins/server-hook.model'
-import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks'
-import { RegisterServerOptions } from '../../typings/plugins/register-server-option.model'
-import { PluginLibrary } from '../../typings/plugins'
+import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
+import { logger } from '../../helpers/logger'
+import { CONFIG } from '../../initializers/config'
+import { PLUGIN_GLOBAL_CSS_PATH } from '../../initializers/constants'
+import { PluginModel } from '../../models/server/plugin'
+import { PluginLibrary, RegisterServerAuthExternalOptions, RegisterServerAuthPassOptions, RegisterServerOptions } from '../../types/plugins'
import { ClientHtml } from '../client-html'
-import { PluginTranslation } from '../../../shared/models/plugins/plugin-translation.model'
import { RegisterHelpersStore } from './register-helpers-store'
-import { RegisterServerHookOptions } from '@shared/models/plugins/register-server-hook.model'
-import { MOAuthTokenUser, MUser } from '@server/types/models'
-import { RegisterServerAuthPassOptions, RegisterServerAuthExternalOptions } from '@shared/models/plugins/register-server-auth.model'
+import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
export interface RegisteredPlugin {
npmName: string
} from '@server/initializers/constants'
import { onExternalUserAuthenticated } from '@server/lib/auth'
import { PluginModel } from '@server/models/server/plugin'
-import { RegisterServerOptions } from '@server/typings/plugins'
-import { PluginPlaylistPrivacyManager } from '@shared/models/plugins/plugin-playlist-privacy-manager.model'
-import { PluginSettingsManager } from '@shared/models/plugins/plugin-settings-manager.model'
-import { PluginStorageManager } from '@shared/models/plugins/plugin-storage-manager.model'
-import { PluginVideoCategoryManager } from '@shared/models/plugins/plugin-video-category-manager.model'
-import { PluginVideoLanguageManager } from '@shared/models/plugins/plugin-video-language-manager.model'
-import { PluginVideoLicenceManager } from '@shared/models/plugins/plugin-video-licence-manager.model'
-import { PluginVideoPrivacyManager } from '@shared/models/plugins/plugin-video-privacy-manager.model'
import {
RegisterServerAuthExternalOptions,
RegisterServerAuthExternalResult,
RegisterServerAuthPassOptions,
- RegisterServerExternalAuthenticatedResult
-} from '@shared/models/plugins/register-server-auth.model'
-import { RegisterServerHookOptions } from '@shared/models/plugins/register-server-hook.model'
-import { RegisterServerSettingOptions } from '@shared/models/plugins/register-server-setting.model'
+ RegisterServerExternalAuthenticatedResult,
+ RegisterServerOptions
+} from '@server/types/plugins'
+import {
+ PluginPlaylistPrivacyManager,
+ PluginSettingsManager,
+ PluginStorageManager,
+ PluginVideoCategoryManager,
+ PluginVideoLanguageManager,
+ PluginVideoLicenceManager,
+ PluginVideoPrivacyManager,
+ RegisterServerHookOptions,
+ RegisterServerSettingOptions
+} from '@shared/models'
import { serverHookObject } from '@shared/models/plugins/server-hook.model'
import { buildPluginHelpers } from './plugin-helpers'
import { Col } from 'sequelize/types/lib/utils'
import { literal, OrderItem, Op } from 'sequelize'
-type Primitive = string | Function | number | boolean | Symbol | undefined | null
-type DeepOmitHelper<T, K extends keyof T> = {
- [P in K]: // extra level of indirection needed to trigger homomorhic behavior
- T[P] extends infer TP // distribute over unions
- ? TP extends Primitive
- ? TP // leave primitives and functions alone
- : TP extends any[]
- ? DeepOmitArray<TP, K> // Array special handling
- : DeepOmit<TP, K>
- : never
-}
-type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude<keyof T, K>>
-
-type DeepOmitArray<T extends any[], K> = {
- [P in keyof T]: DeepOmit<T[P], K>
-}
-
type SortType = { sortModel: string, sortValue: string }
// Translate for example "-name" to [ [ 'name', 'DESC' ], [ 'id', 'ASC' ] ]
// ---------------------------------------------------------------------------
export {
- DeepOmit,
buildBlockedAccountSQL,
buildLocalActorIdsIn,
SortType,
--- /dev/null
+export * from './plugins'
+export * from './activitypub-processor.model'
+export * from './sequelize'
import { AccountBlocklistModel } from '../../../models/account/account-blocklist'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
import { MAccountDefault, MAccountFormattable } from './account'
type Use<K extends keyof AccountBlocklistModel, M> = PickWith<AccountBlocklistModel, K, M>
MActorSummaryFormattable,
MActorUrl
} from './actor'
-import { FunctionProperties, PickWith } from '../../utils'
+import { FunctionProperties, PickWith } from '@shared/core-utils'
import { MAccountBlocklistId } from './account-blocklist'
import { MChannelDefault } from '../video/video-channels'
MActorHost,
MActorUsername
} from './actor'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
import { ActorModel } from '@server/models/activitypub/actor'
import { MChannelDefault } from '../video/video-channels'
import { ActorModel } from '../../../models/activitypub/actor'
-import { FunctionProperties, PickWith, PickWithOpt } from '../../utils'
+import { FunctionProperties, PickWith, PickWithOpt } from '@shared/core-utils'
import { MAccount, MAccountDefault, MAccountId, MAccountIdActor } from './account'
import { MServer, MServerHost, MServerHostBlocks, MServerRedundancyAllowed } from '../server'
import { MAvatar, MAvatarFormattable } from './avatar'
import { AvatarModel } from '../../../models/avatar/avatar'
-import { FunctionProperties } from '@server/types/utils'
+import { FunctionProperties } from '@shared/core-utils'
export type MAvatar = AvatarModel
import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
import { MUserAccountUrl } from '../user/user'
type Use<K extends keyof OAuthTokenModel, M> = PickWith<OAuthTokenModel, K, M>
import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
import { MAccountDefault, MAccountFormattable } from '../account/account'
import { MServer, MServerFormattable } from './server'
import { ServerModel } from '../../../models/server/server'
-import { FunctionProperties, PickWith } from '../../utils'
+import { FunctionProperties, PickWith } from '@shared/core-utils'
import { MAccountBlocklistId } from '../account'
type Use<K extends keyof ServerModel, M> = PickWith<ServerModel, K, M>
import { UserNotificationModel } from '../../../models/account/user-notification'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
import { VideoModel } from '../../../models/video/video'
import { ActorModel } from '../../../models/activitypub/actor'
import { ServerModel } from '../../../models/server/server'
import { UserModel } from '../../../models/account/user'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
import {
MAccount,
MAccountDefault,
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
import { MVideoAPWithoutCaption, MVideoWithBlacklistLight } from './video'
type Use<K extends keyof ScheduleVideoUpdateModel, M> = PickWith<ScheduleVideoUpdateModel, K, M>
import { VideoAbuseModel } from '../../../models/video/video-abuse'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
import { MVideoAccountLightBlacklistAllFiles, MVideo } from './video'
import { MAccountDefault, MAccountFormattable } from '../account'
import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
import { MVideo, MVideoFormattable } from './video'
type Use<K extends keyof VideoBlacklistModel, M> = PickWith<VideoBlacklistModel, K, M>
import { VideoCaptionModel } from '../../../models/video/video-caption'
-import { FunctionProperties, PickWith } from '@server/types/utils'
+import { FunctionProperties, PickWith } from '@shared/core-utils'
import { MVideo, MVideoUUID } from './video'
type Use<K extends keyof VideoCaptionModel, M> = PickWith<VideoCaptionModel, K, M>
import { VideoChangeOwnershipModel } from '@server/models/video/video-change-ownership'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
import { MAccountDefault, MAccountFormattable } from '../account/account'
import { MVideo, MVideoWithAllFiles } from './video'
-import { FunctionProperties, PickWith, PickWithOpt } from '../../utils'
+import { FunctionProperties, PickWith, PickWithOpt } from '@shared/core-utils'
import { VideoChannelModel } from '../../../models/video/video-channel'
import {
MAccountActor,
import { VideoCommentModel } from '../../../models/video/video-comment'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
import { MAccountDefault, MAccountFormattable, MAccountUrl } from '../account'
import { MVideoAccountLight, MVideoFeed, MVideoIdUrl, MVideoUrl } from './video'
import { VideoFileModel } from '../../../models/video/video-file'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
import { MVideo, MVideoUUID } from './video'
import { MVideoRedundancy, MVideoRedundancyFileUrl } from './video-redundancy'
import { MStreamingPlaylistVideo, MStreamingPlaylist } from './video-streaming-playlist'
import { VideoImportModel } from '@server/models/video/video-import'
-import { PickWith, PickWithOpt } from '@server/types/utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
import { MVideo, MVideoAccountLight, MVideoFormattable, MVideoTag, MVideoThumbnail, MVideoWithFile } from './video'
import { MUser } from '../user/user'
import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
import { MVideoFormattable, MVideoThumbnail, MVideoUrl } from './video'
import { MVideoPlaylistPrivacy } from './video-playlist'
import { VideoPlaylistModel } from '../../../models/video/video-playlist'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
import { MAccount, MAccountDefault, MAccountSummary, MAccountSummaryFormattable } from '../account'
import { MThumbnail } from './thumbnail'
import { MChannelDefault, MChannelSummary, MChannelSummaryFormattable, MChannelUrl } from './video-channels'
import { AccountVideoRateModel } from '@server/models/account/account-video-rate'
-import { PickWith } from '@server/types/utils'
+import { PickWith } from '@shared/core-utils'
import { MAccountAudience, MAccountUrl } from '../account/account'
import { MVideo, MVideoFormattable } from './video'
import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
-import { PickWith, PickWithOpt } from '@server/types/utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
import { VideoFileModel } from '@server/models/video/video-file'
import { MVideoFile, MVideoFileVideo } from './video-file'
import { VideoShareModel } from '../../../models/video/video-share'
-import { PickWith } from '../../utils'
+import { PickWith } from '@shared/core-utils'
import { MActorDefault } from '../account'
import { MVideo } from './video'
import { VideoStreamingPlaylistModel } from '../../../models/video/video-streaming-playlist'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
import { MVideoRedundancyFileUrl, MVideoRedundancy } from './video-redundancy'
import { MVideo } from './video'
import { MVideoFile } from './video-file'
import { VideoModel } from '../../../models/video/video'
-import { PickWith, PickWithOpt } from '../../utils'
+import { PickWith, PickWithOpt } from '@shared/core-utils'
import {
MChannelAccountDefault,
MChannelAccountLight,
--- /dev/null
+export * from './plugin-library.model'
+export * from './register-server-auth.model'
+export * from './register-server-option.model'
--- /dev/null
+import { RegisterServerOptions } from './register-server-option.model'
+
+export interface PluginLibrary {
+ register: (options: RegisterServerOptions) => Promise<any>
+
+ unregister: () => Promise<any>
+}
--- /dev/null
+import * as express from 'express'
+import { UserRole } from '@shared/models'
+import { MOAuthToken, MUser } from '../models'
+
+export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
+
+export interface RegisterServerAuthenticatedResult {
+ username: string
+ email: string
+ role?: UserRole
+ displayName?: string
+}
+
+export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
+ req: express.Request
+ res: express.Response
+}
+
+interface RegisterServerAuthBase {
+ // Authentication name (a plugin can register multiple auth strategies)
+ authName: string
+
+ // Called by PeerTube when a user from your plugin logged out
+ onLogout?(user: MUser): void
+
+ // Your plugin can hook PeerTube access/refresh token validity
+ // So you can control for your plugin the user session lifetime
+ hookTokenValidity?(options: { token: MOAuthToken, type: 'access' | 'refresh' }): Promise<{ valid: boolean }>
+}
+
+export interface RegisterServerAuthPassOptions extends RegisterServerAuthBase {
+ // Weight of this authentication so PeerTube tries the auth methods in DESC weight order
+ getWeight(): number
+
+ // Used by PeerTube to login a user
+ // Returns null if the login failed, or { username, email } on success
+ login(body: {
+ id: string
+ password: string
+ }): Promise<RegisterServerAuthenticatedResult | null>
+}
+
+export interface RegisterServerAuthExternalOptions extends RegisterServerAuthBase {
+ // Will be displayed in a block next to the login form
+ authDisplayName: () => string
+
+ onAuthRequest: (req: express.Request, res: express.Response) => void
+}
+
+export interface RegisterServerAuthExternalResult {
+ userAuthenticated (options: RegisterServerExternalAuthenticatedResult): void
+}
--- /dev/null
+import * as Bluebird from 'bluebird'
+import { Router } from 'express'
+import { Logger } from 'winston'
+import { ActorModel } from '@server/models/activitypub/actor'
+import {
+ PluginPlaylistPrivacyManager,
+ PluginSettingsManager,
+ PluginStorageManager,
+ PluginVideoCategoryManager,
+ PluginVideoLanguageManager,
+ PluginVideoLicenceManager,
+ PluginVideoPrivacyManager,
+ RegisterServerHookOptions,
+ RegisterServerSettingOptions,
+ VideoBlacklistCreate
+} from '@shared/models'
+import { MVideoThumbnail } from '../models'
+import {
+ RegisterServerAuthExternalOptions,
+ RegisterServerAuthExternalResult,
+ RegisterServerAuthPassOptions
+} from './register-server-auth.model'
+
+export type PeerTubeHelpers = {
+ logger: Logger
+
+ database: {
+ query: Function
+ }
+
+ videos: {
+ loadByUrl: (url: string) => Bluebird<MVideoThumbnail>
+
+ removeVideo: (videoId: number) => Promise<void>
+ }
+
+ config: {
+ getWebserverUrl: () => string
+ }
+
+ moderation: {
+ blockServer: (options: { byAccountId: number, hostToBlock: string }) => Promise<void>
+ unblockServer: (options: { byAccountId: number, hostToUnblock: string }) => Promise<void>
+ blockAccount: (options: { byAccountId: number, handleToBlock: string }) => Promise<void>
+ unblockAccount: (options: { byAccountId: number, handleToUnblock: string }) => Promise<void>
+
+ blacklistVideo: (options: { videoIdOrUUID: number | string, createOptions: VideoBlacklistCreate }) => Promise<void>
+ unblacklistVideo: (options: { videoIdOrUUID: number | string }) => Promise<void>
+ }
+
+ server: {
+ getServerActor: () => Promise<ActorModel>
+ }
+}
+
+export type RegisterServerOptions = {
+ registerHook: (options: RegisterServerHookOptions) => void
+
+ registerSetting: (options: RegisterServerSettingOptions) => void
+
+ settingsManager: PluginSettingsManager
+
+ storageManager: PluginStorageManager
+
+ videoCategoryManager: PluginVideoCategoryManager
+ videoLanguageManager: PluginVideoLanguageManager
+ videoLicenceManager: PluginVideoLicenceManager
+
+ videoPrivacyManager: PluginVideoPrivacyManager
+ playlistPrivacyManager: PluginPlaylistPrivacyManager
+
+ registerIdAndPassAuth: (options: RegisterServerAuthPassOptions) => void
+ registerExternalAuth: (options: RegisterServerAuthExternalOptions) => RegisterServerAuthExternalResult
+ unregisterIdAndPassAuth: (authName: string) => void
+ unregisterExternalAuth: (authName: string) => void
+
+ // Get plugin router to create custom routes
+ // Base routes of this router are
+ // * /plugins/:pluginName/:pluginVersion/router/...
+ // * /plugins/:pluginName/router/...
+ getRouter(): Router
+
+ peertubeHelpers: PeerTubeHelpers
+}
+++ /dev/null
-/* eslint-disable @typescript-eslint/array-type */
-
-export type FunctionPropertyNames<T> = {
- [K in keyof T]: T[K] extends Function ? K : never
-}[keyof T]
-
-export type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>
-
-export type PickWith<T, KT extends keyof T, V> = {
- [P in KT]: T[P] extends V ? V : never
-}
-
-export type PickWithOpt<T, KT extends keyof T, V> = {
- [P in KT]?: T[P] extends V ? V : never
-}
-
-// https://github.com/krzkaczor/ts-essentials Rocks!
-export type DeepPartial<T> = {
- [P in keyof T]?: T[P] extends Array<infer U>
- ? Array<DeepPartial<U>>
- : T[P] extends ReadonlyArray<infer U>
- ? ReadonlyArray<DeepPartial<U>>
- : DeepPartial<T[P]>
-}
+import { RegisterServerAuthExternalOptions } from '@server/types'
+import {
+ MAccountBlocklist,
+ MActorUrl,
+ MStreamingPlaylist,
+ MVideoChangeOwnershipFull,
+ MVideoFile,
+ MVideoImmutable,
+ MVideoPlaylistFull,
+ MVideoPlaylistFullSummary
+} from '@server/types/models'
+import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
+import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server'
+import { MVideoImportDefault } from '@server/types/models/video/video-import'
+import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element'
+import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate'
+import { UserRole } from '@shared/models'
import { RegisteredPlugin } from '../../lib/plugins/plugin-manager'
import {
MAccountDefault,
MVideoThumbnail,
MVideoWithRights
} from '../../types/models'
-import { MVideoPlaylistFull, MVideoPlaylistFullSummary } from '../../types/models/video/video-playlist'
-import { MVideoImportDefault } from '@server/types/models/video/video-import'
-import { MAccountBlocklist, MActorUrl, MStreamingPlaylist, MVideoFile, MVideoImmutable } from '@server/types/models'
-import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element'
-import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate'
-import { MVideoChangeOwnershipFull } from '../../types/models/video/video-change-ownership'
-import { MPlugin, MServer } from '@server/types/models/server'
-import { MServerBlocklist } from '../../types/models/server/server-blocklist'
-import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
-import { UserRole } from '@shared/models'
-import { RegisterServerAuthExternalOptions } from '@shared/models/plugins/register-server-auth.model'
declare module 'express' {
export interface Request {
+++ /dev/null
-export * from './register-server-option.model'
-export * from './plugin-library.model'
+++ /dev/null
-import { RegisterServerOptions } from './register-server-option.model'
-
-export interface PluginLibrary {
- register: (options: RegisterServerOptions) => Promise<any>
-
- unregister: () => Promise<any>
-}
+++ /dev/null
-import * as Bluebird from 'bluebird'
-import { Router } from 'express'
-import { Logger } from 'winston'
-import { ActorModel } from '@server/models/activitypub/actor'
-import { VideoBlacklistCreate } from '@shared/models'
-import { PluginPlaylistPrivacyManager } from '@shared/models/plugins/plugin-playlist-privacy-manager.model'
-import { PluginVideoPrivacyManager } from '@shared/models/plugins/plugin-video-privacy-manager.model'
-import {
- RegisterServerAuthExternalOptions,
- RegisterServerAuthExternalResult,
- RegisterServerAuthPassOptions
-} from '@shared/models/plugins/register-server-auth.model'
-import { PluginSettingsManager } from '../../../shared/models/plugins/plugin-settings-manager.model'
-import { PluginStorageManager } from '../../../shared/models/plugins/plugin-storage-manager.model'
-import { PluginVideoCategoryManager } from '../../../shared/models/plugins/plugin-video-category-manager.model'
-import { PluginVideoLanguageManager } from '../../../shared/models/plugins/plugin-video-language-manager.model'
-import { PluginVideoLicenceManager } from '../../../shared/models/plugins/plugin-video-licence-manager.model'
-import { RegisterServerHookOptions } from '../../../shared/models/plugins/register-server-hook.model'
-import { RegisterServerSettingOptions } from '../../../shared/models/plugins/register-server-setting.model'
-import { MVideoThumbnail } from '../../types/models'
-
-export type PeerTubeHelpers = {
- logger: Logger
-
- database: {
- query: Function
- }
-
- videos: {
- loadByUrl: (url: string) => Bluebird<MVideoThumbnail>
-
- removeVideo: (videoId: number) => Promise<void>
- }
-
- config: {
- getWebserverUrl: () => string
- }
-
- moderation: {
- blockServer: (options: { byAccountId: number, hostToBlock: string }) => Promise<void>
- unblockServer: (options: { byAccountId: number, hostToUnblock: string }) => Promise<void>
- blockAccount: (options: { byAccountId: number, handleToBlock: string }) => Promise<void>
- unblockAccount: (options: { byAccountId: number, handleToUnblock: string }) => Promise<void>
-
- blacklistVideo: (options: { videoIdOrUUID: number | string, createOptions: VideoBlacklistCreate }) => Promise<void>
- unblacklistVideo: (options: { videoIdOrUUID: number | string }) => Promise<void>
- }
-
- server: {
- getServerActor: () => Promise<ActorModel>
- }
-}
-
-export type RegisterServerOptions = {
- registerHook: (options: RegisterServerHookOptions) => void
-
- registerSetting: (options: RegisterServerSettingOptions) => void
-
- settingsManager: PluginSettingsManager
-
- storageManager: PluginStorageManager
-
- videoCategoryManager: PluginVideoCategoryManager
- videoLanguageManager: PluginVideoLanguageManager
- videoLicenceManager: PluginVideoLicenceManager
-
- videoPrivacyManager: PluginVideoPrivacyManager
- playlistPrivacyManager: PluginPlaylistPrivacyManager
-
- registerIdAndPassAuth: (options: RegisterServerAuthPassOptions) => void
- registerExternalAuth: (options: RegisterServerAuthExternalOptions) => RegisterServerAuthExternalResult
- unregisterIdAndPassAuth: (authName: string) => void
- unregisterExternalAuth: (authName: string) => void
-
- // Get plugin router to create custom routes
- // Base routes of this router are
- // * /plugins/:pluginName/:pluginVersion/router/...
- // * /plugins/:pluginName/router/...
- getRouter(): Router
-
- peertubeHelpers: PeerTubeHelpers
-}
--- /dev/null
+export * from './logs'
+export * from './miscs'
+export * from './plugins'
--- /dev/null
+export * from './logs'
--- /dev/null
+export * from './date'
+export * from './miscs'
+export * from './types'
--- /dev/null
+/* eslint-disable @typescript-eslint/array-type */
+
+export type FunctionPropertyNames<T> = {
+ [K in keyof T]: T[K] extends Function ? K : never
+}[keyof T]
+
+export type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>
+
+export type PickWith<T, KT extends keyof T, V> = {
+ [P in KT]: T[P] extends V ? V : never
+}
+
+export type PickWithOpt<T, KT extends keyof T, V> = {
+ [P in KT]?: T[P] extends V ? V : never
+}
+
+// https://github.com/krzkaczor/ts-essentials Rocks!
+export type DeepPartial<T> = {
+ [P in keyof T]?: T[P] extends Array<infer U>
+ ? Array<DeepPartial<U>>
+ : T[P] extends ReadonlyArray<infer U>
+ ? ReadonlyArray<DeepPartial<U>>
+ : DeepPartial<T[P]>
+}
+
+type Primitive = string | Function | number | boolean | Symbol | undefined | null
+export type DeepOmitHelper<T, K extends keyof T> = {
+ [P in K]: // extra level of indirection needed to trigger homomorhic behavior
+ T[P] extends infer TP // distribute over unions
+ ? TP extends Primitive
+ ? TP // leave primitives and functions alone
+ : TP extends any[]
+ ? DeepOmitArray<TP, K> // Array special handling
+ : DeepOmit<TP, K>
+ : never
+}
+export type DeepOmit<T, K> = T extends Primitive ? T : DeepOmitHelper<T, Exclude<keyof T, K>>
+
+export type DeepOmitArray<T extends any[], K> = {
+ [P in keyof T]: DeepOmit<T[P], K>
+}
--- /dev/null
+export * from './hooks'
import { makeDeleteRequest, makeGetRequest, makePutBodyRequest } from '../requests/requests'
import { CustomConfig } from '../../models/server/custom-config.model'
-import { DeepPartial } from '@server/types/utils'
+import { DeepPartial } from '@shared/core-utils'
import { merge } from 'lodash'
function getConfig (url: string) {
export * from './account.model'
+export * from './actor.model'
export * from './follow.model'
export * from './feeds'
export * from './i18n'
export * from './overviews'
+export * from './plugins'
export * from './search'
-export * from './server/job.model'
+export * from './server'
export * from './oauth-client-local.model'
export * from './result-list.model'
export * from './server/server-config.model'
--- /dev/null
+export * from './client-hook.model'
+export * from './hook-type.enum'
+export * from './install-plugin.model'
+export * from './manage-plugin.model'
+export * from './peertube-plugin-index-list.model'
+export * from './peertube-plugin-index.model'
+export * from './peertube-plugin-latest-version.model'
+export * from './peertube-plugin.model'
+export * from './plugin-client-scope.type'
+export * from './plugin-package-json.model'
+export * from './plugin-playlist-privacy-manager.model'
+export * from './plugin-settings-manager.model'
+export * from './plugin-storage-manager.model'
+export * from './plugin-translation.model'
+export * from './plugin-video-category-manager.model'
+export * from './plugin-video-language-manager.model'
+export * from './plugin-video-licence-manager.model'
+export * from './plugin-video-privacy-manager.model'
+export * from './plugin.type'
+export * from './public-server.setting'
+export * from './register-client-hook.model'
+export * from './register-server-hook.model'
+export * from './register-server-setting.model'
+export * from './server-hook.model'
-import { VideoPlaylistPrivacy } from '@shared/models'
+import { VideoPlaylistPrivacy } from '../videos/playlist/video-playlist-privacy.model'
export interface PluginPlaylistPrivacyManager {
// PUBLIC = 1,
-import { VideoPrivacy } from '@shared/models'
+import { VideoPrivacy } from '@shared/models/videos/video-privacy.enum'
export interface PluginVideoPrivacyManager {
// PUBLIC = 1
+++ /dev/null
-import { UserRole } from '@shared/models'
-import { MOAuthToken, MUser } from '@server/types/models'
-import * as express from 'express'
-
-export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
-
-export interface RegisterServerAuthenticatedResult {
- username: string
- email: string
- role?: UserRole
- displayName?: string
-}
-
-export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
- req: express.Request
- res: express.Response
-}
-
-interface RegisterServerAuthBase {
- // Authentication name (a plugin can register multiple auth strategies)
- authName: string
-
- // Called by PeerTube when a user from your plugin logged out
- onLogout?(user: MUser): void
-
- // Your plugin can hook PeerTube access/refresh token validity
- // So you can control for your plugin the user session lifetime
- hookTokenValidity?(options: { token: MOAuthToken, type: 'access' | 'refresh' }): Promise<{ valid: boolean }>
-}
-
-export interface RegisterServerAuthPassOptions extends RegisterServerAuthBase {
- // Weight of this authentication so PeerTube tries the auth methods in DESC weight order
- getWeight(): number
-
- // Used by PeerTube to login a user
- // Returns null if the login failed, or { username, email } on success
- login(body: {
- id: string
- password: string
- }): Promise<RegisterServerAuthenticatedResult | null>
-}
-
-export interface RegisterServerAuthExternalOptions extends RegisterServerAuthBase {
- // Will be displayed in a block next to the login form
- authDisplayName: () => string
-
- onAuthRequest: (req: express.Request, res: express.Response) => void
-}
-
-export interface RegisterServerAuthExternalResult {
- userAuthenticated (options: RegisterServerExternalAuthenticatedResult): void
-}
export * from './nsfw-query.model'
+export * from './search-target-query.model'
export * from './videos-search-query.model'
export * from './video-channels-search-query.model'
export * from './debug.model'
export * from './emailer.model'
export * from './job.model'
+export * from './log-level.type'
export * from './server-config.model'
export * from './server-stats.model'
-export * from './user.model'
export * from './user-create.model'
+export * from './user-flag.model'
export * from './user-login.model'
-export * from './user-notification.model'
export * from './user-notification-setting.model'
+export * from './user-notification.model'
export * from './user-refresh-token.model'
-export * from './user-update.model'
-export * from './user-update-me.model'
+export * from './user-register.model'
export * from './user-right.enum'
export * from './user-role'
+export * from './user-update-me.model'
+export * from './user-update.model'
export * from './user-video-quota.model'
export * from './user-watching-video.model'
+export * from './user.model'
--- /dev/null
+export * from './video-abuse-create.model'
+export * from './video-abuse-state.model'
+export * from './video-abuse-update.model'
+export * from './video-abuse-video-is.type'
+export * from './video-abuse.model'
--- /dev/null
+export * from './video-blacklist.model'
+export * from './video-blacklist-create.model'
+export * from './video-blacklist-update.model'
--- /dev/null
+export * from './video-caption.model'
+export * from './video-caption-update.model'
--- /dev/null
+export * from './video-channel-create.model'
+export * from './video-channel-update.model'
+export * from './video-channel.model'
--- /dev/null
+export * from './video-import-create.model'
+export * from './video-import-state.enum'
+export * from './video-import.model'
-export * from './rate/user-video-rate-update.model'
-export * from './rate/user-video-rate.model'
-export * from './rate/account-video-rate.model'
-export * from './rate/user-video-rate.type'
-export * from './abuse/video-abuse-state.model'
-export * from './abuse/video-abuse-create.model'
-export * from './abuse/video-abuse-reason.model'
-export * from './abuse/video-abuse.model'
-export * from './abuse/video-abuse-update.model'
-export * from './blacklist/video-blacklist.model'
-export * from './blacklist/video-blacklist-create.model'
-export * from './blacklist/video-blacklist-update.model'
-export * from './channel/video-channel-create.model'
-export * from './channel/video-channel-update.model'
-export * from './channel/video-channel.model'
-export * from './playlist/video-playlist-create.model'
-export * from './playlist/video-playlist-element-create.model'
-export * from './playlist/video-playlist-element-update.model'
-export * from './playlist/video-playlist-privacy.model'
-export * from './playlist/video-playlist-type.model'
-export * from './playlist/video-playlist-update.model'
-export * from './playlist/video-playlist.model'
-export * from './playlist/video-playlist-element.model'
-export * from './video-change-ownership.model'
+export * from './abuse'
+export * from './blacklist'
+export * from './caption'
+export * from './channel'
+export * from './import'
+export * from './playlist'
+export * from './rate'
+
+export * from './nsfw-policy.type'
+
+export * from './thumbnail.type'
+
+export * from './video-change-ownership-accept.model'
export * from './video-change-ownership-create.model'
+export * from './video-change-ownership.model'
+
+export * from './video-comment.model'
+export * from './video-constant.model'
export * from './video-create.model'
+export * from './video-file-metadata'
export * from './video-file.model'
+
export * from './video-privacy.enum'
+export * from './video-query.type'
export * from './video-rate.type'
export * from './video-resolution.enum'
-export * from './video-update.model'
-export * from './video.model'
-export * from './video-query.type'
+
+export * from './video-schedule-update.model'
+export * from './video-sort-field.type'
export * from './video-state.enum'
+
+export * from './video-streaming-playlist.model'
+export * from './video-streaming-playlist.type'
+
export * from './video-transcoding-fps.model'
-export * from './caption/video-caption.model'
-export * from './caption/video-caption-update.model'
-export * from './import/video-import-create.model'
-export * from './import/video-import-state.enum'
-export * from './import/video-import.model'
-export * from './video-constant.model'
+
+export * from './video-update.model'
+export * from './video.model'
--- /dev/null
+export * from './video-exist-in-playlist.model'
+export * from './video-playlist-create.model'
+export * from './video-playlist-element-create.model'
+export * from './video-playlist-element-update.model'
+export * from './video-playlist-element.model'
+export * from './video-playlist-privacy.model'
+export * from './video-playlist-reorder.model'
+export * from './video-playlist-type.model'
+export * from './video-playlist-update.model'
+export * from './video-playlist.model'
--- /dev/null
+
+export * from './user-video-rate-update.model'
+export * from './user-video-rate.model'
+export * from './account-video-rate.model'
+export * from './user-video-rate.type'
-import { FfprobeData } from "fluent-ffmpeg"
-import { DeepOmit } from "@server/models/utils"
+import { FfprobeData } from 'fluent-ffmpeg'
+import { DeepOmit } from '../../core-utils'
export type VideoFileMetadataModel = DeepOmit<FfprobeData, 'filename'>
--- /dev/null
+export type VideoSortField =
+ 'name' | '-name' |
+ 'duration' | '-duration' |
+ 'publishedAt' | '-publishedAt' |
+ 'createdAt' | '-createdAt' |
+ 'views' | '-views' |
+ 'likes' | '-likes' |
+ 'trending' | '-trending'