diff options
Diffstat (limited to 'client/src/app/shared/shared-main')
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 @@ | |||
1 | import { Account as ServerAccount } from '@shared/models/actors/account.model' | ||
2 | import { Actor } from './actor.model' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { Observable, ReplaySubject } from 'rxjs' | ||
2 | import { catchError, map, tap } from 'rxjs/operators' | ||
3 | import { HttpClient } from '@angular/common/http' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | import { RestExtractor } from '@app/core' | ||
6 | import { Account as ServerAccount } from '@shared/models' | ||
7 | import { environment } from '../../../../environments/environment' | ||
8 | import { Account } from './account.model' | ||
9 | |||
10 | @Injectable() | ||
11 | export 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 @@ | |||
1 | import { BytesPipe } from 'ngx-pipes' | ||
2 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | ||
3 | import { Notifier, ServerService } from '@app/core' | ||
4 | import { Account, VideoChannel } from '@app/shared/shared-main' | ||
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
6 | import { 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 | }) | ||
13 | export 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 @@ | |||
1 | import { Actor as ActorServer, Avatar } from '@shared/models' | ||
2 | import { getAbsoluteAPIUrl } from '@app/helpers' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { Video } from '../video/video.model' | ||
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | |||
5 | @Component({ | ||
6 | selector: 'avatar-channel', | ||
7 | templateUrl: './avatar.component.html', | ||
8 | styleUrls: [ './avatar.component.scss' ] | ||
9 | }) | ||
10 | export 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 @@ | |||
1 | export * from './account.model' | ||
2 | export * from './account.service' | ||
3 | export * from './actor-avatar-info.component' | ||
4 | export * from './actor.model' | ||
5 | export * 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 @@ | |||
1 | import { Pipe, PipeTransform } from '@angular/core' | ||
2 | import { 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' }) | ||
6 | export 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 @@ | |||
1 | export * from './from-now.pipe' | ||
2 | export * from './infinite-scroller.directive' | ||
3 | export * from './number-formatter.pipe' | ||
4 | export * 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 @@ | |||
1 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' | ||
2 | import { AfterContentChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | ||
3 | import { fromEvent, Observable, Subscription } from 'rxjs' | ||
4 | |||
5 | @Directive({ | ||
6 | selector: '[myInfiniteScroller]' | ||
7 | }) | ||
8 | export 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 @@ | |||
1 | import { 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' }) | ||
6 | export 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 @@ | |||
1 | import { Directive, Input, TemplateRef } from '@angular/core' | ||
2 | |||
3 | @Directive({ | ||
4 | selector: '[ptTemplate]' | ||
5 | }) | ||
6 | export 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 @@ | |||
1 | import { Observable, throwError as observableThrowError } from 'rxjs' | ||
2 | import { catchError, switchMap } from 'rxjs/operators' | ||
3 | import { HTTP_INTERCEPTORS, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http' | ||
4 | import { Injectable, Injector } from '@angular/core' | ||
5 | import { AuthService } from '@app/core/auth/auth.service' | ||
6 | |||
7 | @Injectable() | ||
8 | export 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 | |||
56 | export 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
3 | |||
4 | export 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 | |||
15 | export type DropdownButtonSize = 'normal' | 'small' | ||
16 | export type DropdownTheme = 'orange' | 'grey' | ||
17 | export 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 | |||
25 | export 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 | |||
4 | my-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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { 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 | |||
10 | export 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 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { 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 | |||
10 | export 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 @@ | |||
1 | import { 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 | |||
9 | export 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 @@ | |||
1 | export * from './action-dropdown.component' | ||
2 | export * from './button.component' | ||
3 | export * from './delete-button.component' | ||
4 | export * 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 @@ | |||
1 | import { DatePipe } from '@angular/common' | ||
2 | import { Component, Input, OnChanges, OnInit } from '@angular/core' | ||
3 | import { 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 | }) | ||
10 | export 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { Syndication } from './syndication.model' | ||
3 | |||
4 | @Component({ | ||
5 | selector: 'my-feed', | ||
6 | styleUrls: [ './feed.component.scss' ], | ||
7 | templateUrl: './feed.component.html' | ||
8 | }) | ||
9 | export 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 @@ | |||
1 | export * from './feed.component' | ||
2 | export * 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 @@ | |||
1 | import { FeedFormat } from '@shared/models' | ||
2 | |||
3 | export 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 @@ | |||
1 | export * from './account' | ||
2 | export * from './angular' | ||
3 | export * from './buttons' | ||
4 | export * from './date' | ||
5 | export * from './feeds' | ||
6 | export * from './loaders' | ||
7 | export * from './misc' | ||
8 | export * from './users' | ||
9 | export * from './video' | ||
10 | export * from './video-caption' | ||
11 | export * from './video-channel' | ||
12 | export * 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 @@ | |||
1 | export * from './loader.component' | ||
2 | export * 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-loader', | ||
5 | styleUrls: [ './loader.component.scss' ], | ||
6 | templateUrl: './loader.component.html' | ||
7 | }) | ||
8 | export 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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | |||
3 | @Component({ | ||
4 | selector: 'my-small-loader', | ||
5 | styleUrls: [ ], | ||
6 | templateUrl: './small-loader.component.html' | ||
7 | }) | ||
8 | |||
9 | export 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 @@ | |||
1 | import { AfterContentInit, Component, ContentChildren, Input, OnChanges, OnInit, QueryList, TemplateRef } from '@angular/core' | ||
2 | import { MarkdownService } from '@app/core' | ||
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | import { PeerTubeTemplateDirective } from '../angular' | ||
5 | |||
6 | @Component({ | ||
7 | selector: 'my-help', | ||
8 | styleUrls: [ './help.component.scss' ], | ||
9 | templateUrl: './help.component.html' | ||
10 | }) | ||
11 | |||
12 | export 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 @@ | |||
1 | export * from './help.component' | ||
2 | export * 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 | |||
16 | button { | ||
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 @@ | |||
1 | import { lowerFirst, uniqueId } from 'lodash-es' | ||
2 | import { take } from 'rxjs/operators' | ||
3 | import { | ||
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' | ||
16 | import { ScreenService } from '@app/core' | ||
17 | import { NgbDropdown, NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||
18 | |||
19 | export 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 | }) | ||
30 | export 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 @@ | |||
1 | import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes' | ||
2 | import { SharedModule as PrimeSharedModule } from 'primeng/api' | ||
3 | import { InputMaskModule } from 'primeng/inputmask' | ||
4 | import { InputSwitchModule } from 'primeng/inputswitch' | ||
5 | import { MultiSelectModule } from 'primeng/multiselect' | ||
6 | import { ClipboardModule } from '@angular/cdk/clipboard' | ||
7 | import { CommonModule, DatePipe } from '@angular/common' | ||
8 | import { HttpClientModule } from '@angular/common/http' | ||
9 | import { NgModule } from '@angular/core' | ||
10 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||
11 | import { RouterModule } from '@angular/router' | ||
12 | import { | ||
13 | NgbCollapseModule, | ||
14 | NgbDropdownModule, | ||
15 | NgbModalModule, | ||
16 | NgbNavModule, | ||
17 | NgbPopoverModule, | ||
18 | NgbTooltipModule | ||
19 | } from '@ng-bootstrap/ng-bootstrap' | ||
20 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
21 | import { SharedGlobalIconModule } from '../shared-icons' | ||
22 | import { AccountService, ActorAvatarInfoComponent, AvatarComponent } from './account' | ||
23 | import { FromNowPipe, InfiniteScrollerDirective, NumberFormatterPipe, PeerTubeTemplateDirective } from './angular' | ||
24 | import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' | ||
25 | import { DateToggleComponent } from './date' | ||
26 | import { FeedComponent } from './feeds' | ||
27 | import { LoaderComponent, SmallLoaderComponent } from './loaders' | ||
28 | import { HelpComponent, ListOverflowComponent } from './misc' | ||
29 | import { UserHistoryService, UserNotificationsComponent, UserNotificationService } from './users' | ||
30 | import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video' | ||
31 | import { VideoCaptionService } from './video-caption' | ||
32 | import { VideoChannelService } from './video-channel' | ||
33 | import { 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 | }) | ||
164 | export 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 @@ | |||
1 | export * from './user-history.service' | ||
2 | export * from './user-notification.model' | ||
3 | export * from './user-notification.service' | ||
4 | export * 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 @@ | |||
1 | import { catchError, map, switchMap } from 'rxjs/operators' | ||
2 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' | ||
5 | import { ResultList } from '@shared/models' | ||
6 | import { environment } from '../../../../environments/environment' | ||
7 | import { Video } from '../video/video.model' | ||
8 | import { VideoService } from '../video/video.service' | ||
9 | |||
10 | @Injectable() | ||
11 | export 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 @@ | |||
1 | import { Actor } from '../account/actor.model' | ||
2 | import { ActorInfo, Avatar, FollowState, UserNotification as UserNotificationServer, UserNotificationType, VideoInfo } from '@shared/models' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { catchError, map, tap } from 'rxjs/operators' | ||
2 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { ComponentPaginationLight, RestExtractor, RestService, User, UserNotificationSocket } from '@app/core' | ||
5 | import { ResultList, UserNotification as UserNotificationServer, UserNotificationSetting } from '@shared/models' | ||
6 | import { environment } from '../../../../environments/environment' | ||
7 | import { UserNotification } from './user-notification.model' | ||
8 | |||
9 | @Injectable() | ||
10 | export 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 @@ | |||
1 | import { Subject } from 'rxjs' | ||
2 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' | ||
3 | import { ComponentPagination, hasMoreItems, Notifier } from '@app/core' | ||
4 | import { UserNotificationType } from '@shared/models' | ||
5 | import { UserNotification } from './user-notification.model' | ||
6 | import { 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 | }) | ||
13 | export 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 @@ | |||
1 | export * from './video-caption-edit.model' | ||
2 | export * 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 @@ | |||
1 | export 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 @@ | |||
1 | import { Observable, of } from 'rxjs' | ||
2 | import { catchError, map, switchMap } from 'rxjs/operators' | ||
3 | import { HttpClient } from '@angular/common/http' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | import { RestExtractor, ServerService } from '@app/core' | ||
6 | import { objectToFormData, sortBy } from '@app/helpers' | ||
7 | import { VideoService } from '@app/shared/shared-main/video' | ||
8 | import { peertubeTranslate, ResultList, VideoCaption } from '@shared/models' | ||
9 | import { VideoCaptionEdit } from './video-caption-edit.model' | ||
10 | |||
11 | @Injectable() | ||
12 | export 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 @@ | |||
1 | export * from './video-channel.model' | ||
2 | export * 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 @@ | |||
1 | import { VideoChannel as ServerVideoChannel, ViewsPerDate, Account } from '@shared/models' | ||
2 | import { Actor } from '../account/actor.model' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { Observable, ReplaySubject } from 'rxjs' | ||
2 | import { catchError, map, tap } from 'rxjs/operators' | ||
3 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' | ||
6 | import { Avatar, ResultList, VideoChannel as VideoChannelServer, VideoChannelCreate, VideoChannelUpdate } from '@shared/models' | ||
7 | import { environment } from '../../../../environments/environment' | ||
8 | import { Account } from '../account' | ||
9 | import { AccountService } from '../account/account.service' | ||
10 | import { VideoChannel } from './video-channel.model' | ||
11 | |||
12 | @Injectable() | ||
13 | export 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 @@ | |||
1 | export * from './redundancy.service' | ||
2 | export * from './video-details.model' | ||
3 | export * from './video-edit.model' | ||
4 | export * from './video-import.service' | ||
5 | export * from './video-ownership.service' | ||
6 | export * from './video.model' | ||
7 | export * 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 @@ | |||
1 | import { SortMeta } from 'primeng/api' | ||
2 | import { concat, Observable } from 'rxjs' | ||
3 | import { catchError, map, toArray } from 'rxjs/operators' | ||
4 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
5 | import { Injectable } from '@angular/core' | ||
6 | import { RestExtractor, RestPagination, RestService } from '@app/core' | ||
7 | import { ResultList, Video, VideoRedundanciesTarget, VideoRedundancy } from '@shared/models' | ||
8 | import { environment } from '../../../../environments/environment' | ||
9 | |||
10 | @Injectable() | ||
11 | export 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 @@ | |||
1 | import { Account } from '@app/shared/shared-main/account/account.model' | ||
2 | import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model' | ||
3 | import { | ||
4 | VideoConstant, | ||
5 | VideoDetails as VideoDetailsServerModel, | ||
6 | VideoFile, | ||
7 | VideoState, | ||
8 | VideoStreamingPlaylist, | ||
9 | VideoStreamingPlaylistType | ||
10 | } from '@shared/models' | ||
11 | import { Video } from './video.model' | ||
12 | |||
13 | export 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 @@ | |||
1 | import { Video, VideoPrivacy, VideoScheduleUpdate, VideoUpdate } from '@shared/models' | ||
2 | |||
3 | export 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 @@ | |||
1 | import { SortMeta } from 'primeng/api' | ||
2 | import { Observable } from 'rxjs' | ||
3 | import { catchError, map, switchMap } from 'rxjs/operators' | ||
4 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
5 | import { Injectable } from '@angular/core' | ||
6 | import { RestExtractor, RestPagination, RestService, ServerService, UserService } from '@app/core' | ||
7 | import { objectToFormData } from '@app/helpers' | ||
8 | import { peertubeTranslate, ResultList, VideoImport, VideoImportCreate, VideoUpdate } from '@shared/models' | ||
9 | import { environment } from '../../../../environments/environment' | ||
10 | |||
11 | @Injectable() | ||
12 | export 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 @@ | |||
1 | import { SortMeta } from 'primeng/api' | ||
2 | import { Observable } from 'rxjs' | ||
3 | import { catchError, map } from 'rxjs/operators' | ||
4 | import { HttpClient, HttpParams } from '@angular/common/http' | ||
5 | import { Injectable } from '@angular/core' | ||
6 | import { RestExtractor, RestPagination, RestService } from '@app/core' | ||
7 | import { ResultList, VideoChangeOwnership, VideoChangeOwnershipAccept, VideoChangeOwnershipCreate } from '@shared/models' | ||
8 | import { environment } from '../../../../environments/environment' | ||
9 | |||
10 | @Injectable() | ||
11 | export 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 @@ | |||
1 | import { AuthUser } from '@app/core' | ||
2 | import { User } from '@app/core/users/user.model' | ||
3 | import { durationToString, getAbsoluteAPIUrl } from '@app/helpers' | ||
4 | import { | ||
5 | Avatar, | ||
6 | peertubeTranslate, | ||
7 | ServerConfig, | ||
8 | UserRight, | ||
9 | Video as VideoServerModel, | ||
10 | VideoConstant, | ||
11 | VideoPrivacy, | ||
12 | VideoScheduleUpdate, | ||
13 | VideoState | ||
14 | } from '@shared/models' | ||
15 | import { environment } from '../../../../environments/environment' | ||
16 | import { Actor } from '../account/actor.model' | ||
17 | |||
18 | export 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 @@ | |||
1 | import { FfprobeData } from 'fluent-ffmpeg' | ||
2 | import { Observable } from 'rxjs' | ||
3 | import { catchError, map, switchMap } from 'rxjs/operators' | ||
4 | import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' | ||
5 | import { Injectable } from '@angular/core' | ||
6 | import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' | ||
7 | import { objectToFormData } from '@app/helpers' | ||
8 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
9 | import { | ||
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' | ||
24 | import { environment } from '../../../../environments/environment' | ||
25 | import { Account, AccountService } from '../account' | ||
26 | import { VideoChannel, VideoChannelService } from '../video-channel' | ||
27 | import { VideoDetails } from './video-details.model' | ||
28 | import { VideoEdit } from './video-edit.model' | ||
29 | import { Video } from './video.model' | ||
30 | |||
31 | export 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() | ||
43 | export 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 | } | ||