From 17119e4a546522468878cf115558b17949ab50d0 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 12 Nov 2020 15:28:54 +0100 Subject: 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 --- .../my-video-channel-create.component.ts | 78 ++++++++++ .../my-video-channel-edit.component.html | 105 +++++++++++++ .../my-video-channel-edit.component.scss | 67 ++++++++ .../+my-video-channels/my-video-channel-edit.ts | 22 +++ .../my-video-channel-update.component.ts | 135 ++++++++++++++++ .../my-video-channels-routing.module.ts | 41 +++++ .../my-video-channels.component.html | 49 ++++++ .../my-video-channels.component.scss | 125 +++++++++++++++ .../my-video-channels.component.ts | 171 +++++++++++++++++++++ .../+my-video-channels/my-video-channels.module.ts | 31 ++++ 10 files changed, 824 insertions(+) create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channel-create.component.ts create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.html create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.scss create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channel-edit.ts create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channels-routing.module.ts create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channels.component.html create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts create mode 100644 client/src/app/+my-library/+my-video-channels/my-video-channels.module.ts (limited to 'client/src/app/+my-library/+my-video-channels') 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 @@ +import { Component, OnInit } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService, Notifier } from '@app/core' +import { + VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, + VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, + VIDEO_CHANNEL_NAME_VALIDATOR, + VIDEO_CHANNEL_SUPPORT_VALIDATOR +} from '@app/shared/form-validators/video-channel-validators' +import { FormValidatorService } from '@app/shared/shared-forms' +import { VideoChannelService } from '@app/shared/shared-main' +import { VideoChannelCreate } from '@shared/models' +import { MyVideoChannelEdit } from './my-video-channel-edit' + +@Component({ + templateUrl: './my-video-channel-edit.component.html', + styleUrls: [ './my-video-channel-edit.component.scss' ] +}) +export class MyVideoChannelCreateComponent extends MyVideoChannelEdit implements OnInit { + error: string + + constructor ( + protected formValidatorService: FormValidatorService, + private authService: AuthService, + private notifier: Notifier, + private router: Router, + private videoChannelService: VideoChannelService + ) { + super() + } + + ngOnInit () { + this.buildForm({ + name: VIDEO_CHANNEL_NAME_VALIDATOR, + 'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, + description: VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, + support: VIDEO_CHANNEL_SUPPORT_VALIDATOR + }) + } + + formValidated () { + this.error = undefined + + const body = this.form.value + const videoChannelCreate: VideoChannelCreate = { + name: body.name, + displayName: body['display-name'], + description: body.description || null, + support: body.support || null + } + + this.videoChannelService.createVideoChannel(videoChannelCreate).subscribe( + () => { + this.authService.refreshUserInformation() + + this.notifier.success($localize`Video channel ${videoChannelCreate.displayName} created.`) + this.router.navigate([ '/my-library', 'video-channels' ]) + }, + + err => { + if (err.status === 409) { + this.error = $localize`This name already exists on this instance.` + return + } + + this.error = err.message + } + ) + } + + isCreation () { + return true + } + + getFormButtonTitle () { + return $localize`Create` + } +} 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 @@ + + +
{{ error }}
+ +
+ +
+
+
NEW CHANNEL
+
CHANNEL
+
+ +
+ +
+ +
+ +
+ @{{ instanceHost }} +
+
+
+ {{ formErrors['name'] }} +
+
+ + + +
+ + +
+ {{ formErrors['display-name'] }} +
+
+ +
+ + +
+ {{ formErrors.description }} +
+
+ +
+ + + +
+ {{ formErrors.support }} +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
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 @@ +@import '_variables'; +@import '_mixins'; + +label { + font-weight: $font-regular; + font-size: 100%; +} + +.video-channel-title { + @include settings-big-title; +} + +my-actor-avatar-info { + display: block; + margin-bottom: 20px; +} + +.input-group { + @include peertube-input-group(fit-content); +} + +.input-group-append { + height: 30px; +} + +input { + &[type=text] { + @include peertube-input-text(340px); + + display: block; + + &#name { + width: auto; + flex-grow: 1; + } + } + + &[type=submit] { + @include peertube-button; + @include orange-button; + margin-left: auto; + } +} + +textarea { + @include peertube-textarea(500px, 150px); + + display: block; +} + +.peertube-select-container { + @include peertube-select-container(340px); +} + +.breadcrumb { + @include breadcrumb; +} + +@media screen and (max-width: $small-view) { + input[type=text]#name { + width: auto !important; + } + + label[for=name] + div, textarea { + width: 100%; + } +} 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 @@ +import { FormReactive } from '@app/shared/shared-forms' +import { VideoChannel } from '@app/shared/shared-main' + +export abstract class MyVideoChannelEdit extends FormReactive { + // We need it even in the create component because it's used in the edit template + videoChannelToUpdate: VideoChannel + + abstract isCreation (): boolean + abstract getFormButtonTitle (): string + + get instanceHost () { + return window.location.host + } + + // We need this method so angular does not complain in child template that doesn't need this + onAvatarChange (formData: FormData) { /* empty */ } + + // Should be implemented by the child + isBulkUpdateVideosDisplayed () { + return false + } +} 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 @@ +import { Subscription } from 'rxjs' +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { AuthService, Notifier, ServerService } from '@app/core' +import { + VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, + VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, + VIDEO_CHANNEL_SUPPORT_VALIDATOR +} from '@app/shared/form-validators/video-channel-validators' +import { FormValidatorService } from '@app/shared/shared-forms' +import { VideoChannel, VideoChannelService } from '@app/shared/shared-main' +import { ServerConfig, VideoChannelUpdate } from '@shared/models' +import { MyVideoChannelEdit } from './my-video-channel-edit' + +@Component({ + selector: 'my-video-channel-update', + templateUrl: './my-video-channel-edit.component.html', + styleUrls: [ './my-video-channel-edit.component.scss' ] +}) +export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements OnInit, OnDestroy { + error: string + videoChannelToUpdate: VideoChannel + + private paramsSub: Subscription + private oldSupportField: string + private serverConfig: ServerConfig + + constructor ( + protected formValidatorService: FormValidatorService, + private authService: AuthService, + private notifier: Notifier, + private router: Router, + private route: ActivatedRoute, + private videoChannelService: VideoChannelService, + private serverService: ServerService + ) { + super() + } + + ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + + this.buildForm({ + 'display-name': VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, + description: VIDEO_CHANNEL_DESCRIPTION_VALIDATOR, + support: VIDEO_CHANNEL_SUPPORT_VALIDATOR, + bulkVideosSupportUpdate: null + }) + + this.paramsSub = this.route.params.subscribe(routeParams => { + const videoChannelId = routeParams['videoChannelId'] + + this.videoChannelService.getVideoChannel(videoChannelId).subscribe( + videoChannelToUpdate => { + this.videoChannelToUpdate = videoChannelToUpdate + + this.oldSupportField = videoChannelToUpdate.support + + this.form.patchValue({ + 'display-name': videoChannelToUpdate.displayName, + description: videoChannelToUpdate.description, + support: videoChannelToUpdate.support + }) + }, + + err => this.error = err.message + ) + }) + } + + ngOnDestroy () { + if (this.paramsSub) this.paramsSub.unsubscribe() + } + + formValidated () { + this.error = undefined + + const body = this.form.value + const videoChannelUpdate: VideoChannelUpdate = { + displayName: body['display-name'], + description: body.description || null, + support: body.support || null, + bulkVideosSupportUpdate: body.bulkVideosSupportUpdate || false + } + + this.videoChannelService.updateVideoChannel(this.videoChannelToUpdate.name, videoChannelUpdate).subscribe( + () => { + this.authService.refreshUserInformation() + + this.notifier.success($localize`Video channel ${videoChannelUpdate.displayName} updated.`) + + this.router.navigate([ '/my-library', 'video-channels' ]) + }, + + err => this.error = err.message + ) + } + + onAvatarChange (formData: FormData) { + this.videoChannelService.changeVideoChannelAvatar(this.videoChannelToUpdate.name, formData) + .subscribe( + data => { + this.notifier.success($localize`Avatar changed.`) + + this.videoChannelToUpdate.updateAvatar(data.avatar) + }, + + err => this.notifier.error(err.message) + ) + } + + get maxAvatarSize () { + return this.serverConfig.avatar.file.size.max + } + + get avatarExtensions () { + return this.serverConfig.avatar.file.extensions.join(',') + } + + isCreation () { + return false + } + + getFormButtonTitle () { + return $localize`Update` + } + + isBulkUpdateVideosDisplayed () { + if (this.oldSupportField === undefined) return false + + return this.oldSupportField !== this.form.value['support'] + } +} 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 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { MyVideoChannelUpdateComponent } from './my-video-channel-update.component' +import { MyVideoChannelCreateComponent } from './my-video-channel-create.component' +import { MyVideoChannelsComponent } from './my-video-channels.component' + +const myVideoChannelsRoutes: Routes = [ + { + path: '', + component: MyVideoChannelsComponent, + data: { + meta: { + title: $localize`My video channels` + } + } + }, + { + path: 'create', + component: MyVideoChannelCreateComponent, + data: { + meta: { + title: $localize`Create a new video channel` + } + } + }, + { + path: 'update/:videoChannelId', + component: MyVideoChannelUpdateComponent, + data: { + meta: { + title: $localize`Update video channel` + } + } + } +] + +@NgModule({ + imports: [ RouterModule.forChild(myVideoChannelsRoutes) ], + exports: [ RouterModule ] +}) +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 @@ +

+ + + My channels + {{ totalItems }} + +

+ +
+
+ + + Clear filters +
+ + + + Create video channel + +
+ +
+
+ + Avatar + + +
+ +
{{ videoChannel.displayName }}
+
{{ videoChannel.nameWithHost }}
+
+ +
{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}
+ +
{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}
+ +
+ + +
+ +
+ +
+
+
+
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 @@ +@import '_variables'; +@import '_mixins'; + +.create-button { + @include create-button; +} + +input[type=text] { + @include peertube-input-text(300px); +} + +::ng-deep .action-button { + &.action-button-edit { + margin-right: 10px; + } +} + +.video-channel { + @include row-blocks; + padding-bottom: 0; + + img { + @include avatar(80px); + + margin-right: 10px; + } + + .video-channel-info { + flex-grow: 1; + + a.video-channel-names { + @include disable-default-a-behaviour; + + width: fit-content; + display: flex; + align-items: baseline; + color: pvar(--mainForegroundColor); + + .video-channel-display-name { + font-weight: $font-semibold; + font-size: 18px; + } + + .video-channel-name { + font-size: 14px; + color: $grey-actor-name; + margin-left: 5px; + } + } + } + + .video-channel-buttons { + margin-top: 10px; + min-width: 190px; + } +} + +::ng-deep .chartjs-render-monitor { + position: relative; + top: 1px; +} + +.video-channels-header { + margin-bottom: 30px; +} + +@media screen and (max-width: $small-view) { + .video-channels-header { + text-align: center; + } + + .video-channel { + padding-bottom: 10px; + + .video-channel-info { + padding-bottom: 10px; + text-align: center; + + .video-channel-names { + flex-direction: column; + align-items: center !important; + margin: auto; + + .video-channel-name { + margin-left: 0px !important; + } + } + } + + img { + margin-right: 0; + } + + .video-channel-buttons { + align-self: center; + } + } +} + +@media screen and (max-width: $mobile-view) { + .video-channels-header { + flex-direction: column; + + input[type=text] { + width: 100% !important; + margin-bottom: 12px; + } + } +} + +@media screen and (min-width: breakpoint(lg)) { + :host-context(.main-col:not(.expanded)) { + .video-channel-buttons { + float: right; + } + } +} + +@media screen and (min-width: $small-view) { + :host-context(.expanded) { + .video-channel-buttons { + float: right; + } + } +} 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 @@ +import { ChartData } from 'chart.js' +import { max, maxBy, min, minBy } from 'lodash-es' +import { Subject } from 'rxjs' +import { debounceTime, mergeMap } from 'rxjs/operators' +import { Component, OnInit } from '@angular/core' +import { AuthService, ConfirmService, Notifier, ScreenService, User } from '@app/core' +import { VideoChannel, VideoChannelService } from '@app/shared/shared-main' + +@Component({ + templateUrl: './my-video-channels.component.html', + styleUrls: [ './my-video-channels.component.scss' ] +}) +export class MyVideoChannelsComponent implements OnInit { + totalItems: number + + videoChannels: VideoChannel[] = [] + videoChannelsChartData: ChartData[] + videoChannelsMinimumDailyViews = 0 + videoChannelsMaximumDailyViews: number + + channelsSearch: string + channelsSearchChanged = new Subject() + + private user: User + + constructor ( + private authService: AuthService, + private notifier: Notifier, + private confirmService: ConfirmService, + private videoChannelService: VideoChannelService, + private screenService: ScreenService + ) {} + + ngOnInit () { + this.user = this.authService.getUser() + + this.loadVideoChannels() + + this.channelsSearchChanged + .pipe(debounceTime(500)) + .subscribe(() => { + this.loadVideoChannels() + }) + } + + get isInSmallView () { + return this.screenService.isInSmallView() + } + + get chartOptions () { + return { + legend: { + display: false + }, + scales: { + xAxes: [{ + display: false + }], + yAxes: [{ + display: false, + ticks: { + min: Math.max(0, this.videoChannelsMinimumDailyViews - (3 * this.videoChannelsMaximumDailyViews / 100)), + max: Math.max(1, this.videoChannelsMaximumDailyViews) + } + }] + }, + layout: { + padding: { + left: 15, + right: 15, + top: 10, + bottom: 0 + } + }, + elements: { + point: { + radius: 0 + } + }, + tooltips: { + mode: 'index', + intersect: false, + custom: function (tooltip: any) { + if (!tooltip) return + // disable displaying the color box + tooltip.displayColors = false + }, + callbacks: { + label: (tooltip: any, data: any) => `${tooltip.value} views` + } + }, + hover: { + mode: 'index', + intersect: false + } + } + } + + resetSearch () { + this.channelsSearch = '' + this.onChannelsSearchChanged() + } + + onChannelsSearchChanged () { + this.channelsSearchChanged.next() + } + + async deleteVideoChannel (videoChannel: VideoChannel) { + const res = await this.confirmService.confirmWithInput( + $localize`Do you really want to delete ${videoChannel.displayName}? +It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another +channel with the same name (${videoChannel.name})!`, + + $localize`Please type the display name of the video channel (${videoChannel.displayName}) to confirm`, + + videoChannel.displayName, + + $localize`Delete` + ) + if (res === false) return + + this.videoChannelService.removeVideoChannel(videoChannel) + .subscribe( + () => { + this.loadVideoChannels() + this.notifier.success($localize`Video channel ${videoChannel.displayName} deleted.`) + }, + + error => this.notifier.error(error.message) + ) + } + + private loadVideoChannels () { + this.authService.userInformationLoaded + .pipe(mergeMap(() => this.videoChannelService.listAccountVideoChannels(this.user.account, null, true, this.channelsSearch))) + .subscribe(res => { + this.videoChannels = res.data + this.totalItems = res.total + + // chart data + this.videoChannelsChartData = this.videoChannels.map(v => ({ + labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()), + datasets: [ + { + label: $localize`Views for the day`, + data: v.viewsPerDay.map(day => day.views), + fill: false, + borderColor: '#c6c6c6' + } + ] + } as ChartData)) + + // chart options that depend on chart data: + // we don't want to skew values and have min at 0, so we define what the floor/ceiling is here + this.videoChannelsMinimumDailyViews = min( + // compute local minimum daily views for each channel, by their "views" attribute + this.videoChannels.map(v => minBy( + v.viewsPerDay, + day => day.views + ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute + ) + this.videoChannelsMaximumDailyViews = max( + // compute local maximum daily views for each channel, by their "views" attribute + this.videoChannels.map(v => maxBy( + v.viewsPerDay, + day => day.views + ).views) // the object returned is a ViewPerDate, so we still need to get the views attribute + ) + }) + } +} 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 @@ +import { ChartModule } from 'primeng/chart' +import { NgModule } from '@angular/core' +import { SharedFormModule } from '@app/shared/shared-forms' +import { SharedGlobalIconModule } from '@app/shared/shared-icons' +import { SharedMainModule } from '@app/shared/shared-main' +import { MyVideoChannelCreateComponent } from './my-video-channel-create.component' +import { MyVideoChannelUpdateComponent } from './my-video-channel-update.component' +import { MyVideoChannelsRoutingModule } from './my-video-channels-routing.module' +import { MyVideoChannelsComponent } from './my-video-channels.component' + +@NgModule({ + imports: [ + MyVideoChannelsRoutingModule, + + ChartModule, + + SharedMainModule, + SharedFormModule, + SharedGlobalIconModule + ], + + declarations: [ + MyVideoChannelsComponent, + MyVideoChannelCreateComponent, + MyVideoChannelUpdateComponent + ], + + exports: [], + providers: [] +}) +export class MyVideoChannelsModule { } -- cgit v1.2.3