Add ability to update the banner

This commit is contained in:
Chocobozzz 2021-04-07 17:01:29 +02:00 committed by Chocobozzz
parent 282695e699
commit cdeddff142
42 changed files with 481 additions and 206 deletions

View File

@ -3,6 +3,7 @@ import { SelectButtonModule } from 'primeng/selectbutton'
import { TableModule } from 'primeng/table'
import { NgModule } from '@angular/core'
import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
import { SharedActorImageModule } from '@app/shared/shared-actor-image'
import { SharedFormModule } from '@app/shared/shared-forms'
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
@ -49,6 +50,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
SharedGlobalIconModule,
SharedAbuseListModule,
SharedVideoCommentModule,
SharedActorImageModule,
TableModule,
SelectButtonModule,

View File

@ -72,7 +72,7 @@
<div class="anchor" id="user"></div> <!-- user anchor -->
<div *ngIf="isCreation()" class="account-title" i18n>NEW USER</div>
<div *ngIf="!isCreation() && user" class="account-title">
<my-actor-avatar-info [actor]="user.account"></my-actor-avatar-info>
<my-actor-avatar-edit [actor]="user.account" [editable]="false" [displaySubscribers]="false" [displayUsername]="false"></my-actor-avatar-edit>
</div>
</div>

View File

@ -72,11 +72,3 @@ input[type=submit], button {
@include dashboard;
max-width: 900px;
}
my-actor-avatar-info ::ng-deep {
.actor-img-edit-container,
.actor-info-followers,
.actor-info-username {
display: none;
}
}

View File

@ -3,7 +3,7 @@
<div class="form-group col-12 col-lg-4 col-xl-3"></div>
<div class="form-group col-12 col-lg-8 col-xl-9">
<my-actor-avatar-info [actor]="user.account" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"></my-actor-avatar-info>
<my-actor-avatar-edit [actor]="user.account" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"></my-actor-avatar-edit>
</div>
</div>

View File

@ -3,6 +3,7 @@ import { TableModule } from 'primeng/table'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { NgModule } from '@angular/core'
import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
import { SharedActorImageModule } from '@app/shared/shared-actor-image'
import { SharedFormModule } from '@app/shared/shared-forms'
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
@ -10,6 +11,7 @@ import { SharedModerationModule } from '@app/shared/shared-moderation'
import { SharedShareModal } from '@app/shared/shared-share-modal'
import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-settings'
import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
@ -20,7 +22,6 @@ import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-d
import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
import { MyAccountComponent } from './my-account.component'
@NgModule({
@ -37,7 +38,8 @@ import { MyAccountComponent } from './my-account.component'
SharedUserInterfaceSettingsModule,
SharedGlobalIconModule,
SharedAbuseListModule,
SharedShareModal
SharedShareModal,
SharedActorImageModule
],
declarations: [

View File

@ -44,10 +44,17 @@
</div>
</div>
<my-actor-avatar-info
<h6 i18n>Banner image of your channel</h6>
<my-actor-banner-edit
*ngIf="!isCreation() && videoChannelToUpdate"
[actor]="videoChannelToUpdate" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
></my-actor-banner-edit>
<my-actor-avatar-edit
*ngIf="!isCreation() && videoChannelToUpdate"
[actor]="videoChannelToUpdate" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-info>
></my-actor-avatar-edit>
<div class="form-group">
<label i18n for="display-name">Display name</label>

View File

@ -10,11 +10,16 @@ label {
@include settings-big-title;
}
my-actor-avatar-info {
my-actor-avatar-edit,
my-actor-banner-edit {
display: block;
margin-bottom: 20px;
}
my-actor-banner-edit {
max-width: 500px;
}
.input-group {
@include peertube-input-group(fit-content);
}

View File

@ -15,6 +15,8 @@ export abstract class MyVideoChannelEdit extends FormReactive {
// We need this method so angular does not complain in child template that doesn't need this
onAvatarChange (formData: FormData) { /* empty */ }
onAvatarDelete () { /* empty */ }
onBannerChange (formData: FormData) { /* empty */ }
onBannerDelete () { /* empty */ }
// Should be implemented by the child
isBulkUpdateVideosDisplayed () {

View File

@ -1,7 +1,9 @@
import { Subscription } from 'rxjs'
import { HttpErrorResponse } from '@angular/common/http'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, Notifier, ServerService } from '@app/core'
import { uploadErrorHandler } from '@app/helpers'
import {
VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
@ -11,8 +13,6 @@ 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'
import { HttpErrorResponse } from '@angular/common/http'
import { uploadErrorHandler } from '@app/helpers'
@Component({
selector: 'my-video-channel-update',
@ -101,7 +101,7 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements
}
onAvatarChange (formData: FormData) {
this.videoChannelService.changeVideoChannelAvatar(this.videoChannelToUpdate.name, formData)
this.videoChannelService.changeVideoChannelImage(this.videoChannelToUpdate.name, formData, 'avatar')
.subscribe(
data => {
this.notifier.success($localize`Avatar changed.`)
@ -118,7 +118,7 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements
}
onAvatarDelete () {
this.videoChannelService.deleteVideoChannelAvatar(this.videoChannelToUpdate.name)
this.videoChannelService.deleteVideoChannelImage(this.videoChannelToUpdate.name, 'avatar')
.subscribe(
data => {
this.notifier.success($localize`Avatar deleted.`)
@ -130,6 +130,36 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements
)
}
onBannerChange (formData: FormData) {
this.videoChannelService.changeVideoChannelImage(this.videoChannelToUpdate.name, formData, 'banner')
.subscribe(
data => {
this.notifier.success($localize`Banner changed.`)
this.videoChannelToUpdate.updateBanner(data.banner)
},
(err: HttpErrorResponse) => uploadErrorHandler({
err,
name: $localize`banner`,
notifier: this.notifier
})
)
}
onBannerDelete () {
this.videoChannelService.deleteVideoChannelImage(this.videoChannelToUpdate.name, 'banner')
.subscribe(
data => {
this.notifier.success($localize`Banner deleted.`)
this.videoChannelToUpdate.resetBanner()
},
err => this.notifier.error(err.message)
)
}
get maxAvatarSize () {
return this.serverConfig.avatar.file.size.max
}

View File

@ -1,5 +1,6 @@
import { ChartModule } from 'primeng/chart'
import { NgModule } from '@angular/core'
import { SharedActorImageModule } from '@app/shared/shared-actor-image'
import { SharedFormModule } from '@app/shared/shared-forms'
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
@ -16,7 +17,8 @@ import { MyVideoChannelsComponent } from './my-video-channels.component'
SharedMainModule,
SharedFormModule,
SharedGlobalIconModule
SharedGlobalIconModule,
SharedActorImageModule
],
declarations: [

View File

@ -1,5 +1,5 @@
import { Component, Input, OnInit } from '@angular/core'
import { Video } from '../video/video.model'
import { Video } from '@app/shared/shared-main/video'
@Component({
selector: 'my-video-avatar-channel',

View File

@ -29,7 +29,12 @@ import { MetaService } from '@ngx-meta/core'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { ServerConfig, ServerErrorCode, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models'
import { cleanupVideoWatch, getStoredP2PEnabled, getStoredTheater, getStoredVideoWatchHistory } from '../../../assets/player/peertube-player-local-storage'
import {
cleanupVideoWatch,
getStoredP2PEnabled,
getStoredTheater,
getStoredVideoWatchHistory
} from '../../../assets/player/peertube-player-local-storage'
import {
CustomizationOptions,
P2PMediaLoaderOptions,

View File

@ -16,6 +16,7 @@ import { VideoCommentComponent } from './comment/video-comment.component'
import { VideoCommentsComponent } from './comment/video-comments.component'
import { RecommendationsModule } from './recommendations/recommendations.module'
import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive'
import { VideoAvatarChannelComponent } from './video-avatar-channel.component'
import { VideoWatchPlaylistComponent } from './video-watch-playlist.component'
import { VideoWatchRoutingModule } from './video-watch-routing.module'
import { VideoWatchComponent } from './video-watch.component'
@ -46,6 +47,8 @@ import { VideoWatchComponent } from './video-watch.component'
VideoCommentAddComponent,
VideoCommentComponent,
VideoAvatarChannelComponent,
TimestampRouteTransformerDirective,
TimestampRouteTransformerDirective
],

View File

@ -98,6 +98,12 @@ export class ServerService {
extensions: []
}
},
banner: {
file: {
size: { max: 0 },
extensions: []
}
},
video: {
image: {
size: { max: 0 },

View File

@ -0,0 +1,41 @@
<div class="actor" *ngIf="actor">
<div class="d-flex">
<img [ngClass]="{ channel: isChannel() }" [src]="actor.avatarUrl" alt="Avatar" />
<div class="actor-img-edit-container">
<div *ngIf="editable && !hasAvatar()" class="actor-img-edit-button" [ngbTooltip]="avatarFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon>
<label class="sr-only" for="avatarfile" i18n>Upload a new avatar</label>
<input #avatarfileInput type="file" name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange(avatarfileInput)"/>
</div>
<div
*ngIf="editable && hasAvatar()" class="actor-img-edit-button"
#avatarPopover="ngbPopover" [ngbPopover]="avatarEditContent" popoverClass="popover-image-info" autoClose="outside" placement="right"
>
<my-global-icon iconName="edit"></my-global-icon>
<label class="sr-only" for="avatarMenu" i18n>Change your avatar</label>
</div>
</div>
</div>
<div class="actor-info">
<div class="actor-info-display-name">{{ actor.displayName }}</div>
<div *ngIf="displayUsername" class="actor-info-username">{{ actor.name }}</div>
<div *ngIf="displaySubscribers" i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div>
</div>
</div>
<ng-template #avatarEditContent>
<div class="dropdown-item c-hand" [ngbTooltip]="avatarFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon>
<span for="avatarfile" i18n>Upload a new avatar</span>
<input #avatarfileInput type="file" name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange(avatarfileInput)"/>
</div>
<div class="dropdown-item c-hand" (click)="deleteAvatar()" (key.enter)="deleteAvatar()">
<my-global-icon iconName="delete"></my-global-icon>
<span i18n>Remove avatar</span>
</div>
</ng-template>

View File

@ -0,0 +1,54 @@
@import '_variables';
@import '_mixins';
.actor {
display: flex;
img {
margin-right: 15px;
&:not(.channel) {
@include avatar(100px);
}
&.channel {
@include channel-avatar(100px);
}
}
.actor-info {
display: inline-flex;
flex-direction: column;
.actor-info-display-name {
font-size: 20px;
font-weight: $font-bold;
@media screen and (max-width: $small-view) {
font-size: 16px;
}
}
.actor-info-username {
position: relative;
font-size: 14px;
color: pvar(--greyForegroundColor);
}
.actor-info-followers {
font-size: 15px;
padding-bottom: .5rem;
}
}
}
.actor-img-edit-container {
position: relative;
width: 0;
}
.actor-img-edit-button {
top: 55px;
right: 45px;
border-radius: 50%;
}

View File

@ -1,21 +1,25 @@
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier, ServerService } from '@app/core'
import { Account, VideoChannel } from '@app/shared/shared-main'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { getBytes } from '@root-helpers/bytes'
import { Account } from '../account/account.model'
import { VideoChannel } from '../video-channel/video-channel.model'
import { Actor } from './actor.model'
@Component({
selector: 'my-actor-avatar-info',
templateUrl: './actor-avatar-info.component.html',
styleUrls: [ './actor-avatar-info.component.scss' ]
selector: 'my-actor-avatar-edit',
templateUrl: './actor-avatar-edit.component.html',
styleUrls: [
'./actor-image-edit.scss',
'./actor-avatar-edit.component.scss'
]
})
export class ActorAvatarInfoComponent implements OnInit, OnChanges {
export class ActorAvatarEditComponent implements OnInit {
@ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
@ViewChild('avatarPopover') avatarPopover: NgbPopover
@Input() actor: VideoChannel | Account
@Input() editable = true
@Input() displaySubscribers = true
@Input() displayUsername = true
@Output() avatarChange = new EventEmitter<FormData>()
@Output() avatarDelete = new EventEmitter<void>()
@ -24,8 +28,6 @@ export class ActorAvatarInfoComponent implements OnInit, OnChanges {
maxAvatarSize = 0
avatarExtensions = ''
private avatarUrl: string
constructor (
private serverService: ServerService,
private notifier: Notifier
@ -42,12 +44,6 @@ export class ActorAvatarInfoComponent implements OnInit, OnChanges {
})
}
ngOnChanges (changes: SimpleChanges) {
if (changes['actor']) {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.actor)
}
}
onAvatarChange (input: HTMLInputElement) {
this.avatarfileInput = new ElementRef(input)
@ -68,7 +64,7 @@ export class ActorAvatarInfoComponent implements OnInit, OnChanges {
}
hasAvatar () {
return !!this.avatarUrl
return !!this.actor.avatar
}
isChannel () {

View File

@ -0,0 +1,34 @@
<div class="actor" *ngIf="actor">
<div class="actor-img-edit-container">
<div class="banner-placeholder">
<img *ngIf="hasBanner()" [src]="actor.bannerUrl" alt="Banner" />
</div>
<div *ngIf="!hasBanner()" class="actor-img-edit-button" [ngbTooltip]="bannerFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon>
<label for="bannerfile" i18n>Upload a new banner</label>
<input #bannerfileInput type="file" name="bannerfile" id="bannerfile" [accept]="bannerExtensions" (change)="onBannerChange(bannerfileInput)"/>
</div>
<div
*ngIf="hasBanner()" class="actor-img-edit-button"
#bannerPopover="ngbPopover" [ngbPopover]="bannerEditContent" popoverClass="popover-image-info" autoClose="outside" placement="right"
>
<my-global-icon iconName="edit"></my-global-icon>
<label for="bannerMenu" i18n>Change your banner</label>
</div>
</div>
</div>
<ng-template #bannerEditContent>
<div class="dropdown-item c-hand" [ngbTooltip]="bannerFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon>
<span for="bannerfile" i18n>Upload a new banner</span>
<input #bannerfileInput type="file" name="bannerfile" id="bannerfile" [accept]="bannerExtensions" (change)="onBannerChange(bannerfileInput)"/>
</div>
<div class="dropdown-item c-hand" (click)="deleteBanner()" (key.enter)="deleteBanner()">
<my-global-icon iconName="delete"></my-global-icon>
<span i18n>Remove banner</span>
</div>
</ng-template>

View File

@ -0,0 +1,27 @@
@import '_variables';
@import '_mixins';
.banner-placeholder {
@include block-ratio('> div, > img', $banner-inverted-ratio);
}
.banner-placeholder {
background-color: pvar(--greyBackgroundColor);
}
.actor-img-edit-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.actor-img-edit-button {
position: absolute;
width: auto;
label {
font-weight: $font-semibold;
margin-bottom: 0;
}
}

View File

@ -0,0 +1,65 @@
import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
import { Notifier, ServerService } from '@app/core'
import { VideoChannel } from '@app/shared/shared-main'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { getBytes } from '@root-helpers/bytes'
@Component({
selector: 'my-actor-banner-edit',
templateUrl: './actor-banner-edit.component.html',
styleUrls: [
'./actor-image-edit.scss',
'./actor-banner-edit.component.scss'
]
})
export class ActorBannerEditComponent implements OnInit {
@ViewChild('bannerfileInput') bannerfileInput: ElementRef<HTMLInputElement>
@ViewChild('bannerPopover') bannerPopover: NgbPopover
@Input() actor: VideoChannel
@Output() bannerChange = new EventEmitter<FormData>()
@Output() bannerDelete = new EventEmitter<void>()
bannerFormat = ''
maxBannerSize = 0
bannerExtensions = ''
constructor (
private serverService: ServerService,
private notifier: Notifier
) { }
ngOnInit (): void {
this.serverService.getConfig()
.subscribe(config => {
this.maxBannerSize = config.banner.file.size.max
this.bannerExtensions = config.banner.file.extensions.join(', ')
this.bannerFormat = $localize`maxsize: ${getBytes(this.maxBannerSize)}, extensions: ${this.bannerExtensions}`
})
}
onBannerChange (input: HTMLInputElement) {
this.bannerfileInput = new ElementRef(input)
const bannerfile = this.bannerfileInput.nativeElement.files[ 0 ]
if (bannerfile.size > this.maxBannerSize) {
this.notifier.error('Error', $localize`This image is too large.`)
return
}
const formData = new FormData()
formData.append('bannerfile', bannerfile)
this.bannerPopover?.close()
this.bannerChange.emit(formData)
}
deleteBanner () {
this.bannerDelete.emit()
}
hasBanner () {
return !!this.actor.bannerUrl
}
}

View File

@ -0,0 +1,35 @@
@import '_variables';
@import '_mixins';
.actor ::ng-deep .popover-image-info .popover-body {
padding: 0;
.dropdown-item {
padding: 6px 10px;
border-radius: 4px;
&:first-child {
@include peertube-file;
display: block;
}
}
}
.actor-img-edit-button {
@include peertube-button-file(21px);
@include button-with-icon(19px);
@include orange-button;
margin-top: 10px;
margin-bottom: 5px;
cursor: pointer;
input {
width: 30px;
height: 30px;
}
my-global-icon {
right: 7px;
}
}

View File

@ -0,0 +1 @@
export * from './shared-actor-image.module'

View File

@ -0,0 +1,29 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { SharedGlobalIconModule } from '../shared-icons'
import { SharedMainModule } from '../shared-main'
import { ActorAvatarEditComponent } from './actor-avatar-edit.component'
import { ActorBannerEditComponent } from './actor-banner-edit.component'
@NgModule({
imports: [
CommonModule,
SharedMainModule,
SharedGlobalIconModule
],
declarations: [
ActorAvatarEditComponent,
ActorBannerEditComponent
],
exports: [
ActorAvatarEditComponent,
ActorBannerEditComponent
],
providers: [ ]
})
export class SharedActorImageModule { }

View File

@ -1,42 +0,0 @@
<ng-container *ngIf="actor">
<div class="actor">
<div class="d-flex">
<img [ngClass]="{ channel: isChannel() }" [src]="actor.avatarUrl" alt="Avatar" />
<div class="actor-img-edit-container">
<div *ngIf="!hasAvatar()" class="actor-img-edit-button" [ngbTooltip]="avatarFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon>
<label class="sr-only" for="avatarfile" i18n>Upload a new avatar</label>
<input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange(avatarfileInput)"/>
</div>
<div *ngIf="hasAvatar()" class="actor-img-edit-button" #avatarPopover="ngbPopover" [ngbPopover]="avatarEditContent" popoverClass="popover-avatar-info" autoClose="outside" placement="right">
<my-global-icon iconName="edit"></my-global-icon>
<label class="sr-only" for="avatarMenu" i18n>Change your avatar</label>
</div>
</div>
</div>
<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>
</ng-container>
<ng-template #avatarEditContent>
<div class="dropdown-item c-hand" [ngbTooltip]="avatarFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon>
<span for="avatarfile" i18n>Upload a new avatar</span>
<input #avatarfileInput type="file" title=" " name="avatarfile" id="avatarfile" [accept]="avatarExtensions" (change)="onAvatarChange(avatarfileInput)"/>
</div>
<div class="dropdown-item c-hand" (click)="deleteAvatar()" (key.enter)="deleteAvatar()">
<my-global-icon iconName="delete"></my-global-icon>
<span i18n>Remove avatar</span>
</div>
</ng-template>

View File

@ -1,92 +0,0 @@
@import '_variables';
@import '_mixins';
.actor {
display: flex;
img {
margin-right: 15px;
&:not(.channel) {
@include avatar(100px);
}
&.channel {
@include channel-avatar(100px);
}
}
.actor-img-edit-container {
position: relative;
width: 0;
.actor-img-edit-button {
@include peertube-button-file(21px);
@include button-with-icon(19px);
@include orange-button;
margin-top: 10px;
margin-bottom: 5px;
border-radius: 50%;
top: 55px;
right: 45px;
cursor: pointer;
input {
width: 30px;
height: 30px;
}
my-global-icon {
right: 7px;
}
}
}
.actor-info {
justify-content: center;
display: inline-flex;
flex-direction: column;
.actor-info-names {
display: flex;
align-items: center;
.actor-info-display-name {
font-size: 20px;
font-weight: $font-bold;
@media screen and (max-width: $small-view) {
font-size: 16px;
}
}
.actor-info-username {
margin-left: 7px;
position: relative;
top: 2px;
font-size: 14px;
color: $grey-actor-name;
}
}
.actor-info-followers {
font-size: 15px;
padding-bottom: .5rem;
}
}
}
.actor-img-edit-container ::ng-deep .popover-avatar-info .popover-body {
padding: 0;
.dropdown-item {
padding: 6px 10px;
border-radius: 4px;
&:first-child {
@include peertube-file;
display: block;
}
}
}

View File

@ -3,15 +3,18 @@ import { getAbsoluteAPIUrl } from '@app/helpers'
export abstract class Actor implements ActorServer {
id: number
url: string
name: string
host: string
url: string
followingCount: number
followersCount: number
createdAt: Date | string
updatedAt: Date | string
avatar: ActorImage
avatar: ActorImage
avatarUrl: string
isLocal: boolean
@ -24,6 +27,8 @@ export abstract class Actor implements ActorServer {
return absoluteAPIUrl + actor.avatar.path
}
return ''
}
static CREATE_BY_STRING (accountName: string, host: string, forceHostname = false) {

View File

@ -1,5 +1,3 @@
export * from './account.model'
export * from './account.service'
export * from './actor-avatar-info.component'
export * from './actor.model'
export * from './video-avatar-channel.component'

View File

@ -6,18 +6,18 @@ import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import {
NgbButtonsModule,
NgbCollapseModule,
NgbDropdownModule,
NgbModalModule,
NgbNavModule,
NgbPopoverModule,
NgbTooltipModule,
NgbButtonsModule
NgbTooltipModule
} from '@ng-bootstrap/ng-bootstrap'
import { LoadingBarModule } from '@ngx-loading-bar/core'
import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
import { SharedGlobalIconModule } from '../shared-icons'
import { AccountService, ActorAvatarInfoComponent, VideoAvatarChannelComponent } from './account'
import { AccountService } from './account'
import {
AutofocusDirective,
BytesPipe,
@ -32,7 +32,7 @@ import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditBu
import { DateToggleComponent } from './date'
import { FeedComponent } from './feeds'
import { LoaderComponent, SmallLoaderComponent } from './loaders'
import { HelpComponent, ListOverflowComponent, TopMenuDropdownComponent, SimpleSearchInputComponent } from './misc'
import { HelpComponent, ListOverflowComponent, SimpleSearchInputComponent, TopMenuDropdownComponent } from './misc'
import { UserHistoryService, UserNotificationsComponent, UserNotificationService, UserQuotaComponent } from './users'
import { RedundancyService, VideoImportService, VideoOwnershipService, VideoService } from './video'
import { VideoCaptionService } from './video-caption'
@ -65,9 +65,6 @@ import { VideoChannelService } from './video-channel'
],
declarations: [
VideoAvatarChannelComponent,
ActorAvatarInfoComponent,
FromNowPipe,
NumberFormatterPipe,
BytesPipe,
@ -120,9 +117,6 @@ import { VideoChannelService } from './video-channel'
PrimeSharedModule,
VideoAvatarChannelComponent,
ActorAvatarInfoComponent,
FromNowPipe,
BytesPipe,
NumberFormatterPipe,

View File

@ -1,3 +1,4 @@
import { getAbsoluteAPIUrl } from '@app/helpers'
import { Account as ServerAccount, ActorImage, VideoChannel as ServerVideoChannel, ViewsPerDate } from '@shared/models'
import { Account } from '../account/account.model'
import { Actor } from '../account/actor.model'
@ -6,10 +7,15 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
displayName: string
description: string
support: string
isLocal: boolean
nameWithHost: string
nameWithHostForced: string
banner: ActorImage
bannerUrl: string
ownerAccount?: ServerAccount
ownerBy?: string
ownerAvatarUrl?: string
@ -22,6 +28,18 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
return Actor.GET_ACTOR_AVATAR_URL(actor) || this.GET_DEFAULT_AVATAR_URL()
}
static GET_ACTOR_BANNER_URL (channel: ServerVideoChannel) {
if (channel?.banner?.url) return channel.banner.url
if (channel && channel.banner) {
const absoluteAPIUrl = getAbsoluteAPIUrl()
return absoluteAPIUrl + channel.banner.path
}
return ''
}
static GET_DEFAULT_AVATAR_URL () {
return `${window.location.origin}/client/assets/images/default-avatar-videochannel.png`
}
@ -29,12 +47,14 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
constructor (hash: ServerVideoChannel) {
super(hash)
this.updateComputedAttributes()
this.displayName = hash.displayName
this.description = hash.description
this.support = hash.support
this.banner = hash.banner
this.isLocal = hash.isLocal
this.nameWithHost = Actor.CREATE_BY_STRING(this.name, this.host)
this.nameWithHostForced = Actor.CREATE_BY_STRING(this.name, this.host, true)
@ -49,6 +69,8 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
this.ownerBy = Actor.CREATE_BY_STRING(hash.ownerAccount.name, hash.ownerAccount.host)
this.ownerAvatarUrl = Account.GET_ACTOR_AVATAR_URL(this.ownerAccount)
}
this.updateComputedAttributes()
}
updateAvatar (newAvatar: ActorImage) {
@ -58,11 +80,21 @@ export class VideoChannel extends Actor implements ServerVideoChannel {
}
resetAvatar () {
this.avatar = null
this.avatarUrl = VideoChannel.GET_DEFAULT_AVATAR_URL()
this.updateAvatar(null)
}
updateBanner (newBanner: ActorImage) {
this.banner = newBanner
this.updateComputedAttributes()
}
resetBanner () {
this.updateBanner(null)
}
private updateComputedAttributes () {
this.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(this)
this.bannerUrl = VideoChannel.GET_ACTOR_BANNER_URL(this)
}
}

View File

@ -82,15 +82,15 @@ export class VideoChannelService {
)
}
changeVideoChannelAvatar (videoChannelName: string, avatarForm: FormData) {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar/pick'
changeVideoChannelImage (videoChannelName: string, avatarForm: FormData, type: 'avatar' | 'banner') {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/' + type + '/pick'
return this.authHttp.post<{ avatar: ActorImage }>(url, avatarForm)
return this.authHttp.post<{ avatar?: ActorImage, banner?: ActorImage }>(url, avatarForm)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
deleteVideoChannelAvatar (videoChannelName: string) {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar'
deleteVideoChannelImage (videoChannelName: string, type: 'avatar' | 'banner') {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/' + type
return this.authHttp.delete(url)
.pipe(

View File

@ -32,7 +32,7 @@
color: pvar(--inputPlaceholderColor);
}
@include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
@include block-ratio($selector: 'div, ::ng-deep iframe') {
width: 100% !important;
height: 100% !important;
left: 0;

View File

@ -21,7 +21,7 @@ textarea {
}
.screenratio {
@include large-screen-ratio($selector: 'div, ::ng-deep iframe') {
@include block-ratio($selector: 'div, ::ng-deep iframe') {
left: 0;
};
}

View File

@ -97,7 +97,7 @@ $more-button-width: 40px;
width: 100%;
my-video-thumbnail {
@include large-screen-ratio($selector: '::ng-deep .video-thumbnail');
@include block-ratio($selector: '::ng-deep .video-thumbnail');
}
.video-bottom {

View File

@ -886,14 +886,16 @@
}
}
// applies 16:9 ratio to a child element (using $selector) only using
// an immediate's parent size. This allows 16:9 ratio without explicit
// applies ratio (default to 16:9) to a child element (using $selector) only using
// an immediate's parent size. This allows to set a ratio without explicit
// dimensions, as width/height cannot be computed from each other.
@mixin large-screen-ratio ($selector: 'div') {
@mixin block-ratio ($selector: 'div', $inverted-ratio: 9/16) {
$padding-percent: percentage($inverted-ratio);
position: relative;
height: 0;
width: 100%;
padding-top: 56%;
padding-top: $padding-percent;
#{$selector} {
position: absolute;

View File

@ -52,6 +52,9 @@ $sub-menu-background-color: #F7F7F7;
$sub-menu-height: 81px;
$channel-background-color: #f6ede8;
$banner-inverted-ratio: 1/5;
$max-channels-width: 1200px;
$footer-height: 30px;

View File

@ -163,6 +163,14 @@ async function getConfig (req: express.Request, res: express.Response) {
extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
}
},
banner: {
file: {
size: {
max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
},
extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
}
},
video: {
image: {
extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,

View File

@ -72,7 +72,11 @@ export class ActorImageModel extends Model {
}
getStaticPath () {
return join(LAZY_STATIC_PATHS.AVATARS, this.filename)
if (this.type === ActorImageType.AVATAR) {
return join(LAZY_STATIC_PATHS.AVATARS, this.filename)
}
return join(LAZY_STATIC_PATHS.BANNERS, this.filename)
}
getPath () {

View File

@ -71,6 +71,7 @@ import { VideoLiveModel } from '../video/video-live'
import { VideoPlaylistModel } from '../video/video-playlist'
import { AccountModel } from './account'
import { UserNotificationSettingModel } from './user-notification-setting'
import { ActorImageModel } from './actor-image'
enum ScopeNames {
FOR_ME_API = 'FOR_ME_API',
@ -97,7 +98,20 @@ enum ScopeNames {
model: AccountModel,
include: [
{
model: VideoChannelModel
model: VideoChannelModel.unscoped(),
include: [
{
model: ActorModel,
required: true,
include: [
{
model: ActorImageModel,
as: 'Banner',
required: false
}
]
}
]
},
{
attributes: [ 'id', 'name', 'type' ],

View File

@ -9,6 +9,7 @@ import {
doubleFollow,
flushAndRunMultipleServers,
getVideo,
getVideoChannel,
getVideoChannelVideos,
testImage,
updateVideo,
@ -306,7 +307,8 @@ describe('Test video channels', function () {
await waitJobs(servers)
for (const server of servers) {
const videoChannel = await findChannel(server, secondVideoChannelId)
const res = await getVideoChannel(server.url, 'second_video_channel@' + servers[0].host)
const videoChannel = res.body
await testImage(server.url, 'banner-resized', videoChannel.banner.path)
}

View File

@ -151,6 +151,15 @@ export interface ServerConfig {
}
}
banner: {
file: {
size: {
max: number
}
extensions: string[]
}
}
video: {
image: {
size: {