1
0
Fork 0

Add ability to set avatar to instance

This commit is contained in:
Chocobozzz 2024-02-23 14:27:11 +01:00
parent db06d13c67
commit bb7cb0d2fd
No known key found for this signature in database
GPG key ID: 583A612D890159BE
29 changed files with 693 additions and 348 deletions

View file

@ -8,11 +8,25 @@
</div> </div>
<div class="col-12 col-lg-8 col-xl-9"> <div class="col-12 col-lg-8 col-xl-9">
<div class="form-group">
<label i18n for="avatarfile">Square icon</label>
<div class="label-small-info">
<p i18n class="mb-0">Square icon can be used on your custom homepage.</p>
</div>
<my-actor-avatar-edit
class="d-block mb-4"
actorType="account" previewImage="false" [username]="instanceName" displayUsername="false"
[avatars]="instanceAvatars" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit>
</div>
<div class="form-group"> <div class="form-group">
<label i18n for="bannerfile">Banner</label> <label i18n for="bannerfile">Banner</label>
<div class="label-small-info"> <div class="label-small-info">
<p i18n class="mb-0">Banner is displayed in the about, login and registration pages.</p> <p i18n class="mb-0">Banner is displayed in the about, login and registration pages and be used on your custom homepage.</p>
<p i18n>It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances">JoinPeerTube.org</a>.</p> <p i18n>It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances">JoinPeerTube.org</a>.</p>
</div> </div>

View file

@ -2,10 +2,11 @@ import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnInit } from '@angular/core' import { Component, Input, OnInit } from '@angular/core'
import { FormGroup } from '@angular/forms' import { FormGroup } from '@angular/forms'
import { CustomMarkupService } from '@app/shared/shared-custom-markup' import { CustomMarkupService } from '@app/shared/shared-custom-markup'
import { Notifier } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { HttpErrorResponse } from '@angular/common/http' import { HttpErrorResponse } from '@angular/common/http'
import { genericUploadErrorHandler } from '@app/helpers' import { genericUploadErrorHandler } from '@app/helpers'
import { InstanceService } from '@app/shared/shared-instance' import { InstanceService } from '@app/shared/shared-instance'
import { ActorImage, HTMLServerConfig } from '@peertube/peertube-models'
@Component({ @Component({
selector: 'my-edit-instance-information', selector: 'my-edit-instance-information',
@ -20,17 +21,27 @@ export class EditInstanceInformationComponent implements OnInit {
@Input() categoryItems: SelectOptionsItem[] = [] @Input() categoryItems: SelectOptionsItem[] = []
instanceBannerUrl: string instanceBannerUrl: string
instanceAvatars: ActorImage[] = []
private serverConfig: HTMLServerConfig
constructor ( constructor (
private customMarkup: CustomMarkupService, private customMarkup: CustomMarkupService,
private notifier: Notifier, private notifier: Notifier,
private instanceService: InstanceService private instanceService: InstanceService,
private server: ServerService
) { ) {
} }
get instanceName () {
return this.server.getHTMLConfig().instance.name
}
ngOnInit () { ngOnInit () {
this.resetBannerUrl() this.serverConfig = this.server.getHTMLConfig()
this.updateActorImages()
} }
getCustomMarkdownRenderer () { getCustomMarkdownRenderer () {
@ -43,7 +54,7 @@ export class EditInstanceInformationComponent implements OnInit {
next: () => { next: () => {
this.notifier.success($localize`Banner changed.`) this.notifier.success($localize`Banner changed.`)
this.resetBannerUrl() this.resetActorImages()
}, },
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier }) error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier })
@ -56,17 +67,51 @@ export class EditInstanceInformationComponent implements OnInit {
next: () => { next: () => {
this.notifier.success($localize`Banner deleted.`) this.notifier.success($localize`Banner deleted.`)
this.resetBannerUrl() this.resetActorImages()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
}) })
} }
private resetBannerUrl () { onAvatarChange (formData: FormData) {
this.instanceService.getInstanceBannerUrl() this.instanceService.updateInstanceAvatar(formData)
.subscribe(instanceBannerUrl => { .subscribe({
this.instanceBannerUrl = instanceBannerUrl next: () => {
this.notifier.success($localize`Avatar changed.`)
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`avatar`, notifier: this.notifier })
}) })
} }
onAvatarDelete () {
this.instanceService.deleteInstanceAvatar()
.subscribe({
next: () => {
this.notifier.success($localize`Avatar deleted.`)
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
private updateActorImages () {
this.instanceBannerUrl = this.serverConfig.instance.banners?.[0]?.path
this.instanceAvatars = this.serverConfig.instance.avatars
}
private resetActorImages () {
this.server.resetConfig()
.subscribe(config => {
this.serverConfig = config
this.updateActorImages()
})
}
} }

View file

@ -70,10 +70,18 @@
<div class="row mt-4"> <!-- user grid --> <div class="row mt-4"> <!-- user grid -->
<div class="col-12 col-lg-4 col-xl-3"> <div class="col-12 col-lg-4 col-xl-3">
<div class="anchor" id="user"></div> <!-- user anchor --> <div class="anchor" id="user"></div> <!-- user anchor -->
<div *ngIf="isCreation()" class="section-left-column-title" i18n>NEW USER</div>
<div *ngIf="!isCreation() && user" class="section-left-column-title"> @if (isCreation()) {
<my-actor-avatar-edit [actor]="user.account" [editable]="false" [displaySubscribers]="false" [displayUsername]="false"></my-actor-avatar-edit> <div class="section-left-column-title" i18n>NEW USER</div>
} @else if (user) {
<div class="section-left-column-title">
<my-actor-avatar-edit
actorType="account" [displayName]="user.account.displayName" [avatars]="user.account.avatars"
editable="false" [username]="user.username" displayUsername="false"
></my-actor-avatar-edit>
</div> </div>
}
</div> </div>
<div class="col-12 col-lg-8 col-xl-9"> <div class="col-12 col-lg-8 col-xl-9">

View file

@ -16,9 +16,10 @@
></my-actor-banner-edit> ></my-actor-banner-edit>
<my-actor-avatar-edit <my-actor-avatar-edit
*ngIf="videoChannel" [previewImage]="isCreation()" class="d-block mb-4" *ngIf="videoChannel" class="d-block mb-4" actorType="channel"
[actor]="videoChannel" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()" [displayName]="videoChannel.displayName" [previewImage]="isCreation()" [avatars]="videoChannel.avatars"
[displayUsername]="!isCreation()" [displaySubscribers]="!isCreation()" [username]="!isCreation() && videoChannel.displayName" [subscribers]="!isCreation() && videoChannel.followersCount"
(avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit> ></my-actor-avatar-edit>
<div class="form-group" *ngIf="isCreation()"> <div class="form-group" *ngIf="isCreation()">

View file

@ -4,7 +4,11 @@
<div class="col-12 col-lg-4 col-xl-3"></div> <div class="col-12 col-lg-4 col-xl-3"></div>
<div class="col-12 col-lg-8 col-xl-9"> <div class="col-12 col-lg-8 col-xl-9">
<my-actor-avatar-edit [actor]="user.account" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"></my-actor-avatar-edit> <my-actor-avatar-edit
actorType="account" [avatars]="user.account.avatars"
[displayName]="user.account.displayName" [username]="user.username" [subscribers]="user.account.followersCount"
(avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit>
</div> </div>
</div> </div>

View file

@ -1,6 +1,6 @@
<div class="actor" *ngIf="actor"> <div class="actor" *ngIf="actor">
<div class="position-relative me-3"> <div class="position-relative me-3">
<my-actor-avatar [actor]="actor" [actorType]="getActorType()" [previewImage]="preview" size="100"></my-actor-avatar> <my-actor-avatar [actor]="actor" [actorType]="actorType" [previewImage]="preview" size="100"></my-actor-avatar>
<div *ngIf="editable && !hasAvatar()" class="actor-img-edit-button button-focus-within" [ngbTooltip]="avatarFormat" placement="right" container="body"> <div *ngIf="editable && !hasAvatar()" class="actor-img-edit-button button-focus-within" [ngbTooltip]="avatarFormat" placement="right" container="body">
<my-global-icon iconName="upload"></my-global-icon> <my-global-icon iconName="upload"></my-global-icon>
@ -31,8 +31,8 @@
</div> </div>
<div class="actor-info"> <div class="actor-info">
<div class="actor-info-display-name">{{ actor.displayName }}</div> <div *ngIf="displayName" class="actor-info-display-name">{{ displayName }}</div>
<div *ngIf="displayUsername" class="actor-info-username">{{ actor.name }}</div> <div *ngIf="displayUsername && username" class="actor-info-username">{{ username }}</div>
<div *ngIf="displaySubscribers" i18n class="actor-info-followers">{{ actor.followersCount }} subscribers</div> <div *ngIf="subscribers" i18n class="actor-info-followers">{{ subscribers }} subscribers</div>
</div> </div>
</div> </div>

View file

@ -1,8 +1,9 @@
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild, booleanAttribute } from '@angular/core'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { Account, VideoChannel } from '@app/shared/shared-main'
import { getBytes } from '@root-helpers/bytes' import { getBytes } from '@root-helpers/bytes'
import { imageToDataURL } from '@root-helpers/images' import { imageToDataURL } from '@root-helpers/images'
import { ActorAvatarInput } from '../shared-actor-image/actor-avatar.component'
import { ActorImage } from '@peertube/peertube-models'
@Component({ @Component({
selector: 'my-actor-avatar-edit', selector: 'my-actor-avatar-edit',
@ -12,14 +13,19 @@ import { imageToDataURL } from '@root-helpers/images'
'./actor-avatar-edit.component.scss' './actor-avatar-edit.component.scss'
] ]
}) })
export class ActorAvatarEditComponent implements OnInit { export class ActorAvatarEditComponent implements OnInit, OnChanges {
@ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement> @ViewChild('avatarfileInput') avatarfileInput: ElementRef<HTMLInputElement>
@Input() actor: VideoChannel | Account @Input({ required: true }) actorType: 'channel' | 'account'
@Input() editable = true @Input({ required: true }) avatars: ActorImage[]
@Input() displaySubscribers = true @Input({ required: true }) username: string
@Input() displayUsername = true
@Input() previewImage = false @Input() displayName: string
@Input() subscribers: number
@Input({ transform: booleanAttribute }) displayUsername = true
@Input({ transform: booleanAttribute }) editable = true
@Input({ transform: booleanAttribute }) previewImage = false
@Output() avatarChange = new EventEmitter<FormData>() @Output() avatarChange = new EventEmitter<FormData>()
@Output() avatarDelete = new EventEmitter<void>() @Output() avatarDelete = new EventEmitter<void>()
@ -30,6 +36,8 @@ export class ActorAvatarEditComponent implements OnInit {
preview: string preview: string
actor: ActorAvatarInput
constructor ( constructor (
private serverService: ServerService, private serverService: ServerService,
private notifier: Notifier private notifier: Notifier
@ -41,8 +49,14 @@ export class ActorAvatarEditComponent implements OnInit {
this.maxAvatarSize = config.avatar.file.size.max this.maxAvatarSize = config.avatar.file.size.max
this.avatarExtensions = config.avatar.file.extensions.join(', ') this.avatarExtensions = config.avatar.file.extensions.join(', ')
this.avatarFormat = `${$localize`max size`}: 192*192px, ` + this.avatarFormat = $localize`max size: 192*192px, ${getBytes(this.maxAvatarSize)} extensions: ${this.avatarExtensions}`
`${getBytes(this.maxAvatarSize)} ${$localize`extensions`}: ${this.avatarExtensions}` }
ngOnChanges () {
this.actor = {
avatars: this.avatars,
name: this.username
}
} }
onAvatarChange (input: HTMLInputElement) { onAvatarChange (input: HTMLInputElement) {
@ -69,12 +83,6 @@ export class ActorAvatarEditComponent implements OnInit {
} }
hasAvatar () { hasAvatar () {
return !!this.preview || this.actor.avatars.length !== 0 return !!this.preview || this.avatars.length !== 0
}
getActorType () {
if ((this.actor as VideoChannel).ownerAccount) return 'channel'
return 'account'
} }
} }

View file

@ -1,11 +1,11 @@
<ng-template #img> <ng-template #img>
<img *ngIf="displayImage()" [class]="classes" [src]="previewImage || avatarUrl || defaultAvatarUrl" alt="" /> <img #avatarEl *ngIf="displayImage()" [ngClass]="classes" [src]="previewImage || avatarUrl || defaultAvatarUrl" alt="" />
<div *ngIf="displayActorInitial()" [ngClass]="classes"> <div #avatarEl *ngIf="displayActorInitial()" [ngClass]="classes">
<span>{{ getActorInitial() }}</span> <span>{{ getActorInitial() }}</span>
</div> </div>
<div *ngIf="displayPlaceholder()" [ngClass]="classes"></div> <div #avatarEl *ngIf="displayPlaceholder()" [ngClass]="classes"></div>
</ng-template> </ng-template>
<a *ngIf="actor && href" [href]="href" target="_blank" rel="noopener noreferrer" [title]="title"> <a *ngIf="actor && href" [href]="href" target="_blank" rel="noopener noreferrer" [title]="title">

View file

@ -2,9 +2,7 @@
@use '_mixins' as *; @use '_mixins' as *;
.avatar { .avatar {
--avatarSize: 100%; // Defined in component
--initialFontSize: 22px;
width: var(--avatarSize); width: var(--avatarSize);
height: var(--avatarSize); height: var(--avatarSize);
min-width: var(--avatarSize); min-width: var(--avatarSize);
@ -20,26 +18,6 @@
} }
} }
$sizes: '18', '25', '28', '32', '34', '35', '36', '40', '48', '75', '80', '100', '120';
@each $size in $sizes {
.avatar-#{$size} {
--avatarSize: #{$size}px;
}
}
.avatar-18 {
--initialFontSize: 13px;
}
.avatar-100 {
--initialFontSize: 40px;
}
.avatar-120 {
--initialFontSize: 46px;
}
a:hover { a:hover {
text-decoration: none; text-decoration: none;
} }

View file

@ -1,36 +1,35 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core' import { Component, ElementRef, Input, OnChanges, OnInit, ViewChild, numberAttribute } from '@angular/core'
import { VideoChannel } from '../shared-main' import { VideoChannel } from '../shared-main'
import { Account } from '../shared-main/account/account.model' import { Account } from '../shared-main/account/account.model'
import { objectKeysTyped } from '@peertube/peertube-core-utils' import { objectKeysTyped } from '@peertube/peertube-core-utils'
type ActorInput = { export type ActorAvatarInput = {
name: string name: string
avatars: { width: number, url?: string, path: string }[] avatars: { width: number, url?: string, path: string }[]
url: string
} }
export type ActorAvatarSize = '18' | '25' | '28' | '32' | '34' | '35' | '36' | '40' | '48' | '75' | '80' | '100' | '120'
@Component({ @Component({
selector: 'my-actor-avatar', selector: 'my-actor-avatar',
styleUrls: [ './actor-avatar.component.scss' ], styleUrls: [ './actor-avatar.component.scss' ],
templateUrl: './actor-avatar.component.html' templateUrl: './actor-avatar.component.html'
}) })
export class ActorAvatarComponent implements OnInit, OnChanges { export class ActorAvatarComponent implements OnInit, OnChanges {
private _title: string @ViewChild('avatarEl') avatarEl: ElementRef
@Input() actor: ActorInput @Input() actor: ActorAvatarInput
@Input() actorType: 'channel' | 'account' | 'unlogged' @Input() actorType: 'channel' | 'account' | 'unlogged'
@Input() previewImage: string @Input() previewImage: string
@Input() size: ActorAvatarSize @Input({ transform: numberAttribute }) size: number
// Use an external link // Use an external link
@Input() href: string @Input() href: string
// Use routerLink // Use routerLink
@Input() internalHref: string | any[] @Input() internalHref: string | any[]
private _title: string
@Input() set title (value) { @Input() set title (value) {
this._title = value this._title = value
} }
@ -47,6 +46,10 @@ export class ActorAvatarComponent implements OnInit, OnChanges {
defaultAvatarUrl: string defaultAvatarUrl: string
avatarUrl: string avatarUrl: string
constructor (private el: ElementRef) {
}
ngOnInit () { ngOnInit () {
this.buildDefaultAvatarUrl() this.buildDefaultAvatarUrl()
@ -60,10 +63,21 @@ export class ActorAvatarComponent implements OnInit, OnChanges {
} }
private buildClasses () { private buildClasses () {
let avatarSize = '100%'
let initialFontSize = '22px'
this.classes = [ 'avatar' ] this.classes = [ 'avatar' ]
if (this.size) { if (this.size) {
this.classes.push(`avatar-${this.size}`) avatarSize = `${this.size}px`
if (this.size <= 18) {
initialFontSize = '13px'
} else if (this.size >= 100) {
initialFontSize = '40px'
} else if (this.size >= 120) {
initialFontSize = '46px'
}
} }
if (this.isChannel()) { if (this.isChannel()) {
@ -77,6 +91,10 @@ export class ActorAvatarComponent implements OnInit, OnChanges {
this.classes.push('initial') this.classes.push('initial')
this.classes.push(this.getColorTheme()) this.classes.push(this.getColorTheme())
} }
const elStyle = (this.el.nativeElement as HTMLElement).style
elStyle.setProperty('--avatarSize', avatarSize)
elStyle.setProperty('--initialFontSize', initialFontSize)
} }
private buildDefaultAvatarUrl () { private buildDefaultAvatarUrl () {

View file

@ -6,6 +6,7 @@ import {
ChannelMiniatureMarkupData, ChannelMiniatureMarkupData,
ContainerMarkupData, ContainerMarkupData,
EmbedMarkupData, EmbedMarkupData,
InstanceAvatarMarkupData,
InstanceBannerMarkupData, InstanceBannerMarkupData,
PlaylistMiniatureMarkupData, PlaylistMiniatureMarkupData,
VideoMiniatureMarkupData, VideoMiniatureMarkupData,
@ -16,6 +17,7 @@ import {
ButtonMarkupComponent, ButtonMarkupComponent,
ChannelMiniatureMarkupComponent, ChannelMiniatureMarkupComponent,
EmbedMarkupComponent, EmbedMarkupComponent,
InstanceAvatarMarkupComponent,
InstanceBannerMarkupComponent, InstanceBannerMarkupComponent,
PlaylistMiniatureMarkupComponent, PlaylistMiniatureMarkupComponent,
VideoMiniatureMarkupComponent, VideoMiniatureMarkupComponent,
@ -30,6 +32,7 @@ type HTMLBuilderFunction = (el: HTMLElement) => HTMLElement
export class CustomMarkupService { export class CustomMarkupService {
private angularBuilders: { [ selector: string ]: AngularBuilderFunction } = { private angularBuilders: { [ selector: string ]: AngularBuilderFunction } = {
'peertube-instance-banner': el => this.instanceBannerBuilder(el), 'peertube-instance-banner': el => this.instanceBannerBuilder(el),
'peertube-instance-avatar': el => this.instanceAvatarBuilder(el),
'peertube-button': el => this.buttonBuilder(el), 'peertube-button': el => this.buttonBuilder(el),
'peertube-video-embed': el => this.embedBuilder(el, 'video'), 'peertube-video-embed': el => this.embedBuilder(el, 'video'),
'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'), 'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'),
@ -171,6 +174,19 @@ export class CustomMarkupService {
return { component, loadedPromise } return { component, loadedPromise }
} }
private instanceAvatarBuilder (el: HTMLElement) {
const data = el.dataset as InstanceAvatarMarkupData
const { component, loadedPromise } = this.dynamicElementService.createElement(InstanceAvatarMarkupComponent)
const model = {
size: this.buildNumber(data.size)
}
this.dynamicElementService.setModel(component, model)
return { component, loadedPromise }
}
private videoMiniatureBuilder (el: HTMLElement) { private videoMiniatureBuilder (el: HTMLElement) {
const data = el.dataset as VideoMiniatureMarkupData const data = el.dataset as VideoMiniatureMarkupData
const { component, loadedPromise } = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent) const { component, loadedPromise } = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent)

View file

@ -1,6 +1,7 @@
export * from './button-markup.component' export * from './button-markup.component'
export * from './channel-miniature-markup.component' export * from './channel-miniature-markup.component'
export * from './embed-markup.component' export * from './embed-markup.component'
export * from './instance-avatar-markup.component'
export * from './instance-banner-markup.component' export * from './instance-banner-markup.component'
export * from './playlist-miniature-markup.component' export * from './playlist-miniature-markup.component'
export * from './video-miniature-markup.component' export * from './video-miniature-markup.component'

View file

@ -0,0 +1 @@
<my-actor-avatar *ngIf="actor" [actor]="actor" actorType="account" [size]="size"></my-actor-avatar>

View file

@ -0,0 +1,36 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'
import { CustomMarkupComponent } from './shared'
import { ActorAvatarInput } from '@app/shared/shared-actor-image/actor-avatar.component'
import { ServerService } from '@app/core'
/*
* Markup component that creates the img HTML element containing the instance avatar
*/
@Component({
selector: 'my-instance-avatar-markup',
templateUrl: 'instance-avatar-markup.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class InstanceAvatarMarkupComponent implements OnInit, CustomMarkupComponent {
@Input() size: number
actor: ActorAvatarInput
loaded: undefined
constructor (
private cd: ChangeDetectorRef,
private server: ServerService
) {}
ngOnInit () {
const { instance } = this.server.getHTMLConfig()
this.actor = {
avatars: instance.avatars,
name: this.server.getHTMLConfig().instance.name
}
this.cd.markForCheck()
}
}

View file

@ -1,7 +1,6 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core' import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'
import { CustomMarkupComponent } from './shared' import { CustomMarkupComponent } from './shared'
import { InstanceService } from '@app/shared/shared-instance' import { ServerService } from '@app/core'
import { finalize } from 'rxjs'
/* /*
* Markup component that creates the img HTML element containing the instance banner * Markup component that creates the img HTML element containing the instance banner
@ -15,21 +14,18 @@ import { finalize } from 'rxjs'
export class InstanceBannerMarkupComponent implements OnInit, CustomMarkupComponent { export class InstanceBannerMarkupComponent implements OnInit, CustomMarkupComponent {
@Input() revertHomePaddingTop: boolean @Input() revertHomePaddingTop: boolean
@Output() loaded = new EventEmitter<boolean>()
instanceBannerUrl: string instanceBannerUrl: string
loaded: undefined
constructor ( constructor (
private cd: ChangeDetectorRef, private cd: ChangeDetectorRef,
private instance: InstanceService private server: ServerService
) {} ) {}
ngOnInit () { ngOnInit () {
this.instance.getInstanceBannerUrl() const { instance } = this.server.getHTMLConfig()
.pipe(finalize(() => this.loaded.emit(true)))
.subscribe(instanceBannerUrl => { this.instanceBannerUrl = instance.banners?.[0]?.path
this.instanceBannerUrl = instanceBannerUrl
this.cd.markForCheck() this.cd.markForCheck()
})
} }
} }

View file

@ -14,6 +14,7 @@ import {
ButtonMarkupComponent, ButtonMarkupComponent,
ChannelMiniatureMarkupComponent, ChannelMiniatureMarkupComponent,
EmbedMarkupComponent, EmbedMarkupComponent,
InstanceAvatarMarkupComponent,
InstanceBannerMarkupComponent, InstanceBannerMarkupComponent,
PlaylistMiniatureMarkupComponent, PlaylistMiniatureMarkupComponent,
VideoMiniatureMarkupComponent, VideoMiniatureMarkupComponent,
@ -41,7 +42,8 @@ import {
ButtonMarkupComponent, ButtonMarkupComponent,
CustomMarkupHelpComponent, CustomMarkupHelpComponent,
CustomMarkupContainerComponent, CustomMarkupContainerComponent,
InstanceBannerMarkupComponent InstanceBannerMarkupComponent,
InstanceAvatarMarkupComponent
], ],
exports: [ exports: [
@ -53,7 +55,8 @@ import {
ButtonMarkupComponent, ButtonMarkupComponent,
CustomMarkupHelpComponent, CustomMarkupHelpComponent,
CustomMarkupContainerComponent, CustomMarkupContainerComponent,
InstanceBannerMarkupComponent InstanceBannerMarkupComponent,
InstanceAvatarMarkupComponent
], ],
providers: [ providers: [

View file

@ -1,5 +1,5 @@
import { Component, Input, OnInit, booleanAttribute } from '@angular/core' import { Component, Input, OnInit, booleanAttribute } from '@angular/core'
import { InstanceService } from './instance.service' import { ServerService } from '@app/core'
@Component({ @Component({
selector: 'my-instance-banner', selector: 'my-instance-banner',
@ -10,12 +10,13 @@ export class InstanceBannerComponent implements OnInit {
instanceBannerUrl: string instanceBannerUrl: string
constructor (private instanceService: InstanceService) { constructor (private server: ServerService) {
} }
ngOnInit () { ngOnInit () {
this.instanceService.getInstanceBannerUrl() const { instance } = this.server.getHTMLConfig()
.subscribe(instanceBannerUrl => this.instanceBannerUrl = instanceBannerUrl)
this.instanceBannerUrl = instance.banners?.[0]?.path
} }
} }

View file

@ -1,5 +1,5 @@
import { forkJoin, of } from 'rxjs' import { forkJoin } from 'rxjs'
import { catchError, map, tap } from 'rxjs/operators' import { catchError, map } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { MarkdownService, RestExtractor, ServerService } from '@app/core' import { MarkdownService, RestExtractor, ServerService } from '@app/core'
@ -18,8 +18,6 @@ export class InstanceService {
private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config' private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server' private static BASE_SERVER_URL = environment.apiUrl + '/api/v1/server'
private instanceBannerUrl: string
constructor ( constructor (
private authHttp: HttpClient, private authHttp: HttpClient,
private restExtractor: RestExtractor, private restExtractor: RestExtractor,
@ -30,29 +28,12 @@ export class InstanceService {
getAbout () { getAbout () {
return this.authHttp.get<About>(InstanceService.BASE_CONFIG_URL + '/about') return this.authHttp.get<About>(InstanceService.BASE_CONFIG_URL + '/about')
.pipe( .pipe(catchError(res => this.restExtractor.handleError(res)))
tap(about => {
const banners = about.instance.banners
if (banners.length !== 0) this.instanceBannerUrl = banners[0].path
}),
catchError(res => this.restExtractor.handleError(res))
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
getInstanceBannerUrl () {
if (this.instanceBannerUrl || this.instanceBannerUrl === null) {
return of(this.instanceBannerUrl)
}
return this.getAbout()
.pipe(map(() => this.instanceBannerUrl))
}
updateInstanceBanner (formData: FormData) { updateInstanceBanner (formData: FormData) {
this.instanceBannerUrl = undefined
const url = InstanceService.BASE_CONFIG_URL + '/instance-banner/pick' const url = InstanceService.BASE_CONFIG_URL + '/instance-banner/pick'
return this.authHttp.post(url, formData) return this.authHttp.post(url, formData)
@ -60,8 +41,6 @@ export class InstanceService {
} }
deleteInstanceBanner () { deleteInstanceBanner () {
this.instanceBannerUrl = null
const url = InstanceService.BASE_CONFIG_URL + '/instance-banner' const url = InstanceService.BASE_CONFIG_URL + '/instance-banner'
return this.authHttp.delete(url) return this.authHttp.delete(url)
@ -70,6 +49,22 @@ export class InstanceService {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
updateInstanceAvatar (formData: FormData) {
const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar/pick'
return this.authHttp.post(url, formData)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
deleteInstanceAvatar () {
const url = InstanceService.BASE_CONFIG_URL + '/instance-avatar'
return this.authHttp.delete(url)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
// ---------------------------------------------------------------------------
contactAdministrator (fromEmail: string, fromName: string, subject: string, message: string) { contactAdministrator (fromEmail: string, fromName: string, subject: string, message: string) {
const body = { const body = {
fromEmail, fromEmail,

View file

@ -8,12 +8,12 @@ import {
Input, Input,
LOCALE_ID, LOCALE_ID,
OnInit, OnInit,
Output Output,
numberAttribute
} from '@angular/core' } from '@angular/core'
import { AuthService, ScreenService, ServerService, User } from '@app/core' import { AuthService, ScreenService, ServerService, User } from '@app/core'
import { HTMLServerConfig, VideoExistInPlaylist, VideoPlaylistType, VideoPrivacy, VideoState } from '@peertube/peertube-models' import { HTMLServerConfig, VideoExistInPlaylist, VideoPlaylistType, VideoPrivacy, VideoState } from '@peertube/peertube-models'
import { LinkType } from '../../../types/link.type' import { LinkType } from '../../../types/link.type'
import { ActorAvatarSize } from '../shared-actor-image/actor-avatar.component'
import { Video, VideoService } from '../shared-main' import { Video, VideoService } from '../shared-main'
import { VideoPlaylistService } from '../shared-video-playlist' import { VideoPlaylistService } from '../shared-video-playlist'
import { VideoActionsDisplayType } from './video-actions-dropdown.component' import { VideoActionsDisplayType } from './video-actions-dropdown.component'
@ -68,7 +68,7 @@ export class VideoMiniatureComponent implements OnInit {
stats: false stats: false
} }
@Input() actorImageSize: ActorAvatarSize = '40' @Input({ transform: numberAttribute }) actorImageSize = 40
@Input() displayAsRow = false @Input() displayAsRow = false

View file

@ -62,3 +62,7 @@ export type ContainerMarkupData = {
export type InstanceBannerMarkupData = { export type InstanceBannerMarkupData = {
revertHomePaddingTop?: StringBoolean // default to 'true' revertHomePaddingTop?: StringBoolean // default to 'true'
} }
export type InstanceAvatarMarkupData = {
size: string // size in pixels
}

View file

@ -20,5 +20,6 @@ export interface About {
categories: number[] categories: number[]
banners: ActorImage[] banners: ActorImage[]
avatars: ActorImage[]
} }
} }

View file

@ -1,3 +1,4 @@
import { ActorImage } from '../index.js'
import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js' import { ClientScriptJSON } from '../plugins/plugin-package-json.model.js'
import { NSFWPolicyType } from '../videos/nsfw-policy.type.js' import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
import { VideoPrivacyType } from '../videos/video-privacy.enum.js' import { VideoPrivacyType } from '../videos/video-privacy.enum.js'
@ -90,6 +91,9 @@ export interface ServerConfig {
javascript: string javascript: string
css: string css: string
} }
avatars: ActorImage[]
banners: ActorImage[]
} }
search: { search: {

View file

@ -1,5 +1,5 @@
import merge from 'lodash-es/merge.js' import merge from 'lodash-es/merge.js'
import { About, CustomConfig, HttpStatusCode, ServerConfig } from '@peertube/peertube-models' import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, ServerConfig } from '@peertube/peertube-models'
import { DeepPartial } from '@peertube/peertube-typescript-utils' import { DeepPartial } from '@peertube/peertube-typescript-utils'
import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-command.js' import { AbstractCommand, OverrideCommandOptions } from '../shared/abstract-command.js'
@ -365,27 +365,38 @@ export class ConfigCommand extends AbstractCommand {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
updateInstanceBanner (options: OverrideCommandOptions & { updateInstanceImage (options: OverrideCommandOptions & {
fixture: string fixture: string
type: ActorImageType_Type
}) { }) {
const { fixture } = options const { fixture, type } = options
const path = `/api/v1/config/instance-banner/pick` const path = type === ActorImageType.BANNER
? `/api/v1/config/instance-banner/pick`
: `/api/v1/config/instance-avatar/pick`
return this.updateImageRequest({ return this.updateImageRequest({
...options, ...options,
path, path,
fixture, fixture,
fieldname: 'bannerfile', fieldname: type === ActorImageType.BANNER
? 'bannerfile'
: 'avatarfile',
implicitToken: true, implicitToken: true,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
}) })
} }
deleteInstanceBanner (options: OverrideCommandOptions = {}) { deleteInstanceImage (options: OverrideCommandOptions & {
const path = `/api/v1/config/instance-banner` type: ActorImageType_Type
}) {
const suffix = options.type === ActorImageType.BANNER
? 'instance-banner'
: 'instance-avatar'
const path = `/api/v1/config/${suffix}`
return this.deleteRequest({ return this.deleteRequest({
...options, ...options,

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import merge from 'lodash-es/merge.js' import merge from 'lodash-es/merge.js'
import { omit } from '@peertube/peertube-core-utils' import { omit } from '@peertube/peertube-core-utils'
import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models' import { ActorImageType, CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
import { import {
cleanupTests, cleanupTests,
createSingleServer, createSingleServer,
@ -421,6 +421,7 @@ describe('Test config API validators', function () {
}) })
describe('When deleting the configuration', function () { describe('When deleting the configuration', function () {
it('Should fail without token', async function () { it('Should fail without token', async function () {
await makeDeleteRequest({ await makeDeleteRequest({
url: server.url, url: server.url,
@ -439,17 +440,23 @@ describe('Test config API validators', function () {
}) })
}) })
describe('Updating instance banner', function () { describe('Updating instance image', function () {
const path = '/api/v1/config/instance-banner/pick' const toTest = [
{ path: '/api/v1/config/instance-banner/pick', attachName: 'bannerfile' },
{ path: '/api/v1/config/instance-avatar/pick', attachName: 'avatarfile' }
]
it('Should fail with an incorrect input file', async function () { it('Should fail with an incorrect input file', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('video_short.mp4') } for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('video_short.mp4') }
await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields: {}, attaches }) await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields: {}, attaches })
}
}) })
it('Should fail with a big file', async function () { it('Should fail with a big file', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar-big.png') } for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('avatar-big.png') }
await makeUploadRequest({ await makeUploadRequest({
url: server.url, url: server.url,
@ -459,10 +466,12 @@ describe('Test config API validators', function () {
attaches, attaches,
expectedStatus: HttpStatusCode.BAD_REQUEST_400 expectedStatus: HttpStatusCode.BAD_REQUEST_400
}) })
}
}) })
it('Should fail without token', async function () { it('Should fail without token', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar.png') } for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('avatar.png') }
await makeUploadRequest({ await makeUploadRequest({
url: server.url, url: server.url,
@ -471,10 +480,12 @@ describe('Test config API validators', function () {
attaches, attaches,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
}) })
}
}) })
it('Should fail without the appropriate rights', async function () { it('Should fail without the appropriate rights', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar.png') } for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('avatar.png') }
await makeUploadRequest({ await makeUploadRequest({
url: server.url, url: server.url,
@ -484,10 +495,12 @@ describe('Test config API validators', function () {
attaches, attaches,
expectedStatus: HttpStatusCode.FORBIDDEN_403 expectedStatus: HttpStatusCode.FORBIDDEN_403
}) })
}
}) })
it('Should succeed with the correct params', async function () { it('Should succeed with the correct params', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar.png') } for (const { attachName, path } of toTest) {
const attaches = { [attachName]: buildAbsoluteFixturePath('avatar.png') }
await makeUploadRequest({ await makeUploadRequest({
url: server.url, url: server.url,
@ -497,21 +510,29 @@ describe('Test config API validators', function () {
attaches, attaches,
expectedStatus: HttpStatusCode.NO_CONTENT_204 expectedStatus: HttpStatusCode.NO_CONTENT_204
}) })
}
}) })
}) })
describe('Deleting instance banner', function () { describe('Deleting instance image', function () {
const types = [ ActorImageType.BANNER, ActorImageType.AVATAR ]
it('Should fail without token', async function () { it('Should fail without token', async function () {
await server.config.deleteInstanceBanner({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) for (const type of types) {
await server.config.deleteInstanceImage({ type, token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
}
}) })
it('Should fail without the appropriate rights', async function () { it('Should fail without the appropriate rights', async function () {
await server.config.deleteInstanceBanner({ token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) for (const type of types) {
await server.config.deleteInstanceImage({ type, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
}
}) })
it('Should succeed with the correct params', async function () { it('Should succeed with the correct params', async function () {
await server.config.deleteInstanceBanner() for (const type of types) {
await server.config.deleteInstanceImage({ type })
}
}) })
}) })

View file

@ -2,7 +2,7 @@
import { expect } from 'chai' import { expect } from 'chai'
import { parallelTests } from '@peertube/peertube-node-utils' import { parallelTests } from '@peertube/peertube-node-utils'
import { CustomConfig, HttpStatusCode } from '@peertube/peertube-models' import { ActorImageType, CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
import { import {
cleanupTests, cleanupTests,
createSingleServer, createSingleServer,
@ -11,7 +11,7 @@ import {
PeerTubeServer, PeerTubeServer,
setAccessTokensToServers setAccessTokensToServers
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { testFileExistsOrNot, testImage } from '@tests/shared/checks.js' import { testFileExistsOrNot, testImage, testImageSize } from '@tests/shared/checks.js'
import { basename } from 'path' import { basename } from 'path'
function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
@ -521,7 +521,6 @@ describe('Test static config', function () {
describe('Test config', function () { describe('Test config', function () {
let server: PeerTubeServer let server: PeerTubeServer
let bannerPath: string
before(async function () { before(async function () {
this.timeout(30000) this.timeout(30000)
@ -530,6 +529,8 @@ describe('Test config', function () {
await setAccessTokensToServers([ server ]) await setAccessTokensToServers([ server ])
}) })
describe('Config keys', function () {
it('Should have the correct default config', async function () { it('Should have the correct default config', async function () {
const data = await server.config.getConfig() const data = await server.config.getConfig()
@ -641,32 +642,6 @@ describe('Test config', function () {
expect(instance.banners).to.have.lengthOf(0) expect(instance.banners).to.have.lengthOf(0)
}) })
it('Should update instance banner', async function () {
await server.config.updateInstanceBanner({ fixture: 'banner.jpg' })
const { instance } = await server.config.getAbout()
expect(instance.banners).to.have.lengthOf(1)
bannerPath = instance.banners[0].path
await testImage(server.url, 'banner-resized', bannerPath)
await testFileExistsOrNot(server, 'avatars', basename(bannerPath), true)
})
it('Should re-update an existing instance banner', async function () {
await server.config.updateInstanceBanner({ fixture: 'banner.jpg' })
})
it('Should remove instance banner', async function () {
await server.config.deleteInstanceBanner()
const { instance } = await server.config.getAbout()
expect(instance.banners).to.have.lengthOf(0)
await testFileExistsOrNot(server, 'avatars', basename(bannerPath), false)
})
it('Should remove the custom configuration', async function () { it('Should remove the custom configuration', async function () {
await server.config.deleteCustomConfig() await server.config.deleteCustomConfig()
@ -709,6 +684,83 @@ describe('Test config', function () {
expect(res.headers['x-powered-by']).to.not.exist expect(res.headers['x-powered-by']).to.not.exist
} }
}) })
})
describe('Image files', function () {
async function checkAndGetServerImages () {
const { instance } = await server.config.getAbout()
const htmlConfig = await server.config.getIndexHTMLConfig()
const serverConfig = await server.config.getIndexHTMLConfig()
expect(instance.avatars).to.deep.equal(htmlConfig.instance.avatars)
expect(serverConfig.instance.avatars).to.deep.equal(htmlConfig.instance.avatars)
expect(instance.banners).to.deep.equal(htmlConfig.instance.banners)
expect(serverConfig.instance.banners).to.deep.equal(htmlConfig.instance.banners)
return htmlConfig.instance
}
describe('Banner', function () {
let bannerPath: string
it('Should update instance banner', async function () {
await server.config.updateInstanceImage({ type: ActorImageType.BANNER, fixture: 'banner.jpg' })
const { banners } = await checkAndGetServerImages()
expect(banners).to.have.lengthOf(1)
bannerPath = banners[0].path
await testImage(server.url, 'banner-resized', bannerPath)
await testFileExistsOrNot(server, 'avatars', basename(bannerPath), true)
})
it('Should re-update an existing instance banner', async function () {
await server.config.updateInstanceImage({ type: ActorImageType.BANNER, fixture: 'banner.jpg' })
})
it('Should remove instance banner', async function () {
await server.config.deleteInstanceImage({ type: ActorImageType.BANNER })
const { banners } = await checkAndGetServerImages()
expect(banners).to.have.lengthOf(0)
await testFileExistsOrNot(server, 'avatars', basename(bannerPath), false)
})
})
describe('Avatar', function () {
let avatarPath: string
it('Should update instance avatar', async function () {
for (const extension of [ '.png', '.gif' ]) {
const fixture = 'avatar' + extension
await server.config.updateInstanceImage({ type: ActorImageType.AVATAR, fixture })
const { avatars } = await checkAndGetServerImages()
for (const avatar of avatars) {
await testImageSize(server.url, `avatar-resized-${avatar.width}x${avatar.width}`, avatar.path, extension)
}
avatarPath = avatars[0].path
await testFileExistsOrNot(server, 'avatars', basename(avatarPath), true)
}
})
it('Should remove instance banner', async function () {
await server.config.deleteInstanceImage({ type: ActorImageType.AVATAR })
const { avatars } = await checkAndGetServerImages()
expect(avatars).to.have.lengthOf(0)
await testFileExistsOrNot(server, 'avatars', basename(avatarPath), false)
})
})
})
after(async function () { after(async function () {
await cleanupTests([ server ]) await cleanupTests([ server ])

View file

@ -3,7 +3,7 @@ import { remove, writeJSON } from 'fs-extra/esm'
import snakeCase from 'lodash-es/snakeCase.js' import snakeCase from 'lodash-es/snakeCase.js'
import validator from 'validator' import validator from 'validator'
import { ServerConfigManager } from '@server/lib/server-config-manager.js' import { ServerConfigManager } from '@server/lib/server-config-manager.js'
import { About, ActorImageType, CustomConfig, HttpStatusCode, UserRight } from '@peertube/peertube-models' import { About, ActorImageType, ActorImageType_Type, CustomConfig, HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger.js' import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '../../helpers/audit-logger.js'
import { objectConverter } from '../../helpers/core-utils.js' import { objectConverter } from '../../helpers/core-utils.js'
import { CONFIG, reloadConfig } from '../../initializers/config.js' import { CONFIG, reloadConfig } from '../../initializers/config.js'
@ -14,6 +14,7 @@ import {
authenticate, authenticate,
ensureUserHasRight, ensureUserHasRight,
openapiOperationDoc, openapiOperationDoc,
updateAvatarValidator,
updateBannerValidator updateBannerValidator
} from '../../middlewares/index.js' } from '../../middlewares/index.js'
import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js' import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js'
@ -63,18 +64,36 @@ configRouter.delete('/custom',
asyncMiddleware(deleteCustomConfig) asyncMiddleware(deleteCustomConfig)
) )
// ---------------------------------------------------------------------------
configRouter.post('/instance-banner/pick', configRouter.post('/instance-banner/pick',
authenticate, authenticate,
createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT), createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
updateBannerValidator, updateBannerValidator,
asyncMiddleware(updateInstanceBanner) asyncMiddleware(updateInstanceImageFactory(ActorImageType.BANNER))
) )
configRouter.delete('/instance-banner', configRouter.delete('/instance-banner',
authenticate, authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
asyncMiddleware(deleteInstanceBanner) asyncMiddleware(deleteInstanceImageFactory(ActorImageType.BANNER))
)
// ---------------------------------------------------------------------------
configRouter.post('/instance-avatar/pick',
authenticate,
createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
updateAvatarValidator,
asyncMiddleware(updateInstanceImageFactory(ActorImageType.AVATAR))
)
configRouter.delete('/instance-avatar',
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
asyncMiddleware(deleteInstanceImageFactory(ActorImageType.AVATAR))
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -86,7 +105,7 @@ async function getConfig (req: express.Request, res: express.Response) {
} }
async function getAbout (req: express.Request, res: express.Response) { async function getAbout (req: express.Request, res: express.Response) {
const banners = await ActorImageModel.listByActor(await getServerActor(), ActorImageType.BANNER) const { avatars, banners } = await ActorImageModel.listServerActorImages()
const about: About = { const about: About = {
instance: { instance: {
@ -107,7 +126,8 @@ async function getAbout (req: express.Request, res: express.Response) {
languages: CONFIG.INSTANCE.LANGUAGES, languages: CONFIG.INSTANCE.LANGUAGES,
categories: CONFIG.INSTANCE.CATEGORIES, categories: CONFIG.INSTANCE.CATEGORIES,
banners: banners.map(b => b.toFormattedJSON()) banners: banners.map(b => b.toFormattedJSON()),
avatars: avatars.map(a => a.toFormattedJSON())
} }
} }
@ -155,30 +175,48 @@ async function updateCustomConfig (req: express.Request, res: express.Response)
return res.json(data) return res.json(data)
} }
async function updateInstanceBanner (req: express.Request, res: express.Response) { // ---------------------------------------------------------------------------
const bannerPhysicalFile = req.files['bannerfile'][0]
const serverActor = await getServerActor() function updateInstanceImageFactory (imageType: ActorImageType_Type) {
serverActor.Banners = await ActorImageModel.listByActor(serverActor, ActorImageType.BANNER) // Reload banners from DB return async (req: express.Request, res: express.Response) => {
const field = imageType === ActorImageType.BANNER
? 'bannerfile'
: 'avatarfile'
const imagePhysicalFile = req.files[field][0]
await updateLocalActorImageFiles({ await updateLocalActorImageFiles({
accountOrChannel: serverActor.Account, accountOrChannel: (await getServerActorWithUpdatedImages(imageType)).Account,
imagePhysicalFile: bannerPhysicalFile, imagePhysicalFile,
type: ActorImageType.BANNER, type: imageType,
sendActorUpdate: false sendActorUpdate: false
}) })
ClientHtml.invalidateCache()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204) return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
} }
}
async function deleteInstanceBanner (req: express.Request, res: express.Response) { function deleteInstanceImageFactory (imageType: ActorImageType_Type) {
const serverActor = await getServerActor() return async (req: express.Request, res: express.Response) => {
serverActor.Banners = await ActorImageModel.listByActor(serverActor, ActorImageType.BANNER) // Reload banners from DB await deleteLocalActorImageFile((await getServerActorWithUpdatedImages(imageType)).Account, imageType)
await deleteLocalActorImageFile(serverActor.Account, ActorImageType.BANNER) ClientHtml.invalidateCache()
return res.sendStatus(HttpStatusCode.NO_CONTENT_204) return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
} }
}
async function getServerActorWithUpdatedImages (imageType: ActorImageType_Type) {
const serverActor = await getServerActor()
const updatedImages = await ActorImageModel.listByActor(serverActor, imageType) // Reload images from DB
if (imageType === ActorImageType.BANNER) serverActor.Banners = updatedImages
else serverActor.Avatars = updatedImages
return serverActor
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -15,6 +15,7 @@ import { Hooks } from './plugins/hooks.js'
import { PluginManager } from './plugins/plugin-manager.js' import { PluginManager } from './plugins/plugin-manager.js'
import { getThemeOrDefault } from './plugins/theme-utils.js' import { getThemeOrDefault } from './plugins/theme-utils.js'
import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles.js' import { VideoTranscodingProfilesManager } from './transcoding/default-transcoding-profiles.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
/** /**
* *
@ -48,6 +49,8 @@ class ServerConfigManager {
const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME) const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
const { avatars, banners } = await ActorImageModel.listServerActorImages()
return { return {
client: { client: {
videos: { videos: {
@ -100,7 +103,9 @@ class ServerConfigManager {
customizations: { customizations: {
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT, javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
} },
avatars: avatars.map(a => a.toFormattedJSON()),
banners: banners.map(b => b.toFormattedJSON())
}, },
search: { search: {
remoteUri: { remoteUri: {

View file

@ -20,6 +20,7 @@ import { CONFIG } from '../../initializers/config.js'
import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants.js' import { LAZY_STATIC_PATHS, MIMETYPES, WEBSERVER } from '../../initializers/constants.js'
import { SequelizeModel, buildSQLAttributes, throwIfNotValid } from '../shared/index.js' import { SequelizeModel, buildSQLAttributes, throwIfNotValid } from '../shared/index.js'
import { ActorModel } from './actor.js' import { ActorModel } from './actor.js'
import { getServerActor } from '../application/application.js'
@Table({ @Table({
tableName: 'actorImage', tableName: 'actorImage',
@ -123,6 +124,15 @@ export class ActorImageModel extends SequelizeModel<ActorImageModel> {
return ActorImageModel.findAll(query) return ActorImageModel.findAll(query)
} }
static async listServerActorImages () {
const serverActor = await getServerActor()
const promises = [ ActorImageType.AVATAR, ActorImageType.BANNER ].map(type => ActorImageModel.listByActor(serverActor, type))
const [ avatars, banners ] = await Promise.all(promises)
return { avatars, banners }
}
static getImageUrl (image: MActorImage) { static getImageUrl (image: MActorImage) {
if (!image) return undefined if (!image) return undefined

View file

@ -953,17 +953,8 @@ paths:
tags: tags:
- Config - Config
responses: responses:
'200': '204':
description: successful operation description: successful operation
content:
application/json:
schema:
type: object
properties:
banners:
type: array
items:
$ref: '#/components/schemas/ActorImage'
'413': '413':
description: image file too large description: image file too large
headers: headers:
@ -998,6 +989,51 @@ paths:
'204': '204':
description: successful operation description: successful operation
/api/v1/config/instance-avatar/pick:
post:
summary: Update instance avatar
security:
- OAuth2:
- admin
tags:
- Config
responses:
'204':
description: successful operation
'413':
description: image file too large
headers:
X-File-Maximum-Size:
schema:
type: string
format: Nginx size
description: Maximum file size for the avatar
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
avatarfile:
description: The file to upload.
type: string
format: binary
encoding:
avatarfile:
contentType: image/png, image/jpeg
'/api/v1/config/instance-avatar':
delete:
summary: Delete instance avatar
security:
- OAuth2:
- admin
tags:
- Config
responses:
'204':
description: successful operation
/api/v1/custom-pages/homepage/instance: /api/v1/custom-pages/homepage/instance:
get: get:
summary: Get instance custom homepage summary: Get instance custom homepage
@ -8251,6 +8287,14 @@ components:
type: string type: string
css: css:
type: string type: string
avatars:
type: array
items:
$ref: '#/components/schemas/ActorImage'
banners:
type: array
items:
$ref: '#/components/schemas/ActorImage'
search: search:
type: object type: object
properties: properties:
@ -8613,6 +8657,36 @@ components:
type: string type: string
terms: terms:
type: string type: string
codeOfConduct:
type: string
hardwareInformation:
type: string
creationReason:
type: string
moderationInformation:
type: string
administrator:
type: string
maintenanceLifetime:
type: string
businessModel:
type: string
languages:
type: array
items:
type: string
categories:
type: array
items:
type: integer
avatars:
type: array
items:
$ref: '#/components/schemas/ActorImage'
banners:
type: array
items:
$ref: '#/components/schemas/ActorImage'
ServerConfigCustom: ServerConfigCustom:
properties: properties:
instance: instance: