aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared/shared-main
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/shared/shared-main')
-rw-r--r--client/src/app/shared/shared-main/account/account.model.ts30
-rw-r--r--client/src/app/shared/shared-main/account/account.service.ts29
-rw-r--r--client/src/app/shared/shared-main/account/actor-avatar-info.component.html24
-rw-r--r--client/src/app/shared/shared-main/account/actor-avatar-info.component.scss71
-rw-r--r--client/src/app/shared/shared-main/account/actor-avatar-info.component.ts64
-rw-r--r--client/src/app/shared/shared-main/account/actor.model.ts65
-rw-r--r--client/src/app/shared/shared-main/account/avatar.component.html8
-rw-r--r--client/src/app/shared/shared-main/account/avatar.component.scss40
-rw-r--r--client/src/app/shared/shared-main/account/avatar.component.ts31
-rw-r--r--client/src/app/shared/shared-main/account/index.ts5
-rw-r--r--client/src/app/shared/shared-main/angular/from-now.pipe.ts39
-rw-r--r--client/src/app/shared/shared-main/angular/index.ts4
-rw-r--r--client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts96
-rw-r--r--client/src/app/shared/shared-main/angular/number-formatter.pipe.ts19
-rw-r--r--client/src/app/shared/shared-main/angular/peertube-template.directive.ts12
-rw-r--r--client/src/app/shared/shared-main/auth/auth-interceptor.service.ts60
-rw-r--r--client/src/app/shared/shared-main/auth/index.ts1
-rw-r--r--client/src/app/shared/shared-main/buttons/action-dropdown.component.html55
-rw-r--r--client/src/app/shared/shared-main/buttons/action-dropdown.component.scss72
-rw-r--r--client/src/app/shared/shared-main/buttons/action-dropdown.component.ts52
-rw-r--r--client/src/app/shared/shared-main/buttons/button.component.html6
-rw-r--r--client/src/app/shared/shared-main/buttons/button.component.scss46
-rw-r--r--client/src/app/shared/shared-main/buttons/button.component.ts20
-rw-r--r--client/src/app/shared/shared-main/buttons/delete-button.component.html6
-rw-r--r--client/src/app/shared/shared-main/buttons/delete-button.component.ts20
-rw-r--r--client/src/app/shared/shared-main/buttons/edit-button.component.html6
-rw-r--r--client/src/app/shared/shared-main/buttons/edit-button.component.ts12
-rw-r--r--client/src/app/shared/shared-main/buttons/index.ts4
-rw-r--r--client/src/app/shared/shared-main/date/date-toggle.component.html6
-rw-r--r--client/src/app/shared/shared-main/date/date-toggle.component.scss5
-rw-r--r--client/src/app/shared/shared-main/date/date-toggle.component.ts46
-rw-r--r--client/src/app/shared/shared-main/date/index.ts1
-rw-r--r--client/src/app/shared/shared-main/feeds/feed.component.html15
-rw-r--r--client/src/app/shared/shared-main/feeds/feed.component.scss20
-rw-r--r--client/src/app/shared/shared-main/feeds/feed.component.ts11
-rw-r--r--client/src/app/shared/shared-main/feeds/index.ts2
-rw-r--r--client/src/app/shared/shared-main/feeds/syndication.model.ts7
-rw-r--r--client/src/app/shared/shared-main/index.ts12
-rw-r--r--client/src/app/shared/shared-main/loaders/index.ts2
-rw-r--r--client/src/app/shared/shared-main/loaders/loader.component.html8
-rw-r--r--client/src/app/shared/shared-main/loaders/loader.component.scss45
-rw-r--r--client/src/app/shared/shared-main/loaders/loader.component.ts10
-rw-r--r--client/src/app/shared/shared-main/loaders/small-loader.component.html3
-rw-r--r--client/src/app/shared/shared-main/loaders/small-loader.component.ts11
-rw-r--r--client/src/app/shared/shared-main/misc/help.component.html40
-rw-r--r--client/src/app/shared/shared-main/misc/help.component.scss42
-rw-r--r--client/src/app/shared/shared-main/misc/help.component.ts94
-rw-r--r--client/src/app/shared/shared-main/misc/index.ts2
-rw-r--r--client/src/app/shared/shared-main/misc/list-overflow.component.html35
-rw-r--r--client/src/app/shared/shared-main/misc/list-overflow.component.scss61
-rw-r--r--client/src/app/shared/shared-main/misc/list-overflow.component.ts120
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts164
-rw-r--r--client/src/app/shared/shared-main/users/index.ts4
-rw-r--r--client/src/app/shared/shared-main/users/user-history.service.ts43
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.model.ts184
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.service.ts81
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.html166
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.scss53
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.ts100
-rw-r--r--client/src/app/shared/shared-main/video-caption/index.ts2
-rw-r--r--client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts9
-rw-r--r--client/src/app/shared/shared-main/video-caption/video-caption.service.ts74
-rw-r--r--client/src/app/shared/shared-main/video-channel/index.ts2
-rw-r--r--client/src/app/shared/shared-main/video-channel/video-channel.model.ts42
-rw-r--r--client/src/app/shared/shared-main/video-channel/video-channel.service.ts94
-rw-r--r--client/src/app/shared/shared-main/video/index.ts7
-rw-r--r--client/src/app/shared/shared-main/video/redundancy.service.ts73
-rw-r--r--client/src/app/shared/shared-main/video/video-details.model.ts69
-rw-r--r--client/src/app/shared/shared-main/video/video-edit.model.ts120
-rw-r--r--client/src/app/shared/shared-main/video/video-import.service.ts100
-rw-r--r--client/src/app/shared/shared-main/video/video-ownership.service.ts64
-rw-r--r--client/src/app/shared/shared-main/video/video.model.ts188
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts380
73 files changed, 3514 insertions, 0 deletions
diff --git a/client/src/app/shared/shared-main/account/account.model.ts b/client/src/app/shared/shared-main/account/account.model.ts
new file mode 100644
index 000000000..6df2e9d10
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/account.model.ts
@@ -0,0 +1,30 @@
1import { Account as ServerAccount } from '@shared/models/actors/account.model'
2import { Actor } from './actor.model'
3
4export class Account extends Actor implements ServerAccount {
5 displayName: string
6 description: string
7 nameWithHost: string
8 nameWithHostForced: string
9 mutedByUser: boolean
10 mutedByInstance: boolean
11 mutedServerByUser: boolean
12 mutedServerByInstance: boolean
13
14 userId?: number
15
16 constructor (hash: ServerAccount) {
17 super(hash)
18
19 this.displayName = hash.displayName
20 this.description = hash.description
21 this.userId = hash.userId
22 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
23 this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
24
25 this.mutedByUser = false
26 this.mutedByInstance = false
27 this.mutedServerByUser = false
28 this.mutedServerByInstance = false
29 }
30}
diff --git a/client/src/app/shared/shared-main/account/account.service.ts b/client/src/app/shared/shared-main/account/account.service.ts
new file mode 100644
index 000000000..8f4abf070
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/account.service.ts
@@ -0,0 +1,29 @@
1import { Observable, ReplaySubject } from 'rxjs'
2import { catchError, map, tap } from 'rxjs/operators'
3import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { RestExtractor } from '@app/core'
6import { Account as ServerAccount } from '@shared/models'
7import { environment } from '../../../../environments/environment'
8import { Account } from './account.model'
9
10@Injectable()
11export class AccountService {
12 static BASE_ACCOUNT_URL = environment.apiUrl + '/api/v1/accounts/'
13
14 accountLoaded = new ReplaySubject<Account>(1)
15
16 constructor (
17 private authHttp: HttpClient,
18 private restExtractor: RestExtractor
19 ) {}
20
21 getAccount (id: number | string): Observable<Account> {
22 return this.authHttp.get<ServerAccount>(AccountService.BASE_ACCOUNT_URL + id)
23 .pipe(
24 map(accountHash => new Account(accountHash)),
25 tap(account => this.accountLoaded.next(account)),
26 catchError(res => this.restExtractor.handleError(res))
27 )
28 }
29}
diff --git a/client/src/app/shared/shared-main/account/actor-avatar-info.component.html b/client/src/app/shared/shared-main/account/actor-avatar-info.component.html
new file mode 100644
index 000000000..d01b9ac7f
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/actor-avatar-info.component.html
@@ -0,0 +1,24 @@
1<ng-container *ngIf="actor">
2 <div class="actor">
3 <div class="d-flex">
4 <img [src]="actor.avatarUrl" alt="Avatar" />
5
6 <div class="actor-img-edit-container">
7 <div class="actor-img-edit-button" [ngbTooltip]="'(extensions: '+ avatarExtensions +', '+ maxSizeText +': '+ maxAvatarSizeInBytes +')'" placement="right" container="body">
8 <my-global-icon iconName="edit"></my-global-icon>
9 <label for="avatarfile" i18n>Change your avatar</label>
10 <input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()"/>
11 </div>
12 </div>
13 </div>
14
15
16 <div class="actor-info">
17 <div class="actor-info-names">
18 <div class="actor-info-display-name">{{ actor.displayName }}</div>
19 <div class="actor-info-username">{{ actor.name }}</div>
20 </div>
21 <div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
22 </div>
23 </div>
24</ng-container> \ No newline at end of file
diff --git a/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss b/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss
new file mode 100644
index 000000000..5a66ecfd2
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss
@@ -0,0 +1,71 @@
1@import '_variables';
2@import '_mixins';
3
4.actor {
5 display: flex;
6
7 img {
8 @include avatar(100px);
9
10 margin-right: 15px;
11 }
12
13 .actor-img-edit-container {
14 position: relative;
15 width: 0;
16
17 .actor-img-edit-button {
18 @include peertube-button-file(21px);
19 @include button-with-icon(19px);
20
21 margin-top: 10px;
22 margin-bottom: 5px;
23 border-radius: 50%;
24 top: 55px;
25 right: 45px;
26 cursor: pointer;
27
28 input {
29 width: 30px;
30 height: 30px;
31 }
32
33 my-global-icon {
34 right: 7px;
35 }
36 }
37 }
38
39 .actor-info {
40 justify-content: center;
41 display: inline-flex;
42 flex-direction: column;
43
44 .actor-info-names {
45 display: flex;
46 align-items: center;
47
48 .actor-info-display-name {
49 font-size: 20px;
50 font-weight: $font-bold;
51
52 @media screen and (max-width: $small-view) {
53 font-size: 16px;
54 }
55 }
56
57 .actor-info-username {
58 margin-left: 7px;
59 position: relative;
60 top: 2px;
61 font-size: 14px;
62 color: $grey-actor-name;
63 }
64 }
65
66 .actor-info-followers {
67 font-size: 15px;
68 padding-bottom: .5rem;
69 }
70 }
71}
diff --git a/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts b/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts
new file mode 100644
index 000000000..0c04ae4a6
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts
@@ -0,0 +1,64 @@
1import { BytesPipe } from 'ngx-pipes'
2import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
3import { Notifier, ServerService } from '@app/core'
4import { Account, VideoChannel } from '@app/shared/shared-main'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { ServerConfig } from '@shared/models'
7
8@Component({
9 selector: 'my-actor-avatar-info',
10 templateUrl: './actor-avatar-info.component.html',
11 styleUrls: [ './actor-avatar-info.component.scss' ]
12})
13export class ActorAvatarInfoComponent implements OnInit {
14 @ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
15
16 @Input() actor: VideoChannel | Account
17
18 @Output() avatarChange = new EventEmitter<FormData>()
19
20 maxSizeText: string
21
22 private serverConfig: ServerConfig
23 private bytesPipe: BytesPipe
24
25 constructor (
26 private serverService: ServerService,
27 private notifier: Notifier,
28 private i18n: I18n
29 ) {
30 this.bytesPipe = new BytesPipe()
31 this.maxSizeText = this.i18n('max size')
32 }
33
34 ngOnInit (): void {
35 this.serverConfig = this.serverService.getTmpConfig()
36 this.serverService.getConfig()
37 .subscribe(config => this.serverConfig = config)
38 }
39
40 onAvatarChange () {
41 const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ]
42 if (avatarfile.size > this.maxAvatarSize) {
43 this.notifier.error('Error', 'This image is too large.')
44 return
45 }
46
47 const formData = new FormData()
48 formData.append('avatarfile', avatarfile)
49
50 this.avatarChange.emit(formData)
51 }
52
53 get maxAvatarSize () {
54 return this.serverConfig.avatar.file.size.max
55 }
56
57 get maxAvatarSizeInBytes () {
58 return this.bytesPipe.transform(this.maxAvatarSize)
59 }
60
61 get avatarExtensions () {
62 return this.serverConfig.avatar.file.extensions.join(', ')
63 }
64}
diff --git a/client/src/app/shared/shared-main/account/actor.model.ts b/client/src/app/shared/shared-main/account/actor.model.ts
new file mode 100644
index 000000000..5fc7989dd
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/actor.model.ts
@@ -0,0 +1,65 @@
1import { Actor as ActorServer, Avatar } from '@shared/models'
2import { getAbsoluteAPIUrl } from '@app/helpers'
3
4export abstract class Actor implements ActorServer {
5 id: number
6 url: string
7 name: string
8 host: string
9 followingCount: number
10 followersCount: number
11 createdAt: Date | string
12 updatedAt: Date | string
13 avatar: Avatar
14
15 avatarUrl: string
16
17 static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) {
18 if (actor?.avatar?.url) return actor.avatar.url
19
20 if (actor && actor.avatar) {
21 const absoluteAPIUrl = getAbsoluteAPIUrl()
22
23 return absoluteAPIUrl + actor.avatar.path
24 }
25
26 return this.GET_DEFAULT_AVATAR_URL()
27 }
28
29 static GET_DEFAULT_AVATAR_URL () {
30 return window.location.origin + '/client/assets/images/default-avatar.png'
31 }
32
33 static CREATE_BY_STRING (accountName: string, host: string, forceHostname = false) {
34 const absoluteAPIUrl = getAbsoluteAPIUrl()
35 const thisHost = new URL(absoluteAPIUrl).host
36
37 if (host.trim() === thisHost && !forceHostname) return accountName
38
39 return accountName + '@' + host
40 }
41
42 protected constructor (hash: ActorServer) {
43 this.id = hash.id
44 this.url = hash.url
45 this.name = hash.name
46 this.host = hash.host
47 this.followingCount = hash.followingCount
48 this.followersCount = hash.followersCount
49 this.createdAt = new Date(hash.createdAt.toString())
50 this.updatedAt = new Date(hash.updatedAt.toString())
51 this.avatar = hash.avatar
52
53 this.updateComputedAttributes()
54 }
55
56 updateAvatar (newAvatar: Avatar) {
57 this.avatar = newAvatar
58
59 this.updateComputedAttributes()
60 }
61
62 private updateComputedAttributes () {
63 this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this)
64 }
65}
diff --git a/client/src/app/shared/shared-main/account/avatar.component.html b/client/src/app/shared/shared-main/account/avatar.component.html
new file mode 100644
index 000000000..09871fca4
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/avatar.component.html
@@ -0,0 +1,8 @@
1<div class="wrapper" [ngClass]="'avatar-' + size">
2 <a [routerLink]="[ '/video-channels', video.byVideoChannel ]" [title]="channelLinkTitle">
3 <img [src]="video.videoChannelAvatarUrl" i18n-alt alt="Channel avatar" />
4 </a>
5 <a [routerLink]="[ '/accounts', video.byAccount ]" [title]="accountLinkTitle">
6 <img [src]="video.accountAvatarUrl" i18n-alt alt="Account avatar" />
7 </a>
8</div>
diff --git a/client/src/app/shared/shared-main/account/avatar.component.scss b/client/src/app/shared/shared-main/account/avatar.component.scss
new file mode 100644
index 000000000..37709fce6
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/avatar.component.scss
@@ -0,0 +1,40 @@
1@import '_mixins';
2
3.wrapper {
4 $avatar-size: 35px;
5
6 width: $avatar-size;
7 height: $avatar-size;
8 position: relative;
9 margin-right: 5px;
10 margin-bottom: 5px;
11
12 &.avatar-sm {
13 width: 28px;
14 height: 28px;
15 margin-bottom: 3px;
16 }
17
18 a {
19 @include disable-outline;
20 }
21
22 a img {
23 height: 100%;
24 object-fit: cover;
25 position: absolute;
26 top:50%;
27 left:50%;
28 border-radius: 50%;
29 transform: translate(-50%,-50%)
30 }
31
32 a:nth-of-type(2) img {
33 height: 60%;
34 width: 60%;
35 border: 2px solid pvar(--mainBackgroundColor);
36 transform: translateX(15%);
37 position: relative;
38 background-color: pvar(--mainBackgroundColor);
39 }
40}
diff --git a/client/src/app/shared/shared-main/account/avatar.component.ts b/client/src/app/shared/shared-main/account/avatar.component.ts
new file mode 100644
index 000000000..31f39c200
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/avatar.component.ts
@@ -0,0 +1,31 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { Video } from '../video/video.model'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4
5@Component({
6 selector: 'avatar-channel',
7 templateUrl: './avatar.component.html',
8 styleUrls: [ './avatar.component.scss' ]
9})
10export class AvatarComponent implements OnInit {
11 @Input() video: Video
12 @Input() size: 'md' | 'sm' = 'md'
13
14 channelLinkTitle = ''
15 accountLinkTitle = ''
16
17 constructor (
18 private i18n: I18n
19 ) {}
20
21 ngOnInit () {
22 this.channelLinkTitle = this.i18n(
23 '{{name}} (channel page)',
24 { name: this.video.channel.name, handle: this.video.byVideoChannel }
25 )
26 this.accountLinkTitle = this.i18n(
27 '{{name}} (account page)',
28 { name: this.video.account.name, handle: this.video.byAccount }
29 )
30 }
31}
diff --git a/client/src/app/shared/shared-main/account/index.ts b/client/src/app/shared/shared-main/account/index.ts
new file mode 100644
index 000000000..f5b9f3634
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/index.ts
@@ -0,0 +1,5 @@
1export * from './account.model'
2export * from './account.service'
3export * from './actor-avatar-info.component'
4export * from './actor.model'
5export * from './avatar.component'
diff --git a/client/src/app/shared/shared-main/angular/from-now.pipe.ts b/client/src/app/shared/shared-main/angular/from-now.pipe.ts
new file mode 100644
index 000000000..9851468ee
--- /dev/null
+++ b/client/src/app/shared/shared-main/angular/from-now.pipe.ts
@@ -0,0 +1,39 @@
1import { Pipe, PipeTransform } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill'
3
4// Thanks: https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
5@Pipe({ name: 'myFromNow' })
6export class FromNowPipe implements PipeTransform {
7
8 constructor (private i18n: I18n) { }
9
10 transform (arg: number | Date | string) {
11 const argDate = new Date(arg)
12 const seconds = Math.floor((Date.now() - argDate.getTime()) / 1000)
13
14 let interval = Math.floor(seconds / 31536000)
15 if (interval > 1) return this.i18n('{{interval}} years ago', { interval })
16 if (interval === 1) return this.i18n('{{interval}} year ago', { interval })
17
18 interval = Math.floor(seconds / 2592000)
19 if (interval > 1) return this.i18n('{{interval}} months ago', { interval })
20 if (interval === 1) return this.i18n('{{interval}} month ago', { interval })
21
22 interval = Math.floor(seconds / 604800)
23 if (interval > 1) return this.i18n('{{interval}} weeks ago', { interval })
24 if (interval === 1) return this.i18n('{{interval}} week ago', { interval })
25
26 interval = Math.floor(seconds / 86400)
27 if (interval > 1) return this.i18n('{{interval}} days ago', { interval })
28 if (interval === 1) return this.i18n('{{interval}} day ago', { interval })
29
30 interval = Math.floor(seconds / 3600)
31 if (interval > 1) return this.i18n('{{interval}} hours ago', { interval })
32 if (interval === 1) return this.i18n('{{interval}} hour ago', { interval })
33
34 interval = Math.floor(seconds / 60)
35 if (interval >= 1) return this.i18n('{{interval}} min ago', { interval })
36
37 return this.i18n('just now')
38 }
39}
diff --git a/client/src/app/shared/shared-main/angular/index.ts b/client/src/app/shared/shared-main/angular/index.ts
new file mode 100644
index 000000000..3b072fb84
--- /dev/null
+++ b/client/src/app/shared/shared-main/angular/index.ts
@@ -0,0 +1,4 @@
1export * from './from-now.pipe'
2export * from './infinite-scroller.directive'
3export * from './number-formatter.pipe'
4export * from './peertube-template.directive'
diff --git a/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts
new file mode 100644
index 000000000..f09c3d1fc
--- /dev/null
+++ b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts
@@ -0,0 +1,96 @@
1import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators'
2import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'
3import { fromEvent, Observable, Subscription } from 'rxjs'
4
5@Directive({
6 selector: '[myInfiniteScroller]'
7})
8export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterContentChecked {
9 @Input() percentLimit = 70
10 @Input() autoInit = false
11 @Input() onItself = false
12 @Input() dataObservable: Observable<any[]>
13
14 @Output() nearOfBottom = new EventEmitter<void>()
15
16 private decimalLimit = 0
17 private lastCurrentBottom = -1
18 private scrollDownSub: Subscription
19 private container: HTMLElement
20
21 private checkScroll = false
22
23 constructor (private el: ElementRef) {
24 this.decimalLimit = this.percentLimit / 100
25 }
26
27 ngAfterContentChecked () {
28 if (this.checkScroll) {
29 this.checkScroll = false
30
31 console.log('Checking if the initial state has a scroll.')
32
33 if (this.hasScroll() === false) this.nearOfBottom.emit()
34 }
35 }
36
37 ngOnInit () {
38 if (this.autoInit === true) return this.initialize()
39 }
40
41 ngOnDestroy () {
42 if (this.scrollDownSub) this.scrollDownSub.unsubscribe()
43 }
44
45 initialize () {
46 this.container = this.onItself
47 ? this.el.nativeElement
48 : document.documentElement
49
50 // Emit the last value
51 const throttleOptions = { leading: true, trailing: true }
52
53 const scrollableElement = this.onItself ? this.container : window
54 const scrollObservable = fromEvent(scrollableElement, 'scroll')
55 .pipe(
56 startWith(true),
57 throttleTime(200, undefined, throttleOptions),
58 map(() => this.getScrollInfo()),
59 distinctUntilChanged((o1, o2) => o1.current === o2.current),
60 share()
61 )
62
63 // Scroll Down
64 this.scrollDownSub = scrollObservable
65 .pipe(
66 filter(({ current }) => this.isScrollingDown(current)),
67 filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit)
68 )
69 .subscribe(() => this.nearOfBottom.emit())
70
71 if (this.dataObservable) {
72 this.dataObservable
73 .pipe(filter(d => d.length !== 0))
74 .subscribe(() => this.checkScroll = true)
75 }
76 }
77
78 private getScrollInfo () {
79 return { current: this.container.scrollTop, maximumScroll: this.getMaximumScroll() }
80 }
81
82 private getMaximumScroll () {
83 return this.container.scrollHeight - window.innerHeight
84 }
85
86 private hasScroll () {
87 return this.getMaximumScroll() > 0
88 }
89
90 private isScrollingDown (current: number) {
91 const result = this.lastCurrentBottom < current
92
93 this.lastCurrentBottom = current
94 return result
95 }
96}
diff --git a/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts b/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts
new file mode 100644
index 000000000..8a0756a36
--- /dev/null
+++ b/client/src/app/shared/shared-main/angular/number-formatter.pipe.ts
@@ -0,0 +1,19 @@
1import { Pipe, PipeTransform } from '@angular/core'
2
3// Thanks: https://github.com/danrevah/ngx-pipes/blob/master/src/pipes/math/bytes.ts
4
5@Pipe({ name: 'myNumberFormatter' })
6export class NumberFormatterPipe implements PipeTransform {
7 private dictionary: Array<{max: number, type: string}> = [
8 { max: 1000, type: '' },
9 { max: 1000000, type: 'K' },
10 { max: 1000000000, type: 'M' }
11 ]
12
13 transform (value: number) {
14 const format = this.dictionary.find(d => value < d.max) || this.dictionary[this.dictionary.length - 1]
15 const calc = Math.floor(value / (format.max / 1000))
16
17 return `${calc}${format.type}`
18 }
19}
diff --git a/client/src/app/shared/shared-main/angular/peertube-template.directive.ts b/client/src/app/shared/shared-main/angular/peertube-template.directive.ts
new file mode 100644
index 000000000..e04c25d9a
--- /dev/null
+++ b/client/src/app/shared/shared-main/angular/peertube-template.directive.ts
@@ -0,0 +1,12 @@
1import { Directive, Input, TemplateRef } from '@angular/core'
2
3@Directive({
4 selector: '[ptTemplate]'
5})
6export class PeerTubeTemplateDirective <T extends string> {
7 @Input('ptTemplate') name: T
8
9 constructor (public template: TemplateRef<any>) {
10 // empty
11 }
12}
diff --git a/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
new file mode 100644
index 000000000..68a4acdb5
--- /dev/null
+++ b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
@@ -0,0 +1,60 @@
1import { Observable, throwError as observableThrowError } from 'rxjs'
2import { catchError, switchMap } from 'rxjs/operators'
3import { HTTP_INTERCEPTORS, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'
4import { Injectable, Injector } from '@angular/core'
5import { AuthService } from '@app/core/auth/auth.service'
6
7@Injectable()
8export class AuthInterceptor implements HttpInterceptor {
9 private authService: AuthService
10
11 // https://github.com/angular/angular/issues/18224#issuecomment-316957213
12 constructor (private injector: Injector) {}
13
14 intercept (req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
15 if (this.authService === undefined) {
16 this.authService = this.injector.get(AuthService)
17 }
18
19 const authReq = this.cloneRequestWithAuth(req)
20
21 // Pass on the cloned request instead of the original request
22 // Catch 401 errors (refresh token expired)
23 return next.handle(authReq)
24 .pipe(
25 catchError(err => {
26 if (err.status === 401 && err.error && err.error.code === 'invalid_token') {
27 return this.handleTokenExpired(req, next)
28 }
29
30 return observableThrowError(err)
31 })
32 )
33 }
34
35 private handleTokenExpired (req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
36 return this.authService.refreshAccessToken()
37 .pipe(
38 switchMap(() => {
39 const authReq = this.cloneRequestWithAuth(req)
40
41 return next.handle(authReq)
42 })
43 )
44 }
45
46 private cloneRequestWithAuth (req: HttpRequest<any>) {
47 const authHeaderValue = this.authService.getRequestHeaderValue()
48
49 if (authHeaderValue === null) return req
50
51 // Clone the request to add the new header
52 return req.clone({ headers: req.headers.set('Authorization', authHeaderValue) })
53 }
54}
55
56export const AUTH_INTERCEPTOR_PROVIDER = {
57 provide: HTTP_INTERCEPTORS,
58 useClass: AuthInterceptor,
59 multi: true
60}
diff --git a/client/src/app/shared/shared-main/auth/index.ts b/client/src/app/shared/shared-main/auth/index.ts
new file mode 100644
index 000000000..84a07196f
--- /dev/null
+++ b/client/src/app/shared/shared-main/auth/index.ts
@@ -0,0 +1 @@
export * from './auth-interceptor.service'
diff --git a/client/src/app/shared/shared-main/buttons/action-dropdown.component.html b/client/src/app/shared/shared-main/buttons/action-dropdown.component.html
new file mode 100644
index 000000000..12933d4ca
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/action-dropdown.component.html
@@ -0,0 +1,55 @@
1<div class="dropdown-root" ngbDropdown [placement]="placement" [container]="container" *ngIf="areActionsDisplayed(actions, entry)">
2 <button
3 class="action-button" [ngClass]="{ small: buttonSize === 'small', grey: theme === 'grey', orange: theme === 'orange', 'button-styled': buttonStyled }"
4 ngbDropdownToggle role="button"
5 >
6 <my-global-icon *ngIf="!label && buttonDirection === 'horizontal'" class="more-icon" iconName="more-horizontal"></my-global-icon>
7 <my-global-icon *ngIf="!label && buttonDirection === 'vertical'" class="more-icon" iconName="more-vertical"></my-global-icon>
8
9 <span *ngIf="label" class="dropdown-toggle">{{ label }}</span>
10</button>
11
12 <div ngbDropdownMenu class="dropdown-menu">
13 <ng-container *ngFor="let actions of getActions()">
14
15 <ng-container *ngFor="let action of actions">
16 <ng-container *ngIf="action.isDisplayed === undefined || action.isDisplayed(entry) === true">
17
18 <ng-template #templateActionLabel let-action>
19 <my-global-icon *ngIf="action.iconName" [iconName]="action.iconName" [ngClass]="'icon-' + action.iconName" aria-hidden="true"></my-global-icon>
20
21 <div class="d-flex flex-column">
22 <span i18n>{{ action.label }}</span>
23 <small class="text-muted" *ngIf="action.description">{{ action.description }}</small>
24 </div>
25 </ng-template>
26
27 <a
28 *ngIf="action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
29 class="dropdown-item" [routerLink]="action.linkBuilder(entry)" [title]="action.title || ''"
30 >
31 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
32 </a>
33
34 <span
35 *ngIf="!action.linkBuilder && !action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
36 class="custom-action dropdown-item" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
37 >
38 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
39 </span>
40
41 <h6
42 *ngIf="!action.linkBuilder && action.isHeader" [ngClass]="{ 'with-icon': !!action.iconName }"
43 class="dropdown-header" tabindex="0" role="button" [title]="action.title || ''" (click)="action.handler(entry)" (keyup.enter)="action.handler(entry)"
44 >
45 <ng-container *ngTemplateOutlet="templateActionLabel; context:{ $implicit: action }"></ng-container>
46 </h6>
47
48 </ng-container>
49 </ng-container>
50
51 <div *ngIf="areActionsDisplayed(actions, entry)" class="dropdown-divider"></div>
52
53 </ng-container>
54 </div>
55</div>
diff --git a/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss b/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss
new file mode 100644
index 000000000..724a04efc
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/action-dropdown.component.scss
@@ -0,0 +1,72 @@
1@import '_variables';
2@import '_mixins';
3
4.dropdown-divider:last-child {
5 display: none;
6}
7
8.action-button {
9 @include peertube-button;
10
11 &.button-styled {
12
13 &.grey {
14 @include grey-button;
15 }
16
17 &.orange {
18 @include orange-button;
19 }
20
21 &:hover, &:active, &:focus {
22 background-color: $grey-background-color;
23 }
24 }
25
26 display: inline-block;
27 padding: 0 10px;
28
29 &::after {
30 display: none;
31 }
32
33 .more-icon {
34 width: 21px;
35
36 ::ng-deep {
37 @include apply-svg-color(pvar(--actionButtonColor));
38 }
39 }
40
41 &.small {
42 font-size: 14px;
43 height: 20px;
44 line-height: 20px;
45 }
46}
47
48.dropdown-toggle::after {
49 position: relative;
50 top: 1px;
51}
52
53.dropdown-menu {
54 .dropdown-header {
55 padding: 0.2rem 1rem;
56 }
57
58 .dropdown-item {
59 display: flex;
60 cursor: pointer;
61 color: #000 !important;
62
63 &.with-icon {
64 @include dropdown-with-icon-item;
65 }
66
67 a, span {
68 display: block;
69 width: 100%;
70 }
71 }
72}
diff --git a/client/src/app/shared/shared-main/buttons/action-dropdown.component.ts b/client/src/app/shared/shared-main/buttons/action-dropdown.component.ts
new file mode 100644
index 000000000..36d7d6229
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/action-dropdown.component.ts
@@ -0,0 +1,52 @@
1import { Component, Input } from '@angular/core'
2import { GlobalIconName } from '@app/shared/shared-icons'
3
4export type DropdownAction<T> = {
5 label?: string
6 iconName?: GlobalIconName
7 description?: string
8 title?: string
9 handler?: (a: T) => any
10 linkBuilder?: (a: T) => (string | number)[]
11 isDisplayed?: (a: T) => boolean
12 isHeader?: boolean
13}
14
15export type DropdownButtonSize = 'normal' | 'small'
16export type DropdownTheme = 'orange' | 'grey'
17export type DropdownDirection = 'horizontal' | 'vertical'
18
19@Component({
20 selector: 'my-action-dropdown',
21 styleUrls: [ './action-dropdown.component.scss' ],
22 templateUrl: './action-dropdown.component.html'
23})
24
25export class ActionDropdownComponent<T> {
26 @Input() actions: DropdownAction<T>[] | DropdownAction<T>[][] = []
27 @Input() entry: T
28
29 @Input() placement = 'bottom-left auto'
30 @Input() container: null | 'body'
31
32 @Input() buttonSize: DropdownButtonSize = 'normal'
33 @Input() buttonDirection: DropdownDirection = 'horizontal'
34 @Input() buttonStyled = true
35
36 @Input() label: string
37 @Input() theme: DropdownTheme = 'grey'
38
39 getActions (): DropdownAction<T>[][] {
40 if (this.actions.length !== 0 && Array.isArray(this.actions[0])) return this.actions as DropdownAction<T>[][]
41
42 return [ this.actions as DropdownAction<T>[] ]
43 }
44
45 areActionsDisplayed (actions: Array<DropdownAction<T> | DropdownAction<T>[]>, entry: T): boolean {
46 return actions.some(a => {
47 if (Array.isArray(a)) return this.areActionsDisplayed(a, entry)
48
49 return a.isDisplayed === undefined || a.isDisplayed(entry)
50 })
51 }
52}
diff --git a/client/src/app/shared/shared-main/buttons/button.component.html b/client/src/app/shared/shared-main/buttons/button.component.html
new file mode 100644
index 000000000..d2b0eb81a
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/button.component.html
@@ -0,0 +1,6 @@
1<span class="action-button" [ngClass]="className" [title]="getTitle()">
2 <my-global-icon *ngIf="!loading" [iconName]="icon"></my-global-icon>
3 <my-small-loader [loading]="loading"></my-small-loader>
4
5 <span class="button-label">{{ label }}</span>
6</span>
diff --git a/client/src/app/shared/shared-main/buttons/button.component.scss b/client/src/app/shared/shared-main/buttons/button.component.scss
new file mode 100644
index 000000000..3ccfefd7e
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/button.component.scss
@@ -0,0 +1,46 @@
1@import '_variables';
2@import '_mixins';
3
4my-small-loader ::ng-deep .root {
5 display: inline-block;
6 margin: 0 3px 0 0;
7 width: 20px;
8}
9
10.action-button {
11 @include peertube-button-link;
12 @include button-with-icon(21px, 0, -2px);
13}
14
15.orange-button {
16 @include peertube-button;
17 @include orange-button;
18}
19
20.orange-button-link {
21 @include peertube-button-link;
22 @include orange-button;
23}
24
25.grey-button {
26 @include peertube-button;
27 @include grey-button;
28}
29
30.grey-button-link {
31 @include peertube-button-link;
32 @include grey-button;
33}
34
35// In a table, try to minimize the space taken by this button
36@media screen and (max-width: 1400px) {
37 :host-context(td) {
38 .action-button {
39 padding: 0 13px;
40 }
41
42 .button-label {
43 display: none;
44 }
45 }
46}
diff --git a/client/src/app/shared/shared-main/buttons/button.component.ts b/client/src/app/shared/shared-main/buttons/button.component.ts
new file mode 100644
index 000000000..e23b90945
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/button.component.ts
@@ -0,0 +1,20 @@
1import { Component, Input } from '@angular/core'
2import { GlobalIconName } from '@app/shared/shared-icons'
3
4@Component({
5 selector: 'my-button',
6 styleUrls: ['./button.component.scss'],
7 templateUrl: './button.component.html'
8})
9
10export class ButtonComponent {
11 @Input() label = ''
12 @Input() className = 'grey-button'
13 @Input() icon: GlobalIconName = undefined
14 @Input() title: string = undefined
15 @Input() loading = false
16
17 getTitle () {
18 return this.title || this.label
19 }
20}
diff --git a/client/src/app/shared/shared-main/buttons/delete-button.component.html b/client/src/app/shared/shared-main/buttons/delete-button.component.html
new file mode 100644
index 000000000..398b6db1e
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/delete-button.component.html
@@ -0,0 +1,6 @@
1<span class="action-button action-button-delete grey-button" [title]="title" role="button">
2 <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
3
4 <span class="button-label" *ngIf="label">{{ label }}</span>
5 <span class="button-label" i18n *ngIf="!label">Delete</span>
6</span>
diff --git a/client/src/app/shared/shared-main/buttons/delete-button.component.ts b/client/src/app/shared/shared-main/buttons/delete-button.component.ts
new file mode 100644
index 000000000..39e31900f
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/delete-button.component.ts
@@ -0,0 +1,20 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { I18n } from '@ngx-translate/i18n-polyfill'
3
4@Component({
5 selector: 'my-delete-button',
6 styleUrls: [ './button.component.scss' ],
7 templateUrl: './delete-button.component.html'
8})
9
10export class DeleteButtonComponent implements OnInit {
11 @Input() label: string
12
13 title: string
14
15 constructor (private i18n: I18n) { }
16
17 ngOnInit () {
18 this.title = this.label || this.i18n('Delete')
19 }
20}
diff --git a/client/src/app/shared/shared-main/buttons/edit-button.component.html b/client/src/app/shared/shared-main/buttons/edit-button.component.html
new file mode 100644
index 000000000..b852bb38a
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/edit-button.component.html
@@ -0,0 +1,6 @@
1<a class="action-button action-button-edit grey-button" [routerLink]="routerLink" i18n-title title="Edit">
2 <my-global-icon iconName="edit" aria-hidden="true"></my-global-icon>
3
4 <span class="button-label" *ngIf="label">{{ label }}</span>
5 <span i18n class="button-label" *ngIf="!label">Edit</span>
6</a>
diff --git a/client/src/app/shared/shared-main/buttons/edit-button.component.ts b/client/src/app/shared/shared-main/buttons/edit-button.component.ts
new file mode 100644
index 000000000..9cfe1a3bb
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/edit-button.component.ts
@@ -0,0 +1,12 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-edit-button',
5 styleUrls: [ './button.component.scss' ],
6 templateUrl: './edit-button.component.html'
7})
8
9export class EditButtonComponent {
10 @Input() label: string
11 @Input() routerLink: string[] | string = []
12}
diff --git a/client/src/app/shared/shared-main/buttons/index.ts b/client/src/app/shared/shared-main/buttons/index.ts
new file mode 100644
index 000000000..775a47a39
--- /dev/null
+++ b/client/src/app/shared/shared-main/buttons/index.ts
@@ -0,0 +1,4 @@
1export * from './action-dropdown.component'
2export * from './button.component'
3export * from './delete-button.component'
4export * from './edit-button.component'
diff --git a/client/src/app/shared/shared-main/date/date-toggle.component.html b/client/src/app/shared/shared-main/date/date-toggle.component.html
new file mode 100644
index 000000000..ebd4ce442
--- /dev/null
+++ b/client/src/app/shared/shared-main/date/date-toggle.component.html
@@ -0,0 +1,6 @@
1<span
2 class="date-toggle"
3 [title]="getTitle()"
4 [innerHtml]="getContent()"
5 (click)="toggle()"
6></span>
diff --git a/client/src/app/shared/shared-main/date/date-toggle.component.scss b/client/src/app/shared/shared-main/date/date-toggle.component.scss
new file mode 100644
index 000000000..86700d1d4
--- /dev/null
+++ b/client/src/app/shared/shared-main/date/date-toggle.component.scss
@@ -0,0 +1,5 @@
1.date-toggle {
2 &:hover {
3 cursor: default
4 }
5}
diff --git a/client/src/app/shared/shared-main/date/date-toggle.component.ts b/client/src/app/shared/shared-main/date/date-toggle.component.ts
new file mode 100644
index 000000000..bedf0ba4e
--- /dev/null
+++ b/client/src/app/shared/shared-main/date/date-toggle.component.ts
@@ -0,0 +1,46 @@
1import { DatePipe } from '@angular/common'
2import { Component, Input, OnChanges, OnInit } from '@angular/core'
3import { FromNowPipe } from '../angular/from-now.pipe'
4
5@Component({
6 selector: 'my-date-toggle',
7 templateUrl: './date-toggle.component.html',
8 styleUrls: [ './date-toggle.component.scss' ]
9})
10export class DateToggleComponent implements OnInit, OnChanges {
11 @Input() date: Date
12 @Input() toggled = false
13
14 dateRelative: string
15 dateAbsolute: string
16
17 constructor (
18 private datePipe: DatePipe,
19 private fromNowPipe: FromNowPipe
20 ) { }
21
22 ngOnInit () {
23 this.updateDates()
24 }
25
26 ngOnChanges () {
27 this.updateDates()
28 }
29
30 toggle () {
31 this.toggled = !this.toggled
32 }
33
34 getTitle () {
35 return this.toggled ? this.dateRelative : this.dateAbsolute
36 }
37
38 getContent () {
39 return this.toggled ? this.dateAbsolute : this.dateRelative
40 }
41
42 private updateDates () {
43 this.dateRelative = this.fromNowPipe.transform(this.date)
44 this.dateAbsolute = this.datePipe.transform(this.date, 'long')
45 }
46}
diff --git a/client/src/app/shared/shared-main/date/index.ts b/client/src/app/shared/shared-main/date/index.ts
new file mode 100644
index 000000000..db00aef52
--- /dev/null
+++ b/client/src/app/shared/shared-main/date/index.ts
@@ -0,0 +1 @@
export * from './date-toggle.component'
diff --git a/client/src/app/shared/shared-main/feeds/feed.component.html b/client/src/app/shared/shared-main/feeds/feed.component.html
new file mode 100644
index 000000000..ac0b1f454
--- /dev/null
+++ b/client/src/app/shared/shared-main/feeds/feed.component.html
@@ -0,0 +1,15 @@
1<div class="video-feed"
2 [ngbTooltip]="'Feeds available'"
3 placement="right auto"
4 container="body"
5>
6 <my-global-icon
7 *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom"
8 class="icon-syndication" role="button" iconName="syndication"
9 >
10 </my-global-icon>
11
12 <ng-template #feedsList>
13 <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a>
14 </ng-template>
15</div>
diff --git a/client/src/app/shared/shared-main/feeds/feed.component.scss b/client/src/app/shared/shared-main/feeds/feed.component.scss
new file mode 100644
index 000000000..34dd0e937
--- /dev/null
+++ b/client/src/app/shared/shared-main/feeds/feed.component.scss
@@ -0,0 +1,20 @@
1@import '_variables';
2@import '_mixins';
3
4.video-feed {
5 width: min-content;
6
7 a {
8 color: black;
9 display: block;
10 }
11
12 my-global-icon {
13 cursor: pointer;
14 width: 12px;
15 position: relative;
16 top: -2px;
17
18 @include apply-svg-color(pvar(--mainForegroundColor))
19 }
20}
diff --git a/client/src/app/shared/shared-main/feeds/feed.component.ts b/client/src/app/shared/shared-main/feeds/feed.component.ts
new file mode 100644
index 000000000..ee3731c1d
--- /dev/null
+++ b/client/src/app/shared/shared-main/feeds/feed.component.ts
@@ -0,0 +1,11 @@
1import { Component, Input } from '@angular/core'
2import { Syndication } from './syndication.model'
3
4@Component({
5 selector: 'my-feed',
6 styleUrls: [ './feed.component.scss' ],
7 templateUrl: './feed.component.html'
8})
9export class FeedComponent {
10 @Input() syndicationItems: Syndication[]
11}
diff --git a/client/src/app/shared/shared-main/feeds/index.ts b/client/src/app/shared/shared-main/feeds/index.ts
new file mode 100644
index 000000000..6bc396699
--- /dev/null
+++ b/client/src/app/shared/shared-main/feeds/index.ts
@@ -0,0 +1,2 @@
1export * from './feed.component'
2export * from './syndication.model'
diff --git a/client/src/app/shared/shared-main/feeds/syndication.model.ts b/client/src/app/shared/shared-main/feeds/syndication.model.ts
new file mode 100644
index 000000000..2466ae7c6
--- /dev/null
+++ b/client/src/app/shared/shared-main/feeds/syndication.model.ts
@@ -0,0 +1,7 @@
1import { FeedFormat } from '@shared/models'
2
3export interface Syndication {
4 format: FeedFormat,
5 label: string,
6 url: string
7}
diff --git a/client/src/app/shared/shared-main/index.ts b/client/src/app/shared/shared-main/index.ts
new file mode 100644
index 000000000..a4d813c06
--- /dev/null
+++ b/client/src/app/shared/shared-main/index.ts
@@ -0,0 +1,12 @@
1export * from './account'
2export * from './angular'
3export * from './buttons'
4export * from './date'
5export * from './feeds'
6export * from './loaders'
7export * from './misc'
8export * from './users'
9export * from './video'
10export * from './video-caption'
11export * from './video-channel'
12export * from './shared-main.module'
diff --git a/client/src/app/shared/shared-main/loaders/index.ts b/client/src/app/shared/shared-main/loaders/index.ts
new file mode 100644
index 000000000..a061914d5
--- /dev/null
+++ b/client/src/app/shared/shared-main/loaders/index.ts
@@ -0,0 +1,2 @@
1export * from './loader.component'
2export * from './small-loader.component'
diff --git a/client/src/app/shared/shared-main/loaders/loader.component.html b/client/src/app/shared/shared-main/loaders/loader.component.html
new file mode 100644
index 000000000..ca8ed063e
--- /dev/null
+++ b/client/src/app/shared/shared-main/loaders/loader.component.html
@@ -0,0 +1,8 @@
1<div *ngIf="loading">
2 <div class="loader">
3 <div></div>
4 <div></div>
5 <div></div>
6 <div></div>
7 </div>
8</div>
diff --git a/client/src/app/shared/shared-main/loaders/loader.component.scss b/client/src/app/shared/shared-main/loaders/loader.component.scss
new file mode 100644
index 000000000..ffac9c707
--- /dev/null
+++ b/client/src/app/shared/shared-main/loaders/loader.component.scss
@@ -0,0 +1,45 @@
1@import '_variables';
2@import '_mixins';
3
4// Thanks to https://loading.io/css/ (CC0 License)
5
6.loader {
7 display: inline-block;
8 position: relative;
9 width: 50px;
10 height: 50px;
11}
12
13.loader div {
14 box-sizing: border-box;
15 display: block;
16 position: absolute;
17 width: 44px;
18 height: 44px;
19 margin: 6px;
20 border: 4px solid;
21 border-radius: 50%;
22 animation: loader 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
23 border-color: #999999 transparent transparent transparent;
24}
25
26.loader div:nth-child(1) {
27 animation-delay: -0.45s;
28}
29
30.loader div:nth-child(2) {
31 animation-delay: -0.3s;
32}
33
34.loader div:nth-child(3) {
35 animation-delay: -0.15s;
36}
37
38@keyframes loader {
39 0% {
40 transform: rotate(0deg);
41 }
42 100% {
43 transform: rotate(360deg);
44 }
45}
diff --git a/client/src/app/shared/shared-main/loaders/loader.component.ts b/client/src/app/shared/shared-main/loaders/loader.component.ts
new file mode 100644
index 000000000..e3b1eea3a
--- /dev/null
+++ b/client/src/app/shared/shared-main/loaders/loader.component.ts
@@ -0,0 +1,10 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-loader',
5 styleUrls: [ './loader.component.scss' ],
6 templateUrl: './loader.component.html'
7})
8export class LoaderComponent {
9 @Input() loading: boolean
10}
diff --git a/client/src/app/shared/shared-main/loaders/small-loader.component.html b/client/src/app/shared/shared-main/loaders/small-loader.component.html
new file mode 100644
index 000000000..7886f8918
--- /dev/null
+++ b/client/src/app/shared/shared-main/loaders/small-loader.component.html
@@ -0,0 +1,3 @@
1<div class="root" *ngIf="loading">
2 <div class="glyphicon glyphicon-refresh glyphicon-refresh-animate"></div>
3</div>
diff --git a/client/src/app/shared/shared-main/loaders/small-loader.component.ts b/client/src/app/shared/shared-main/loaders/small-loader.component.ts
new file mode 100644
index 000000000..191877f14
--- /dev/null
+++ b/client/src/app/shared/shared-main/loaders/small-loader.component.ts
@@ -0,0 +1,11 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-small-loader',
5 styleUrls: [ ],
6 templateUrl: './small-loader.component.html'
7})
8
9export class SmallLoaderComponent {
10 @Input() loading: boolean
11}
diff --git a/client/src/app/shared/shared-main/misc/help.component.html b/client/src/app/shared/shared-main/misc/help.component.html
new file mode 100644
index 000000000..9a6d3e48e
--- /dev/null
+++ b/client/src/app/shared/shared-main/misc/help.component.html
@@ -0,0 +1,40 @@
1<ng-template #tooltipTemplate>
2 <p *ngIf="preHtmlTemplate">
3 <ng-template *ngTemplateOutlet="preHtmlTemplate"></ng-template>
4 </p>
5
6 <ng-container *ngIf="preHtmlTemplate && (customHtmlTemplate || mainHtml || postHtmlTemplate)">
7 <br /><br />
8 </ng-container>
9
10 <p *ngIf="customHtmlTemplate">
11 <ng-template *ngTemplateOutlet="customHtmlTemplate"></ng-template>
12 </p>
13
14 <p *ngIf="mainHtml" [innerHTML]="mainHtml"></p>
15
16 <ng-container *ngIf="(customHtmlTemplate || mainHtml) && postHtmlTemplate">
17 <br /><br />
18 </ng-container>
19
20 <p *ngIf="postHtmlTemplate">
21 <ng-template *ngTemplateOutlet="postHtmlTemplate"></ng-template>
22 </p>
23</ng-template>
24
25<span
26 role="button"
27 class="help-tooltip-button"
28 container="body"
29 title="Get help"
30 i18n-title
31 popoverClass="help-popover"
32 [attr.aria-pressed]="isPopoverOpened"
33 [ngbPopover]="tooltipTemplate"
34 [placement]="tooltipPlacement"
35 autoClose="outside"
36 (onHidden)="onPopoverHidden()"
37 (onShown)="onPopoverShown()"
38>
39 <my-global-icon iconName="help"></my-global-icon>
40</span>
diff --git a/client/src/app/shared/shared-main/misc/help.component.scss b/client/src/app/shared/shared-main/misc/help.component.scss
new file mode 100644
index 000000000..43f33a53a
--- /dev/null
+++ b/client/src/app/shared/shared-main/misc/help.component.scss
@@ -0,0 +1,42 @@
1@import '_variables';
2@import '_mixins';
3
4.help-tooltip-button {
5 cursor: pointer;
6 border: none;
7
8 my-global-icon {
9 width: 17px;
10 position: relative;
11 top: -2px;
12 margin: 5px;
13
14 @include apply-svg-color(pvar(--mainForegroundColor))
15 }
16}
17
18::ng-deep {
19 .help-popover {
20 z-index: z(help-popover) !important;
21 max-width: 300px;
22
23 .popover-body {
24 font-family: $main-fonts;
25 text-align: left;
26 padding: 10px;
27 font-size: 13px;
28 background-color: pvar(--mainBackgroundColor);
29 color: pvar(--mainForegroundColor);
30 border-radius: 3px;
31
32 p {
33 margin-bottom: 0;
34 }
35
36 ul {
37 padding-left: 20px;
38 margin-bottom: 0;
39 }
40 }
41 }
42}
diff --git a/client/src/app/shared/shared-main/misc/help.component.ts b/client/src/app/shared/shared-main/misc/help.component.ts
new file mode 100644
index 000000000..0825b96de
--- /dev/null
+++ b/client/src/app/shared/shared-main/misc/help.component.ts
@@ -0,0 +1,94 @@
1import { AfterContentInit, Component, ContentChildren, Input, OnChanges, OnInit, QueryList, TemplateRef } from '@angular/core'
2import { MarkdownService } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { PeerTubeTemplateDirective } from '../angular'
5
6@Component({
7 selector: 'my-help',
8 styleUrls: [ './help.component.scss' ],
9 templateUrl: './help.component.html'
10})
11
12export class HelpComponent implements OnInit, OnChanges, AfterContentInit {
13 @Input() helpType: 'custom' | 'markdownText' | 'markdownEnhanced' = 'custom'
14 @Input() tooltipPlacement = 'right auto'
15
16 @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'preHtml' | 'customHtml' | 'postHtml'>>
17
18 isPopoverOpened = false
19 mainHtml = ''
20
21 preHtmlTemplate: TemplateRef<any>
22 customHtmlTemplate: TemplateRef<any>
23 postHtmlTemplate: TemplateRef<any>
24
25 constructor (private i18n: I18n) { }
26
27 ngOnInit () {
28 this.init()
29 }
30
31 ngAfterContentInit () {
32 {
33 const t = this.templates.find(t => t.name === 'preHtml')
34 if (t) this.preHtmlTemplate = t.template
35 }
36
37 {
38 const t = this.templates.find(t => t.name === 'customHtml')
39 if (t) this.customHtmlTemplate = t.template
40 }
41
42 {
43 const t = this.templates.find(t => t.name === 'postHtml')
44 if (t) this.postHtmlTemplate = t.template
45 }
46 }
47
48 ngOnChanges () {
49 this.init()
50 }
51
52 onPopoverHidden () {
53 this.isPopoverOpened = false
54 }
55
56 onPopoverShown () {
57 this.isPopoverOpened = true
58 }
59
60 private init () {
61 if (this.helpType === 'markdownText') {
62 this.mainHtml = this.formatMarkdownSupport(MarkdownService.TEXT_RULES)
63 return
64 }
65
66 if (this.helpType === 'markdownEnhanced') {
67 this.mainHtml = this.formatMarkdownSupport(MarkdownService.ENHANCED_RULES)
68 return
69 }
70 }
71
72 private formatMarkdownSupport (rules: string[]) {
73 // tslint:disable:max-line-length
74 return this.i18n('<a href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noopener noreferrer">Markdown</a> compatible that supports:') +
75 this.createMarkdownList(rules)
76 }
77
78 private createMarkdownList (rules: string[]) {
79 const rulesToText = {
80 'emphasis': this.i18n('Emphasis'),
81 'link': this.i18n('Links'),
82 'newline': this.i18n('New lines'),
83 'list': this.i18n('Lists'),
84 'image': this.i18n('Images')
85 }
86
87 const bullets = rules.map(r => rulesToText[r])
88 .filter(text => text)
89 .map(text => '<li>' + text + '</li>')
90 .join('')
91
92 return '<ul>' + bullets + '</ul>'
93 }
94}
diff --git a/client/src/app/shared/shared-main/misc/index.ts b/client/src/app/shared/shared-main/misc/index.ts
new file mode 100644
index 000000000..d3e7e4be7
--- /dev/null
+++ b/client/src/app/shared/shared-main/misc/index.ts
@@ -0,0 +1,2 @@
1export * from './help.component'
2export * from './list-overflow.component'
diff --git a/client/src/app/shared/shared-main/misc/list-overflow.component.html b/client/src/app/shared/shared-main/misc/list-overflow.component.html
new file mode 100644
index 000000000..986572801
--- /dev/null
+++ b/client/src/app/shared/shared-main/misc/list-overflow.component.html
@@ -0,0 +1,35 @@
1<div #itemsParent class="d-flex align-items-center text-nowrap w-100 list-overflow-parent">
2 <span [id]="getId(id)" #itemsRendered *ngFor="let item of items; index as id">
3 <ng-container *ngTemplateOutlet="itemTemplate; context: {item: item}"></ng-container>
4 </span>
5
6 <ng-container *ngIf="isMenuDisplayed()">
7 <button *ngIf="isInMobileView" class="btn btn-outline-secondary btn-sm list-overflow-menu" (click)="toggleModal()">
8 <span class="glyphicon glyphicon-chevron-down"></span>
9 </button>
10
11 <div *ngIf="!isInMobileView" class="list-overflow-menu" ngbDropdown container="body" #dropdown="ngbDropdown" (mouseleave)="closeDropdownIfHovered(dropdown)" (mouseenter)="openDropdownOnHover(dropdown)">
12 <button class="btn btn-outline-secondary btn-sm" [ngClass]="{ routeActive: active }"
13 ngbDropdownAnchor (click)="dropdownAnchorClicked(dropdown)" role="button"
14 >
15 <span class="glyphicon glyphicon-chevron-down"></span>
16 </button>
17
18 <div ngbDropdownMenu>
19 <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
20 [routerLink]="item.routerLink" routerLinkActive="active" class="dropdown-item">
21 {{ item.label }}
22 </a>
23 </div>
24 </div>
25 </ng-container>
26</div >
27
28<ng-template #modal let-close="close" let-dismiss="dismiss">
29 <div class="modal-body">
30 <a *ngFor="let item of items | slice:showItemsUntilIndexExcluded:items.length"
31 [routerLink]="item.routerLink" routerLinkActive="active" (click)="dismissOtherModals()">
32 {{ item.label }}
33 </a>
34 </div>
35</ng-template>
diff --git a/client/src/app/shared/shared-main/misc/list-overflow.component.scss b/client/src/app/shared/shared-main/misc/list-overflow.component.scss
new file mode 100644
index 000000000..1ec044489
--- /dev/null
+++ b/client/src/app/shared/shared-main/misc/list-overflow.component.scss
@@ -0,0 +1,61 @@
1@import '_mixins';
2
3:host {
4 width: 100%;
5}
6
7.list-overflow-parent {
8 overflow: hidden;
9}
10
11.list-overflow-menu {
12 position: absolute;
13 right: 25px;
14}
15
16button {
17 width: 30px;
18 border: none;
19
20 &::after {
21 display: none;
22 }
23
24 &.routeActive {
25 &::after {
26 display: inherit;
27 border: 2px solid pvar(--mainColor);
28 position: relative;
29 right: 95%;
30 top: 50%;
31 }
32 }
33}
34
35::ng-deep .dropdown-menu {
36 margin-top: 0 !important;
37 position: static;
38 right: auto;
39 bottom: auto
40}
41
42.modal-body {
43 a {
44 @include disable-default-a-behaviour;
45
46 color: currentColor;
47 box-sizing: border-box;
48 display: block;
49 font-size: 1.2rem;
50 padding: 9px 12px;
51 text-align: initial;
52 text-transform: unset;
53 width: 100%;
54
55 &.active {
56 color: pvar(--mainBackgroundColor) !important;
57 background-color: pvar(--mainHoverColor);
58 opacity: .9;
59 }
60 }
61}
diff --git a/client/src/app/shared/shared-main/misc/list-overflow.component.ts b/client/src/app/shared/shared-main/misc/list-overflow.component.ts
new file mode 100644
index 000000000..144e0f156
--- /dev/null
+++ b/client/src/app/shared/shared-main/misc/list-overflow.component.ts
@@ -0,0 +1,120 @@
1import { lowerFirst, uniqueId } from 'lodash-es'
2import { take } from 'rxjs/operators'
3import {
4 AfterViewInit,
5 ChangeDetectionStrategy,
6 ChangeDetectorRef,
7 Component,
8 ElementRef,
9 HostListener,
10 Input,
11 QueryList,
12 TemplateRef,
13 ViewChild,
14 ViewChildren
15} from '@angular/core'
16import { ScreenService } from '@app/core'
17import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap'
18
19export interface ListOverflowItem {
20 label: string
21 routerLink: string | any[]
22}
23
24@Component({
25 selector: 'list-overflow',
26 templateUrl: './list-overflow.component.html',
27 styleUrls: [ './list-overflow.component.scss' ],
28 changeDetection: ChangeDetectionStrategy.OnPush
29})
30export class ListOverflowComponent<T extends ListOverflowItem> implements AfterViewInit {
31 @Input() items: T[]
32 @Input() itemTemplate: TemplateRef<{item: T}>
33
34 @ViewChild('modal', { static: true }) modal: ElementRef
35 @ViewChild('itemsParent', { static: true }) parent: ElementRef<HTMLDivElement>
36 @ViewChildren('itemsRendered') itemsRendered: QueryList<ElementRef>
37
38 showItemsUntilIndexExcluded: number
39 active = false
40 isInTouchScreen = false
41 isInMobileView = false
42
43 private openedOnHover = false
44
45 constructor (
46 private cdr: ChangeDetectorRef,
47 private modalService: NgbModal,
48 private screenService: ScreenService
49 ) {}
50
51 ngAfterViewInit () {
52 setTimeout(() => this.onWindowResize(), 0)
53 }
54
55 isMenuDisplayed () {
56 return !!this.showItemsUntilIndexExcluded
57 }
58
59 @HostListener('window:resize')
60 onWindowResize () {
61 this.isInTouchScreen = !!this.screenService.isInTouchScreen()
62 this.isInMobileView = !!this.screenService.isInMobileView()
63
64 const parentWidth = this.parent.nativeElement.getBoundingClientRect().width
65 let showItemsUntilIndexExcluded: number
66 let accWidth = 0
67
68 for (const [index, el] of this.itemsRendered.toArray().entries()) {
69 accWidth += el.nativeElement.getBoundingClientRect().width
70 if (showItemsUntilIndexExcluded === undefined) {
71 showItemsUntilIndexExcluded = (parentWidth < accWidth) ? index : undefined
72 }
73
74 const e = document.getElementById(this.getId(index))
75 const shouldBeVisible = showItemsUntilIndexExcluded ? index < showItemsUntilIndexExcluded : true
76 e.style.visibility = shouldBeVisible ? 'inherit' : 'hidden'
77 }
78
79 this.showItemsUntilIndexExcluded = showItemsUntilIndexExcluded
80 this.cdr.markForCheck()
81 }
82
83 openDropdownOnHover (dropdown: NgbDropdown) {
84 this.openedOnHover = true
85 dropdown.open()
86
87 // Menu was closed
88 dropdown.openChange
89 .pipe(take(1))
90 .subscribe(() => this.openedOnHover = false)
91 }
92
93 dropdownAnchorClicked (dropdown: NgbDropdown) {
94 if (this.openedOnHover) {
95 this.openedOnHover = false
96 return
97 }
98
99 return dropdown.toggle()
100 }
101
102 closeDropdownIfHovered (dropdown: NgbDropdown) {
103 if (this.openedOnHover === false) return
104
105 dropdown.close()
106 this.openedOnHover = false
107 }
108
109 toggleModal () {
110 this.modalService.open(this.modal, { centered: true })
111 }
112
113 dismissOtherModals () {
114 this.modalService.dismissAll()
115 }
116
117 getId (id: number | string = uniqueId()): string {
118 return lowerFirst(this.constructor.name) + '_' + id
119 }
120}
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
new file mode 100644
index 000000000..fd96a42a0
--- /dev/null
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -0,0 +1,164 @@
1import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
2import { SharedModule as PrimeSharedModule } from 'primeng/api'
3import { InputMaskModule } from 'primeng/inputmask'
4import { InputSwitchModule } from 'primeng/inputswitch'
5import { MultiSelectModule } from 'primeng/multiselect'
6import { ClipboardModule } from '@angular/cdk/clipboard'
7import { CommonModule, DatePipe } from '@angular/common'
8import { HttpClientModule } from '@angular/common/http'
9import { NgModule } from '@angular/core'
10import { FormsModule, ReactiveFormsModule } from '@angular/forms'
11import { RouterModule } from '@angular/router'
12import {
13 NgbCollapseModule,
14 NgbDropdownModule,
15 NgbModalModule,
16 NgbNavModule,
17 NgbPopoverModule,
18 NgbTooltipModule
19} from '@ng-bootstrap/ng-bootstrap'
20import { I18n } from '@ngx-translate/i18n-polyfill'
21import { SharedGlobalIconModule } from '../shared-icons'
22import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account'
23import { FromNowPipe, InfiniteScrollerDirective, NumberFormatterPipe, PeerTubeTemplateDirective } from './angular'
24import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
25import { DateToggleComponent } from './date'
26import { FeedComponent } from './feeds'
27import { LoaderComponent, SmallLoaderComponent } from './loaders'
28import { HelpComponent, ListOverflowComponent } from './misc'
29import { UserHistoryService, UserNotificationsComponent, UserNotificationService } from './users'
30import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
31import { VideoCaptionService } from './video-caption'
32import { VideoChannelService } from './video-channel'
33import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
34
35@NgModule({
36 imports: [
37 CommonModule,
38 FormsModule,
39 ReactiveFormsModule,
40 RouterModule,
41 HttpClientModule,
42
43 NgbDropdownModule,
44 NgbModalModule,
45 NgbPopoverModule,
46 NgbNavModule,
47 NgbTooltipModule,
48 NgbCollapseModule,
49
50 ClipboardModule,
51
52 PrimeSharedModule,
53 InputMaskModule,
54 NgPipesModule,
55 MultiSelectModule,
56 InputSwitchModule,
57
58 SharedGlobalIconModule
59 ],
60
61 declarations: [
62 AvatarComponent,
63 ActorAvatarInfoComponent,
64
65 FromNowPipe,
66 InfiniteScrollerDirective,
67 NumberFormatterPipe,
68 PeerTubeTemplateDirective,
69
70 ActionDropdownComponent,
71 ButtonComponent,
72 DeleteButtonComponent,
73 EditButtonComponent,
74
75 DateToggleComponent,
76
77 FeedComponent,
78
79 LoaderComponent,
80 SmallLoaderComponent,
81
82 HelpComponent,
83 ListOverflowComponent,
84
85 UserNotificationsComponent,
86
87 FeedComponent
88 ],
89
90 exports: [
91 CommonModule,
92 FormsModule,
93 ReactiveFormsModule,
94 RouterModule,
95 HttpClientModule,
96
97 NgbDropdownModule,
98 NgbModalModule,
99 NgbPopoverModule,
100 NgbNavModule,
101 NgbTooltipModule,
102 NgbCollapseModule,
103
104 ClipboardModule,
105
106 PrimeSharedModule,
107 InputMaskModule,
108 BytesPipe,
109 KeysPipe,
110 MultiSelectModule,
111
112 AvatarComponent,
113 ActorAvatarInfoComponent,
114
115 FromNowPipe,
116 InfiniteScrollerDirective,
117 NumberFormatterPipe,
118 PeerTubeTemplateDirective,
119
120 ActionDropdownComponent,
121 ButtonComponent,
122 DeleteButtonComponent,
123 EditButtonComponent,
124
125 DateToggleComponent,
126
127 FeedComponent,
128
129 LoaderComponent,
130 SmallLoaderComponent,
131
132 HelpComponent,
133 ListOverflowComponent,
134
135 UserNotificationsComponent,
136
137 FeedComponent
138 ],
139
140 providers: [
141 I18n,
142
143 DatePipe,
144
145 FromNowPipe,
146
147 AUTH_INTERCEPTOR_PROVIDER,
148
149 AccountService,
150
151 UserHistoryService,
152 UserNotificationService,
153
154 RedundancyService,
155 VideoImportService,
156 VideoOwnershipService,
157 VideoService,
158
159 VideoCaptionService,
160
161 VideoChannelService
162 ]
163})
164export class SharedMainModule { }
diff --git a/client/src/app/shared/shared-main/users/index.ts b/client/src/app/shared/shared-main/users/index.ts
new file mode 100644
index 000000000..83401ab52
--- /dev/null
+++ b/client/src/app/shared/shared-main/users/index.ts
@@ -0,0 +1,4 @@
1export * from './user-history.service'
2export * from './user-notification.model'
3export * from './user-notification.service'
4export * from './user-notifications.component'
diff --git a/client/src/app/shared/shared-main/users/user-history.service.ts b/client/src/app/shared/shared-main/users/user-history.service.ts
new file mode 100644
index 000000000..43970dc5b
--- /dev/null
+++ b/client/src/app/shared/shared-main/users/user-history.service.ts
@@ -0,0 +1,43 @@
1import { catchError, map, switchMap } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
5import { ResultList } from '@shared/models'
6import { environment } from '../../../../environments/environment'
7import { Video } from '../video/video.model'
8import { VideoService } from '../video/video.service'
9
10@Injectable()
11export class UserHistoryService {
12 static BASE_USER_VIDEOS_HISTORY_URL = environment.apiUrl + '/api/v1/users/me/history/videos'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restExtractor: RestExtractor,
17 private restService: RestService,
18 private videoService: VideoService
19 ) {}
20
21 getUserVideosHistory (historyPagination: ComponentPaginationLight) {
22 const pagination = this.restService.componentPaginationToRestPagination(historyPagination)
23
24 let params = new HttpParams()
25 params = this.restService.addRestGetParams(params, pagination)
26
27 return this.authHttp
28 .get<ResultList<Video>>(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL, { params })
29 .pipe(
30 switchMap(res => this.videoService.extractVideos(res)),
31 catchError(err => this.restExtractor.handleError(err))
32 )
33 }
34
35 deleteUserVideosHistory () {
36 return this.authHttp
37 .post(UserHistoryService.BASE_USER_VIDEOS_HISTORY_URL + '/remove', {})
38 .pipe(
39 map(() => this.restExtractor.extractDataBool()),
40 catchError(err => this.restExtractor.handleError(err))
41 )
42 }
43}
diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts
new file mode 100644
index 000000000..de25d3ab9
--- /dev/null
+++ b/client/src/app/shared/shared-main/users/user-notification.model.ts
@@ -0,0 +1,184 @@
1import { Actor } from '../account/actor.model'
2import { ActorInfo, Avatar, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '@shared/models'
3
4export class UserNotification implements UserNotificationServer {
5 id: number
6 type: UserNotificationType
7 read: boolean
8
9 video?: VideoInfo & {
10 channel: ActorInfo & { avatarUrl?: string }
11 }
12
13 videoImport?: {
14 id: number
15 video?: VideoInfo
16 torrentName?: string
17 magnetUri?: string
18 targetUrl?: string
19 }
20
21 comment?: {
22 id: number
23 threadId: number
24 account: ActorInfo & { avatarUrl?: string }
25 video: VideoInfo
26 }
27
28 videoAbuse?: {
29 id: number
30 video: VideoInfo
31 }
32
33 videoBlacklist?: {
34 id: number
35 video: VideoInfo
36 }
37
38 account?: ActorInfo & { avatarUrl?: string }
39
40 actorFollow?: {
41 id: number
42 state: FollowState
43 follower: ActorInfo & { avatarUrl?: string }
44 following: {
45 type: 'account' | 'channel' | 'instance'
46 name: string
47 displayName: string
48 host: string
49 }
50 }
51
52 createdAt: string
53 updatedAt: string
54
55 // Additional fields
56 videoUrl?: string
57 commentUrl?: any[]
58 videoAbuseUrl?: string
59 videoAutoBlacklistUrl?: string
60 accountUrl?: string
61 videoImportIdentifier?: string
62 videoImportUrl?: string
63 instanceFollowUrl?: string
64
65 constructor (hash: UserNotificationServer) {
66 this.id = hash.id
67 this.type = hash.type
68 this.read = hash.read
69
70 // We assume that some fields exist
71 // To prevent a notification popup crash in case of bug, wrap it inside a try/catch
72 try {
73 this.video = hash.video
74 if (this.video) this.setAvatarUrl(this.video.channel)
75
76 this.videoImport = hash.videoImport
77
78 this.comment = hash.comment
79 if (this.comment) this.setAvatarUrl(this.comment.account)
80
81 this.videoAbuse = hash.videoAbuse
82
83 this.videoBlacklist = hash.videoBlacklist
84
85 this.account = hash.account
86 if (this.account) this.setAvatarUrl(this.account)
87
88 this.actorFollow = hash.actorFollow
89 if (this.actorFollow) this.setAvatarUrl(this.actorFollow.follower)
90
91 this.createdAt = hash.createdAt
92 this.updatedAt = hash.updatedAt
93
94 switch (this.type) {
95 case UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION:
96 this.videoUrl = this.buildVideoUrl(this.video)
97 break
98
99 case UserNotificationType.UNBLACKLIST_ON_MY_VIDEO:
100 this.videoUrl = this.buildVideoUrl(this.video)
101 break
102
103 case UserNotificationType.NEW_COMMENT_ON_MY_VIDEO:
104 case UserNotificationType.COMMENT_MENTION:
105 if (!this.comment) break
106 this.accountUrl = this.buildAccountUrl(this.comment.account)
107 this.commentUrl = [ this.buildVideoUrl(this.comment.video), { threadId: this.comment.threadId } ]
108 break
109
110 case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
111 this.videoAbuseUrl = '/admin/moderation/video-abuses/list'
112 this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
113 break
114
115 case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
116 this.videoAutoBlacklistUrl = '/admin/moderation/video-auto-blacklist/list'
117 // Backward compatibility where we did not assign videoBlacklist to this type of notification before
118 if (!this.videoBlacklist) this.videoBlacklist = { id: null, video: this.video }
119
120 this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
121 break
122
123 case UserNotificationType.BLACKLIST_ON_MY_VIDEO:
124 this.videoUrl = this.buildVideoUrl(this.videoBlacklist.video)
125 break
126
127 case UserNotificationType.MY_VIDEO_PUBLISHED:
128 this.videoUrl = this.buildVideoUrl(this.video)
129 break
130
131 case UserNotificationType.MY_VIDEO_IMPORT_SUCCESS:
132 this.videoImportUrl = this.buildVideoImportUrl()
133 this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
134
135 if (this.videoImport.video) this.videoUrl = this.buildVideoUrl(this.videoImport.video)
136 break
137
138 case UserNotificationType.MY_VIDEO_IMPORT_ERROR:
139 this.videoImportUrl = this.buildVideoImportUrl()
140 this.videoImportIdentifier = this.buildVideoImportIdentifier(this.videoImport)
141 break
142
143 case UserNotificationType.NEW_USER_REGISTRATION:
144 this.accountUrl = this.buildAccountUrl(this.account)
145 break
146
147 case UserNotificationType.NEW_FOLLOW:
148 this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
149 break
150
151 case UserNotificationType.NEW_INSTANCE_FOLLOWER:
152 this.instanceFollowUrl = '/admin/follows/followers-list'
153 break
154
155 case UserNotificationType.AUTO_INSTANCE_FOLLOWING:
156 this.instanceFollowUrl = '/admin/follows/following-list'
157 break
158 }
159 } catch (err) {
160 this.type = null
161 console.error(err)
162 }
163 }
164
165 private buildVideoUrl (video: { uuid: string }) {
166 return '/videos/watch/' + video.uuid
167 }
168
169 private buildAccountUrl (account: { name: string, host: string }) {
170 return '/accounts/' + Actor.CREATE_BY_STRING(account.name, account.host)
171 }
172
173 private buildVideoImportUrl () {
174 return '/my-account/video-imports'
175 }
176
177 private buildVideoImportIdentifier (videoImport: { targetUrl?: string, magnetUri?: string, torrentName?: string }) {
178 return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
179 }
180
181 private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) {
182 actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
183 }
184}
diff --git a/client/src/app/shared/shared-main/users/user-notification.service.ts b/client/src/app/shared/shared-main/users/user-notification.service.ts
new file mode 100644
index 000000000..8dd9472fe
--- /dev/null
+++ b/client/src/app/shared/shared-main/users/user-notification.service.ts
@@ -0,0 +1,81 @@
1import { catchError, map, tap } from 'rxjs/operators'
2import { HttpClient, HttpParams } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { ComponentPaginationLight, RestExtractor, RestService, User, UserNotificationSocket } from '@app/core'
5import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models'
6import { environment } from '../../../../environments/environment'
7import { UserNotification } from './user-notification.model'
8
9@Injectable()
10export class UserNotificationService {
11 static BASE_NOTIFICATIONS_URL = environment.apiUrl + '/api/v1/users/me/notifications'
12 static BASE_NOTIFICATION_SETTINGS = environment.apiUrl + '/api/v1/users/me/notification-settings'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restExtractor: RestExtractor,
17 private restService: RestService,
18 private userNotificationSocket: UserNotificationSocket
19 ) {}
20
21 listMyNotifications (pagination: ComponentPaginationLight, unread?: boolean, ignoreLoadingBar = false) {
22 let params = new HttpParams()
23 params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination))
24
25 if (unread) params = params.append('unread', `${unread}`)
26
27 const headers = ignoreLoadingBar ? { ignoreLoadingBar: '' } : undefined
28
29 return this.authHttp.get<ResultList<UserNotification>>(UserNotificationService.BASE_NOTIFICATIONS_URL, { params, headers })
30 .pipe(
31 map(res => this.restExtractor.convertResultListDateToHuman(res)),
32 map(res => this.restExtractor.applyToResultListData(res, this.formatNotification.bind(this))),
33 catchError(err => this.restExtractor.handleError(err))
34 )
35 }
36
37 countUnreadNotifications () {
38 return this.listMyNotifications({ currentPage: 1, itemsPerPage: 0 }, true)
39 .pipe(map(n => n.total))
40 }
41
42 markAsRead (notification: UserNotification) {
43 const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read'
44
45 const body = { ids: [ notification.id ] }
46 const headers = { ignoreLoadingBar: '' }
47
48 return this.authHttp.post(url, body, { headers })
49 .pipe(
50 map(this.restExtractor.extractDataBool),
51 tap(() => this.userNotificationSocket.dispatch('read')),
52 catchError(res => this.restExtractor.handleError(res))
53 )
54 }
55
56 markAllAsRead () {
57 const url = UserNotificationService.BASE_NOTIFICATIONS_URL + '/read-all'
58 const headers = { ignoreLoadingBar: '' }
59
60 return this.authHttp.post(url, {}, { headers })
61 .pipe(
62 map(this.restExtractor.extractDataBool),
63 tap(() => this.userNotificationSocket.dispatch('read-all')),
64 catchError(res => this.restExtractor.handleError(res))
65 )
66 }
67
68 updateNotificationSettings (user: User, settings: UserNotificationSetting) {
69 const url = UserNotificationService.BASE_NOTIFICATION_SETTINGS
70
71 return this.authHttp.put(url, settings)
72 .pipe(
73 map(this.restExtractor.extractDataBool),
74 catchError(res => this.restExtractor.handleError(res))
75 )
76 }
77
78 private formatNotification (notification: UserNotificationServer) {
79 return new UserNotification(notification)
80 }
81}
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html
new file mode 100644
index 000000000..08771110d
--- /dev/null
+++ b/client/src/app/shared/shared-main/users/user-notifications.component.html
@@ -0,0 +1,166 @@
1<div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div>
2
3<div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
4 <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)">
5
6 <ng-container [ngSwitch]="notification.type">
7 <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION">
8 <ng-container *ngIf="notification.video; then hasVideo; else noVideo"></ng-container>
9
10 <ng-template #hasVideo>
11 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
12 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.video.channel.avatarUrl" />
13 </a>
14
15 <div class="message" i18n>
16 {{ notification.video.channel.displayName }} published a new video: <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a>
17 </div>
18 </ng-template>
19
20 <ng-template #noVideo>
21 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
22
23 <div class="message" i18n>
24 The notification concerns a video now unavailable
25 </div>
26 </ng-template>
27 </ng-container>
28
29 <ng-container *ngSwitchCase="UserNotificationType.UNBLACKLIST_ON_MY_VIDEO">
30 <my-global-icon iconName="undo" aria-hidden="true"></my-global-icon>
31
32 <div class="message" i18n>
33 Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been unblocked
34 </div>
35 </ng-container>
36
37 <ng-container *ngSwitchCase="UserNotificationType.BLACKLIST_ON_MY_VIDEO">
38 <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
39
40 <div class="message" i18n>
41 Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.videoBlacklist.video.name }}</a> has been blocked
42 </div>
43 </ng-container>
44
45 <ng-container *ngSwitchCase="UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS">
46 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
47
48 <div class="message" i18n>
49 <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>
50 </div>
51 </ng-container>
52
53 <ng-container *ngSwitchCase="UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS">
54 <my-global-icon iconName="no" aria-hidden="true"></my-global-icon>
55
56 <div class="message" i18n>
57 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>
58 </div>
59 </ng-container>
60
61 <ng-container *ngSwitchCase="UserNotificationType.NEW_COMMENT_ON_MY_VIDEO">
62 <ng-container *ngIf="notification.comment; then hasComment; else noComment"></ng-container>
63
64 <ng-template #hasComment>
65 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
66 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
67 </a>
68
69 <div class="message" i18n>
70 <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>
71 </div>
72 </ng-template>
73
74 <ng-template #noComment>
75 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
76
77 <div class="message" i18n>
78 The notification concerns a comment now unavailable
79 </div>
80 </ng-template>
81 </ng-container>
82
83 <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_PUBLISHED">
84 <my-global-icon iconName="sparkle" aria-hidden="true"></my-global-icon>
85
86 <div class="message" i18n>
87 Your video <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl">{{ notification.video.name }}</a> has been published
88 </div>
89 </ng-container>
90
91 <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_SUCCESS">
92 <my-global-icon iconName="cloud-download" aria-hidden="true"></my-global-icon>
93
94 <div class="message" i18n>
95 <a (click)="markAsRead(notification)" [routerLink]="notification.videoUrl || notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} succeeded
96 </div>
97 </ng-container>
98
99 <ng-container *ngSwitchCase="UserNotificationType.MY_VIDEO_IMPORT_ERROR">
100 <my-global-icon iconName="cloud-error" aria-hidden="true"></my-global-icon>
101
102 <div class="message" i18n>
103 <a (click)="markAsRead(notification)" [routerLink]="notification.videoImportUrl">Your video import</a> {{ notification.videoImportIdentifier }} failed
104 </div>
105 </ng-container>
106
107 <ng-container *ngSwitchCase="UserNotificationType.NEW_USER_REGISTRATION">
108 <my-global-icon iconName="user-add" aria-hidden="true"></my-global-icon>
109
110 <div class="message" i18n>
111 User <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.account.name }}</a> registered on your instance
112 </div>
113 </ng-container>
114
115 <ng-container *ngSwitchCase="UserNotificationType.NEW_FOLLOW">
116 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
117 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.actorFollow.follower.avatarUrl" />
118 </a>
119
120 <div class="message" i18n>
121 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">{{ notification.actorFollow.follower.displayName }}</a> is following
122
123 <ng-container *ngIf="notification.actorFollow.following.type === 'channel'">your channel {{ notification.actorFollow.following.displayName }}</ng-container>
124 <ng-container *ngIf="notification.actorFollow.following.type === 'account'">your account</ng-container>
125 </div>
126 </ng-container>
127
128 <ng-container *ngSwitchCase="UserNotificationType.COMMENT_MENTION">
129 <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
130 <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
131 </a>
132
133 <div class="message" i18n>
134 <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>
135 </div>
136 </ng-container>
137
138 <ng-container *ngSwitchCase="UserNotificationType.NEW_INSTANCE_FOLLOWER">
139 <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
140
141 <div class="message" i18n>
142 Your instance has <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">a new follower</a> ({{ notification.actorFollow?.follower.host }})
143 <ng-container *ngIf="notification.actorFollow?.state === 'pending'"> awaiting your approval</ng-container>
144 </div>
145 </ng-container>
146
147 <ng-container *ngSwitchCase="UserNotificationType.AUTO_INSTANCE_FOLLOWING">
148 <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
149
150 <div class="message" i18n>
151 Your instance automatically followed <a (click)="markAsRead(notification)" [routerLink]="notification.instanceFollowUrl">{{ notification.actorFollow.following.host }}</a>
152 </div>
153 </ng-container>
154
155 <ng-container *ngSwitchDefault>
156 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
157
158 <div class="message" i18n>
159 The notification points to a content now unavailable
160 </div>
161 </ng-container>
162 </ng-container>
163
164 <div [title]="notification.createdAt" class="from-date">{{ notification.createdAt | myFromNow }}</div>
165 </div>
166</div>
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.scss b/client/src/app/shared/shared-main/users/user-notifications.component.scss
new file mode 100644
index 000000000..5166bd559
--- /dev/null
+++ b/client/src/app/shared/shared-main/users/user-notifications.component.scss
@@ -0,0 +1,53 @@
1@import '_variables';
2@import '_mixins';
3
4.no-notification {
5 display: flex;
6 justify-content: center;
7 align-items: center;
8 padding: 20px 0;
9}
10
11.notification {
12 display: flex;
13 align-items: center;
14 font-size: inherit;
15 padding: 15px 5px 15px 10px;
16 border-bottom: 1px solid $separator-border-color;
17 word-break: break-word;
18
19 &.unread {
20 background-color: rgba(0, 0, 0, 0.05);
21 }
22
23 my-global-icon {
24 width: 24px;
25 margin-right: 11px;
26 margin-left: 3px;
27
28 @include apply-svg-color(#333);
29 }
30
31 .avatar {
32 @include avatar(30px);
33
34 margin-right: 10px;
35 }
36
37 .message {
38 flex-grow: 1;
39
40 a {
41 font-weight: $font-semibold;
42 }
43 }
44
45 .from-date {
46 font-size: 0.85em;
47 color: pvar(--greyForegroundColor);
48 padding-left: 5px;
49 min-width: 70px;
50 text-align: right;
51 margin-left: auto;
52 }
53}
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.ts b/client/src/app/shared/shared-main/users/user-notifications.component.ts
new file mode 100644
index 000000000..6abd8b7d8
--- /dev/null
+++ b/client/src/app/shared/shared-main/users/user-notifications.component.ts
@@ -0,0 +1,100 @@
1import { Subject } from 'rxjs'
2import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
3import { ComponentPagination, hasMoreItems, Notifier } from '@app/core'
4import { UserNotificationType } from '@shared/models'
5import { UserNotification } from './user-notification.model'
6import { UserNotificationService } from './user-notification.service'
7
8@Component({
9 selector: 'my-user-notifications',
10 templateUrl: 'user-notifications.component.html',
11 styleUrls: [ 'user-notifications.component.scss' ]
12})
13export class UserNotificationsComponent implements OnInit {
14 @Input() ignoreLoadingBar = false
15 @Input() infiniteScroll = true
16 @Input() itemsPerPage = 20
17 @Input() markAllAsReadSubject: Subject<boolean>
18
19 @Output() notificationsLoaded = new EventEmitter()
20
21 notifications: UserNotification[] = []
22
23 // So we can access it in the template
24 UserNotificationType = UserNotificationType
25
26 componentPagination: ComponentPagination
27
28 onDataSubject = new Subject<any[]>()
29
30 constructor (
31 private userNotificationService: UserNotificationService,
32 private notifier: Notifier
33 ) { }
34
35 ngOnInit () {
36 this.componentPagination = {
37 currentPage: 1,
38 itemsPerPage: this.itemsPerPage, // Reset items per page, because of the @Input() variable
39 totalItems: null
40 }
41
42 this.loadMoreNotifications()
43
44 if (this.markAllAsReadSubject) {
45 this.markAllAsReadSubject.subscribe(() => this.markAllAsRead())
46 }
47 }
48
49 loadMoreNotifications () {
50 this.userNotificationService.listMyNotifications(this.componentPagination, undefined, this.ignoreLoadingBar)
51 .subscribe(
52 result => {
53 this.notifications = this.notifications.concat(result.data)
54 this.componentPagination.totalItems = result.total
55
56 this.notificationsLoaded.emit()
57
58 this.onDataSubject.next(result.data)
59 },
60
61 err => this.notifier.error(err.message)
62 )
63 }
64
65 onNearOfBottom () {
66 if (this.infiniteScroll === false) return
67
68 this.componentPagination.currentPage++
69
70 if (hasMoreItems(this.componentPagination)) {
71 this.loadMoreNotifications()
72 }
73 }
74
75 markAsRead (notification: UserNotification) {
76 if (notification.read) return
77
78 this.userNotificationService.markAsRead(notification)
79 .subscribe(
80 () => {
81 notification.read = true
82 },
83
84 err => this.notifier.error(err.message)
85 )
86 }
87
88 markAllAsRead () {
89 this.userNotificationService.markAllAsRead()
90 .subscribe(
91 () => {
92 for (const notification of this.notifications) {
93 notification.read = true
94 }
95 },
96
97 err => this.notifier.error(err.message)
98 )
99 }
100}
diff --git a/client/src/app/shared/shared-main/video-caption/index.ts b/client/src/app/shared/shared-main/video-caption/index.ts
new file mode 100644
index 000000000..308200f27
--- /dev/null
+++ b/client/src/app/shared/shared-main/video-caption/index.ts
@@ -0,0 +1,2 @@
1export * from './video-caption-edit.model'
2export * from './video-caption.service'
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts b/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts
new file mode 100644
index 000000000..732f20158
--- /dev/null
+++ b/client/src/app/shared/shared-main/video-caption/video-caption-edit.model.ts
@@ -0,0 +1,9 @@
1export interface VideoCaptionEdit {
2 language: {
3 id: string
4 label?: string
5 }
6
7 action?: 'CREATE' | 'REMOVE'
8 captionfile?: any
9}
diff --git a/client/src/app/shared/shared-main/video-caption/video-caption.service.ts b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
new file mode 100644
index 000000000..d45fb837a
--- /dev/null
+++ b/client/src/app/shared/shared-main/video-caption/video-caption.service.ts
@@ -0,0 +1,74 @@
1import { Observable, of } from 'rxjs'
2import { catchError, map, switchMap } from 'rxjs/operators'
3import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { RestExtractor, ServerService } from '@app/core'
6import { objectToFormData, sortBy } from '@app/helpers'
7import { VideoService } from '@app/shared/shared-main/video'
8import { peertubeTranslate, ResultList, VideoCaption } from '@shared/models'
9import { VideoCaptionEdit } from './video-caption-edit.model'
10
11@Injectable()
12export class VideoCaptionService {
13 constructor (
14 private authHttp: HttpClient,
15 private serverService: ServerService,
16 private restExtractor: RestExtractor
17 ) {}
18
19 listCaptions (videoId: number | string): Observable<ResultList<VideoCaption>> {
20 return this.authHttp.get<ResultList<VideoCaption>>(VideoService.BASE_VIDEO_URL + videoId + '/captions')
21 .pipe(
22 switchMap(captionsResult => {
23 return this.serverService.getServerLocale()
24 .pipe(map(translations => ({ captionsResult, translations })))
25 }),
26 map(({ captionsResult, translations }) => {
27 for (const c of captionsResult.data) {
28 c.language.label = peertubeTranslate(c.language.label, translations)
29 }
30
31 return captionsResult
32 }),
33 map(captionsResult => {
34 sortBy(captionsResult.data, 'language', 'label')
35
36 return captionsResult
37 })
38 )
39 .pipe(catchError(res => this.restExtractor.handleError(res)))
40 }
41
42 removeCaption (videoId: number | string, language: string) {
43 return this.authHttp.delete(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language)
44 .pipe(
45 map(this.restExtractor.extractDataBool),
46 catchError(res => this.restExtractor.handleError(res))
47 )
48 }
49
50 addCaption (videoId: number | string, language: string, captionfile: File) {
51 const body = { captionfile }
52 const data = objectToFormData(body)
53
54 return this.authHttp.put(VideoService.BASE_VIDEO_URL + videoId + '/captions/' + language, data)
55 .pipe(
56 map(this.restExtractor.extractDataBool),
57 catchError(res => this.restExtractor.handleError(res))
58 )
59 }
60
61 updateCaptions (videoId: number | string, videoCaptions: VideoCaptionEdit[]) {
62 let obs = of(true)
63
64 for (const videoCaption of videoCaptions) {
65 if (videoCaption.action === 'CREATE') {
66 obs = obs.pipe(switchMap(() => this.addCaption(videoId, videoCaption.language.id, videoCaption.captionfile)))
67 } else if (videoCaption.action === 'REMOVE') {
68 obs = obs.pipe(switchMap(() => this.removeCaption(videoId, videoCaption.language.id)))
69 }
70 }
71
72 return obs
73 }
74}
diff --git a/client/src/app/shared/shared-main/video-channel/index.ts b/client/src/app/shared/shared-main/video-channel/index.ts
new file mode 100644
index 000000000..1fcf6d3be
--- /dev/null
+++ b/client/src/app/shared/shared-main/video-channel/index.ts
@@ -0,0 +1,2 @@
1export * from './video-channel.model'
2export * from './video-channel.service'
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
new file mode 100644
index 000000000..123389afb
--- /dev/null
+++ b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts
@@ -0,0 +1,42 @@
1import { VideoChannel as ServerVideoChannel, ViewsPerDate, Account } from '@shared/models'
2import { Actor } from '../account/actor.model'
3
4export class VideoChannel extends Actor implements ServerVideoChannel {
5 displayName: string
6 description: string
7 support: string
8 isLocal: boolean
9 nameWithHost: string
10 nameWithHostForced: string
11
12 ownerAccount?: Account
13 ownerBy?: string
14 ownerAvatarUrl?: string
15
16 videosCount?: number
17
18 viewsPerDay?: ViewsPerDate[]
19
20 constructor (hash: ServerVideoChannel) {
21 super(hash)
22
23 this.displayName = hash.displayName
24 this.description = hash.description
25 this.support = hash.support
26 this.isLocal = hash.isLocal
27 this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
28 this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
29
30 this.videosCount = hash.videosCount
31
32 if (hash.viewsPerDay) {
33 this.viewsPerDay = hash.viewsPerDay.map(v => ({ ...v, date: new Date(v.date) }))
34 }
35
36 if (hash.ownerAccount) {
37 this.ownerAccount = hash.ownerAccount
38 this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
39 this.ownerAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.ownerAccount)
40 }
41 }
42}
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts
new file mode 100644
index 000000000..5483e305f
--- /dev/null
+++ b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts
@@ -0,0 +1,94 @@
1import { Observable, ReplaySubject } from 'rxjs'
2import { catchError, map, tap } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
6import { Avatar, ResultList, VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '@shared/models'
7import { environment } from '../../../../environments/environment'
8import { Account } from '../account'
9import { AccountService } from '../account/account.service'
10import { VideoChannel } from './video-channel.model'
11
12@Injectable()
13export class VideoChannelService {
14 static BASE_VIDEO_CHANNEL_URL = environment.apiUrl + '/api/v1/video-channels/'
15
16 videoChannelLoaded = new ReplaySubject<VideoChannel>(1)
17
18 static extractVideoChannels (result: ResultList<VideoChannelServer>) {
19 const videoChannels: VideoChannel[] = []
20
21 for (const videoChannelJSON of result.data) {
22 videoChannels.push(new VideoChannel(videoChannelJSON))
23 }
24
25 return { data: videoChannels, total: result.total }
26 }
27
28 constructor (
29 private authHttp: HttpClient,
30 private restService: RestService,
31 private restExtractor: RestExtractor
32 ) { }
33
34 getVideoChannel (videoChannelName: string) {
35 return this.authHttp.get<VideoChannel>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName)
36 .pipe(
37 map(videoChannelHash => new VideoChannel(videoChannelHash)),
38 tap(videoChannel => this.videoChannelLoaded.next(videoChannel)),
39 catchError(err => this.restExtractor.handleError(err))
40 )
41 }
42
43 listAccountVideoChannels (
44 account: Account,
45 componentPagination?: ComponentPaginationLight,
46 withStats = false
47 ): Observable<ResultList<VideoChannel>> {
48 const pagination = componentPagination
49 ? this.restService.componentPaginationToRestPagination(componentPagination)
50 : { start: 0, count: 20 }
51
52 let params = new HttpParams()
53 params = this.restService.addRestGetParams(params, pagination)
54 params = params.set('withStats', withStats + '')
55
56 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-channels'
57 return this.authHttp.get<ResultList<VideoChannelServer>>(url, { params })
58 .pipe(
59 map(res => VideoChannelService.extractVideoChannels(res)),
60 catchError(err => this.restExtractor.handleError(err))
61 )
62 }
63
64 createVideoChannel (videoChannel: VideoChannelCreate) {
65 return this.authHttp.post(VideoChannelService.BASE_VIDEO_CHANNEL_URL, videoChannel)
66 .pipe(
67 map(this.restExtractor.extractDataBool),
68 catchError(err => this.restExtractor.handleError(err))
69 )
70 }
71
72 updateVideoChannel (videoChannelName: string, videoChannel: VideoChannelUpdate) {
73 return this.authHttp.put(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName, videoChannel)
74 .pipe(
75 map(this.restExtractor.extractDataBool),
76 catchError(err => this.restExtractor.handleError(err))
77 )
78 }
79
80 changeVideoChannelAvatar (videoChannelName: string, avatarForm: FormData) {
81 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar/pick'
82
83 return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm)
84 .pipe(catchError(err => this.restExtractor.handleError(err)))
85 }
86
87 removeVideoChannel (videoChannel: VideoChannel) {
88 return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost)
89 .pipe(
90 map(this.restExtractor.extractDataBool),
91 catchError(err => this.restExtractor.handleError(err))
92 )
93 }
94}
diff --git a/client/src/app/shared/shared-main/video/index.ts b/client/src/app/shared/shared-main/video/index.ts
new file mode 100644
index 000000000..3053df4ef
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/index.ts
@@ -0,0 +1,7 @@
1export * from './redundancy.service'
2export * from './video-details.model'
3export * from './video-edit.model'
4export * from './video-import.service'
5export * from './video-ownership.service'
6export * from './video.model'
7export * from './video.service'
diff --git a/client/src/app/shared/shared-main/video/redundancy.service.ts b/client/src/app/shared/shared-main/video/redundancy.service.ts
new file mode 100644
index 000000000..6e839e655
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/redundancy.service.ts
@@ -0,0 +1,73 @@
1import { SortMeta } from 'primeng/api'
2import { concat, Observable } from 'rxjs'
3import { catchError, map, toArray } from 'rxjs/operators'
4import { HttpClient, HttpParams } from '@angular/common/http'
5import { Injectable } from '@angular/core'
6import { RestExtractor, RestPagination, RestService } from '@app/core'
7import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
8import { environment } from '../../../../environments/environment'
9
10@Injectable()
11export class RedundancyService {
12 static BASE_REDUNDANCY_URL = environment.apiUrl + '/api/v1/server/redundancy'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) { }
19
20 updateRedundancy (host: string, redundancyAllowed: boolean) {
21 const url = RedundancyService.BASE_REDUNDANCY_URL + '/' + host
22
23 const body = { redundancyAllowed }
24
25 return this.authHttp.put(url, body)
26 .pipe(
27 map(this.restExtractor.extractDataBool),
28 catchError(err => this.restExtractor.handleError(err))
29 )
30 }
31
32 listVideoRedundancies (options: {
33 pagination: RestPagination,
34 sort: SortMeta,
35 target?: VideoRedundanciesTarget
36 }): Observable<ResultList<VideoRedundancy>> {
37 const { pagination, sort, target } = options
38
39 let params = new HttpParams()
40 params = this.restService.addRestGetParams(params, pagination, sort)
41
42 if (target) params = params.append('target', target)
43
44 return this.authHttp.get<ResultList<VideoRedundancy>>(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { params })
45 .pipe(
46 catchError(res => this.restExtractor.handleError(res))
47 )
48 }
49
50 addVideoRedundancy (video: Video) {
51 return this.authHttp.post(RedundancyService.BASE_REDUNDANCY_URL + '/videos', { videoId: video.id })
52 .pipe(
53 catchError(res => this.restExtractor.handleError(res))
54 )
55 }
56
57 removeVideoRedundancies (redundancy: VideoRedundancy) {
58 const observables = redundancy.redundancies.streamingPlaylists.map(r => r.id)
59 .concat(redundancy.redundancies.files.map(r => r.id))
60 .map(id => this.removeRedundancy(id))
61
62 return concat(...observables)
63 .pipe(toArray())
64 }
65
66 private removeRedundancy (redundancyId: number) {
67 return this.authHttp.delete(RedundancyService.BASE_REDUNDANCY_URL + '/videos/' + redundancyId)
68 .pipe(
69 map(this.restExtractor.extractDataBool),
70 catchError(res => this.restExtractor.handleError(res))
71 )
72 }
73}
diff --git a/client/src/app/shared/shared-main/video/video-details.model.ts b/client/src/app/shared/shared-main/video/video-details.model.ts
new file mode 100644
index 000000000..a1cb051e9
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video-details.model.ts
@@ -0,0 +1,69 @@
1import { Account } from '@app/shared/shared-main/account/account.model'
2import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
3import {
4 VideoConstant,
5 VideoDetails as VideoDetailsServerModel,
6 VideoFile,
7 VideoState,
8 VideoStreamingPlaylist,
9 VideoStreamingPlaylistType
10} from '@shared/models'
11import { Video } from './video.model'
12
13export class VideoDetails extends Video implements VideoDetailsServerModel {
14 descriptionPath: string
15 support: string
16 channel: VideoChannel
17 tags: string[]
18 files: VideoFile[]
19 account: Account
20 commentsEnabled: boolean
21 downloadEnabled: boolean
22
23 waitTranscoding: boolean
24 state: VideoConstant<VideoState>
25
26 likesPercent: number
27 dislikesPercent: number
28
29 trackerUrls: string[]
30
31 streamingPlaylists: VideoStreamingPlaylist[]
32
33 constructor (hash: VideoDetailsServerModel, translations = {}) {
34 super(hash, translations)
35
36 this.descriptionPath = hash.descriptionPath
37 this.files = hash.files
38 this.channel = new VideoChannel(hash.channel)
39 this.account = new Account(hash.account)
40 this.tags = hash.tags
41 this.support = hash.support
42 this.commentsEnabled = hash.commentsEnabled
43 this.downloadEnabled = hash.downloadEnabled
44
45 this.trackerUrls = hash.trackerUrls
46 this.streamingPlaylists = hash.streamingPlaylists
47
48 this.buildLikeAndDislikePercents()
49 }
50
51 buildLikeAndDislikePercents () {
52 this.likesPercent = (this.likes / (this.likes + this.dislikes)) * 100
53 this.dislikesPercent = (this.dislikes / (this.likes + this.dislikes)) * 100
54 }
55
56 getHlsPlaylist () {
57 return this.streamingPlaylists.find(p => p.type === VideoStreamingPlaylistType.HLS)
58 }
59
60 hasHlsPlaylist () {
61 return !!this.getHlsPlaylist()
62 }
63
64 getFiles () {
65 if (this.files.length === 0) return this.getHlsPlaylist().files
66
67 return this.files
68 }
69}
diff --git a/client/src/app/shared/shared-main/video/video-edit.model.ts b/client/src/app/shared/shared-main/video/video-edit.model.ts
new file mode 100644
index 000000000..6a529e052
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video-edit.model.ts
@@ -0,0 +1,120 @@
1import { Video, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models'
2
3export class VideoEdit implements VideoUpdate {
4 static readonly SPECIAL_SCHEDULED_PRIVACY = -1
5
6 category: number
7 licence: number
8 language: string
9 description: string
10 name: string
11 tags: string[]
12 nsfw: boolean
13 commentsEnabled: boolean
14 downloadEnabled: boolean
15 waitTranscoding: boolean
16 channelId: number
17 privacy: VideoPrivacy
18 support: string
19 thumbnailfile?: any
20 previewfile?: any
21 thumbnailUrl: string
22 previewUrl: string
23 uuid?: string
24 id?: number
25 scheduleUpdate?: VideoScheduleUpdate
26 originallyPublishedAt?: Date | string
27
28 constructor (
29 video?: Video & {
30 tags: string[],
31 commentsEnabled: boolean,
32 downloadEnabled: boolean,
33 support: string,
34 thumbnailUrl: string,
35 previewUrl: string
36 }) {
37 if (video) {
38 this.id = video.id
39 this.uuid = video.uuid
40 this.category = video.category.id
41 this.licence = video.licence.id
42 this.language = video.language.id
43 this.description = video.description
44 this.name = video.name
45 this.tags = video.tags
46 this.nsfw = video.nsfw
47 this.commentsEnabled = video.commentsEnabled
48 this.downloadEnabled = video.downloadEnabled
49 this.waitTranscoding = video.waitTranscoding
50 this.channelId = video.channel.id
51 this.privacy = video.privacy.id
52 this.support = video.support
53 this.thumbnailUrl = video.thumbnailUrl
54 this.previewUrl = video.previewUrl
55
56 this.scheduleUpdate = video.scheduledUpdate
57 this.originallyPublishedAt = video.originallyPublishedAt ? new Date(video.originallyPublishedAt) : null
58 }
59 }
60
61 patch (values: { [ id: string ]: string }) {
62 Object.keys(values).forEach((key) => {
63 this[ key ] = values[ key ]
64 })
65
66 // If schedule publication, the video is private and will be changed to public privacy
67 if (parseInt(values['privacy'], 10) === VideoEdit.SPECIAL_SCHEDULED_PRIVACY) {
68 const updateAt = new Date(values['schedulePublicationAt'])
69 updateAt.setSeconds(0)
70
71 this.privacy = VideoPrivacy.PRIVATE
72 this.scheduleUpdate = {
73 updateAt: updateAt.toISOString(),
74 privacy: VideoPrivacy.PUBLIC
75 }
76 } else {
77 this.scheduleUpdate = null
78 }
79
80 // Convert originallyPublishedAt to string so that function objectToFormData() works correctly
81 if (this.originallyPublishedAt) {
82 const originallyPublishedAt = new Date(values['originallyPublishedAt'])
83 this.originallyPublishedAt = originallyPublishedAt.toISOString()
84 }
85
86 // Use the same file than the preview for the thumbnail
87 if (this.previewfile) {
88 this.thumbnailfile = this.previewfile
89 }
90 }
91
92 toFormPatch () {
93 const json = {
94 category: this.category,
95 licence: this.licence,
96 language: this.language,
97 description: this.description,
98 support: this.support,
99 name: this.name,
100 tags: this.tags,
101 nsfw: this.nsfw,
102 commentsEnabled: this.commentsEnabled,
103 downloadEnabled: this.downloadEnabled,
104 waitTranscoding: this.waitTranscoding,
105 channelId: this.channelId,
106 privacy: this.privacy,
107 originallyPublishedAt: this.originallyPublishedAt
108 }
109
110 // Special case if we scheduled an update
111 if (this.scheduleUpdate) {
112 Object.assign(json, {
113 privacy: VideoEdit.SPECIAL_SCHEDULED_PRIVACY,
114 schedulePublicationAt: new Date(this.scheduleUpdate.updateAt.toString())
115 })
116 }
117
118 return json
119 }
120}
diff --git a/client/src/app/shared/shared-main/video/video-import.service.ts b/client/src/app/shared/shared-main/video/video-import.service.ts
new file mode 100644
index 000000000..a700abacb
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video-import.service.ts
@@ -0,0 +1,100 @@
1import { SortMeta } from 'primeng/api'
2import { Observable } from 'rxjs'
3import { catchError, map, switchMap } from 'rxjs/operators'
4import { HttpClient, HttpParams } from '@angular/common/http'
5import { Injectable } from '@angular/core'
6import { RestExtractor, RestPagination, RestService, ServerService, UserService } from '@app/core'
7import { objectToFormData } from '@app/helpers'
8import { peertubeTranslate, ResultList, VideoImport, VideoImportCreate, VideoUpdate } from '@shared/models'
9import { environment } from '../../../../environments/environment'
10
11@Injectable()
12export class VideoImportService {
13 private static BASE_VIDEO_IMPORT_URL = environment.apiUrl + '/api/v1/videos/imports/'
14
15 constructor (
16 private authHttp: HttpClient,
17 private restService: RestService,
18 private restExtractor: RestExtractor,
19 private serverService: ServerService
20 ) {}
21
22 importVideoUrl (targetUrl: string, video: VideoUpdate): Observable<VideoImport> {
23 const url = VideoImportService.BASE_VIDEO_IMPORT_URL
24
25 const body = this.buildImportVideoObject(video)
26 body.targetUrl = targetUrl
27
28 const data = objectToFormData(body)
29 return this.authHttp.post<VideoImport>(url, data)
30 .pipe(catchError(res => this.restExtractor.handleError(res)))
31 }
32
33 importVideoTorrent (target: string | Blob, video: VideoUpdate): Observable<VideoImport> {
34 const url = VideoImportService.BASE_VIDEO_IMPORT_URL
35 const body: VideoImportCreate = this.buildImportVideoObject(video)
36
37 if (typeof target === 'string') body.magnetUri = target
38 else body.torrentfile = target
39
40 const data = objectToFormData(body)
41 return this.authHttp.post<VideoImport>(url, data)
42 .pipe(catchError(res => this.restExtractor.handleError(res)))
43 }
44
45 getMyVideoImports (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoImport>> {
46 let params = new HttpParams()
47 params = this.restService.addRestGetParams(params, pagination, sort)
48
49 return this.authHttp
50 .get<ResultList<VideoImport>>(UserService.BASE_USERS_URL + '/me/videos/imports', { params })
51 .pipe(
52 switchMap(res => this.extractVideoImports(res)),
53 map(res => this.restExtractor.convertResultListDateToHuman(res)),
54 catchError(err => this.restExtractor.handleError(err))
55 )
56 }
57
58 private buildImportVideoObject (video: VideoUpdate): VideoImportCreate {
59 const language = video.language || null
60 const licence = video.licence || null
61 const category = video.category || null
62 const description = video.description || null
63 const support = video.support || null
64 const scheduleUpdate = video.scheduleUpdate || null
65 const originallyPublishedAt = video.originallyPublishedAt || null
66
67 return {
68 name: video.name,
69 category,
70 licence,
71 language,
72 support,
73 description,
74 channelId: video.channelId,
75 privacy: video.privacy,
76 tags: video.tags,
77 nsfw: video.nsfw,
78 waitTranscoding: video.waitTranscoding,
79 commentsEnabled: video.commentsEnabled,
80 downloadEnabled: video.downloadEnabled,
81 thumbnailfile: video.thumbnailfile,
82 previewfile: video.previewfile,
83 scheduleUpdate,
84 originallyPublishedAt
85 }
86 }
87
88 private extractVideoImports (result: ResultList<VideoImport>): Observable<ResultList<VideoImport>> {
89 return this.serverService.getServerLocale()
90 .pipe(
91 map(translations => {
92 result.data.forEach(d =>
93 d.state.label = peertubeTranslate(d.state.label, translations)
94 )
95
96 return result
97 })
98 )
99 }
100}
diff --git a/client/src/app/shared/shared-main/video/video-ownership.service.ts b/client/src/app/shared/shared-main/video/video-ownership.service.ts
new file mode 100644
index 000000000..273930a6c
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video-ownership.service.ts
@@ -0,0 +1,64 @@
1import { SortMeta } from 'primeng/api'
2import { Observable } from 'rxjs'
3import { catchError, map } from 'rxjs/operators'
4import { HttpClient, HttpParams } from '@angular/common/http'
5import { Injectable } from '@angular/core'
6import { RestExtractor, RestPagination, RestService } from '@app/core'
7import { ResultList, VideoChangeOwnership, VideoChangeOwnershipAccept, VideoChangeOwnershipCreate } from '@shared/models'
8import { environment } from '../../../../environments/environment'
9
10@Injectable()
11export class VideoOwnershipService {
12 private static BASE_VIDEO_CHANGE_OWNERSHIP_URL = environment.apiUrl + '/api/v1/videos/'
13
14 constructor (
15 private authHttp: HttpClient,
16 private restService: RestService,
17 private restExtractor: RestExtractor
18 ) {
19 }
20
21 changeOwnership (id: number, username: string) {
22 const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + id + '/give-ownership'
23 const body: VideoChangeOwnershipCreate = {
24 username
25 }
26
27 return this.authHttp.post(url, body)
28 .pipe(
29 map(this.restExtractor.extractDataBool),
30 catchError(res => this.restExtractor.handleError(res))
31 )
32 }
33
34 getOwnershipChanges (pagination: RestPagination, sort: SortMeta): Observable<ResultList<VideoChangeOwnership>> {
35 const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership'
36
37 let params = new HttpParams()
38 params = this.restService.addRestGetParams(params, pagination, sort)
39
40 return this.authHttp.get<ResultList<VideoChangeOwnership>>(url, { params })
41 .pipe(
42 map(res => this.restExtractor.convertResultListDateToHuman(res)),
43 catchError(res => this.restExtractor.handleError(res))
44 )
45 }
46
47 acceptOwnership (id: number, input: VideoChangeOwnershipAccept) {
48 const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/accept'
49 return this.authHttp.post(url, input)
50 .pipe(
51 map(this.restExtractor.extractDataBool),
52 catchError(this.restExtractor.handleError)
53 )
54 }
55
56 refuseOwnership (id: number) {
57 const url = VideoOwnershipService.BASE_VIDEO_CHANGE_OWNERSHIP_URL + 'ownership/' + id + '/refuse'
58 return this.authHttp.post(url, {})
59 .pipe(
60 map(this.restExtractor.extractDataBool),
61 catchError(this.restExtractor.handleError)
62 )
63 }
64}
diff --git a/client/src/app/shared/shared-main/video/video.model.ts b/client/src/app/shared/shared-main/video/video.model.ts
new file mode 100644
index 000000000..3e6d6a38d
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video.model.ts
@@ -0,0 +1,188 @@
1import { AuthUser } from '@app/core'
2import { User } from '@app/core/users/user.model'
3import { durationToString, getAbsoluteAPIUrl } from '@app/helpers'
4import {
5 Avatar,
6 peertubeTranslate,
7 ServerConfig,
8 UserRight,
9 Video as VideoServerModel,
10 VideoConstant,
11 VideoPrivacy,
12 VideoScheduleUpdate,
13 VideoState
14} from '@shared/models'
15import { environment } from '../../../../environments/environment'
16import { Actor } from '../account/actor.model'
17
18export class Video implements VideoServerModel {
19 byVideoChannel: string
20 byAccount: string
21
22 accountAvatarUrl: string
23 videoChannelAvatarUrl: string
24
25 createdAt: Date
26 updatedAt: Date
27 publishedAt: Date
28 originallyPublishedAt: Date | string
29 category: VideoConstant<number>
30 licence: VideoConstant<number>
31 language: VideoConstant<string>
32 privacy: VideoConstant<VideoPrivacy>
33 description: string
34 duration: number
35 durationLabel: string
36 id: number
37 uuid: string
38 isLocal: boolean
39 name: string
40 serverHost: string
41 thumbnailPath: string
42 thumbnailUrl: string
43
44 previewPath: string
45 previewUrl: string
46
47 embedPath: string
48 embedUrl: string
49
50 url?: string
51
52 views: number
53 likes: number
54 dislikes: number
55 nsfw: boolean
56
57 originInstanceUrl: string
58 originInstanceHost: string
59
60 waitTranscoding?: boolean
61 state?: VideoConstant<VideoState>
62 scheduledUpdate?: VideoScheduleUpdate
63 blacklisted?: boolean
64 blockedReason?: string
65
66 account: {
67 id: number
68 name: string
69 displayName: string
70 url: string
71 host: string
72 avatar?: Avatar
73 }
74
75 channel: {
76 id: number
77 name: string
78 displayName: string
79 url: string
80 host: string
81 avatar?: Avatar
82 }
83
84 userHistory?: {
85 currentTime: number
86 }
87
88 static buildClientUrl (videoUUID: string) {
89 return '/videos/watch/' + videoUUID
90 }
91
92 constructor (hash: VideoServerModel, translations = {}) {
93 const absoluteAPIUrl = getAbsoluteAPIUrl()
94
95 this.createdAt = new Date(hash.createdAt.toString())
96 this.publishedAt = new Date(hash.publishedAt.toString())
97 this.category = hash.category
98 this.licence = hash.licence
99 this.language = hash.language
100 this.privacy = hash.privacy
101 this.waitTranscoding = hash.waitTranscoding
102 this.state = hash.state
103 this.description = hash.description
104
105 this.duration = hash.duration
106 this.durationLabel = durationToString(hash.duration)
107
108 this.id = hash.id
109 this.uuid = hash.uuid
110
111 this.isLocal = hash.isLocal
112 this.name = hash.name
113
114 this.thumbnailPath = hash.thumbnailPath
115 this.thumbnailUrl = hash.thumbnailUrl || (absoluteAPIUrl + hash.thumbnailPath)
116
117 this.previewPath = hash.previewPath
118 this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath)
119
120 this.embedPath = hash.embedPath
121 this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath)
122
123 this.url = hash.url
124
125 this.views = hash.views
126 this.likes = hash.likes
127 this.dislikes = hash.dislikes
128
129 this.nsfw = hash.nsfw
130
131 this.account = hash.account
132 this.channel = hash.channel
133
134 this.byAccount = Actor.CREATE_BY_STRING(hash.account.name, hash.account.host)
135 this.byVideoChannel = Actor.CREATE_BY_STRING(hash.channel.name, hash.channel.host)
136 this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
137 this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.channel)
138
139 this.category.label = peertubeTranslate(this.category.label, translations)
140 this.licence.label = peertubeTranslate(this.licence.label, translations)
141 this.language.label = peertubeTranslate(this.language.label, translations)
142 this.privacy.label = peertubeTranslate(this.privacy.label, translations)
143
144 this.scheduledUpdate = hash.scheduledUpdate
145 this.originallyPublishedAt = hash.originallyPublishedAt ? new Date(hash.originallyPublishedAt.toString()) : null
146
147 if (this.state) this.state.label = peertubeTranslate(this.state.label, translations)
148
149 this.blacklisted = hash.blacklisted
150 this.blockedReason = hash.blacklistedReason
151
152 this.userHistory = hash.userHistory
153
154 this.originInstanceHost = this.account.host
155 this.originInstanceUrl = 'https://' + this.originInstanceHost
156 }
157
158 isVideoNSFWForUser (user: User, serverConfig: ServerConfig) {
159 // Video is not NSFW, skip
160 if (this.nsfw === false) return false
161
162 // Return user setting if logged in
163 if (user) return user.nsfwPolicy !== 'display'
164
165 // Return default instance config
166 return serverConfig.instance.defaultNSFWPolicy !== 'display'
167 }
168
169 isRemovableBy (user: AuthUser) {
170 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.REMOVE_ANY_VIDEO))
171 }
172
173 isBlockableBy (user: AuthUser) {
174 return this.blacklisted !== true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
175 }
176
177 isUnblockableBy (user: AuthUser) {
178 return this.blacklisted === true && user && user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST) === true
179 }
180
181 isUpdatableBy (user: AuthUser) {
182 return user && this.isLocal === true && (this.account.name === user.username || user.hasRight(UserRight.UPDATE_ANY_VIDEO))
183 }
184
185 canBeDuplicatedBy (user: AuthUser) {
186 return user && this.isLocal === false && user.hasRight(UserRight.MANAGE_VIDEOS_REDUNDANCIES)
187 }
188}
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts
new file mode 100644
index 000000000..20d13fa10
--- /dev/null
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -0,0 +1,380 @@
1import { FfprobeData } from 'fluent-ffmpeg'
2import { Observable } from 'rxjs'
3import { catchError, map, switchMap } from 'rxjs/operators'
4import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http'
5import { Injectable } from '@angular/core'
6import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core'
7import { objectToFormData } from '@app/helpers'
8import { I18n } from '@ngx-translate/i18n-polyfill'
9import {
10 FeedFormat,
11 NSFWPolicyType,
12 ResultList,
13 UserVideoRate,
14 UserVideoRateType,
15 UserVideoRateUpdate,
16 Video as VideoServerModel,
17 VideoConstant,
18 VideoDetails as VideoDetailsServerModel,
19 VideoFilter,
20 VideoPrivacy,
21 VideoSortField,
22 VideoUpdate
23} from '@shared/models'
24import { environment } from '../../../../environments/environment'
25import { Account, AccountService } from '../account'
26import { VideoChannel, VideoChannelService } from '../video-channel'
27import { VideoDetails } from './video-details.model'
28import { VideoEdit } from './video-edit.model'
29import { Video } from './video.model'
30
31export interface VideosProvider {
32 getVideos (parameters: {
33 videoPagination: ComponentPaginationLight,
34 sort: VideoSortField,
35 filter?: VideoFilter,
36 categoryOneOf?: number[],
37 languageOneOf?: string[]
38 nsfwPolicy: NSFWPolicyType
39 }): Observable<ResultList<Video>>
40}
41
42@Injectable()
43export class VideoService implements VideosProvider {
44 static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
45 static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.'
46
47 constructor (
48 private authHttp: HttpClient,
49 private restExtractor: RestExtractor,
50 private restService: RestService,
51 private serverService: ServerService,
52 private i18n: I18n
53 ) {}
54
55 getVideoViewUrl (uuid: string) {
56 return VideoService.BASE_VIDEO_URL + uuid + '/views'
57 }
58
59 getUserWatchingVideoUrl (uuid: string) {
60 return VideoService.BASE_VIDEO_URL + uuid + '/watching'
61 }
62
63 getVideo (options: { videoId: string }): Observable<VideoDetails> {
64 return this.serverService.getServerLocale()
65 .pipe(
66 switchMap(translations => {
67 return this.authHttp.get<VideoDetailsServerModel>(VideoService.BASE_VIDEO_URL + options.videoId)
68 .pipe(map(videoHash => ({ videoHash, translations })))
69 }),
70 map(({ videoHash, translations }) => new VideoDetails(videoHash, translations)),
71 catchError(err => this.restExtractor.handleError(err))
72 )
73 }
74
75 updateVideo (video: VideoEdit) {
76 const language = video.language || null
77 const licence = video.licence || null
78 const category = video.category || null
79 const description = video.description || null
80 const support = video.support || null
81 const scheduleUpdate = video.scheduleUpdate || null
82 const originallyPublishedAt = video.originallyPublishedAt || null
83
84 const body: VideoUpdate = {
85 name: video.name,
86 category,
87 licence,
88 language,
89 support,
90 description,
91 channelId: video.channelId,
92 privacy: video.privacy,
93 tags: video.tags,
94 nsfw: video.nsfw,
95 waitTranscoding: video.waitTranscoding,
96 commentsEnabled: video.commentsEnabled,
97 downloadEnabled: video.downloadEnabled,
98 thumbnailfile: video.thumbnailfile,
99 previewfile: video.previewfile,
100 scheduleUpdate,
101 originallyPublishedAt
102 }
103
104 const data = objectToFormData(body)
105
106 return this.authHttp.put(VideoService.BASE_VIDEO_URL + video.id, data)
107 .pipe(
108 map(this.restExtractor.extractDataBool),
109 catchError(err => this.restExtractor.handleError(err))
110 )
111 }
112
113 uploadVideo (video: FormData) {
114 const req = new HttpRequest('POST', VideoService.BASE_VIDEO_URL + 'upload', video, { reportProgress: true })
115
116 return this.authHttp
117 .request<{ video: { id: number, uuid: string } }>(req)
118 .pipe(catchError(err => this.restExtractor.handleError(err)))
119 }
120
121 getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable<ResultList<Video>> {
122 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
123
124 let params = new HttpParams()
125 params = this.restService.addRestGetParams(params, pagination, sort)
126 params = this.restService.addObjectParams(params, { search })
127
128 return this.authHttp
129 .get<ResultList<Video>>(UserService.BASE_USERS_URL + 'me/videos', { params })
130 .pipe(
131 switchMap(res => this.extractVideos(res)),
132 catchError(err => this.restExtractor.handleError(err))
133 )
134 }
135
136 getAccountVideos (
137 account: Account,
138 videoPagination: ComponentPaginationLight,
139 sort: VideoSortField
140 ): Observable<ResultList<Video>> {
141 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
142
143 let params = new HttpParams()
144 params = this.restService.addRestGetParams(params, pagination, sort)
145
146 return this.authHttp
147 .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params })
148 .pipe(
149 switchMap(res => this.extractVideos(res)),
150 catchError(err => this.restExtractor.handleError(err))
151 )
152 }
153
154 getVideoChannelVideos (
155 videoChannel: VideoChannel,
156 videoPagination: ComponentPaginationLight,
157 sort: VideoSortField,
158 nsfwPolicy?: NSFWPolicyType
159 ): Observable<ResultList<Video>> {
160 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
161
162 let params = new HttpParams()
163 params = this.restService.addRestGetParams(params, pagination, sort)
164
165 if (nsfwPolicy) {
166 params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
167 }
168
169 return this.authHttp
170 .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params })
171 .pipe(
172 switchMap(res => this.extractVideos(res)),
173 catchError(err => this.restExtractor.handleError(err))
174 )
175 }
176
177 getVideos (parameters: {
178 videoPagination: ComponentPaginationLight,
179 sort: VideoSortField,
180 filter?: VideoFilter,
181 categoryOneOf?: number[],
182 languageOneOf?: string[],
183 skipCount?: boolean,
184 nsfwPolicy?: NSFWPolicyType
185 }): Observable<ResultList<Video>> {
186 const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy } = parameters
187
188 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
189
190 let params = new HttpParams()
191 params = this.restService.addRestGetParams(params, pagination, sort)
192
193 if (filter) params = params.set('filter', filter)
194 if (skipCount) params = params.set('skipCount', skipCount + '')
195
196 if (nsfwPolicy) {
197 params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy))
198 }
199
200 if (languageOneOf) {
201 for (const l of languageOneOf) {
202 params = params.append('languageOneOf[]', l)
203 }
204 }
205
206 if (categoryOneOf) {
207 for (const c of categoryOneOf) {
208 params = params.append('categoryOneOf[]', c + '')
209 }
210 }
211
212 return this.authHttp
213 .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params })
214 .pipe(
215 switchMap(res => this.extractVideos(res)),
216 catchError(err => this.restExtractor.handleError(err))
217 )
218 }
219
220 buildBaseFeedUrls (params: HttpParams) {
221 const feeds = [
222 {
223 format: FeedFormat.RSS,
224 label: 'rss 2.0',
225 url: VideoService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase()
226 },
227 {
228 format: FeedFormat.ATOM,
229 label: 'atom 1.0',
230 url: VideoService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase()
231 },
232 {
233 format: FeedFormat.JSON,
234 label: 'json 1.0',
235 url: VideoService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase()
236 }
237 ]
238
239 if (params && params.keys().length !== 0) {
240 for (const feed of feeds) {
241 feed.url += '?' + params.toString()
242 }
243 }
244
245 return feeds
246 }
247
248 getVideoFeedUrls (sort: VideoSortField, filter?: VideoFilter, categoryOneOf?: number[]) {
249 let params = this.restService.addRestGetParams(new HttpParams(), undefined, sort)
250
251 if (filter) params = params.set('filter', filter)
252
253 if (categoryOneOf) {
254 for (const c of categoryOneOf) {
255 params = params.append('categoryOneOf[]', c + '')
256 }
257 }
258
259 return this.buildBaseFeedUrls(params)
260 }
261
262 getAccountFeedUrls (accountId: number) {
263 let params = this.restService.addRestGetParams(new HttpParams())
264 params = params.set('accountId', accountId.toString())
265
266 return this.buildBaseFeedUrls(params)
267 }
268
269 getVideoChannelFeedUrls (videoChannelId: number) {
270 let params = this.restService.addRestGetParams(new HttpParams())
271 params = params.set('videoChannelId', videoChannelId.toString())
272
273 return this.buildBaseFeedUrls(params)
274 }
275
276 getVideoFileMetadata (metadataUrl: string) {
277 return this.authHttp
278 .get<FfprobeData>(metadataUrl)
279 .pipe(
280 catchError(err => this.restExtractor.handleError(err))
281 )
282 }
283
284 removeVideo (id: number) {
285 return this.authHttp
286 .delete(VideoService.BASE_VIDEO_URL + id)
287 .pipe(
288 map(this.restExtractor.extractDataBool),
289 catchError(err => this.restExtractor.handleError(err))
290 )
291 }
292
293 loadCompleteDescription (descriptionPath: string) {
294 return this.authHttp
295 .get<{ description: string }>(environment.apiUrl + descriptionPath)
296 .pipe(
297 map(res => res.description),
298 catchError(err => this.restExtractor.handleError(err))
299 )
300 }
301
302 setVideoLike (id: number) {
303 return this.setVideoRate(id, 'like')
304 }
305
306 setVideoDislike (id: number) {
307 return this.setVideoRate(id, 'dislike')
308 }
309
310 unsetVideoLike (id: number) {
311 return this.setVideoRate(id, 'none')
312 }
313
314 getUserVideoRating (id: number) {
315 const url = UserService.BASE_USERS_URL + 'me/videos/' + id + '/rating'
316
317 return this.authHttp.get<UserVideoRate>(url)
318 .pipe(catchError(err => this.restExtractor.handleError(err)))
319 }
320
321 extractVideos (result: ResultList<VideoServerModel>) {
322 return this.serverService.getServerLocale()
323 .pipe(
324 map(translations => {
325 const videosJson = result.data
326 const totalVideos = result.total
327 const videos: Video[] = []
328
329 for (const videoJson of videosJson) {
330 videos.push(new Video(videoJson, translations))
331 }
332
333 return { total: totalVideos, data: videos }
334 })
335 )
336 }
337
338 explainedPrivacyLabels (privacies: VideoConstant<VideoPrivacy>[]) {
339 const base = [
340 {
341 id: VideoPrivacy.PRIVATE,
342 label: this.i18n('Only I can see this video')
343 },
344 {
345 id: VideoPrivacy.UNLISTED,
346 label: this.i18n('Only people with the private link can see this video')
347 },
348 {
349 id: VideoPrivacy.PUBLIC,
350 label: this.i18n('Anyone can see this video')
351 },
352 {
353 id: VideoPrivacy.INTERNAL,
354 label: this.i18n('Only users of this instance can see this video')
355 }
356 ]
357
358 return base.filter(o => !!privacies.find(p => p.id === o.id))
359 }
360
361 nsfwPolicyToParam (nsfwPolicy: NSFWPolicyType) {
362 return nsfwPolicy === 'do_not_list'
363 ? 'false'
364 : 'both'
365 }
366
367 private setVideoRate (id: number, rateType: UserVideoRateType) {
368 const url = VideoService.BASE_VIDEO_URL + id + '/rate'
369 const body: UserVideoRateUpdate = {
370 rating: rateType
371 }
372
373 return this.authHttp
374 .put(url, body)
375 .pipe(
376 map(this.restExtractor.extractDataBool),
377 catchError(err => this.restExtractor.handleError(err))
378 )
379 }
380}