aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/+my-library
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/+my-library')
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channel-create.component.ts78
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.html105
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.scss67
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channel-edit.ts22
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts135
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels-routing.module.ts41
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.html49
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss125
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts171
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.module.ts31
-rw-r--r--client/src/app/+my-library/index.ts3
-rw-r--r--client/src/app/+my-library/my-history/my-history.component.html28
-rw-r--r--client/src/app/+my-library/my-history/my-history.component.scss59
-rw-r--r--client/src/app/+my-library/my-history/my-history.component.ts102
-rw-r--r--client/src/app/+my-library/my-library-routing.module.ts134
-rw-r--r--client/src/app/+my-library/my-library.component.html7
-rw-r--r--client/src/app/+my-library/my-library.component.scss13
-rw-r--r--client/src/app/+my-library/my-library.component.ts76
-rw-r--r--client/src/app/+my-library/my-library.module.ts79
-rw-r--r--client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.html36
-rw-r--r--client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.scss14
-rw-r--r--client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts72
-rw-r--r--client/src/app/+my-library/my-ownership/my-ownership.component.html90
-rw-r--r--client/src/app/+my-library/my-ownership/my-ownership.component.scss71
-rw-r--r--client/src/app/+my-library/my-ownership/my-ownership.component.ts81
-rw-r--r--client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html42
-rw-r--r--client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss81
-rw-r--r--client/src/app/+my-library/my-subscriptions/my-subscriptions.component.ts74
-rw-r--r--client/src/app/+my-library/my-video-imports/my-video-imports.component.html70
-rw-r--r--client/src/app/+my-library/my-video-imports/my-video-imports.component.scss14
-rw-r--r--client/src/app/+my-library/my-video-imports/my-video-imports.component.ts76
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts91
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html100
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss36
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.ts13
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html51
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.scss83
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.ts197
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts129
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html35
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlists.component.scss78
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlists.component.ts101
-rw-r--r--client/src/app/+my-library/my-videos/modals/video-change-ownership.component.html33
-rw-r--r--client/src/app/+my-library/my-videos/modals/video-change-ownership.component.scss10
-rw-r--r--client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts69
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.html58
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.scss138
-rw-r--r--client/src/app/+my-library/my-videos/my-videos.component.ts178
48 files changed, 3446 insertions, 0 deletions
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-create.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channel-create.component.ts
new file mode 100644
index 000000000..1d0cbf246
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-create.component.ts
@@ -0,0 +1,78 @@
1import { Component, OnInit } from '@angular/core'
2import { Router } from '@angular/router'
3import { AuthService, Notifier } from '@app/core'
4import {
5 VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
6 VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
7 VIDEO_CHANNEL_NAME_VALIDATOR,
8 VIDEO_CHANNEL_SUPPORT_VALIDATOR
9} from '@app/shared/form-validators/video-channel-validators'
10import { FormValidatorService } from '@app/shared/shared-forms'
11import { VideoChannelService } from '@app/shared/shared-main'
12import { VideoChannelCreate } from '@shared/models'
13import { MyVideoChannelEdit } from './my-video-channel-edit'
14
15@Component({
16 templateUrl: './my-video-channel-edit.component.html',
17 styleUrls: [ './my-video-channel-edit.component.scss' ]
18})
19export class MyVideoChannelCreateComponent extends MyVideoChannelEdit implements OnInit {
20 error: string
21
22 constructor (
23 protected formValidatorService: FormValidatorService,
24 private authService: AuthService,
25 private notifier: Notifier,
26 private router: Router,
27 private videoChannelService: VideoChannelService
28 ) {
29 super()
30 }
31
32 ngOnInit () {
33 this.buildForm({
34 name: VIDEO_CHANNEL_NAME_VALIDATOR,
35 'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
36 description: VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
37 support: VIDEO_CHANNEL_SUPPORT_VALIDATOR
38 })
39 }
40
41 formValidated () {
42 this.error = undefined
43
44 const body = this.form.value
45 const videoChannelCreate: VideoChannelCreate = {
46 name: body.name,
47 displayName: body['display-name'],
48 description: body.description || null,
49 support: body.support || null
50 }
51
52 this.videoChannelService.createVideoChannel(videoChannelCreate).subscribe(
53 () => {
54 this.authService.refreshUserInformation()
55
56 this.notifier.success($localize`Video channel ${videoChannelCreate.displayName} created.`)
57 this.router.navigate([ '/my-library', 'video-channels' ])
58 },
59
60 err => {
61 if (err.status === 409) {
62 this.error = $localize`This name already exists on this instance.`
63 return
64 }
65
66 this.error = err.message
67 }
68 )
69 }
70
71 isCreation () {
72 return true
73 }
74
75 getFormButtonTitle () {
76 return $localize`Create`
77 }
78}
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.html b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.html
new file mode 100644
index 000000000..7e0c4e732
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.html
@@ -0,0 +1,105 @@
1<nav aria-label="breadcrumb">
2 <ol class="breadcrumb">
3 <li class="breadcrumb-item">
4 <a routerLink="/my-library/video-channels" i18n>My Channels</a>
5 </li>
6
7 <ng-container *ngIf="isCreation()">
8 <li class="breadcrumb-item active" i18n>Create</li>
9 </ng-container>
10 <ng-container *ngIf="!isCreation()">
11 <li class="breadcrumb-item active" i18n>Edit</li>
12 <li class="breadcrumb-item active" aria-current="page">
13 <a *ngIf="videoChannelToUpdate" [routerLink]="[ '/my-library/video-channels/update', videoChannelToUpdate?.nameWithHost ]">{{ videoChannelToUpdate?.displayName }}</a>
14 </li>
15 </ng-container>
16 </ol>
17</nav>
18
19<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
20
21<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
22
23 <div class="form-row"> <!-- channel grid -->
24 <div class="form-group col-12 col-lg-4 col-xl-3">
25 <div *ngIf="isCreation()" class="video-channel-title" i18n>NEW CHANNEL</div>
26 <div *ngIf="!isCreation() && videoChannelToUpdate" class="video-channel-title" i18n>CHANNEL</div>
27 </div>
28
29 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
30
31 <div class="form-group" *ngIf="isCreation()">
32 <label i18n for="name">Name</label>
33 <div class="input-group">
34 <input
35 type="text" id="name" i18n-placeholder placeholder="Example: my_channel"
36 formControlName="name" [ngClass]="{ 'input-error': formErrors['name'] }" class="form-control"
37 >
38 <div class="input-group-append">
39 <span class="input-group-text">@{{ instanceHost }}</span>
40 </div>
41 </div>
42 <div *ngIf="formErrors['name']" class="form-error">
43 {{ formErrors['name'] }}
44 </div>
45 </div>
46
47 <my-actor-avatar-info
48 *ngIf="!isCreation() && videoChannelToUpdate"
49 [actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)"
50 ></my-actor-avatar-info>
51
52 <div class="form-group">
53 <label i18n for="display-name">Display name</label>
54 <input
55 type="text" id="display-name" class="form-control"
56 formControlName="display-name" [ngClass]="{ 'input-error': formErrors['display-name'] }"
57 >
58 <div *ngIf="formErrors['display-name']" class="form-error">
59 {{ formErrors['display-name'] }}
60 </div>
61 </div>
62
63 <div class="form-group">
64 <label i18n for="description">Description</label>
65 <textarea
66 id="description" formControlName="description" class="form-control"
67 [ngClass]="{ 'input-error': formErrors['description'] }"
68 ></textarea>
69 <div *ngIf="formErrors.description" class="form-error">
70 {{ formErrors.description }}
71 </div>
72 </div>
73
74 <div class="form-group">
75 <label for="support">Support</label>
76 <my-help
77 helpType="markdownEnhanced" i18n-preHtml preHtml="Short text to tell people how they can support your channel (membership platform...).<br /><br />
78 When you will upload a video in this channel, the video support field will be automatically filled by this text."
79 ></my-help>
80 <my-markdown-textarea
81 id="support" formControlName="support" textareaMaxWidth="500px" markdownType="enhanced"
82 [classes]="{ 'input-error': formErrors['support'] }"
83 ></my-markdown-textarea>
84 <div *ngIf="formErrors.support" class="form-error">
85 {{ formErrors.support }}
86 </div>
87 </div>
88
89 <div class="form-group" *ngIf="isBulkUpdateVideosDisplayed()">
90 <my-peertube-checkbox
91 inputName="bulkVideosSupportUpdate" formControlName="bulkVideosSupportUpdate"
92 i18n-labelText labelText="Overwrite support field of all videos of this channel"
93 ></my-peertube-checkbox>
94 </div>
95
96 </div>
97 </div>
98
99 <div class="form-row"> <!-- submit placement block -->
100 <div class="col-md-7 col-xl-5"></div>
101 <div class="col-md-5 col-xl-5 d-inline-flex">
102 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
103 </div>
104 </div>
105</form>
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.scss b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.scss
new file mode 100644
index 000000000..8f8af655c
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.scss
@@ -0,0 +1,67 @@
1@import '_variables';
2@import '_mixins';
3
4label {
5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
9.video-channel-title {
10 @include settings-big-title;
11}
12
13my-actor-avatar-info {
14 display: block;
15 margin-bottom: 20px;
16}
17
18.input-group {
19 @include peertube-input-group(fit-content);
20}
21
22.input-group-append {
23 height: 30px;
24}
25
26input {
27 &[type=text] {
28 @include peertube-input-text(340px);
29
30 display: block;
31
32 &#name {
33 width: auto;
34 flex-grow: 1;
35 }
36 }
37
38 &[type=submit] {
39 @include peertube-button;
40 @include orange-button;
41 margin-left: auto;
42 }
43}
44
45textarea {
46 @include peertube-textarea(500px, 150px);
47
48 display: block;
49}
50
51.peertube-select-container {
52 @include peertube-select-container(340px);
53}
54
55.breadcrumb {
56 @include breadcrumb;
57}
58
59@media screen and (max-width: $small-view) {
60 input[type=text]#name {
61 width: auto !important;
62 }
63
64 label[for=name] + div, textarea {
65 width: 100%;
66 }
67}
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.ts b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.ts
new file mode 100644
index 000000000..09db0df9d
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.ts
@@ -0,0 +1,22 @@
1import { FormReactive } from '@app/shared/shared-forms'
2import { VideoChannel } from '@app/shared/shared-main'
3
4export abstract class MyVideoChannelEdit extends FormReactive {
5 // We need it even in the create component because it's used in the edit template
6 videoChannelToUpdate: VideoChannel
7
8 abstract isCreation (): boolean
9 abstract getFormButtonTitle (): string
10
11 get instanceHost () {
12 return window.location.host
13 }
14
15 // We need this method so angular does not complain in child template that doesn't need this
16 onAvatarChange (formData: FormData) { /* empty */ }
17
18 // Should be implemented by the child
19 isBulkUpdateVideosDisplayed () {
20 return false
21 }
22}
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts
new file mode 100644
index 000000000..c6cb5ade6
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts
@@ -0,0 +1,135 @@
1import { Subscription } from 'rxjs'
2import { Component, OnDestroy, OnInit } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, Notifier, ServerService } from '@app/core'
5import {
6 VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
7 VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
8 VIDEO_CHANNEL_SUPPORT_VALIDATOR
9} from '@app/shared/form-validators/video-channel-validators'
10import { FormValidatorService } from '@app/shared/shared-forms'
11import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
12import { ServerConfig, VideoChannelUpdate } from '@shared/models'
13import { MyVideoChannelEdit } from './my-video-channel-edit'
14
15@Component({
16 selector: 'my-video-channel-update',
17 templateUrl: './my-video-channel-edit.component.html',
18 styleUrls: [ './my-video-channel-edit.component.scss' ]
19})
20export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements OnInit, OnDestroy {
21 error: string
22 videoChannelToUpdate: VideoChannel
23
24 private paramsSub: Subscription
25 private oldSupportField: string
26 private serverConfig: ServerConfig
27
28 constructor (
29 protected formValidatorService: FormValidatorService,
30 private authService: AuthService,
31 private notifier: Notifier,
32 private router: Router,
33 private route: ActivatedRoute,
34 private videoChannelService: VideoChannelService,
35 private serverService: ServerService
36 ) {
37 super()
38 }
39
40 ngOnInit () {
41 this.serverConfig = this.serverService.getTmpConfig()
42 this.serverService.getConfig()
43 .subscribe(config => this.serverConfig = config)
44
45 this.buildForm({
46 'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
47 description: VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
48 support: VIDEO_CHANNEL_SUPPORT_VALIDATOR,
49 bulkVideosSupportUpdate: null
50 })
51
52 this.paramsSub = this.route.params.subscribe(routeParams => {
53 const videoChannelId = routeParams['videoChannelId']
54
55 this.videoChannelService.getVideoChannel(videoChannelId).subscribe(
56 videoChannelToUpdate => {
57 this.videoChannelToUpdate = videoChannelToUpdate
58
59 this.oldSupportField = videoChannelToUpdate.support
60
61 this.form.patchValue({
62 'display-name': videoChannelToUpdate.displayName,
63 description: videoChannelToUpdate.description,
64 support: videoChannelToUpdate.support
65 })
66 },
67
68 err => this.error = err.message
69 )
70 })
71 }
72
73 ngOnDestroy () {
74 if (this.paramsSub) this.paramsSub.unsubscribe()
75 }
76
77 formValidated () {
78 this.error = undefined
79
80 const body = this.form.value
81 const videoChannelUpdate: VideoChannelUpdate = {
82 displayName: body['display-name'],
83 description: body.description || null,
84 support: body.support || null,
85 bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false
86 }
87
88 this.videoChannelService.updateVideoChannel(this.videoChannelToUpdate.name, videoChannelUpdate).subscribe(
89 () => {
90 this.authService.refreshUserInformation()
91
92 this.notifier.success($localize`Video channel ${videoChannelUpdate.displayName} updated.`)
93
94 this.router.navigate([ '/my-library', 'video-channels' ])
95 },
96
97 err => this.error = err.message
98 )
99 }
100
101 onAvatarChange (formData: FormData) {
102 this.videoChannelService.changeVideoChannelAvatar(this.videoChannelToUpdate.name, formData)
103 .subscribe(
104 data => {
105 this.notifier.success($localize`Avatar changed.`)
106
107 this.videoChannelToUpdate.updateAvatar(data.avatar)
108 },
109
110 err => this.notifier.error(err.message)
111 )
112 }
113
114 get maxAvatarSize () {
115 return this.serverConfig.avatar.file.size.max
116 }
117
118 get avatarExtensions () {
119 return this.serverConfig.avatar.file.extensions.join(',')
120 }
121
122 isCreation () {
123 return false
124 }
125
126 getFormButtonTitle () {
127 return $localize`Update`
128 }
129
130 isBulkUpdateVideosDisplayed () {
131 if (this.oldSupportField === undefined) return false
132
133 return this.oldSupportField !== this.form.value['support']
134 }
135}
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels-routing.module.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels-routing.module.ts
new file mode 100644
index 000000000..6b8efad0b
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels-routing.module.ts
@@ -0,0 +1,41 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { MyVideoChannelUpdateComponent } from './my-video-channel-update.component'
4import { MyVideoChannelCreateComponent } from './my-video-channel-create.component'
5import { MyVideoChannelsComponent } from './my-video-channels.component'
6
7const myVideoChannelsRoutes: Routes = [
8 {
9 path: '',
10 component: MyVideoChannelsComponent,
11 data: {
12 meta: {
13 title: $localize`My video channels`
14 }
15 }
16 },
17 {
18 path: 'create',
19 component: MyVideoChannelCreateComponent,
20 data: {
21 meta: {
22 title: $localize`Create a new video channel`
23 }
24 }
25 },
26 {
27 path: 'update/:videoChannelId',
28 component: MyVideoChannelUpdateComponent,
29 data: {
30 meta: {
31 title: $localize`Update video channel`
32 }
33 }
34 }
35]
36
37@NgModule({
38 imports: [ RouterModule.forChild(myVideoChannelsRoutes) ],
39 exports: [ RouterModule ]
40})
41export class MyVideoChannelsRoutingModule {}
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
new file mode 100644
index 000000000..205d23cd5
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
@@ -0,0 +1,49 @@
1<h1>
2 <span>
3 <my-global-icon iconName="channel" aria-hidden="true"></my-global-icon>
4 <ng-container i18n>My channels</ng-container>
5 <span class="badge badge-secondary">{{ totalItems }}</span>
6 </span>
7</h1>
8
9<div class="video-channels-header d-flex justify-content-between">
10 <div class="has-feedback has-clear">
11 <input type="text" placeholder="Search your channels" i18n-placeholder [(ngModel)]="channelsSearch"
12 (ngModelChange)="onChannelsSearchChanged()" />
13 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
14 <span class="sr-only" i18n>Clear filters</span>
15 </div>
16
17 <a class="create-button" routerLink="create">
18 <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
19 <ng-container i18n>Create video channel</ng-container>
20 </a>
21</div>
22
23<div class="video-channels">
24 <div *ngFor="let videoChannel of videoChannels; let i = index" class="video-channel">
25 <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]">
26 <img [src]="videoChannel.avatarUrl" alt="Avatar" />
27 </a>
28
29 <div class="video-channel-info">
30 <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
31 <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
32 <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
33 </a>
34
35 <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
36
37 <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div>
38
39 <div class="video-channel-buttons">
40 <my-edit-button label [routerLink]="[ 'update', videoChannel.nameWithHost ]"></my-edit-button>
41 <my-delete-button label (click)="deleteVideoChannel(videoChannel)"></my-delete-button>
42 </div>
43
44 <div *ngIf="!isInSmallView" class="w-100 d-flex justify-content-end">
45 <p-chart *ngIf="videoChannelsChartData && videoChannelsChartData[i]" type="line" [data]="videoChannelsChartData[i]" [options]="chartOptions" width="40vw" height="100px"></p-chart>
46 </div>
47 </div>
48 </div>
49</div>
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss
new file mode 100644
index 000000000..f2f42459f
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss
@@ -0,0 +1,125 @@
1@import '_variables';
2@import '_mixins';
3
4.create-button {
5 @include create-button;
6}
7
8input[type=text] {
9 @include peertube-input-text(300px);
10}
11
12::ng-deep .action-button {
13 &.action-button-edit {
14 margin-right: 10px;
15 }
16}
17
18.video-channel {
19 @include row-blocks;
20 padding-bottom: 0;
21
22 img {
23 @include avatar(80px);
24
25 margin-right: 10px;
26 }
27
28 .video-channel-info {
29 flex-grow: 1;
30
31 a.video-channel-names {
32 @include disable-default-a-behaviour;
33
34 width: fit-content;
35 display: flex;
36 align-items: baseline;
37 color: pvar(--mainForegroundColor);
38
39 .video-channel-display-name {
40 font-weight: $font-semibold;
41 font-size: 18px;
42 }
43
44 .video-channel-name {
45 font-size: 14px;
46 color: $grey-actor-name;
47 margin-left: 5px;
48 }
49 }
50 }
51
52 .video-channel-buttons {
53 margin-top: 10px;
54 min-width: 190px;
55 }
56}
57
58::ng-deep .chartjs-render-monitor {
59 position: relative;
60 top: 1px;
61}
62
63.video-channels-header {
64 margin-bottom: 30px;
65}
66
67@media screen and (max-width: $small-view) {
68 .video-channels-header {
69 text-align: center;
70 }
71
72 .video-channel {
73 padding-bottom: 10px;
74
75 .video-channel-info {
76 padding-bottom: 10px;
77 text-align: center;
78
79 .video-channel-names {
80 flex-direction: column;
81 align-items: center !important;
82 margin: auto;
83
84 .video-channel-name {
85 margin-left: 0px !important;
86 }
87 }
88 }
89
90 img {
91 margin-right: 0;
92 }
93
94 .video-channel-buttons {
95 align-self: center;
96 }
97 }
98}
99
100@media screen and (max-width: $mobile-view) {
101 .video-channels-header {
102 flex-direction: column;
103
104 input[type=text] {
105 width: 100% !important;
106 margin-bottom: 12px;
107 }
108 }
109}
110
111@media screen and (min-width: breakpoint(lg)) {
112 :host-context(.main-col:not(.expanded)) {
113 .video-channel-buttons {
114 float: right;
115 }
116 }
117}
118
119@media screen and (min-width: $small-view) {
120 :host-context(.expanded) {
121 .video-channel-buttons {
122 float: right;
123 }
124 }
125}
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
new file mode 100644
index 000000000..a63e98a51
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
@@ -0,0 +1,171 @@
1import { ChartData } from 'chart.js'
2import { max, maxBy, min, minBy } from 'lodash-es'
3import { Subject } from 'rxjs'
4import { debounceTime, mergeMap } from 'rxjs/operators'
5import { Component, OnInit } from '@angular/core'
6import { AuthService, ConfirmService, Notifier, ScreenService, User } from '@app/core'
7import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
8
9@Component({
10 templateUrl: './my-video-channels.component.html',
11 styleUrls: [ './my-video-channels.component.scss' ]
12})
13export class MyVideoChannelsComponent implements OnInit {
14 totalItems: number
15
16 videoChannels: VideoChannel[] = []
17 videoChannelsChartData: ChartData[]
18 videoChannelsMinimumDailyViews = 0
19 videoChannelsMaximumDailyViews: number
20
21 channelsSearch: string
22 channelsSearchChanged = new Subject<string>()
23
24 private user: User
25
26 constructor (
27 private authService: AuthService,
28 private notifier: Notifier,
29 private confirmService: ConfirmService,
30 private videoChannelService: VideoChannelService,
31 private screenService: ScreenService
32 ) {}
33
34 ngOnInit () {
35 this.user = this.authService.getUser()
36
37 this.loadVideoChannels()
38
39 this.channelsSearchChanged
40 .pipe(debounceTime(500))
41 .subscribe(() => {
42 this.loadVideoChannels()
43 })
44 }
45
46 get isInSmallView () {
47 return this.screenService.isInSmallView()
48 }
49
50 get chartOptions () {
51 return {
52 legend: {
53 display: false
54 },
55 scales: {
56 xAxes: [{
57 display: false
58 }],
59 yAxes: [{
60 display: false,
61 ticks: {
62 min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)),
63 max: Math.max(1, this.videoChannelsMaximumDailyViews)
64 }
65 }]
66 },
67 layout: {
68 padding: {
69 left: 15,
70 right: 15,
71 top: 10,
72 bottom: 0
73 }
74 },
75 elements: {
76 point: {
77 radius: 0
78 }
79 },
80 tooltips: {
81 mode: 'index',
82 intersect: false,
83 custom: function (tooltip: any) {
84 if (!tooltip) return
85 // disable displaying the color box
86 tooltip.displayColors = false
87 },
88 callbacks: {
89 label: (tooltip: any, data: any) => `${tooltip.value} views`
90 }
91 },
92 hover: {
93 mode: 'index',
94 intersect: false
95 }
96 }
97 }
98
99 resetSearch () {
100 this.channelsSearch = ''
101 this.onChannelsSearchChanged()
102 }
103
104 onChannelsSearchChanged () {
105 this.channelsSearchChanged.next()
106 }
107
108 async deleteVideoChannel (videoChannel: VideoChannel) {
109 const res = await this.confirmService.confirmWithInput(
110 $localize`Do you really want to delete ${videoChannel.displayName}?
111It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another
112channel with the same name (${videoChannel.name})!`,
113
114 $localize`Please type the display name of the video channel (${videoChannel.displayName}) to confirm`,
115
116 videoChannel.displayName,
117
118 $localize`Delete`
119 )
120 if (res === false) return
121
122 this.videoChannelService.removeVideoChannel(videoChannel)
123 .subscribe(
124 () => {
125 this.loadVideoChannels()
126 this.notifier.success($localize`Video channel ${videoChannel.displayName} deleted.`)
127 },
128
129 error => this.notifier.error(error.message)
130 )
131 }
132
133 private loadVideoChannels () {
134 this.authService.userInformationLoaded
135 .pipe(mergeMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true, this.channelsSearch)))
136 .subscribe(res => {
137 this.videoChannels = res.data
138 this.totalItems = res.total
139
140 // chart data
141 this.videoChannelsChartData = this.videoChannels.map(v => ({
142 labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()),
143 datasets: [
144 {
145 label: $localize`Views for the day`,
146 data: v.viewsPerDay.map(day => day.views),
147 fill: false,
148 borderColor: '#c6c6c6'
149 }
150 ]
151 } as ChartData))
152
153 // chart options that depend on chart data:
154 // we don't want to skew values and have min at 0, so we define what the floor/ceiling is here
155 this.videoChannelsMinimumDailyViews = min(
156 // compute local minimum daily views for each channel, by their "views" attribute
157 this.videoChannels.map(v => minBy(
158 v.viewsPerDay,
159 day => day.views
160 ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
161 )
162 this.videoChannelsMaximumDailyViews = max(
163 // compute local maximum daily views for each channel, by their "views" attribute
164 this.videoChannels.map(v => maxBy(
165 v.viewsPerDay,
166 day => day.views
167 ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute
168 )
169 })
170 }
171}
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.module.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels.module.ts
new file mode 100644
index 000000000..92b56db49
--- /dev/null
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.module.ts
@@ -0,0 +1,31 @@
1import { ChartModule } from 'primeng/chart'
2import { NgModule } from '@angular/core'
3import { SharedFormModule } from '@app/shared/shared-forms'
4import { SharedGlobalIconModule } from '@app/shared/shared-icons'
5import { SharedMainModule } from '@app/shared/shared-main'
6import { MyVideoChannelCreateComponent } from './my-video-channel-create.component'
7import { MyVideoChannelUpdateComponent } from './my-video-channel-update.component'
8import { MyVideoChannelsRoutingModule } from './my-video-channels-routing.module'
9import { MyVideoChannelsComponent } from './my-video-channels.component'
10
11@NgModule({
12 imports: [
13 MyVideoChannelsRoutingModule,
14
15 ChartModule,
16
17 SharedMainModule,
18 SharedFormModule,
19 SharedGlobalIconModule
20 ],
21
22 declarations: [
23 MyVideoChannelsComponent,
24 MyVideoChannelCreateComponent,
25 MyVideoChannelUpdateComponent
26 ],
27
28 exports: [],
29 providers: []
30})
31export class MyVideoChannelsModule { }
diff --git a/client/src/app/+my-library/index.ts b/client/src/app/+my-library/index.ts
new file mode 100644
index 000000000..d51a36a7c
--- /dev/null
+++ b/client/src/app/+my-library/index.ts
@@ -0,0 +1,3 @@
1export * from './my-library-routing.module'
2export * from './my-library.component'
3export * from './my-library.module'
diff --git a/client/src/app/+my-library/my-history/my-history.component.html b/client/src/app/+my-library/my-history/my-history.component.html
new file mode 100644
index 000000000..cff46a41d
--- /dev/null
+++ b/client/src/app/+my-library/my-history/my-history.component.html
@@ -0,0 +1,28 @@
1<h1>
2 <my-global-icon iconName="history" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>My history</ng-container>
4</h1>
5
6<div class="top-buttons">
7 <div class="history-switch">
8 <p-inputSwitch [(ngModel)]="videosHistoryEnabled" (ngModelChange)="onVideosHistoryChange()"></p-inputSwitch>
9 <label i18n>Video history</label>
10 </div>
11
12 <button class="delete-history" (click)="deleteHistory()" i18n>
13 <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
14 Delete history
15 </button>
16</div>
17
18
19<div class="no-history" i18n *ngIf="pagination.totalItems === 0">You don't have any video history yet.</div>
20
21<div myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [autoInit]="true" [dataObservable]="onDataSubject.asObservable()" class="videos">
22 <div class="video" *ngFor="let video of videos">
23 <my-video-miniature
24 [video]="video" [displayAsRow]="true"
25 (videoRemoved)="removeVideoFromArray(video)" (videoBlocked)="removeVideoFromArray(video)"
26 ></my-video-miniature>
27 </div>
28</div>
diff --git a/client/src/app/+my-library/my-history/my-history.component.scss b/client/src/app/+my-library/my-history/my-history.component.scss
new file mode 100644
index 000000000..9eeeaf310
--- /dev/null
+++ b/client/src/app/+my-library/my-history/my-history.component.scss
@@ -0,0 +1,59 @@
1@import '_variables';
2@import '_mixins';
3
4.no-history {
5 display: flex;
6 justify-content: center;
7 margin-top: 50px;
8 font-weight: $font-semibold;
9 font-size: 16px;
10}
11
12.top-buttons {
13 margin-bottom: 20px;
14 display: flex;
15 align-items: center;
16 flex-wrap: wrap;
17
18 .history-switch {
19 display: flex;
20 flex-grow: 1;
21
22 label {
23 margin: 0 0 0 5px;
24 }
25 }
26
27 .delete-history {
28 @include peertube-button;
29 @include grey-button;
30 @include button-with-icon;
31
32 font-size: 15px;
33 }
34}
35
36.video {
37 @include row-blocks;
38
39 .my-video-miniature {
40 flex-grow: 1;
41 }
42}
43
44@media screen and (max-width: $mobile-view) {
45 .top-buttons {
46 .history-switch label, .delete-history {
47 @include ellipsis;
48 }
49
50 .history-switch label {
51 width: 60%;
52 }
53
54 .delete-history {
55 margin-left: auto;
56 max-width: 32%;
57 }
58 }
59}
diff --git a/client/src/app/+my-library/my-history/my-history.component.ts b/client/src/app/+my-library/my-history/my-history.component.ts
new file mode 100644
index 000000000..e11f05c47
--- /dev/null
+++ b/client/src/app/+my-library/my-history/my-history.component.ts
@@ -0,0 +1,102 @@
1import { Component, OnDestroy, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router'
3import {
4 AuthService,
5 ComponentPagination,
6 ConfirmService,
7 LocalStorageService,
8 Notifier,
9 ScreenService,
10 ServerService,
11 UserService
12} from '@app/core'
13import { immutableAssign } from '@app/helpers'
14import { UserHistoryService } from '@app/shared/shared-main'
15import { AbstractVideoList } from '@app/shared/shared-video-miniature'
16
17@Component({
18 templateUrl: './my-history.component.html',
19 styleUrls: [ './my-history.component.scss' ]
20})
21export class MyHistoryComponent extends AbstractVideoList implements OnInit, OnDestroy {
22 titlePage: string
23 pagination: ComponentPagination = {
24 currentPage: 1,
25 itemsPerPage: 5,
26 totalItems: null
27 }
28 videosHistoryEnabled: boolean
29
30 constructor (
31 protected router: Router,
32 protected serverService: ServerService,
33 protected route: ActivatedRoute,
34 protected authService: AuthService,
35 protected userService: UserService,
36 protected notifier: Notifier,
37 protected screenService: ScreenService,
38 protected storageService: LocalStorageService,
39 private confirmService: ConfirmService,
40 private userHistoryService: UserHistoryService
41 ) {
42 super()
43
44 this.titlePage = $localize`My videos history`
45 }
46
47 ngOnInit () {
48 super.ngOnInit()
49
50 this.videosHistoryEnabled = this.authService.getUser().videosHistoryEnabled
51 }
52
53 ngOnDestroy () {
54 super.ngOnDestroy()
55 }
56
57 getVideosObservable (page: number) {
58 const newPagination = immutableAssign(this.pagination, { currentPage: page })
59
60 return this.userHistoryService.getUserVideosHistory(newPagination)
61 }
62
63 generateSyndicationList () {
64 throw new Error('Method not implemented.')
65 }
66
67 onVideosHistoryChange () {
68 this.userService.updateMyProfile({ videosHistoryEnabled: this.videosHistoryEnabled })
69 .subscribe(
70 () => {
71 const message = this.videosHistoryEnabled === true ?
72 $localize`Videos history is enabled` :
73 $localize`Videos history is disabled`
74
75 this.notifier.success(message)
76
77 this.authService.refreshUserInformation()
78 },
79
80 err => this.notifier.error(err.message)
81 )
82 }
83
84 async deleteHistory () {
85 const title = $localize`Delete videos history`
86 const message = $localize`Are you sure you want to delete all your videos history?`
87
88 const res = await this.confirmService.confirm(message, title)
89 if (res !== true) return
90
91 this.userHistoryService.deleteUserVideosHistory()
92 .subscribe(
93 () => {
94 this.notifier.success($localize`Videos history deleted`)
95
96 this.reloadVideos()
97 },
98
99 err => this.notifier.error(err.message)
100 )
101 }
102}
diff --git a/client/src/app/+my-library/my-library-routing.module.ts b/client/src/app/+my-library/my-library-routing.module.ts
new file mode 100644
index 000000000..d8e5aa562
--- /dev/null
+++ b/client/src/app/+my-library/my-library-routing.module.ts
@@ -0,0 +1,134 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { MetaGuard } from '@ngx-meta/core'
4import { LoginGuard } from '../core'
5import { MyHistoryComponent } from './my-history/my-history.component'
6import { MyLibraryComponent } from './my-library.component'
7import { MyOwnershipComponent } from './my-ownership/my-ownership.component'
8import { MySubscriptionsComponent } from './my-subscriptions/my-subscriptions.component'
9import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component'
10import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component'
11import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component'
12import { MyVideoPlaylistUpdateComponent } from './my-video-playlists/my-video-playlist-update.component'
13import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlists.component'
14import { MyVideosComponent } from './my-videos/my-videos.component'
15
16const myLibraryRoutes: Routes = [
17 {
18 path: '',
19 component: MyLibraryComponent,
20 canActivateChild: [ MetaGuard, LoginGuard ],
21 children: [
22 {
23 path: '',
24 redirectTo: 'video-channels',
25 pathMatch: 'full'
26 },
27
28 {
29 path: 'video-channels',
30 loadChildren: () => {
31 return import('./+my-video-channels/my-video-channels.module').then(m => m.MyVideoChannelsModule)
32 }
33 },
34
35 {
36 path: 'video-playlists',
37 component: MyVideoPlaylistsComponent,
38 data: {
39 meta: {
40 title: $localize`My playlists`
41 }
42 }
43 },
44 {
45 path: 'video-playlists/create',
46 component: MyVideoPlaylistCreateComponent,
47 data: {
48 meta: {
49 title: $localize`Create a new playlist`
50 }
51 }
52 },
53 {
54 path: 'video-playlists/:videoPlaylistId',
55 component: MyVideoPlaylistElementsComponent,
56 data: {
57 meta: {
58 title: $localize`Playlist elements`
59 }
60 }
61 },
62 {
63 path: 'video-playlists/update/:videoPlaylistId',
64 component: MyVideoPlaylistUpdateComponent,
65 data: {
66 meta: {
67 title: $localize`Update playlist`
68 }
69 }
70 },
71
72 {
73 path: 'videos',
74 component: MyVideosComponent,
75 data: {
76 meta: {
77 title: $localize`My videos`
78 },
79 reuse: {
80 enabled: true,
81 key: 'my-videos-list'
82 }
83 }
84 },
85 {
86 path: 'video-imports',
87 component: MyVideoImportsComponent,
88 data: {
89 meta: {
90 title: $localize`My video imports`
91 }
92 }
93 },
94 {
95 path: 'subscriptions',
96 component: MySubscriptionsComponent,
97 data: {
98 meta: {
99 title: $localize`My subscriptions`
100 }
101 }
102 },
103 {
104 path: 'ownership',
105 component: MyOwnershipComponent,
106 data: {
107 meta: {
108 title: $localize`Ownership changes`
109 }
110 }
111 },
112
113 {
114 path: 'history/videos',
115 component: MyHistoryComponent,
116 data: {
117 meta: {
118 title: $localize`My video history`
119 },
120 reuse: {
121 enabled: true,
122 key: 'my-videos-history-list'
123 }
124 }
125 }
126 ]
127 }
128]
129
130@NgModule({
131 imports: [ RouterModule.forChild(myLibraryRoutes) ],
132 exports: [ RouterModule ]
133})
134export class MyLibraryRoutingModule {}
diff --git a/client/src/app/+my-library/my-library.component.html b/client/src/app/+my-library/my-library.component.html
new file mode 100644
index 000000000..b465d0156
--- /dev/null
+++ b/client/src/app/+my-library/my-library.component.html
@@ -0,0 +1,7 @@
1<div class="row">
2 <my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown>
3
4 <div class="margin-content pb-5" [ngClass]="{ 'offset-content': !isBroadcastMessageDisplayed }">
5 <router-outlet></router-outlet>
6 </div>
7</div>
diff --git a/client/src/app/+my-library/my-library.component.scss b/client/src/app/+my-library/my-library.component.scss
new file mode 100644
index 000000000..a5bb499b4
--- /dev/null
+++ b/client/src/app/+my-library/my-library.component.scss
@@ -0,0 +1,13 @@
1@import '_variables';
2@import '_mixins';
3
4.row {
5 flex-direction: column;
6 width: 100%;
7
8 & > my-top-menu-dropdown:nth-child(1) {
9 flex-grow: 1;
10 }
11
12 @include sub-menu-h1;
13}
diff --git a/client/src/app/+my-library/my-library.component.ts b/client/src/app/+my-library/my-library.component.ts
new file mode 100644
index 000000000..0cc91e484
--- /dev/null
+++ b/client/src/app/+my-library/my-library.component.ts
@@ -0,0 +1,76 @@
1import { Component, OnInit } from '@angular/core'
2import { AuthService, AuthUser, ScreenService, ServerService } from '@app/core'
3import { ServerConfig } from '@shared/models'
4import { TopMenuDropdownParam } from '../shared/shared-main/misc/top-menu-dropdown.component'
5
6@Component({
7 templateUrl: './my-library.component.html',
8 styleUrls: [ './my-library.component.scss' ]
9})
10export class MyLibraryComponent implements OnInit {
11 menuEntries: TopMenuDropdownParam[] = []
12 user: AuthUser
13
14 private serverConfig: ServerConfig
15
16 constructor (
17 private serverService: ServerService,
18 private authService: AuthService,
19 private screenService: ScreenService
20 ) { }
21
22 get isBroadcastMessageDisplayed () {
23 return this.screenService.isBroadcastMessageDisplayed
24 }
25
26 ngOnInit (): void {
27 this.serverConfig = this.serverService.getTmpConfig()
28 this.serverService.getConfig()
29 .subscribe(config => this.serverConfig = config)
30
31 this.user = this.authService.getUser()
32
33 this.authService.userInformationLoaded.subscribe(
34 () => this.buildMenu()
35 )
36 }
37
38 isVideoImportEnabled () {
39 const importConfig = this.serverConfig.import.videos
40
41 return importConfig.http.enabled || importConfig.torrent.enabled
42 }
43
44 private buildMenu () {
45 this.menuEntries = [
46 {
47 label: $localize`Channels`,
48 routerLink: '/my-library/video-channels'
49 }
50 ]
51
52 if (this.user.canSeeVideosLink) {
53 this.menuEntries.push({
54 label: $localize`Videos`,
55 routerLink: '/my-library/videos'
56 })
57 }
58
59 this.menuEntries = this.menuEntries.concat([
60 {
61 label: $localize`Playlists`,
62 routerLink: '/my-library/video-playlists'
63 },
64
65 {
66 label: $localize`Subscriptions`,
67 routerLink: '/my-library/subscriptions'
68 },
69
70 {
71 label: $localize`History`,
72 routerLink: '/my-library/history/videos'
73 }
74 ])
75 }
76}
diff --git a/client/src/app/+my-library/my-library.module.ts b/client/src/app/+my-library/my-library.module.ts
new file mode 100644
index 000000000..bf791952c
--- /dev/null
+++ b/client/src/app/+my-library/my-library.module.ts
@@ -0,0 +1,79 @@
1import { AutoCompleteModule } from 'primeng/autocomplete'
2import { InputSwitchModule } from 'primeng/inputswitch'
3import { TableModule } from 'primeng/table'
4import { DragDropModule } from '@angular/cdk/drag-drop'
5import { NgModule } from '@angular/core'
6import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
7import { SharedFormModule } from '@app/shared/shared-forms'
8import { SharedGlobalIconModule } from '@app/shared/shared-icons'
9import { SharedMainModule } from '@app/shared/shared-main'
10import { SharedModerationModule } from '@app/shared/shared-moderation'
11import { SharedShareModal } from '@app/shared/shared-share-modal'
12import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-settings'
13import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription/shared-user-subscription.module'
14import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
15import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
16import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module'
17import { MyHistoryComponent } from './my-history/my-history.component'
18import { MyLibraryRoutingModule } from './my-library-routing.module'
19import { MyLibraryComponent } from './my-library.component'
20import { MyAcceptOwnershipComponent } from './my-ownership/my-accept-ownership/my-accept-ownership.component'
21import { MyOwnershipComponent } from './my-ownership/my-ownership.component'
22import { MySubscriptionsComponent } from './my-subscriptions/my-subscriptions.component'
23import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component'
24import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component'
25import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component'
26import { MyVideoPlaylistUpdateComponent } from './my-video-playlists/my-video-playlist-update.component'
27import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlists.component'
28import { VideoChangeOwnershipComponent } from './my-videos/modals/video-change-ownership.component'
29import { MyVideosComponent } from './my-videos/my-videos.component'
30
31@NgModule({
32 imports: [
33 MyLibraryRoutingModule,
34
35 AutoCompleteModule,
36 TableModule,
37 InputSwitchModule,
38 DragDropModule,
39
40 SharedMainModule,
41 SharedFormModule,
42 SharedModerationModule,
43 SharedVideoMiniatureModule,
44 SharedUserSubscriptionModule,
45 SharedVideoPlaylistModule,
46 SharedUserInterfaceSettingsModule,
47 SharedGlobalIconModule,
48 SharedAbuseListModule,
49 SharedShareModal,
50 SharedVideoLiveModule
51 ],
52
53 declarations: [
54 MyLibraryComponent,
55
56 MyVideosComponent,
57
58 VideoChangeOwnershipComponent,
59
60 MyOwnershipComponent,
61 MyAcceptOwnershipComponent,
62 MyVideoImportsComponent,
63 MySubscriptionsComponent,
64 MyHistoryComponent,
65
66 MyVideoPlaylistCreateComponent,
67 MyVideoPlaylistUpdateComponent,
68 MyVideoPlaylistsComponent,
69 MyVideoPlaylistElementsComponent
70 ],
71
72 exports: [
73 MyLibraryComponent
74 ],
75
76 providers: []
77})
78export class MyLibraryModule {
79}
diff --git a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.html b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.html
new file mode 100644
index 000000000..def1cbab6
--- /dev/null
+++ b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.html
@@ -0,0 +1,36 @@
1<ng-template #modal let-close="close" let-dismiss="dismiss">
2 <div class="modal-header">
3 <h1 i18n class="modal-title">Accept ownership</h1>
4
5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
6 </div>
7
8 <div class="modal-body" [formGroup]="form">
9 <div class="form-group">
10 <label i18n for="channel">Select a channel to receive the video</label>
11 <div class="peertube-select-container">
12 <select formControlName="channel" id="channel" class="form-control">
13 <option i18n value="undefined" disabled>Channel that will receive the video</option>
14 <option *ngFor="let channel of videoChannels" [value]="channel.id">{{ channel.displayName }}
15 </option>
16 </select>
17 </div>
18 <div *ngIf="formErrors.channel" class="form-error">{{ formErrors.channel }}</div>
19 </div>
20 </div>
21
22 <div class="modal-footer inputs">
23 <div class="inputs">
24 <input
25 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
26 (click)="dismiss()" (key.enter)="dismiss()"
27 >
28
29 <input
30 type="submit" i18n-value value="Accept" class="action-button-submit"
31 [disabled]="!form.valid"
32 (click)="close()"
33 >
34 </div>
35 </div>
36</ng-template>
diff --git a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.scss b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.scss
new file mode 100644
index 000000000..c7357f62d
--- /dev/null
+++ b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.scss
@@ -0,0 +1,14 @@
1@import '_variables';
2@import '_mixins';
3
4select {
5 display: block;
6}
7
8.peertube-select-container {
9 @include peertube-select-container(350px);
10}
11
12.form-group {
13 margin: 20px 0;
14} \ No newline at end of file
diff --git a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts
new file mode 100644
index 000000000..587a455f0
--- /dev/null
+++ b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts
@@ -0,0 +1,72 @@
1import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { AuthService, Notifier } from '@app/core'
3import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { VideoChannelService, VideoOwnershipService } from '@app/shared/shared-main'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { VideoChangeOwnership, VideoChannel } from '@shared/models'
8
9@Component({
10 selector: 'my-accept-ownership',
11 templateUrl: './my-accept-ownership.component.html',
12 styleUrls: [ './my-accept-ownership.component.scss' ]
13})
14export class MyAcceptOwnershipComponent extends FormReactive implements OnInit {
15 @Output() accepted = new EventEmitter<void>()
16
17 @ViewChild('modal', { static: true }) modal: ElementRef
18
19 videoChangeOwnership: VideoChangeOwnership | undefined = undefined
20
21 videoChannels: VideoChannel[]
22
23 error: string = null
24
25 constructor (
26 protected formValidatorService: FormValidatorService,
27 private videoOwnershipService: VideoOwnershipService,
28 private notifier: Notifier,
29 private authService: AuthService,
30 private videoChannelService: VideoChannelService,
31 private modalService: NgbModal
32 ) {
33 super()
34 }
35
36 ngOnInit () {
37 this.videoChannels = []
38
39 this.videoChannelService.listAccountVideoChannels(this.authService.getUser().account)
40 .subscribe(videoChannels => this.videoChannels = videoChannels.data)
41
42 this.buildForm({
43 channel: OWNERSHIP_CHANGE_CHANNEL_VALIDATOR
44 })
45 }
46
47 show (videoChangeOwnership: VideoChangeOwnership) {
48 this.videoChangeOwnership = videoChangeOwnership
49 this.modalService
50 .open(this.modal, { centered: true })
51 .result
52 .then(() => this.acceptOwnership())
53 .catch(() => this.videoChangeOwnership = undefined)
54 }
55
56 acceptOwnership () {
57 const channel = this.form.value['channel']
58
59 const videoChangeOwnership = this.videoChangeOwnership
60 this.videoOwnershipService
61 .acceptOwnership(videoChangeOwnership.id, { channelId: channel })
62 .subscribe(
63 () => {
64 this.notifier.success($localize`Ownership accepted`)
65 if (this.accepted) this.accepted.emit()
66 this.videoChangeOwnership = undefined
67 },
68
69 err => this.notifier.error(err.message)
70 )
71 }
72}
diff --git a/client/src/app/+my-library/my-ownership/my-ownership.component.html b/client/src/app/+my-library/my-ownership/my-ownership.component.html
new file mode 100644
index 000000000..6bf562986
--- /dev/null
+++ b/client/src/app/+my-library/my-ownership/my-ownership.component.html
@@ -0,0 +1,90 @@
1<h1>
2 <my-global-icon iconName="download" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>My ownership changes</ng-container>
4</h1>
5
6<p-table
7 [value]="videoChangeOwnerships"
8 [lazy]="true"
9 [paginator]="totalRecords > 0"
10 [totalRecords]="totalRecords"
11 [rows]="rowsPerPage"
12 [sortField]="sort.field"
13 [sortOrder]="sort.order"
14 (onLazyLoad)="loadLazy($event)"
15>
16 <ng-template pTemplate="header">
17 <tr>
18 <th style="width: 150px;" i18n>Actions</th>
19 <th style="width: 35%;" i18n>Initiator</th>
20 <th style="width: 65%;" i18n>Video</th>
21 <th style="width: 150px;" i18n pSortableColumn="createdAt">
22 Created
23 <p-sortIcon field="createdAt"></p-sortIcon>
24 </th>
25 <th style="width: 100px;" i18n>Status</th>
26 </tr>
27 </ng-template>
28
29 <ng-template pTemplate="body" let-videoChangeOwnership>
30 <tr>
31 <td class="action-cell">
32 <ng-container *ngIf="videoChangeOwnership.status === 'WAITING'">
33 <my-button i18n-title title="Accept" icon="tick" (click)="openAcceptModal(videoChangeOwnership)"></my-button>
34 <my-button i18n-title title="Refuse" icon="cross" (click)="refuse(videoChangeOwnership)"></my-button>
35 </ng-container>
36 </td>
37 <td>
38 <a [href]="videoChangeOwnership.initiatorAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
39 <div class="chip two-lines">
40 <img
41 class="avatar"
42 [src]="videoChangeOwnership.initiatorAccount.avatar?.path"
43 (error)="switchToDefaultAvatar($event)"
44 alt="Avatar"
45 >
46 <div>
47 {{ videoChangeOwnership.initiatorAccount.displayName }}
48 <span class="text-muted">{{ videoChangeOwnership.initiatorAccount.nameWithHost }}</span>
49 </div>
50 </div>
51 </a>
52 </td>
53
54 <td>
55 <a [href]="videoChangeOwnership.video.url" class="video-table-video-link" [title]="videoChangeOwnership.video.name" target="_blank" rel="noopener noreferrer">
56 <div class="video-table-video">
57 <div class="video-table-video-image">
58 <img [src]="videoChangeOwnership.video.thumbnailPath">
59 </div>
60 <div class="video-table-video-text">
61 <div>
62 {{ videoChangeOwnership.video.name }}
63 </div>
64 <div class="text-muted">by {{ videoChangeOwnership.video.channel?.displayName }} </div>
65 </div>
66 </div>
67 </a>
68 </td>
69
70 <td>{{ videoChangeOwnership.createdAt | date: 'short' }}</td>
71
72 <td>
73 <span class="badge"
74 [ngClass]="getStatusClass(videoChangeOwnership.status)">{{ videoChangeOwnership.status }}</span>
75 </td>
76 </tr>
77 </ng-template>
78
79 <ng-template pTemplate="emptymessage">
80 <tr>
81 <td colspan="6">
82 <div class="no-results">
83 <ng-container i18n>No ownership change request found.</ng-container>
84 </div>
85 </td>
86 </tr>
87 </ng-template>
88</p-table>
89
90<my-accept-ownership #myAcceptOwnershipComponent (accepted)="accepted()"></my-accept-ownership>
diff --git a/client/src/app/+my-library/my-ownership/my-ownership.component.scss b/client/src/app/+my-library/my-ownership/my-ownership.component.scss
new file mode 100644
index 000000000..7cac9c9f3
--- /dev/null
+++ b/client/src/app/+my-library/my-ownership/my-ownership.component.scss
@@ -0,0 +1,71 @@
1@import 'miniature';
2@import 'mixins';
3
4.chip {
5 @include chip;
6}
7
8.badge {
9 @include table-badge;
10}
11
12.video-table-video {
13 display: inline-flex;
14
15 .video-table-video-image {
16 @include miniature-thumbnail;
17
18 $image-height: 45px;
19
20 height: $image-height;
21 width: #{(16/9) * $image-height};
22 margin-right: 0.5rem;
23 border-radius: 2px;
24 border: none;
25 background: transparent;
26 display: inline-flex;
27 justify-content: center;
28 align-items: center;
29 position: relative;
30
31 img {
32 height: 100%;
33 width: 100%;
34 border-radius: 2px;
35 }
36
37 span {
38 color: pvar(--inputPlaceholderColor);
39 }
40
41 .video-table-video-image-label {
42 @include static-thumbnail-overlay;
43 position: absolute;
44 border-radius: 3px;
45 font-size: 10px;
46 padding: 0 3px;
47 line-height: 1.3;
48 bottom: 2px;
49 right: 2px;
50 }
51 }
52
53 .video-table-video-text {
54 display: inline-flex;
55 flex-direction: column;
56 justify-content: center;
57 font-size: 90%;
58 color: pvar(--mainForegroundColor);
59 line-height: 1rem;
60
61 div .glyphicon {
62 font-size: 80%;
63 color: gray;
64 margin-left: 0.1rem;
65 }
66
67 div + div {
68 font-size: 80%;
69 }
70 }
71}
diff --git a/client/src/app/+my-library/my-ownership/my-ownership.component.ts b/client/src/app/+my-library/my-ownership/my-ownership.component.ts
new file mode 100644
index 000000000..e1aca65f6
--- /dev/null
+++ b/client/src/app/+my-library/my-ownership/my-ownership.component.ts
@@ -0,0 +1,81 @@
1import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core'
3import { Notifier, RestPagination, RestTable } from '@app/core'
4import { Account, Actor, VideoOwnershipService } from '@app/shared/shared-main'
5import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '@shared/models'
6import { MyAcceptOwnershipComponent } from './my-accept-ownership/my-accept-ownership.component'
7
8@Component({
9 templateUrl: './my-ownership.component.html',
10 styleUrls: [ './my-ownership.component.scss' ]
11})
12export class MyOwnershipComponent extends RestTable implements OnInit {
13 videoChangeOwnerships: VideoChangeOwnership[] = []
14 totalRecords = 0
15 sort: SortMeta = { field: 'createdAt', order: -1 }
16 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
17
18 @ViewChild('myAcceptOwnershipComponent', { static: true }) myAccountAcceptOwnershipComponent: MyAcceptOwnershipComponent
19
20 constructor (
21 private notifier: Notifier,
22 private videoOwnershipService: VideoOwnershipService
23 ) {
24 super()
25 }
26
27 ngOnInit () {
28 this.initialize()
29 }
30
31 getIdentifier () {
32 return 'MyOwnershipComponent'
33 }
34
35 getStatusClass (status: VideoChangeOwnershipStatus) {
36 switch (status) {
37 case VideoChangeOwnershipStatus.ACCEPTED:
38 return 'badge-green'
39 case VideoChangeOwnershipStatus.REFUSED:
40 return 'badge-red'
41 default:
42 return 'badge-yellow'
43 }
44 }
45
46 switchToDefaultAvatar ($event: Event) {
47 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
48 }
49
50 openAcceptModal (videoChangeOwnership: VideoChangeOwnership) {
51 this.myAccountAcceptOwnershipComponent.show(videoChangeOwnership)
52 }
53
54 accepted () {
55 this.loadData()
56 }
57
58 refuse (videoChangeOwnership: VideoChangeOwnership) {
59 this.videoOwnershipService.refuseOwnership(videoChangeOwnership.id)
60 .subscribe(
61 () => this.loadData(),
62 err => this.notifier.error(err.message)
63 )
64 }
65
66 protected loadData () {
67 return this.videoOwnershipService.getOwnershipChanges(this.pagination, this.sort)
68 .subscribe(
69 resultList => {
70 this.videoChangeOwnerships = resultList.data.map(change => ({
71 ...change,
72 initiatorAccount: new Account(change.initiatorAccount),
73 nextOwnerAccount: new Account(change.nextOwnerAccount)
74 }))
75 this.totalRecords = resultList.total
76 },
77
78 err => this.notifier.error(err.message)
79 )
80 }
81}
diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html
new file mode 100644
index 000000000..6ab3826ba
--- /dev/null
+++ b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html
@@ -0,0 +1,42 @@
1<h1>
2 <span>
3 <my-global-icon iconName="subscriptions" aria-hidden="true"></my-global-icon>
4 <ng-container i18n>My subscriptions</ng-container>
5 <span class="badge badge-secondary"> {{ pagination.totalItems }}</span>
6 </span>
7</h1>
8
9<div class="video-subscriptions-header d-flex justify-content-between">
10 <div class="has-feedback has-clear">
11 <input type="text" placeholder="Search your subscriptions" i18n-placeholder [(ngModel)]="subscriptionsSearch"
12 (ngModelChange)="onSubscriptionsSearchChanged()" />
13 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
14 <span class="sr-only" i18n>Clear filters</span>
15 </div>
16</div>
17
18<div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscriptions yet.</div>
19
20<div class="video-channels" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
21 <div *ngFor="let videoChannel of videoChannels" class="video-channel">
22 <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]">
23 <img [src]="videoChannel.avatarUrl" alt="Avatar" />
24 </a>
25
26 <div class="video-channel-info">
27 <a [routerLink]="[ '/video-channels', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
28 <div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
29 <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
30 </a>
31
32 <div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div>
33
34 <a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Owner account page" class="actor-owner">
35 <span i18n>Created by {{ videoChannel.ownerBy }}</span>
36 <img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" />
37 </a>
38 </div>
39
40 <my-subscribe-button [videoChannels]="[videoChannel]"></my-subscribe-button>
41 </div>
42</div>
diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss
new file mode 100644
index 000000000..5ead45dd8
--- /dev/null
+++ b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss
@@ -0,0 +1,81 @@
1@import '_variables';
2@import '_mixins';
3
4input[type=text] {
5 @include peertube-input-text(300px);
6}
7
8.video-channel {
9 @include row-blocks;
10
11 img {
12 @include avatar(80px);
13
14 margin-right: 10px;
15 }
16
17 .video-channel-info {
18 flex-grow: 1;
19
20 a.video-channel-names {
21 @include disable-default-a-behaviour;
22
23 width: fit-content;
24 display: flex;
25 align-items: baseline;
26 color: pvar(--mainForegroundColor);
27
28 .video-channel-display-name {
29 font-weight: $font-semibold;
30 font-size: 18px;
31 }
32
33 .video-channel-name {
34 font-size: 14px;
35 color: $grey-actor-name;
36 margin-left: 5px;
37 }
38 }
39 }
40
41 .actor-owner {
42 @include actor-owner;
43
44 margin-top: 0;
45 }
46}
47
48.video-subscriptions-header {
49 margin-bottom: 30px;
50}
51
52@media screen and (max-width: $small-view) {
53 .video-channel {
54 .video-channel-info {
55 padding-bottom: 10px;
56 text-align: center;
57
58 .video-channel-names {
59 flex-direction: column;
60 align-items: center !important;
61 margin: auto;
62 }
63 }
64
65 img {
66 margin-right: 0;
67 }
68 }
69}
70
71@media screen and (max-width: $mobile-view) {
72 .video-subscriptions-header {
73 flex-direction: column;
74
75 input[type=text] {
76 width: 100% !important;
77 }
78 }
79}
80
81
diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.ts b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.ts
new file mode 100644
index 000000000..3b748eccf
--- /dev/null
+++ b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.ts
@@ -0,0 +1,74 @@
1import { Subject } from 'rxjs'
2import { debounceTime } from 'rxjs/operators'
3import { Component, OnInit } from '@angular/core'
4import { ComponentPagination, Notifier } from '@app/core'
5import { VideoChannel } from '@app/shared/shared-main'
6import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
7
8@Component({
9 templateUrl: './my-subscriptions.component.html',
10 styleUrls: [ './my-subscriptions.component.scss' ]
11})
12export class MySubscriptionsComponent implements OnInit {
13 videoChannels: VideoChannel[] = []
14
15 pagination: ComponentPagination = {
16 currentPage: 1,
17 itemsPerPage: 10,
18 totalItems: null
19 }
20
21 onDataSubject = new Subject<any[]>()
22
23 subscriptionsSearch: string
24 subscriptionsSearchChanged = new Subject<string>()
25
26 constructor (
27 private userSubscriptionService: UserSubscriptionService,
28 private notifier: Notifier
29 ) {}
30
31 ngOnInit () {
32 this.loadSubscriptions()
33
34 this.subscriptionsSearchChanged
35 .pipe(debounceTime(500))
36 .subscribe(() => {
37 this.pagination.currentPage = 1
38 this.loadSubscriptions(false)
39 })
40 }
41
42 resetSearch () {
43 this.subscriptionsSearch = ''
44 this.onSubscriptionsSearchChanged()
45 }
46
47 onSubscriptionsSearchChanged () {
48 this.subscriptionsSearchChanged.next()
49 }
50
51 onNearOfBottom () {
52 // Last page
53 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
54
55 this.pagination.currentPage += 1
56 this.loadSubscriptions()
57 }
58
59 private loadSubscriptions (more = true) {
60 this.userSubscriptionService.listSubscriptions({ pagination: this.pagination, search: this.subscriptionsSearch })
61 .subscribe(
62 res => {
63 this.videoChannels = more
64 ? this.videoChannels.concat(res.data)
65 : res.data
66 this.pagination.totalItems = res.total
67
68 this.onDataSubject.next(res.data)
69 },
70
71 error => this.notifier.error(error.message)
72 )
73 }
74}
diff --git a/client/src/app/+my-library/my-video-imports/my-video-imports.component.html b/client/src/app/+my-library/my-video-imports/my-video-imports.component.html
new file mode 100644
index 000000000..1d3a45f76
--- /dev/null
+++ b/client/src/app/+my-library/my-video-imports/my-video-imports.component.html
@@ -0,0 +1,70 @@
1<h1>
2 <my-global-icon iconName="cloud-download" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>My imports</ng-container>
4</h1>
5
6<p-table
7 [value]="videoImports" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions"
8 [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
9 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
10 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} imports"
11 (onPage)="onPage($event)" [expandedRowKeys]="expandedRows"
12>
13 <ng-template pTemplate="header">
14 <tr>
15 <th style="width: 40px;"></th>
16 <th style="width: 70px">Action</th>
17 <th style="width: 45%" i18n>Target</th>
18 <th style="width: 55%" i18n>Video</th>
19 <th style="width: 150px" i18n>State</th>
20 <th style="width: 150px" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
21 </tr>
22 </ng-template>
23
24 <ng-template pTemplate="body" let-expanded="expanded" let-videoImport>
25 <tr>
26 <td class="expand-cell">
27 <span *ngIf="videoImport.error" class="expander" [pRowToggler]="videoImport" i18n-ngbTooltip ngbTooltip="See the error">
28 <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
29 </span>
30 </td>
31
32 <td class="action-cell">
33 <my-edit-button *ngIf="isVideoImportSuccess(videoImport) && videoImport.video"
34 [routerLink]="getEditVideoUrl(videoImport.video)"></my-edit-button>
35 </td>
36
37 <td>
38 <a *ngIf="videoImport.targetUrl; else torrent" [href]="videoImport.targetUrl" target="_blank" rel="noopener noreferrer">{{ videoImport.targetUrl }}</a>
39 <ng-template #torrent>
40 <span [title]="videoImport.torrentName || videoImport.magnetUri">{{ videoImport.torrentName || videoImport.magnetUri }}</span>
41 </ng-template>
42 </td>
43
44 <td>
45 <ng-container *ngIf="isVideoImportPending(videoImport)">{{ videoImport.video?.name }}</ng-container>
46 <ng-container *ngIf="isVideoImportSuccess(videoImport) && videoImport.video">
47 <a [href]="getVideoUrl(videoImport.video)" target="_blank" rel="noopener noreferrer">{{ videoImport.video?.name }}</a>
48 </ng-container>
49 <ng-container *ngIf="isVideoImportSuccess(videoImport) && !videoImport.video" i18n>This video was deleted</ng-container>
50 <ng-container *ngIf="isVideoImportFailed(videoImport)"></ng-container>
51 </td>
52
53 <td>
54 <span class="badge" [ngClass]="getVideoImportStateClass(videoImport.state)">
55 {{ videoImport.state.label }}
56 </span>
57 </td>
58
59 <td>{{ videoImport.createdAt | date: 'short' }}</td>
60 </tr>
61 </ng-template>
62
63 <ng-template pTemplate="rowexpansion" let-videoImport>
64 <tr class="video-import-error" *ngIf="videoImport.error">
65 <td colspan="6">
66 <pre>{{ videoImport.error }}</pre>
67 </td>
68 </tr>
69 </ng-template>
70</p-table>
diff --git a/client/src/app/+my-library/my-video-imports/my-video-imports.component.scss b/client/src/app/+my-library/my-video-imports/my-video-imports.component.scss
new file mode 100644
index 000000000..a93c28028
--- /dev/null
+++ b/client/src/app/+my-library/my-video-imports/my-video-imports.component.scss
@@ -0,0 +1,14 @@
1@import '_variables';
2@import '_mixins';
3
4pre {
5 font-size: 11px;
6}
7
8.video-import-error {
9 color: red;
10}
11
12.badge {
13 @include table-badge;
14}
diff --git a/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts b/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
new file mode 100644
index 000000000..d6d7d7a1b
--- /dev/null
+++ b/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
@@ -0,0 +1,76 @@
1import { SortMeta } from 'primeng/api'
2import { Component, OnInit } from '@angular/core'
3import { Notifier, RestPagination, RestTable } from '@app/core'
4import { VideoImportService } from '@app/shared/shared-main'
5import { VideoImport, VideoImportState } from '@shared/models'
6
7@Component({
8 templateUrl: './my-video-imports.component.html',
9 styleUrls: [ './my-video-imports.component.scss' ]
10})
11export class MyVideoImportsComponent extends RestTable implements OnInit {
12 videoImports: VideoImport[] = []
13 totalRecords = 0
14 sort: SortMeta = { field: 'createdAt', order: 1 }
15 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
16
17 constructor (
18 private notifier: Notifier,
19 private videoImportService: VideoImportService
20 ) {
21 super()
22 }
23
24 ngOnInit () {
25 this.initialize()
26 }
27
28 getIdentifier () {
29 return 'MyVideoImportsComponent'
30 }
31
32 getVideoImportStateClass (state: VideoImportState) {
33 switch (state) {
34 case VideoImportState.FAILED:
35 return 'badge-red'
36 case VideoImportState.REJECTED:
37 return 'badge-banned'
38 case VideoImportState.PENDING:
39 return 'badge-yellow'
40 default:
41 return 'badge-green'
42 }
43 }
44
45 isVideoImportSuccess (videoImport: VideoImport) {
46 return videoImport.state.id === VideoImportState.SUCCESS
47 }
48
49 isVideoImportPending (videoImport: VideoImport) {
50 return videoImport.state.id === VideoImportState.PENDING
51 }
52
53 isVideoImportFailed (videoImport: VideoImport) {
54 return videoImport.state.id === VideoImportState.FAILED
55 }
56
57 getVideoUrl (video: { uuid: string }) {
58 return '/videos/watch/' + video.uuid
59 }
60
61 getEditVideoUrl (video: { uuid: string }) {
62 return '/videos/update/' + video.uuid
63 }
64
65 protected loadData () {
66 this.videoImportService.getMyVideoImports(this.pagination, this.sort)
67 .subscribe(
68 resultList => {
69 this.videoImports = resultList.data
70 this.totalRecords = resultList.total
71 },
72
73 err => this.notifier.error(err.message)
74 )
75 }
76}
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts
new file mode 100644
index 000000000..5abea54b0
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts
@@ -0,0 +1,91 @@
1import { Component, OnInit } from '@angular/core'
2import { Router } from '@angular/router'
3import { AuthService, Notifier, ServerService } from '@app/core'
4import { populateAsyncUserVideoChannels } from '@app/helpers'
5import {
6 setPlaylistChannelValidator,
7 VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR,
8 VIDEO_PLAYLIST_DESCRIPTION_VALIDATOR,
9 VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
10 VIDEO_PLAYLIST_PRIVACY_VALIDATOR
11} from '@app/shared/form-validators/video-playlist-validators'
12import { FormValidatorService } from '@app/shared/shared-forms'
13import { VideoPlaylistService } from '@app/shared/shared-video-playlist'
14import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
15import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
16import { MyVideoPlaylistEdit } from './my-video-playlist-edit'
17
18@Component({
19 templateUrl: './my-video-playlist-edit.component.html',
20 styleUrls: [ './my-video-playlist-edit.component.scss' ]
21})
22export class MyVideoPlaylistCreateComponent extends MyVideoPlaylistEdit implements OnInit {
23 error: string
24
25 constructor (
26 protected formValidatorService: FormValidatorService,
27 private authService: AuthService,
28 private notifier: Notifier,
29 private router: Router,
30 private videoPlaylistService: VideoPlaylistService,
31 private serverService: ServerService
32 ) {
33 super()
34 }
35
36 ngOnInit () {
37 this.buildForm({
38 displayName: VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
39 privacy: VIDEO_PLAYLIST_PRIVACY_VALIDATOR,
40 description: VIDEO_PLAYLIST_DESCRIPTION_VALIDATOR,
41 videoChannelId: VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR,
42 thumbnailfile: null
43 })
44
45 this.form.get('privacy').valueChanges.subscribe(privacy => {
46 setPlaylistChannelValidator(this.form.get('videoChannelId'), privacy)
47 })
48
49 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
50 .catch(err => console.error('Cannot populate user video channels.', err))
51
52 this.serverService.getVideoPlaylistPrivacies()
53 .subscribe(videoPlaylistPrivacies => {
54 this.videoPlaylistPrivacies = videoPlaylistPrivacies
55
56 this.form.patchValue({
57 privacy: VideoPlaylistPrivacy.PRIVATE
58 })
59 })
60 }
61
62 formValidated () {
63 this.error = undefined
64
65 const body = this.form.value
66 const videoPlaylistCreate: VideoPlaylistCreate = {
67 displayName: body.displayName,
68 privacy: body.privacy,
69 description: body.description || null,
70 videoChannelId: body.videoChannelId || null,
71 thumbnailfile: body.thumbnailfile || null
72 }
73
74 this.videoPlaylistService.createVideoPlaylist(videoPlaylistCreate).subscribe(
75 () => {
76 this.notifier.success($localize`Playlist ${videoPlaylistCreate.displayName} created.`)
77 this.router.navigate([ '/my-library', 'video-playlists' ])
78 },
79
80 err => this.error = err.message
81 )
82 }
83
84 isCreation () {
85 return true
86 }
87
88 getFormButtonTitle () {
89 return $localize`Create`
90 }
91}
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html
new file mode 100644
index 000000000..0d8d2a447
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.html
@@ -0,0 +1,100 @@
1<nav aria-label="breadcrumb">
2 <ol class="breadcrumb">
3 <li class="breadcrumb-item">
4 <a routerLink="/my-library/video-playlists" i18n>My Playlists</a>
5 </li>
6
7 <ng-container *ngIf="isCreation()">
8 <li class="breadcrumb-item active" i18n>Create</li>
9 </ng-container>
10 <ng-container *ngIf="!isCreation()">
11 <li class="breadcrumb-item active" i18n>Edit</li>
12 <li class="breadcrumb-item active" aria-current="page">
13 <a *ngIf="videoPlaylistToUpdate" [routerLink]="[ '/my-library/video-playlists/update', videoPlaylistToUpdate?.uuid ]">{{ videoPlaylistToUpdate?.displayName }}</a>
14 </li>
15 </ng-container>
16 </ol>
17</nav>
18
19<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
20
21<form role="form" (ngSubmit)="formValidated()" [formGroup]="form">
22
23 <div class="form-row"> <!-- playlist grid -->
24 <div class="form-group col-12 col-lg-4 col-xl-3">
25 <div *ngIf="isCreation()" class="video-playlist-title" i18n>NEW PLAYLIST</div>
26 <div *ngIf="!isCreation() && videoPlaylistToUpdate" class="video-playlist-title" i18n>PLAYLIST</div>
27 </div>
28
29 <div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
30
31 <div class="col-md-12 col-xl-6">
32 <div class="form-group">
33 <label i18n for="displayName">Display name</label>
34 <input
35 type="text" id="displayName" class="form-control"
36 formControlName="displayName" [ngClass]="{ 'input-error': formErrors['displayName'] }"
37 >
38 <div *ngIf="formErrors['displayName']" class="form-error">
39 {{ formErrors['displayName'] }}
40 </div>
41 </div>
42
43 <div class="form-group">
44 <label i18n for="description">Description</label>
45 <textarea
46 id="description" formControlName="description"
47 class="form-control" [ngClass]="{ 'input-error': formErrors['description'] }"
48 ></textarea>
49 <div *ngIf="formErrors.description" class="form-error">
50 {{ formErrors.description }}
51 </div>
52 </div>
53 </div>
54
55 <div class="col-md-12 col-xl-6">
56 <div class="form-group">
57 <label i18n for="privacy">Privacy</label>
58 <div class="peertube-select-container">
59 <select id="privacy" formControlName="privacy" class="form-control">
60 <option *ngFor="let privacy of videoPlaylistPrivacies" [value]="privacy.id">{{ privacy.label }}</option>
61 </select>
62 </div>
63
64 <div *ngIf="formErrors.privacy" class="form-error">
65 {{ formErrors.privacy }}
66 </div>
67 </div>
68
69 <div class="form-group">
70 <label i18n>Channel</label>
71
72 <my-select-channel
73 labelForId="videoChannelIdl" [items]="userVideoChannels" formControlName="videoChannelId"
74 ></my-select-channel>
75
76 <div *ngIf="formErrors['videoChannelId']" class="form-error">
77 {{ formErrors['videoChannelId'] }}
78 </div>
79 </div>
80
81 <div class="form-group">
82 <label i18n>Playlist thumbnail</label>
83
84 <my-preview-upload
85 i18n-inputLabel inputLabel="Edit" inputName="thumbnailfile" formControlName="thumbnailfile"
86 previewWidth="223px" previewHeight="122px"
87 ></my-preview-upload>
88 </div>
89 </div>
90
91 <div class="form-row"> <!-- submit placement block -->
92 <div class="col-md-7 col-xl-5"></div>
93 <div class="col-md-5 col-xl-5 d-inline-flex">
94 <input type="submit" value="{{ getFormButtonTitle() }}" [disabled]="!form.valid">
95 </div>
96 </div>
97 </div>
98 </div>
99
100</form>
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss
new file mode 100644
index 000000000..08fab1101
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.component.scss
@@ -0,0 +1,36 @@
1@import '_variables';
2@import '_mixins';
3
4label {
5 font-weight: $font-regular;
6 font-size: 100%;
7}
8
9.video-playlist-title {
10 @include settings-big-title;
11}
12
13input[type=text] {
14 @include peertube-input-text(340px);
15
16 display: block;
17}
18
19textarea {
20 @include peertube-textarea(500px, 150px);
21
22 display: block;
23}
24
25.peertube-select-container {
26 @include peertube-select-container(340px);
27}
28
29input[type=submit] {
30 @include peertube-button;
31 @include orange-button;
32}
33
34.breadcrumb {
35 @include breadcrumb;
36}
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.ts
new file mode 100644
index 000000000..40ba23e75
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-edit.ts
@@ -0,0 +1,13 @@
1import { FormReactive, SelectChannelItem } from '@app/shared/shared-forms'
2import { VideoConstant, VideoPlaylistPrivacy } from '@shared/models'
3import { VideoPlaylist } from '@shared/models/videos/playlist/video-playlist.model'
4
5export abstract class MyVideoPlaylistEdit extends FormReactive {
6 // Declare it here to avoid errors in create template
7 videoPlaylistToUpdate: VideoPlaylist
8 userVideoChannels: SelectChannelItem[] = []
9 videoPlaylistPrivacies: VideoConstant<VideoPlaylistPrivacy>[] = []
10
11 abstract isCreation (): boolean
12 abstract getFormButtonTitle (): string
13}
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html
new file mode 100644
index 000000000..09b4c8a1b
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.html
@@ -0,0 +1,51 @@
1<div class="row">
2
3 <div class="playlist-info col-xs-12 col-md-5 col-xl-3">
4 <my-video-playlist-miniature
5 *ngIf="playlist" [playlist]="playlist" [toManage]="false" [displayChannel]="true"
6 [displayDescription]="true" [displayPrivacy]="true"
7 ></my-video-playlist-miniature>
8
9 <div class="playlist-buttons">
10 <button (click)="showShareModal()" class="action-button share-button">
11 <my-global-icon iconName="share" aria-hidden="true"></my-global-icon>
12 <span class="icon-text" i18n>Share</span>
13 </button>
14
15 <my-action-dropdown
16 *ngIf="isRegularPlaylist(playlist)"
17 [entry]="playlist" [actions]="playlistActions" label="More"
18 ></my-action-dropdown>
19 </div>
20
21 </div>
22
23 <div class="playlist-elements col-xs-12 col-md-7 col-xl-9">
24 <div class="no-results" *ngIf="pagination.totalItems === 0">
25 <div i18n>No videos in this playlist.</div>
26
27 <div i18n>
28 Browse videos on PeerTube to add them in your playlist.
29 </div>
30
31 <div i18n>
32 See the <a target="_blank" href="https://docs.joinpeertube.org/#/use-library?id=playlist">documentation</a> for more information.
33 </div>
34 </div>
35
36 <div
37 class="videos" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()"
38 cdkDropList (cdkDropListDropped)="drop($event)" [dataObservable]="onDataSubject.asObservable()"
39 >
40 <div class="video" *ngFor="let playlistElement of playlistElements; trackBy: trackByFn" cdkDrag [cdkDragStartDelay]="getDragStartDelay()">
41 <my-video-playlist-element-miniature
42 [playlistElement]="playlistElement" [playlist]="playlist" [owned]="true" (elementRemoved)="onElementRemoved($event)"
43 [position]="playlistElement.position"
44 >
45 </my-video-playlist-element-miniature>
46 </div>
47 </div>
48 </div>
49</div>
50
51<my-video-share #videoShareModal [playlist]="playlist"></my-video-share>
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.scss b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.scss
new file mode 100644
index 000000000..de7e1993f
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.scss
@@ -0,0 +1,83 @@
1@import '_variables';
2@import '_mixins';
3@import '_miniature';
4
5.playlist-info {
6 background-color: pvar(--submenuColor);
7 margin-left: -$not-expanded-horizontal-margins;
8 margin-top: -$sub-menu-margin-bottom;
9
10 padding: 10px;
11
12 display: flex;
13 flex-direction: column;
14 justify-content: flex-start;
15 align-items: center;
16
17 /* fix ellipsis dots background color */
18 ::ng-deep .miniature-name::after {
19 background-color: pvar(--submenuColor) !important;
20 }
21}
22
23.playlist-buttons {
24 display:flex;
25 margin: 30px 0 10px 0;
26
27 .share-button {
28 @include peertube-button;
29 @include button-with-icon(17px, 3px, -1px);
30 @include grey-button;
31 @include apply-svg-color(pvar(--actionButtonColor));
32
33 margin-right: 10px;
34 }
35}
36
37// Thanks Angular CDK <3 https://material.angular.io/cdk/drag-drop/examples
38.cdk-drag-preview {
39 box-sizing: border-box;
40 border-radius: 4px;
41 box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
42 0 8px 10px 1px rgba(0, 0, 0, 0.14),
43 0 3px 14px 2px rgba(0, 0, 0, 0.12);
44}
45
46.cdk-drag-placeholder {
47 opacity: 0;
48}
49
50.cdk-drag-animating {
51 transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
52}
53
54.video:last-child {
55 border: none;
56}
57
58.videos.cdk-drop-list-dragging .video:not(.cdk-drag-placeholder) {
59 transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
60}
61
62@media screen and (max-width: $small-view) {
63 .playlist-info {
64 width: 100vw;
65 padding-top: 20px;
66 margin-left: calc(#{var(--expanded-horizontal-margin-content)} * -1);
67 }
68
69 .playlist-elements {
70 padding: 0 !important;
71 }
72
73 ::ng-deep my-video-playlist-element-miniature {
74
75 .video {
76 padding: 5px !important;
77 }
78
79 .position {
80 margin-right: 5px !important;
81 }
82 }
83}
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.ts
new file mode 100644
index 000000000..a8fdf6e29
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-elements.component.ts
@@ -0,0 +1,197 @@
1import { Subject, Subscription } from 'rxjs'
2import { CdkDragDrop } from '@angular/cdk/drag-drop'
3import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router'
5import { ComponentPagination, ConfirmService, Notifier, ScreenService } from '@app/core'
6import { DropdownAction } from '@app/shared/shared-main'
7import { VideoShareComponent } from '@app/shared/shared-share-modal'
8import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
9import { VideoPlaylistType } from '@shared/models'
10
11@Component({
12 templateUrl: './my-video-playlist-elements.component.html',
13 styleUrls: [ './my-video-playlist-elements.component.scss' ]
14})
15export class MyVideoPlaylistElementsComponent implements OnInit, OnDestroy {
16 @ViewChild('videoShareModal') videoShareModal: VideoShareComponent
17
18 playlistElements: VideoPlaylistElement[] = []
19 playlist: VideoPlaylist
20
21 playlistActions: DropdownAction<VideoPlaylist>[][] = []
22
23 pagination: ComponentPagination = {
24 currentPage: 1,
25 itemsPerPage: 10,
26 totalItems: null
27 }
28
29 onDataSubject = new Subject<any[]>()
30
31 private videoPlaylistId: string | number
32 private paramsSub: Subscription
33
34 constructor (
35 private notifier: Notifier,
36 private router: Router,
37 private confirmService: ConfirmService,
38 private route: ActivatedRoute,
39 private screenService: ScreenService,
40 private videoPlaylistService: VideoPlaylistService
41 ) {}
42
43 ngOnInit () {
44 this.playlistActions = [
45 [
46 {
47 label: $localize`Update playlist`,
48 iconName: 'edit',
49 linkBuilder: playlist => [ '/my-library', 'video-playlists', 'update', playlist.uuid ]
50 },
51 {
52 label: $localize`Delete playlist`,
53 iconName: 'delete',
54 handler: playlist => this.deleteVideoPlaylist(playlist)
55 }
56 ]
57 ]
58
59 this.paramsSub = this.route.params.subscribe(routeParams => {
60 this.videoPlaylistId = routeParams[ 'videoPlaylistId' ]
61 this.loadElements()
62
63 this.loadPlaylistInfo()
64 })
65 }
66
67 ngOnDestroy () {
68 if (this.paramsSub) this.paramsSub.unsubscribe()
69 }
70
71 drop (event: CdkDragDrop<any>) {
72 const previousIndex = event.previousIndex
73 const newIndex = event.currentIndex
74
75 if (previousIndex === newIndex) return
76
77 const oldPosition = this.playlistElements[previousIndex].position
78 let insertAfter = this.playlistElements[newIndex].position
79
80 if (oldPosition > insertAfter) insertAfter--
81
82 const element = this.playlistElements[previousIndex]
83
84 this.playlistElements.splice(previousIndex, 1)
85 this.playlistElements.splice(newIndex, 0, element)
86
87 this.videoPlaylistService.reorderPlaylist(this.playlist.id, oldPosition, insertAfter)
88 .subscribe(
89 () => {
90 this.reorderClientPositions()
91 },
92
93 err => this.notifier.error(err.message)
94 )
95 }
96
97 onElementRemoved (element: VideoPlaylistElement) {
98 const oldFirst = this.findFirst()
99
100 this.playlistElements = this.playlistElements.filter(v => v.id !== element.id)
101 this.reorderClientPositions(oldFirst)
102 }
103
104 onNearOfBottom () {
105 // Last page
106 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
107
108 this.pagination.currentPage += 1
109 this.loadElements()
110 }
111
112 trackByFn (index: number, elem: VideoPlaylistElement) {
113 return elem.id
114 }
115
116 isRegularPlaylist (playlist: VideoPlaylist) {
117 return playlist?.type.id === VideoPlaylistType.REGULAR
118 }
119
120 showShareModal () {
121 this.videoShareModal.show()
122 }
123
124 async deleteVideoPlaylist (videoPlaylist: VideoPlaylist) {
125 const res = await this.confirmService.confirm(
126 $localize`Do you really want to delete ${videoPlaylist.displayName}?`,
127 $localize`Delete`
128 )
129 if (res === false) return
130
131 this.videoPlaylistService.removeVideoPlaylist(videoPlaylist)
132 .subscribe(
133 () => {
134 this.router.navigate([ '/my-library', 'video-playlists' ])
135 this.notifier.success($localize`Playlist ${videoPlaylist.displayName} deleted.`)
136 },
137
138 error => this.notifier.error(error.message)
139 )
140 }
141
142 /**
143 * Returns null to not have drag and drop delay.
144 * In small views, where elements are about 100% wide,
145 * we add a delay to prevent unwanted drag&drop.
146 *
147 * @see {@link https://github.com/Chocobozzz/PeerTube/issues/2078}
148 *
149 * @returns {null|number} Null for no delay, or a number in milliseconds.
150 */
151 getDragStartDelay (): null | number {
152 if (this.screenService.isInTouchScreen()) {
153 return 500
154 }
155
156 return null
157 }
158
159 private loadElements () {
160 this.videoPlaylistService.getPlaylistVideos(this.videoPlaylistId, this.pagination)
161 .subscribe(({ total, data }) => {
162 this.playlistElements = this.playlistElements.concat(data)
163 this.pagination.totalItems = total
164
165 this.onDataSubject.next(data)
166 })
167 }
168
169 private loadPlaylistInfo () {
170 this.videoPlaylistService.getVideoPlaylist(this.videoPlaylistId)
171 .subscribe(playlist => {
172 this.playlist = playlist
173 })
174 }
175
176 private reorderClientPositions (first?: VideoPlaylistElement) {
177 if (this.playlistElements.length === 0) return
178
179 const oldFirst = first || this.findFirst()
180 let i = 1
181
182 for (const element of this.playlistElements) {
183 element.position = i
184 i++
185 }
186
187 // Reload playlist thumbnail if the first element changed
188 const newFirst = this.findFirst()
189 if (oldFirst && newFirst && oldFirst.id !== newFirst.id) {
190 this.playlist.refreshThumbnail()
191 }
192 }
193
194 private findFirst () {
195 return this.playlistElements.find(e => e.position === 1)
196 }
197}
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts
new file mode 100644
index 000000000..532423ba2
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts
@@ -0,0 +1,129 @@
1import { forkJoin, Subscription } from 'rxjs'
2import { map, switchMap } from 'rxjs/operators'
3import { Component, OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, Notifier, ServerService } from '@app/core'
6import { populateAsyncUserVideoChannels } from '@app/helpers'
7import {
8 setPlaylistChannelValidator,
9 VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR,
10 VIDEO_PLAYLIST_DESCRIPTION_VALIDATOR,
11 VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
12 VIDEO_PLAYLIST_PRIVACY_VALIDATOR
13} from '@app/shared/form-validators/video-playlist-validators'
14import { FormValidatorService } from '@app/shared/shared-forms'
15import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
16import { VideoPlaylistUpdate } from '@shared/models'
17import { MyVideoPlaylistEdit } from './my-video-playlist-edit'
18
19@Component({
20 templateUrl: './my-video-playlist-edit.component.html',
21 styleUrls: [ './my-video-playlist-edit.component.scss' ]
22})
23export class MyVideoPlaylistUpdateComponent extends MyVideoPlaylistEdit implements OnInit, OnDestroy {
24 error: string
25 videoPlaylistToUpdate: VideoPlaylist
26
27 private paramsSub: Subscription
28
29 constructor (
30 protected formValidatorService: FormValidatorService,
31 private authService: AuthService,
32 private notifier: Notifier,
33 private router: Router,
34 private route: ActivatedRoute,
35 private videoPlaylistService: VideoPlaylistService,
36 private serverService: ServerService
37 ) {
38 super()
39 }
40
41 ngOnInit () {
42 this.buildForm({
43 displayName: VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
44 privacy: VIDEO_PLAYLIST_PRIVACY_VALIDATOR,
45 description: VIDEO_PLAYLIST_DESCRIPTION_VALIDATOR,
46 videoChannelId: VIDEO_PLAYLIST_CHANNEL_ID_VALIDATOR,
47 thumbnailfile: null
48 })
49
50 this.form.get('privacy').valueChanges.subscribe(privacy => {
51 setPlaylistChannelValidator(this.form.get('videoChannelId'), privacy)
52 })
53
54 populateAsyncUserVideoChannels(this.authService, this.userVideoChannels)
55 .catch(err => console.error('Cannot populate user video channels.', err))
56
57 this.paramsSub = this.route.params
58 .pipe(
59 map(routeParams => routeParams['videoPlaylistId']),
60 switchMap(videoPlaylistId => {
61 return forkJoin([
62 this.videoPlaylistService.getVideoPlaylist(videoPlaylistId),
63 this.serverService.getVideoPlaylistPrivacies()
64 ])
65 })
66 )
67 .subscribe(
68 ([ videoPlaylistToUpdate, videoPlaylistPrivacies]) => {
69 this.videoPlaylistToUpdate = videoPlaylistToUpdate
70 this.videoPlaylistPrivacies = videoPlaylistPrivacies
71
72 this.hydrateFormFromPlaylist()
73 },
74
75 err => this.error = err.message
76 )
77 }
78
79 ngOnDestroy () {
80 if (this.paramsSub) this.paramsSub.unsubscribe()
81 }
82
83 formValidated () {
84 this.error = undefined
85
86 const body = this.form.value
87 const videoPlaylistUpdate: VideoPlaylistUpdate = {
88 displayName: body.displayName,
89 privacy: body.privacy,
90 description: body.description || null,
91 videoChannelId: body.videoChannelId || null,
92 thumbnailfile: body.thumbnailfile || undefined
93 }
94
95 this.videoPlaylistService.updateVideoPlaylist(this.videoPlaylistToUpdate, videoPlaylistUpdate).subscribe(
96 () => {
97 this.notifier.success($localize`Playlist ${videoPlaylistUpdate.displayName} updated.`)
98 this.router.navigate([ '/my-library', 'video-playlists' ])
99 },
100
101 err => this.error = err.message
102 )
103 }
104
105 isCreation () {
106 return false
107 }
108
109 getFormButtonTitle () {
110 return $localize`Update`
111 }
112
113 private hydrateFormFromPlaylist () {
114 this.form.patchValue({
115 displayName: this.videoPlaylistToUpdate.displayName,
116 privacy: this.videoPlaylistToUpdate.privacy.id,
117 description: this.videoPlaylistToUpdate.description,
118 videoChannelId: this.videoPlaylistToUpdate.videoChannel ? this.videoPlaylistToUpdate.videoChannel.id : null
119 })
120
121 fetch(this.videoPlaylistToUpdate.thumbnailUrl)
122 .then(response => response.blob())
123 .then(data => {
124 this.form.patchValue({
125 thumbnailfile: data
126 })
127 })
128 }
129}
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html b/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html
new file mode 100644
index 000000000..afcf6a084
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.html
@@ -0,0 +1,35 @@
1<h1>
2 <span>
3 <my-global-icon iconName="playlists" aria-hidden="true"></my-global-icon>
4 <ng-container i18n>My playlists</ng-container> <span class="badge badge-secondary">{{ pagination.totalItems }}</span>
5 </span>
6</h1>
7
8<div class="video-playlists-header d-flex justify-content-between">
9 <div class="has-feedback has-clear">
10 <input type="text" placeholder="Search your playlists" i18n-placeholder [(ngModel)]="videoPlaylistsSearch"
11 (ngModelChange)="onVideoPlaylistSearchChanged()" />
12 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
13 <span class="sr-only" i18n>Clear filters</span>
14 </div>
15
16 <a class="create-button" routerLink="create">
17 <my-global-icon iconName="add" aria-hidden="true"></my-global-icon>
18 <ng-container i18n>Create playlist</ng-container>
19 </a>
20</div>
21
22<div class="video-playlists" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
23 <div *ngFor="let playlist of videoPlaylists" class="video-playlist">
24 <div class="miniature-wrapper">
25 <my-video-playlist-miniature [playlist]="playlist" [toManage]="true" [displayChannel]="true" [displayDescription]="true" [displayPrivacy]="true"
26 ></my-video-playlist-miniature>
27 </div>
28
29 <div *ngIf="isRegularPlaylist(playlist)" class="video-playlist-buttons">
30 <my-delete-button label (click)="deleteVideoPlaylist(playlist)"></my-delete-button>
31
32 <my-edit-button label [routerLink]="[ 'update', playlist.uuid ]"></my-edit-button>
33 </div>
34 </div>
35</div>
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.scss b/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.scss
new file mode 100644
index 000000000..2b7c88246
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.scss
@@ -0,0 +1,78 @@
1@import '_variables';
2@import '_mixins';
3
4.create-button {
5 @include create-button;
6}
7
8input[type=text] {
9 @include peertube-input-text(300px);
10}
11
12::ng-deep .action-button {
13 &.action-button-delete {
14 margin-right: 10px;
15 }
16}
17
18.video-playlist {
19 @include row-blocks;
20
21 .miniature-wrapper {
22 flex-grow: 1;
23
24 ::ng-deep .miniature {
25 display: flex;
26
27 .miniature-info {
28 margin-left: 10px;
29 width: auto;
30 }
31 }
32 }
33
34 .video-playlist-buttons {
35 min-width: 190px;
36 height: max-content;
37 }
38}
39
40.video-playlists-header {
41 margin-bottom: 30px;
42}
43
44@media screen and (max-width: $small-view) {
45 .video-playlists-header {
46 text-align: center;
47 }
48
49 .video-playlist {
50
51 .video-playlist-buttons {
52 margin-top: 10px;
53 }
54 }
55
56 my-video-playlist-miniature ::ng-deep .miniature {
57 flex-direction: column;
58
59 .miniature-info {
60 margin-left: 0 !important;
61 }
62
63 .miniature-name {
64 max-width: $video-thumbnail-width;
65 }
66 }
67}
68
69@media screen and (max-width: $mobile-view) {
70 .video-playlists-header {
71 flex-direction: column;
72
73 input[type=text] {
74 width: 100% !important;
75 margin-bottom: 12px;
76 }
77 }
78}
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.ts
new file mode 100644
index 000000000..f6d394923
--- /dev/null
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlists.component.ts
@@ -0,0 +1,101 @@
1import { Subject } from 'rxjs'
2import { debounceTime, mergeMap } from 'rxjs/operators'
3import { Component, OnInit } from '@angular/core'
4import { AuthService, ComponentPagination, ConfirmService, Notifier, User } from '@app/core'
5import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
6import { VideoPlaylistType } from '@shared/models'
7
8@Component({
9 templateUrl: './my-video-playlists.component.html',
10 styleUrls: [ './my-video-playlists.component.scss' ]
11})
12export class MyVideoPlaylistsComponent implements OnInit {
13 videoPlaylistsSearch: string
14 videoPlaylists: VideoPlaylist[] = []
15 videoPlaylistSearchChanged = new Subject<string>()
16
17 pagination: ComponentPagination = {
18 currentPage: 1,
19 itemsPerPage: 5,
20 totalItems: null
21 }
22
23 onDataSubject = new Subject<any[]>()
24
25 private user: User
26
27 constructor (
28 private authService: AuthService,
29 private notifier: Notifier,
30 private confirmService: ConfirmService,
31 private videoPlaylistService: VideoPlaylistService
32 ) {}
33
34 ngOnInit () {
35 this.user = this.authService.getUser()
36
37 this.loadVideoPlaylists()
38
39 this.videoPlaylistSearchChanged
40 .pipe(
41 debounceTime(500))
42 .subscribe(() => {
43 this.loadVideoPlaylists(true)
44 })
45 }
46
47 async deleteVideoPlaylist (videoPlaylist: VideoPlaylist) {
48 const res = await this.confirmService.confirm(
49 $localize`Do you really want to delete ${videoPlaylist.displayName}?`,
50 $localize`Delete`
51 )
52 if (res === false) return
53
54 this.videoPlaylistService.removeVideoPlaylist(videoPlaylist)
55 .subscribe(
56 () => {
57 this.videoPlaylists = this.videoPlaylists
58 .filter(p => p.id !== videoPlaylist.id)
59
60 this.notifier.success($localize`Playlist ${videoPlaylist.displayName}} deleted.`)
61 },
62
63 error => this.notifier.error(error.message)
64 )
65 }
66
67 isRegularPlaylist (playlist: VideoPlaylist) {
68 return playlist.type.id === VideoPlaylistType.REGULAR
69 }
70
71 onNearOfBottom () {
72 // Last page
73 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
74
75 this.pagination.currentPage += 1
76 this.loadVideoPlaylists()
77 }
78
79 resetSearch () {
80 this.videoPlaylistsSearch = ''
81 this.onVideoPlaylistSearchChanged()
82 }
83
84 onVideoPlaylistSearchChanged () {
85 this.videoPlaylistSearchChanged.next()
86 }
87
88 private loadVideoPlaylists (reset = false) {
89 this.authService.userInformationLoaded
90 .pipe(mergeMap(() => {
91 return this.videoPlaylistService.listAccountPlaylists(this.user.account, this.pagination, '-updatedAt', this.videoPlaylistsSearch)
92 }))
93 .subscribe(res => {
94 if (reset) this.videoPlaylists = []
95 this.videoPlaylists = this.videoPlaylists.concat(res.data)
96 this.pagination.totalItems = res.total
97
98 this.onDataSubject.next(res.data)
99 })
100 }
101}
diff --git a/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.html b/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.html
new file mode 100644
index 000000000..c7c5a0b69
--- /dev/null
+++ b/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.html
@@ -0,0 +1,33 @@
1<ng-template #modal let-close="close" let-dismiss="dismiss">
2 <div class="modal-header">
3 <h4 i18n class="modal-title">Change ownership</h4>
4
5 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="dismiss()"></my-global-icon>
6 </div>
7
8 <div class="modal-body" [formGroup]="form">
9 <div class="form-group">
10 <label i18n for="next-ownership-username">Select the next owner</label>
11 <p-autoComplete formControlName="username" [suggestions]="usernamePropositions"
12 (completeMethod)="search($event)" id="next-ownership-username"></p-autoComplete>
13 <div *ngIf="formErrors.username" class="form-error">
14 {{ formErrors.username }}
15 </div>
16 </div>
17 </div>
18
19 <div class="modal-footer">
20 <div class="form-group inputs">
21 <input
22 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
23 (click)="dismiss()" (key.enter)="dismiss()"
24 >
25
26 <input
27 type="submit" i18n-value value="Submit" class="action-button-submit"
28 [disabled]="!form.valid"
29 (click)="close()"
30 />
31 </div>
32 </div>
33</ng-template>
diff --git a/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.scss b/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.scss
new file mode 100644
index 000000000..a79fec179
--- /dev/null
+++ b/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.scss
@@ -0,0 +1,10 @@
1@import '_variables';
2@import '_mixins';
3
4p-autocomplete {
5 display: block;
6}
7
8.form-group {
9 margin: 20px 0;
10} \ No newline at end of file
diff --git a/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts b/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts
new file mode 100644
index 000000000..84237dee1
--- /dev/null
+++ b/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts
@@ -0,0 +1,69 @@
1import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
2import { Notifier, UserService } from '@app/core'
3import { OWNERSHIP_CHANGE_USERNAME_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
5import { Video, VideoOwnershipService } from '@app/shared/shared-main'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7
8@Component({
9 selector: 'my-video-change-ownership',
10 templateUrl: './video-change-ownership.component.html',
11 styleUrls: [ './video-change-ownership.component.scss' ]
12})
13export class VideoChangeOwnershipComponent extends FormReactive implements OnInit {
14 @ViewChild('modal', { static: true }) modal: ElementRef
15
16 usernamePropositions: string[]
17
18 error: string = null
19
20 private video: Video | undefined = undefined
21
22 constructor (
23 protected formValidatorService: FormValidatorService,
24 private videoOwnershipService: VideoOwnershipService,
25 private notifier: Notifier,
26 private userService: UserService,
27 private modalService: NgbModal
28 ) {
29 super()
30 }
31
32 ngOnInit () {
33 this.buildForm({
34 username: OWNERSHIP_CHANGE_USERNAME_VALIDATOR
35 })
36 this.usernamePropositions = []
37 }
38
39 show (video: Video) {
40 this.video = video
41 this.modalService
42 .open(this.modal, { centered: true })
43 .result
44 .then(() => this.changeOwnership())
45 .catch((_) => _) // Called when closing (cancel) the modal without validating, do nothing
46 }
47
48 search (event: { query: string }) {
49 const query = event.query
50 this.userService.autocomplete(query)
51 .subscribe(
52 usernames => this.usernamePropositions = usernames,
53
54 err => this.notifier.error(err.message)
55 )
56 }
57
58 changeOwnership () {
59 const username = this.form.value['username']
60
61 this.videoOwnershipService
62 .changeOwnership(this.video.id, username)
63 .subscribe(
64 () => this.notifier.success($localize`Ownership change request sent.`),
65
66 err => this.notifier.error(err.message)
67 )
68 }
69}
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.html b/client/src/app/+my-library/my-videos/my-videos.component.html
new file mode 100644
index 000000000..977f7b03b
--- /dev/null
+++ b/client/src/app/+my-library/my-videos/my-videos.component.html
@@ -0,0 +1,58 @@
1<h1>
2 <span>
3 <my-global-icon iconName="videos" aria-hidden="true"></my-global-icon>
4 <ng-container i18n>My videos</ng-container>
5 <span class="badge badge-secondary"> {{ pagination.totalItems }}</span>
6 </span>
7
8 <div>
9 <a routerLink="/my-library/video-imports" class="button-link">
10 <my-global-icon iconName="cloud-download" aria-hidden="true"></my-global-icon>
11 <ng-container i18n>My imports</ng-container>
12 </a>
13
14 <a routerLink="/my-library/ownership" class="button-link">
15 <my-global-icon iconName="users" aria-hidden="true"></my-global-icon>
16 <ng-container i18n>Ownership changes</ng-container>
17 </a>
18 </div>
19</h1>
20
21<div class="videos-header d-flex justify-content-between">
22 <div class="has-feedback has-clear">
23 <input type="text" placeholder="Search your videos" i18n-placeholder [(ngModel)]="videosSearch"
24 (ngModelChange)="onVideosSearchChanged()" />
25 <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="resetSearch()"></a>
26 <span class="sr-only" i18n>Clear filters</span>
27 </div>
28</div>
29
30<my-videos-selection
31 [pagination]="pagination"
32 [(selection)]="selection"
33 [(videosModel)]="videos"
34 [miniatureDisplayOptions]="miniatureDisplayOptions"
35 [titlePage]="titlePage"
36 [getVideosObservableFunction]="getVideosObservableFunction"
37 [ownerDisplayType]="ownerDisplayType"
38 #videosSelection
39>
40 <ng-template ptTemplate="globalButtons">
41 <span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
42 <my-global-icon iconName="delete" aria-hidden="true"></my-global-icon>
43 <ng-container i18n>Delete</ng-container>
44 </span>
45 </ng-template>
46
47 <ng-template ptTemplate="rowButtons" let-video>
48 <div class="action-button">
49 <my-edit-button label [routerLink]="[ '/videos', 'update', video.uuid ]"></my-edit-button>
50
51 <my-action-dropdown [actions]="videoActions" [entry]="{ video: video }"></my-action-dropdown>
52 </div>
53 </ng-template>
54</my-videos-selection>
55
56
57<my-video-change-ownership #videoChangeOwnershipModal></my-video-change-ownership>
58<my-live-stream-information #liveStreamInformationModal></my-live-stream-information>
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.scss b/client/src/app/+my-library/my-videos/my-videos.component.scss
new file mode 100644
index 000000000..59fc5fe80
--- /dev/null
+++ b/client/src/app/+my-library/my-videos/my-videos.component.scss
@@ -0,0 +1,138 @@
1@import '_variables';
2@import '_mixins';
3
4input[type=text] {
5 @include peertube-input-text(300px);
6}
7
8h1 {
9 display: flex;
10 justify-content: space-between;
11
12 .button-link {
13 @include peertube-button-link;
14 @include grey-button;
15 @include button-with-icon(18px, 3px, -1px);
16
17 &:not(:last-child) {
18 margin-right: 10px;
19 }
20 }
21}
22
23.action-button-delete-selection {
24 display: inline-block;
25
26 @include peertube-button;
27 @include orange-button;
28 @include button-with-icon(21px);
29
30 my-global-icon {
31 @include apply-svg-color(#fff);
32 }
33}
34
35::ng-deep {
36 .video {
37 flex-wrap: wrap;
38 }
39
40 .action-button span {
41 white-space: nowrap;
42 }
43
44 .video-miniature {
45 &.display-as-row {
46 // width: min-content !important;
47 width: 100% !important;
48
49 .video-bottom .video-miniature-information {
50 width: max-content !important;
51 min-width: unset !important;
52 }
53 }
54
55 .video-bottom {
56 max-width: 350px;
57 }
58 }
59}
60
61.action-button {
62 display: flex;
63 margin-left: 55px;
64 margin-top: 10px;
65 align-self: flex-end;
66}
67
68my-edit-button {
69 margin-right: 10px;
70}
71
72@media screen and (max-width: $small-view) {
73 h1 {
74 flex-direction: column;
75
76 > span,
77 .button-link {
78 margin-bottom: 10px;
79 }
80 }
81
82 .action-button {
83 flex-direction: column;
84 align-self: center;
85 align-items: center;
86 margin-left: 0px;
87 }
88
89 my-edit-button {
90 margin: 15px 0 5px 0;
91 width: 100%;
92 text-align: center;
93
94 ::ng-deep {
95 .action-button {
96 /* same width than a.video-thumbnail */
97 width: $video-thumbnail-width;
98 }
99 }
100 }
101
102 ::ng-deep {
103 .video-miniature {
104 align-items: center;
105
106 .video-bottom,
107 .video-bottom .video-miniature-information {
108 /* same width than a.video-thumbnail */
109 max-width: $video-thumbnail-width !important;
110 }
111 }
112 }
113}
114
115// Adapt my-video-miniature on small screens with menu
116@media screen and (min-width: $small-view) and (max-width: #{breakpoint(lg) + ($not-expanded-horizontal-margins / 3) * 2}) {
117 :host-context(.main-col:not(.expanded)) {
118 ::ng-deep {
119 .video-miniature {
120 flex-direction: column;
121
122 .video-miniature-name {
123 max-width: $video-thumbnail-width;
124 }
125 }
126 }
127 }
128}
129
130@media screen and (max-width: $mobile-view) {
131 .videos-header {
132 flex-direction: column;
133
134 input[type=text] {
135 width: 100% !important;
136 }
137 }
138}
diff --git a/client/src/app/+my-library/my-videos/my-videos.component.ts b/client/src/app/+my-library/my-videos/my-videos.component.ts
new file mode 100644
index 000000000..e89bb12e1
--- /dev/null
+++ b/client/src/app/+my-library/my-videos/my-videos.component.ts
@@ -0,0 +1,178 @@
1import { concat, Observable, Subject } from 'rxjs'
2import { debounceTime, tap, toArray } from 'rxjs/operators'
3import { Component, OnInit, ViewChild } from '@angular/core'
4import { ActivatedRoute, Router } from '@angular/router'
5import { AuthService, ComponentPagination, ConfirmService, Notifier, ScreenService, ServerService } from '@app/core'
6import { DisableForReuseHook } from '@app/core/routing/disable-for-reuse-hook'
7import { immutableAssign } from '@app/helpers'
8import { DropdownAction, Video, VideoService } from '@app/shared/shared-main'
9import { LiveStreamInformationComponent } from '@app/shared/shared-video-live'
10import { MiniatureDisplayOptions, OwnerDisplayType, SelectionType, VideosSelectionComponent } from '@app/shared/shared-video-miniature'
11import { VideoSortField } from '@shared/models'
12import { VideoChangeOwnershipComponent } from './modals/video-change-ownership.component'
13
14@Component({
15 templateUrl: './my-videos.component.html',
16 styleUrls: [ './my-videos.component.scss' ]
17})
18export class MyVideosComponent implements OnInit, DisableForReuseHook {
19 @ViewChild('videosSelection', { static: true }) videosSelection: VideosSelectionComponent
20 @ViewChild('videoChangeOwnershipModal', { static: true }) videoChangeOwnershipModal: VideoChangeOwnershipComponent
21 @ViewChild('liveStreamInformationModal', { static: true }) liveStreamInformationModal: LiveStreamInformationComponent
22
23 titlePage: string
24 selection: SelectionType = {}
25 pagination: ComponentPagination = {
26 currentPage: 1,
27 itemsPerPage: 10,
28 totalItems: null
29 }
30 miniatureDisplayOptions: MiniatureDisplayOptions = {
31 date: true,
32 views: true,
33 by: true,
34 privacyLabel: false,
35 privacyText: true,
36 state: true,
37 blacklistInfo: true
38 }
39 ownerDisplayType: OwnerDisplayType = 'videoChannel'
40
41 videoActions: DropdownAction<{ video: Video }>[] = []
42
43 videos: Video[] = []
44 videosSearch: string
45 videosSearchChanged = new Subject<string>()
46 getVideosObservableFunction = this.getVideosObservable.bind(this)
47
48 constructor (
49 protected router: Router,
50 protected serverService: ServerService,
51 protected route: ActivatedRoute,
52 protected authService: AuthService,
53 protected notifier: Notifier,
54 protected screenService: ScreenService,
55 private confirmService: ConfirmService,
56 private videoService: VideoService
57 ) {
58 this.titlePage = $localize`My videos`
59 }
60
61 ngOnInit () {
62 this.buildActions()
63
64 this.videosSearchChanged
65 .pipe(debounceTime(500))
66 .subscribe(() => {
67 this.videosSelection.reloadVideos()
68 })
69 }
70
71 resetSearch () {
72 this.videosSearch = ''
73 this.onVideosSearchChanged()
74 }
75
76 onVideosSearchChanged () {
77 this.videosSearchChanged.next()
78 }
79
80 disableForReuse () {
81 this.videosSelection.disableForReuse()
82 }
83
84 enabledForReuse () {
85 this.videosSelection.enabledForReuse()
86 }
87
88 getVideosObservable (page: number, sort: VideoSortField) {
89 const newPagination = immutableAssign(this.pagination, { currentPage: page })
90
91 return this.videoService.getMyVideos(newPagination, sort, this.videosSearch)
92 .pipe(
93 tap(res => this.pagination.totalItems = res.total)
94 )
95 }
96
97 async deleteSelectedVideos () {
98 const toDeleteVideosIds = Object.keys(this.selection)
99 .filter(k => this.selection[ k ] === true)
100 .map(k => parseInt(k, 10))
101
102 const res = await this.confirmService.confirm(
103 $localize`Do you really want to delete ${toDeleteVideosIds.length} videos?`,
104 $localize`Delete`
105 )
106 if (res === false) return
107
108 const observables: Observable<any>[] = []
109 for (const videoId of toDeleteVideosIds) {
110 const o = this.videoService.removeVideo(videoId)
111 .pipe(tap(() => this.removeVideoFromArray(videoId)))
112
113 observables.push(o)
114 }
115
116 concat(...observables)
117 .pipe(toArray())
118 .subscribe(
119 () => {
120 this.notifier.success($localize`${toDeleteVideosIds.length} videos deleted.`)
121 this.selection = {}
122 },
123
124 err => this.notifier.error(err.message)
125 )
126 }
127
128 async deleteVideo (video: Video) {
129 const res = await this.confirmService.confirm(
130 $localize`Do you really want to delete ${video.name}?`,
131 $localize`Delete`
132 )
133 if (res === false) return
134
135 this.videoService.removeVideo(video.id)
136 .subscribe(
137 () => {
138 this.notifier.success($localize`Video ${video.name} deleted.`)
139 this.removeVideoFromArray(video.id)
140 },
141
142 error => this.notifier.error(error.message)
143 )
144 }
145
146 changeOwnership (video: Video) {
147 this.videoChangeOwnershipModal.show(video)
148 }
149
150 displayLiveInformation (video: Video) {
151 this.liveStreamInformationModal.show(video)
152 }
153
154 private removeVideoFromArray (id: number) {
155 this.videos = this.videos.filter(v => v.id !== id)
156 }
157
158 private buildActions () {
159 this.videoActions = [
160 {
161 label: $localize`Display live information`,
162 handler: ({ video }) => this.displayLiveInformation(video),
163 isDisplayed: ({ video }) => video.isLive,
164 iconName: 'live'
165 },
166 {
167 label: $localize`Change ownership`,
168 handler: ({ video }) => this.changeOwnership(video),
169 iconName: 'ownership-change'
170 },
171 {
172 label: $localize`Delete`,
173 handler: ({ video }) => this.deleteVideo(video),
174 iconName: 'delete'
175 }
176 ]
177 }
178}