Client: Add ability to update video channel avatar

This commit is contained in:
Chocobozzz 2018-06-29 14:34:04 +02:00
parent 4bbfc6c606
commit 52d9f792b3
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
19 changed files with 207 additions and 108 deletions

View File

@ -1,20 +1,4 @@
<div class="user"> <my-actor-avatar-info [actor]="user.account" (avatarChange)="onAvatarChange($event)"></my-actor-avatar-info>
<img [src]="user.accountAvatarUrl" alt="Avatar" />
<div class="user-info">
<div class="user-info-names">
<div class="user-info-display-name">{{ user.account?.displayName }}</div>
<div class="user-info-username">{{ user.username }}</div>
</div>
<div i18n class="user-info-followers">{{ user.account?.followersCount }} subscribers</div>
</div>
</div>
<div class="button-file">
<span i18n>Change your avatar</span>
<input #avatarfileInput type="file" name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="changeAvatar()" />
</div>
<div i18n class="file-max-size">(extensions: {{ avatarExtensions }}, max size: {{ maxAvatarSize | bytes }})</div>
<div class="user-quota"> <div class="user-quota">
<span i18n class="user-quota-label">Video quota:</span> {{ userVideoQuotaUsed | bytes: 0 }} / {{ userVideoQuota }} <span i18n class="user-quota-label">Video quota:</span> {{ userVideoQuotaUsed | bytes: 0 }} / {{ userVideoQuota }}

View File

@ -1,55 +1,6 @@
@import '_variables'; @import '_variables';
@import '_mixins'; @import '_mixins';
.user {
display: flex;
img {
@include avatar(50px);
margin-right: 15px;
}
.user-info {
.user-info-names {
display: flex;
align-items: center;
.user-info-display-name {
font-size: 20px;
font-weight: $font-bold;
}
.user-info-username {
margin-left: 7px;
position: relative;
top: 2px;
font-size: 14px;
color: #777272;
}
}
.user-info-followers {
font-size: 15px;
}
}
}
.button-file {
@include peertube-button-file(160px);
margin-top: 10px;
margin-bottom: 5px;
}
.file-max-size {
display: inline-block;
font-size: 13px;
position: relative;
top: -10px;
}
.user-quota { .user-quota {
font-size: 15px; font-size: 15px;
margin-top: 20px; margin-top: 20px;

View File

@ -13,8 +13,6 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
styleUrls: [ './my-account-settings.component.scss' ] styleUrls: [ './my-account-settings.component.scss' ]
}) })
export class MyAccountSettingsComponent implements OnInit { export class MyAccountSettingsComponent implements OnInit {
@ViewChild('avatarfileInput') avatarfileInput
user: User = null user: User = null
userVideoQuota = '0' userVideoQuota = '0'
userVideoQuotaUsed = 0 userVideoQuotaUsed = 0
@ -48,16 +46,7 @@ export class MyAccountSettingsComponent implements OnInit {
.subscribe(data => this.userVideoQuotaUsed = data.videoQuotaUsed) .subscribe(data => this.userVideoQuotaUsed = data.videoQuotaUsed)
} }
changeAvatar () { onAvatarChange (formData: FormData) {
const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ]
if (avatarfile.size > this.maxAvatarSize) {
this.notificationsService.error('Error', 'This image is too large.')
return
}
const formData = new FormData()
formData.append('avatarfile', avatarfile)
this.userService.changeAvatar(formData) this.userService.changeAvatar(formData)
.subscribe( .subscribe(
data => { data => {
@ -69,12 +58,4 @@ export class MyAccountSettingsComponent implements OnInit {
err => this.notificationsService.error(this.i18n('Error'), err.message) err => this.notificationsService.error(this.i18n('Error'), err.message)
) )
} }
get maxAvatarSize () {
return this.serverService.getConfig().avatar.file.size.max
}
get avatarExtensions () {
return this.serverService.getConfig().avatar.file.extensions.join(',')
}
} }

View File

@ -1,5 +1,9 @@
<my-actor-avatar-info
*ngIf="isCreation() === false && videoChannelToUpdate"
[actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)"
></my-actor-avatar-info>
<div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a video channel</div> <div i18n class="form-sub-title" *ngIf="isCreation() === true">Create a video channel</div>
<div i18n class="form-sub-title" *ngIf="isCreation() === false">Update {{ videoChannel?.displayName }}</div>
<div *ngIf="error" class="alert alert-danger">{{ error }}</div> <div *ngIf="error" class="alert alert-danger">{{ error }}</div>

View File

@ -5,6 +5,11 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
my-actor-avatar-info {
display: block;
margin-bottom: 20px;
}
input[type=text] { input[type=text] {
@include peertube-input-text(340px); @include peertube-input-text(340px);

View File

@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit } from '@angular/core' import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { NotificationsService } from 'angular2-notifications' import { NotificationsService } from 'angular2-notifications'
import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit' import { MyAccountVideoChannelEdit } from './my-account-video-channel-edit'
@ -6,7 +6,7 @@ import { VideoChannelUpdate } from '../../../../../shared/models/videos'
import { VideoChannelService } from '@app/shared/video-channel/video-channel.service' import { VideoChannelService } from '@app/shared/video-channel/video-channel.service'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model' import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { AuthService } from '@app/core' import { AuthService, ServerService } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service' import { FormValidatorService } from '@app/shared/forms/form-validators/form-validator.service'
import { VideoChannelValidatorsService } from '@app/shared/forms/form-validators/video-channel-validators.service' import { VideoChannelValidatorsService } from '@app/shared/forms/form-validators/video-channel-validators.service'
@ -17,6 +17,8 @@ import { VideoChannelValidatorsService } from '@app/shared/forms/form-validators
styleUrls: [ './my-account-video-channel-edit.component.scss' ] styleUrls: [ './my-account-video-channel-edit.component.scss' ]
}) })
export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelEdit implements OnInit, OnDestroy { export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelEdit implements OnInit, OnDestroy {
@ViewChild('avatarfileInput') avatarfileInput
error: string error: string
private videoChannelToUpdate: VideoChannel private videoChannelToUpdate: VideoChannel
@ -30,7 +32,8 @@ export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelE
private router: Router, private router: Router,
private route: ActivatedRoute, private route: ActivatedRoute,
private videoChannelService: VideoChannelService, private videoChannelService: VideoChannelService,
private i18n: I18n private i18n: I18n,
private serverService: ServerService
) { ) {
super() super()
} }
@ -89,6 +92,27 @@ export class MyAccountVideoChannelUpdateComponent extends MyAccountVideoChannelE
) )
} }
onAvatarChange (formData: FormData) {
this.videoChannelService.changeVideoChannelAvatar(this.videoChannelToUpdate.uuid, formData)
.subscribe(
data => {
this.notificationsService.success(this.i18n('Success'), this.i18n('Avatar changed.'))
this.videoChannelToUpdate.updateAvatar(data.avatar)
},
err => this.notificationsService.error(this.i18n('Error'), err.message)
)
}
get maxAvatarSize () {
return this.serverService.getConfig().avatar.file.size.max
}
get avatarExtensions () {
return this.serverService.getConfig().avatar.file.extensions.join(',')
}
isCreation () { isCreation () {
return false return false
} }

View File

@ -10,6 +10,7 @@ import { MyAccountProfileComponent } from '@app/+my-account/my-account-settings/
import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component' import { MyAccountVideoChannelsComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channels.component'
import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component' import { MyAccountVideoChannelCreateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-create.component'
import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component' import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-account-video-channels/my-account-video-channel-update.component'
import { ActorAvatarInfoComponent } from '@app/+my-account/shared/actor-avatar-info.component'
@NgModule({ @NgModule({
imports: [ imports: [
@ -26,7 +27,8 @@ import { MyAccountVideoChannelUpdateComponent } from '@app/+my-account/my-accoun
MyAccountVideosComponent, MyAccountVideosComponent,
MyAccountVideoChannelsComponent, MyAccountVideoChannelsComponent,
MyAccountVideoChannelCreateComponent, MyAccountVideoChannelCreateComponent,
MyAccountVideoChannelUpdateComponent MyAccountVideoChannelUpdateComponent,
ActorAvatarInfoComponent
], ],
exports: [ exports: [

View File

@ -0,0 +1,19 @@
<ng-container *ngIf="actor">
<div class="actor">
<img [src]="actor.avatarUrl" alt="Avatar" />
<div class="actor-info">
<div class="actor-info-names">
<div class="actor-info-display-name">{{ actor.displayName }}</div>
<div class="actor-info-username">{{ actor.name }}</div>
</div>
<div i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
</div>
</div>
<div class="button-file">
<span i18n>Change the avatar</span>
<input #avatarfileInput type="file" name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange()" />
</div>
<div i18n class="file-max-size">(extensions: {{ avatarExtensions }}, max size: {{ maxAvatarSize | bytes }})</div>
</ng-container>

View File

@ -0,0 +1,51 @@
@import '_variables';
@import '_mixins';
.actor {
display: flex;
img {
@include avatar(50px);
margin-right: 15px;
}
.actor-info {
.actor-info-names {
display: flex;
align-items: center;
.actor-info-display-name {
font-size: 20px;
font-weight: $font-bold;
}
.actor-info-username {
margin-left: 7px;
position: relative;
top: 2px;
font-size: 14px;
color: #777272;
}
}
.actor-info-followers {
font-size: 15px;
}
}
}
.button-file {
@include peertube-button-file(160px);
margin-top: 10px;
margin-bottom: 5px;
}
.file-max-size {
display: inline-block;
font-size: 13px;
position: relative;
top: -10px;
}

View File

@ -0,0 +1,48 @@
import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'
import { AuthService } from '../../core'
import { ServerService } from '../../core/server'
import { UserService } from '../../shared/users'
import { NotificationsService } from 'angular2-notifications'
import { VideoChannel } from '@app/shared/video-channel/video-channel.model'
import { Account } from '@app/shared/account/account.model'
@Component({
selector: 'my-actor-avatar-info',
templateUrl: './actor-avatar-info.component.html',
styleUrls: [ './actor-avatar-info.component.scss' ]
})
export class ActorAvatarInfoComponent {
@ViewChild('avatarfileInput') avatarfileInput
@Input() actor: VideoChannel | Account
@Output() avatarChange = new EventEmitter<FormData>()
constructor (
private userService: UserService,
private authService: AuthService,
private serverService: ServerService,
private notificationsService: NotificationsService
) {}
onAvatarChange () {
const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ]
if (avatarfile.size > this.maxAvatarSize) {
this.notificationsService.error('Error', 'This image is too large.')
return
}
const formData = new FormData()
formData.append('avatarfile', avatarfile)
this.avatarChange.emit(formData)
}
get maxAvatarSize () {
return this.serverService.getConfig().avatar.file.size.max
}
get avatarExtensions () {
return this.serverService.getConfig().avatar.file.extensions.join(',')
}
}

View File

@ -45,6 +45,16 @@ export abstract class Actor implements ActorServer {
this.updatedAt = new Date(hash.updatedAt.toString()) this.updatedAt = new Date(hash.updatedAt.toString())
this.avatar = hash.avatar this.avatar = hash.avatar
this.updateComputedAttributes()
}
updateAvatar (newAvatar: Avatar) {
this.avatar = newAvatar
this.updateComputedAttributes()
}
private updateComputedAttributes () {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this) this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this)
} }
} }

View File

@ -34,7 +34,6 @@ export class User implements UserServerModel {
account: Account account: Account
videoChannels: VideoChannel[] videoChannels: VideoChannel[]
createdAt: Date createdAt: Date
accountAvatarUrl: string
constructor (hash: UserConstructorHash) { constructor (hash: UserConstructorHash) {
this.id = hash.id this.id = hash.id
@ -65,8 +64,12 @@ export class User implements UserServerModel {
if (hash.createdAt !== undefined) { if (hash.createdAt !== undefined) {
this.createdAt = hash.createdAt this.createdAt = hash.createdAt
} }
}
this.updateComputedAttributes() get accountAvatarUrl () {
if (!this.account) return ''
return this.account.avatarUrl
} }
hasRight (right: UserRight) { hasRight (right: UserRight) {
@ -81,17 +84,9 @@ export class User implements UserServerModel {
if (obj.account !== undefined) { if (obj.account !== undefined) {
this.account = new Account(obj.account) this.account = new Account(obj.account)
} }
this.updateComputedAttributes()
} }
updateAccountAvatar (newAccountAvatar: Avatar) { updateAccountAvatar (newAccountAvatar: Avatar) {
this.account.avatar = newAccountAvatar this.account.updateAvatar(newAccountAvatar)
this.updateComputedAttributes()
}
private updateComputedAttributes () {
this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
} }
} }

View File

@ -9,6 +9,7 @@ import { ResultList } from '../../../../../shared'
import { VideoChannel } from './video-channel.model' import { VideoChannel } from './video-channel.model'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { Account } from '@app/shared/account/account.model' import { Account } from '@app/shared/account/account.model'
import { Avatar } from '../../../../../shared/models/avatars/avatar.model'
@Injectable() @Injectable()
export class VideoChannelService { export class VideoChannelService {
@ -54,6 +55,13 @@ export class VideoChannelService {
) )
} }
changeVideoChannelAvatar (videoChannelUUID: string, avatarForm: FormData) {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelUUID + '/avatar/pick'
return this.authHttp.post<{ avatar: Avatar }>(url, avatarForm)
.pipe(catchError(this.restExtractor.handleError))
}
removeVideoChannel (videoChannel: VideoChannel) { removeVideoChannel (videoChannel: VideoChannel) {
return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.uuid) return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.uuid)
.pipe( .pipe(

View File

@ -11,6 +11,7 @@ import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-s
export class Video implements VideoServerModel { export class Video implements VideoServerModel {
by: string by: string
accountAvatarUrl: string accountAvatarUrl: string
videoChannelAvatarUrl: string
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
publishedAt: Date publishedAt: Date
@ -102,9 +103,11 @@ export class Video implements VideoServerModel {
this.dislikes = hash.dislikes this.dislikes = hash.dislikes
this.nsfw = hash.nsfw this.nsfw = hash.nsfw
this.account = hash.account this.account = hash.account
this.channel = hash.channel
this.by = Actor.CREATE_BY_STRING(hash.account.name, hash.account.host) this.by = Actor.CREATE_BY_STRING(hash.account.name, hash.account.host)
this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account) this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
this.videoChannelAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.channel)
this.category.label = peertubeTranslate(this.category.label, translations) this.category.label = peertubeTranslate(this.category.label, translations)
this.licence.label = peertubeTranslate(this.licence.label, translations) this.licence.label = peertubeTranslate(this.licence.label, translations)

View File

@ -132,10 +132,10 @@ export class VideoAddComponent extends FormReactive implements OnInit, OnDestroy
if (!videofile) return if (!videofile) return
// Cannot upload videos > 4GB for now // Cannot upload videos > 4GB for now
if (videofile.size > 4 * 1024 * 1024 * 1024) { // if (videofile.size > 4 * 1024 * 1024 * 1024) {
this.notificationsService.error(this.i18n('Error'), this.i18n('We are sorry but PeerTube cannot handle videos > 4GB')) // this.notificationsService.error(this.i18n('Error'), this.i18n('We are sorry but PeerTube cannot handle videos > 4GB'))
return // return
} // }
const videoQuota = this.authService.getUser().videoQuota const videoQuota = this.authService.getUser().videoQuota
if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) { if (videoQuota !== -1 && (this.userVideoQuotaUsed + videofile.size) > videoQuota) {

View File

@ -25,6 +25,8 @@
<div class="video-info-channel"> <div class="video-info-channel">
<a [routerLink]="[ '/video-channels', video.channel.id ]" i18n-title title="Go the channel page"> <a [routerLink]="[ '/video-channels', video.channel.id ]" i18n-title title="Go the channel page">
{{ video.channel.displayName }} {{ video.channel.displayName }}
<img [src]="video.videoChannelAvatarUrl" alt="Video channel avatar" />
</a> </a>
<!-- Here will be the subscribe button --> <!-- Here will be the subscribe button -->
<my-help helpType="custom" i18n-customHtml customHtml="You can subscribe to this account via any ActivityPub-capable fediverse instance. For instance with Mastodon or Pleroma you can type in the search box <strong>@{{video.account.name}}@{{video.account.host}}</strong> and subscribe there. Subscription as a PeerTube user is being worked on in <a href='https://github.com/Chocobozzz/PeerTube/issues/470'>#470</a>."></my-help> <my-help helpType="custom" i18n-customHtml customHtml="You can subscribe to this account via any ActivityPub-capable fediverse instance. For instance with Mastodon or Pleroma you can type in the search box <strong>@{{video.account.name}}@{{video.account.host}}</strong> and subscribe there. Subscription as a PeerTube user is being worked on in <a href='https://github.com/Chocobozzz/PeerTube/issues/470'>#470</a>."></my-help>

View File

@ -84,6 +84,12 @@
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
} }
img {
@include avatar(18px);
margin: -2px 2px 0 5px;
}
} }
my-help { my-help {
@ -106,6 +112,7 @@
img { img {
@include avatar(18px); @include avatar(18px);
margin-top: -2px;
margin-left: 7px; margin-left: 7px;
} }
} }

View File

@ -251,6 +251,10 @@ export enum ScopeNames {
attributes: [ 'host' ], attributes: [ 'host' ],
model: () => ServerModel.unscoped(), model: () => ServerModel.unscoped(),
required: false required: false
},
{
model: () => AvatarModel.unscoped(),
required: false
} }
] ]
}, },

View File

@ -14,7 +14,8 @@ import {
killallServers, killallServers,
makeGetRequest, makeGetRequest,
makePostBodyRequest, makePostBodyRequest,
makePutBodyRequest, makeUploadRequest, makePutBodyRequest,
makeUploadRequest,
runServer, runServer,
ServerInfo, ServerInfo,
setAccessTokensToServers, setAccessTokensToServers,
@ -22,7 +23,7 @@ import {
} from '../../utils' } from '../../utils'
import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params'
import { User } from '../../../../shared/models/users' import { User } from '../../../../shared/models/users'
import { join } from "path" import { join } from 'path'
const expect = chai.expect const expect = chai.expect