diff options
author | Chocobozzz <me@florianbigard.com> | 2020-11-12 15:28:54 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-11-13 12:02:21 +0100 |
commit | 17119e4a546522468878cf115558b17949ab50d0 (patch) | |
tree | 3f130cfd7fdccf5aeeac9beee941750590239047 /client/src/app/+my-library/+my-video-channels | |
parent | b4bc269e5517849b5b89052f0c1a2c01b6f65089 (diff) | |
download | PeerTube-17119e4a546522468878cf115558b17949ab50d0.tar.gz PeerTube-17119e4a546522468878cf115558b17949ab50d0.tar.zst PeerTube-17119e4a546522468878cf115558b17949ab50d0.zip |
Reorganize left menu and account menu
Add my-settings and my-library in left menu
Move administration below my-library
Split account menu: my-setting and my library
Diffstat (limited to 'client/src/app/+my-library/+my-video-channels')
10 files changed, 824 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 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | import { AuthService, Notifier } from '@app/core' | ||
4 | import { | ||
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' | ||
10 | import { FormValidatorService } from '@app/shared/shared-forms' | ||
11 | import { VideoChannelService } from '@app/shared/shared-main' | ||
12 | import { VideoChannelCreate } from '@shared/models' | ||
13 | import { 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 | }) | ||
19 | export 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 | |||
4 | label { | ||
5 | font-weight: $font-regular; | ||
6 | font-size: 100%; | ||
7 | } | ||
8 | |||
9 | .video-channel-title { | ||
10 | @include settings-big-title; | ||
11 | } | ||
12 | |||
13 | my-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 | |||
26 | input { | ||
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 | |||
45 | textarea { | ||
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 @@ | |||
1 | import { FormReactive } from '@app/shared/shared-forms' | ||
2 | import { VideoChannel } from '@app/shared/shared-main' | ||
3 | |||
4 | export 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 @@ | |||
1 | import { Subscription } from 'rxjs' | ||
2 | import { Component, OnDestroy, OnInit } from '@angular/core' | ||
3 | import { ActivatedRoute, Router } from '@angular/router' | ||
4 | import { AuthService, Notifier, ServerService } from '@app/core' | ||
5 | import { | ||
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' | ||
10 | import { FormValidatorService } from '@app/shared/shared-forms' | ||
11 | import { VideoChannel, VideoChannelService } from '@app/shared/shared-main' | ||
12 | import { ServerConfig, VideoChannelUpdate } from '@shared/models' | ||
13 | import { 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 | }) | ||
20 | export 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 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { MyVideoChannelUpdateComponent } from './my-video-channel-update.component' | ||
4 | import { MyVideoChannelCreateComponent } from './my-video-channel-create.component' | ||
5 | import { MyVideoChannelsComponent } from './my-video-channels.component' | ||
6 | |||
7 | const 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 | }) | ||
41 | export 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 | |||
8 | input[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 @@ | |||
1 | import { ChartData } from 'chart.js' | ||
2 | import { max, maxBy, min, minBy } from 'lodash-es' | ||
3 | import { Subject } from 'rxjs' | ||
4 | import { debounceTime, mergeMap } from 'rxjs/operators' | ||
5 | import { Component, OnInit } from '@angular/core' | ||
6 | import { AuthService, ConfirmService, Notifier, ScreenService, User } from '@app/core' | ||
7 | import { 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 | }) | ||
13 | export 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}? | ||
111 | It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another | ||
112 | channel 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 @@ | |||
1 | import { ChartModule } from 'primeng/chart' | ||
2 | import { NgModule } from '@angular/core' | ||
3 | import { SharedFormModule } from '@app/shared/shared-forms' | ||
4 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' | ||
5 | import { SharedMainModule } from '@app/shared/shared-main' | ||
6 | import { MyVideoChannelCreateComponent } from './my-video-channel-create.component' | ||
7 | import { MyVideoChannelUpdateComponent } from './my-video-channel-update.component' | ||
8 | import { MyVideoChannelsRoutingModule } from './my-video-channels-routing.module' | ||
9 | import { 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 | }) | ||
31 | export class MyVideoChannelsModule { } | ||