Add ability to set a banner to the instance

This commit is contained in:
Chocobozzz 2024-02-20 11:33:01 +01:00
parent 1c0270ca8a
commit 7ee0efb57a
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
39 changed files with 686 additions and 252 deletions

View File

@ -1,28 +1,30 @@
<div class="row"> <div class="margin-content mt-4">
<h1 class="visually-hidden" i18n>Follows</h1> <div class="row">
<h1 class="visually-hidden" i18n>Follows</h1>
<div class="col-xl-6 col-md-12"> <div class="col-xl-6 col-md-12">
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Followers of {{ instanceName }} ({{ followersPagination.totalItems }})</h2> <h2 i18n class="fs-5-5 mb-4 fw-semibold">Followers of {{ instanceName }} ({{ followersPagination.totalItems }})</h2>
<div i18n class="no-results" *ngIf="followersPagination.totalItems === 0">{{ instanceName }} does not have followers.</div> <div i18n class="no-results" *ngIf="followersPagination.totalItems === 0">{{ instanceName }} does not have followers.</div>
<a *ngFor="let follower of followers" [href]="follower.url" target="_blank" rel="noopener noreferrer"> <a *ngFor="let follower of followers" [href]="follower.url" target="_blank" rel="noopener noreferrer">
{{ follower.name }} {{ follower.name }}
</a> </a>
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button>
</div>
<div class="col-xl-6 col-md-12">
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Subscriptions of {{ instanceName }} ({{ followingsPagination.totalItems }})</h2>
<div i18n class="no-results" *ngIf="followingsPagination.totalItems === 0">{{ instanceName }} does not have subscriptions.</div>
<a *ngFor="let following of followings" [href]="following.url" target="_blank" rel="noopener noreferrer">
{{ following.name }}
</a>
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowings && canLoadMoreFollowings()" (click)="loadAllFollowings()">Show full list</button>
</div>
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button>
</div> </div>
<div class="col-xl-6 col-md-12">
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Subscriptions of {{ instanceName }} ({{ followingsPagination.totalItems }})</h2>
<div i18n class="no-results" *ngIf="followingsPagination.totalItems === 0">{{ instanceName }} does not have subscriptions.</div>
<a *ngFor="let following of followings" [href]="following.url" target="_blank" rel="noopener noreferrer">
{{ following.name }}
</a>
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowings && canLoadMoreFollowings()" (click)="loadAllFollowings()">Show full list</button>
</div>
</div> </div>

View File

@ -1,226 +1,232 @@
<div class="row"> <div class="banner" *ngIf="instanceBannerUrl">
<div class="col-md-12 col-xl-6"> <img [src]="instanceBannerUrl" alt="Instance banner">
</div>
<div class="d-flex justify-content-between"> <div class="margin-content mt-4">
<h1 i18n class="fw-semibold fs-5">About {{ instanceName }}</h1> <div class="row ">
<div class="col-md-12 col-xl-6">
<a routerLink="/about/contact" i18n *ngIf="isContactFormEnabled" class="peertube-button-link orange-button h-100 d-flex align-items-center">Contact us</a> <div class="d-flex justify-content-between">
</div> <h1 i18n class="fw-semibold fs-5">About {{ instanceName }}</h1>
<div class="mb-4" *ngIf="categories.length !== 0 || languages.length !== 0"> <a routerLink="/about/contact" i18n *ngIf="isContactFormEnabled" class="peertube-button-link orange-button h-100 d-flex align-items-center">Contact us</a>
<span *ngFor="let category of categories" class="pt-badge badge-primary">{{ category }}</span> </div>
<span *ngFor="let language of languages" class="pt-badge badge-secondary">{{ language }}</span> <div class="mb-4" *ngIf="categories.length !== 0 || languages.length !== 0">
</div> <span *ngFor="let category of categories" class="pt-badge badge-primary">{{ category }}</span>
<div class="mt-2"> <span *ngFor="let language of languages" class="pt-badge badge-secondary">{{ language }}</span>
<div class="block">{{ shortDescription }}</div> </div>
<div i18n *ngIf="isNSFW" class="block mt-4 fw-semibold">This instance is dedicated to sensitive/NSFW content.</div> <div class="mt-2">
</div> <div class="block">{{ shortDescription }}</div>
<div class="anchor" id="administrators-and-sustainability"></div> <div i18n *ngIf="isNSFW" class="block mt-4 fw-semibold">This instance is dedicated to sensitive/NSFW content.</div>
<a </div>
*ngIf="aboutHTML.administrator || aboutHTML.maintenanceLifetime || aboutHTML.businessModel"
class="anchor-link"
routerLink="/about/instance"
fragment="administrators-and-sustainability"
#anchorLink
(click)="onClickCopyLink(anchorLink)"
>
<h2 i18n class="middle-title">
ADMINISTRATORS & SUSTAINABILITY
</h2>
</a>
<div class="block administrator" *ngIf="aboutHTML.administrator"> <div class="anchor" id="administrators-and-sustainability"></div>
<div class="anchor" id="administrators"></div>
<a <a
*ngIf="aboutHTML.administrator || aboutHTML.maintenanceLifetime || aboutHTML.businessModel"
class="anchor-link" class="anchor-link"
routerLink="/about/instance" routerLink="/about/instance"
fragment="administrators" fragment="administrators-and-sustainability"
#anchorLink #anchorLink
(click)="onClickCopyLink(anchorLink)"> (click)="onClickCopyLink(anchorLink)"
<h3 i18n class="section-title">Who we are</h3> >
</a>
<div [innerHTML]="aboutHTML.administrator"></div>
</div>
<div class="block creation-reason" *ngIf="aboutHTML.creationReason">
<div class="anchor" id="creation-reason"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="creation-reason"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Why we created this instance</h3>
</a>
<div [innerHTML]="aboutHTML.creationReason"></div>
</div>
<div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
<div class="anchor" id="maintenance-lifetime"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="maintenance-lifetime"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">How long we plan to maintain this instance</h3>
</a>
<div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
</div>
<div class="block business-model" *ngIf="aboutHTML.businessModel">
<div class="anchor" id="business-model"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="business-model"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">How we will pay for keeping our instance running</h3>
</a>
<div [innerHTML]="aboutHTML.businessModel"></div>
</div>
<div class="anchor" id="information"></div>
<a
*ngIf="descriptionElement"
class="anchor-link"
routerLink="/about/instance"
fragment="information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">
INFORMATION
</h2>
</a>
<div class="block description">
<div class="anchor" id="description"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="description"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Description</h3>
</a>
<my-custom-markup-container [content]="descriptionElement"></my-custom-markup-container>
</div>
<div myPluginSelector pluginSelectorId="about-instance-moderation">
<div class="anchor" id="moderation"></div>
<a
*ngIf="aboutHTML.moderationInformation || aboutHTML.codeOfConduct || aboutHTML.terms"
class="anchor-link"
routerLink="/about/instance"
fragment="moderation"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title"> <h2 i18n class="middle-title">
MODERATION ADMINISTRATORS & SUSTAINABILITY
</h2> </h2>
</a> </a>
<div class="block moderation-information" *ngIf="aboutHTML.moderationInformation"> <div class="block administrator" *ngIf="aboutHTML.administrator">
<div class="anchor" id="moderation-information"></div> <div class="anchor" id="administrators"></div>
<a <a
class="anchor-link" class="anchor-link"
routerLink="/about/instance" routerLink="/about/instance"
fragment="moderation-information" fragment="administrators"
#anchorLink #anchorLink
(click)="onClickCopyLink(anchorLink)"> (click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Moderation information</h3> <h3 i18n class="section-title">Who we are</h3>
</a> </a>
<div [innerHTML]="aboutHTML.moderationInformation"></div> <div [innerHTML]="aboutHTML.administrator"></div>
</div> </div>
<div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct"> <div class="block creation-reason" *ngIf="aboutHTML.creationReason">
<div class="anchor" id="code-of-conduct"></div> <div class="anchor" id="creation-reason"></div>
<a <a
class="anchor-link" class="anchor-link"
routerLink="/about/instance" routerLink="/about/instance"
fragment="code-of-conduct" fragment="creation-reason"
#anchorLink #anchorLink
(click)="onClickCopyLink(anchorLink)"> (click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Code of conduct</h3> <h3 i18n class="section-title">Why we created this instance</h3>
</a> </a>
<div [innerHTML]="aboutHTML.codeOfConduct"></div> <div [innerHTML]="aboutHTML.creationReason"></div>
</div> </div>
<div class="block terms"> <div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
<div class="anchor" id="terms"></div> <div class="anchor" id="maintenance-lifetime"></div>
<a <a
class="anchor-link" class="anchor-link"
routerLink="/about/instance" routerLink="/about/instance"
fragment="terms" fragment="maintenance-lifetime"
#anchorLink #anchorLink
(click)="onClickCopyLink(anchorLink)"> (click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Terms</h3> <h3 i18n class="section-title">How long we plan to maintain this instance</h3>
</a> </a>
<div [innerHTML]="aboutHTML.terms"></div> <div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
</div> </div>
</div>
<div myPluginSelector pluginSelectorId="about-instance-other-information"> <div class="block business-model" *ngIf="aboutHTML.businessModel">
<div class="anchor" id="other-information"></div> <div class="anchor" id="business-model"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="business-model"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">How we will pay for keeping our instance running</h3>
</a>
<div [innerHTML]="aboutHTML.businessModel"></div>
</div>
<div class="anchor" id="information"></div>
<a <a
*ngIf="aboutHTML.hardwareInformation" *ngIf="descriptionElement"
class="anchor-link" class="anchor-link"
routerLink="/about/instance" routerLink="/about/instance"
fragment="other-information" fragment="information"
#anchorLink #anchorLink
(click)="onClickCopyLink(anchorLink)"> (click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title"> <h2 i18n class="middle-title">
OTHER INFORMATION INFORMATION
</h2> </h2>
</a> </a>
<div class="block hardware-information" *ngIf="aboutHTML.hardwareInformation"> <div class="block description">
<div class="anchor" id="hardware-information"></div> <div class="anchor" id="description"></div>
<a <a
class="anchor-link" class="anchor-link"
routerLink="/about/instance" routerLink="/about/instance"
fragment="hardware-information" fragment="description"
#anchorLink #anchorLink
(click)="onClickCopyLink(anchorLink)"> (click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Hardware information</h3> <h3 i18n class="section-title">Description</h3>
</a> </a>
<div [innerHTML]="aboutHTML.hardwareInformation"></div> <my-custom-markup-container [content]="descriptionElement"></my-custom-markup-container>
</div>
<div myPluginSelector pluginSelectorId="about-instance-moderation">
<div class="anchor" id="moderation"></div>
<a
*ngIf="aboutHTML.moderationInformation || aboutHTML.codeOfConduct || aboutHTML.terms"
class="anchor-link"
routerLink="/about/instance"
fragment="moderation"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">
MODERATION
</h2>
</a>
<div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
<div class="anchor" id="moderation-information"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="moderation-information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Moderation information</h3>
</a>
<div [innerHTML]="aboutHTML.moderationInformation"></div>
</div>
<div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
<div class="anchor" id="code-of-conduct"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="code-of-conduct"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Code of conduct</h3>
</a>
<div [innerHTML]="aboutHTML.codeOfConduct"></div>
</div>
<div class="block terms">
<div class="anchor" id="terms"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="terms"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Terms</h3>
</a>
<div [innerHTML]="aboutHTML.terms"></div>
</div>
</div>
<div myPluginSelector pluginSelectorId="about-instance-other-information">
<div class="anchor" id="other-information"></div>
<a
*ngIf="aboutHTML.hardwareInformation"
class="anchor-link"
routerLink="/about/instance"
fragment="other-information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">
OTHER INFORMATION
</h2>
</a>
<div class="block hardware-information" *ngIf="aboutHTML.hardwareInformation">
<div class="anchor" id="hardware-information"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="hardware-information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Hardware information</h3>
</a>
<div [innerHTML]="aboutHTML.hardwareInformation"></div>
</div>
</div> </div>
</div> </div>
</div>
<div class="col-md-12 col-xl-6" myPluginSelector pluginSelectorId="about-instance-features"> <div class="col-md-12 col-xl-6" myPluginSelector pluginSelectorId="about-instance-features">
<h2 class="visually-hidden" i18n>FEATURES</h2> <h2 class="visually-hidden" i18n>FEATURES</h2>
<my-instance-features-table></my-instance-features-table> <my-instance-features-table></my-instance-features-table>
</div> </div>
<div class="col" myPluginSelector pluginSelectorId="about-instance-statistics"> <div class="col" myPluginSelector pluginSelectorId="about-instance-statistics">
<div class="anchor" id="statistics"></div> <div class="anchor" id="statistics"></div>
<a <a
class="anchor-link" class="anchor-link"
routerLink="/about/instance" routerLink="/about/instance"
fragment="statistics" fragment="statistics"
#anchorLink #anchorLink
(click)="onClickCopyLink(anchorLink)"> (click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">STATISTICS</h2> <h2 i18n class="middle-title">STATISTICS</h2>
</a> </a>
<my-instance-statistics [serverStats]="serverStats"></my-instance-statistics> <my-instance-statistics [serverStats]="serverStats"></my-instance-statistics>
</div>
</div> </div>
</div> </div>

View File

@ -20,6 +20,8 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
aboutHTML: AboutHTML aboutHTML: AboutHTML
descriptionElement: HTMLDivElement descriptionElement: HTMLDivElement
instanceBannerUrl: string
languages: string[] = [] languages: string[] = []
categories: string[] = [] categories: string[] = []
shortDescription = '' shortDescription = ''
@ -64,6 +66,10 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
this.shortDescription = about.instance.shortDescription this.shortDescription = about.instance.shortDescription
this.instanceBannerUrl = about.instance.banners.length !== 0
? about.instance.banners[0].path
: undefined
this.serverConfig = this.serverService.getHTMLConfig() this.serverConfig = this.serverService.getHTMLConfig()
this.route.data.subscribe(data => { this.route.data.subscribe(data => {

View File

@ -1,4 +1,4 @@
<div class="root"> <div class="margin-content mt-4">
<h1 i18n class="fs-3 text-center fw-semibold mb-3"> <h1 i18n class="fs-3 text-center fw-semibold mb-3">
This website is powered by PeerTube This website is powered by PeerTube
</h1> </h1>

View File

@ -1,9 +1,10 @@
@use '_variables' as *; @use '_variables' as *;
@use '_mixins' as *; @use '_mixins' as *;
.root { .margin-content {
max-width: 1200px; max-width: 1200px;
margin: auto; margin-inline-start: auto;
margin-inline-end: auto;
} }
.card { .card {

View File

@ -1,5 +1,5 @@
<div> <div>
<div class="sub-menu" [ngClass]="{ 'sub-menu-fixed': !isBroadcastMessageDisplayed }"> <div class="sub-menu mb-0" [ngClass]="{ 'sub-menu-fixed': !isBroadcastMessageDisplayed }">
<a myPluginSelector pluginSelectorId="about-menu-instance" i18n routerLink="instance" routerLinkActive="active" class="sub-menu-entry">Instance</a> <a myPluginSelector pluginSelectorId="about-menu-instance" i18n routerLink="instance" routerLinkActive="active" class="sub-menu-entry">Instance</a>
<a myPluginSelector pluginSelectorId="about-menu-peertube" i18n routerLink="peertube" routerLinkActive="active" class="sub-menu-entry">PeerTube</a> <a myPluginSelector pluginSelectorId="about-menu-peertube" i18n routerLink="peertube" routerLinkActive="active" class="sub-menu-entry">PeerTube</a>
@ -7,7 +7,7 @@
<a myPluginSelector pluginSelectorId="about-menu-network" i18n routerLink="follows" routerLinkActive="active" class="sub-menu-entry">Network</a> <a myPluginSelector pluginSelectorId="about-menu-network" i18n routerLink="follows" routerLinkActive="active" class="sub-menu-entry">Network</a>
</div> </div>
<div class="margin-content" [ngClass]="{ 'offset-content': !isBroadcastMessageDisplayed }"> <div [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
<div class="root"> <div class="root">
<my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown> <my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown>
<div class="margin-content" [ngClass]="{ 'offset-content': !isBroadcastMessageDisplayed }"> <div class="margin-content" [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
@use '_mixins' as *; @use '_mixins' as *;
$form-base-input-width: 340px; $form-base-input-width: 340px;
$form-max-width: 500px;
form { form {
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
@ -9,7 +10,7 @@ form {
my-markdown-textarea { my-markdown-textarea {
display: block; display: block;
max-width: 500px; max-width: $form-max-width;
} }
.homepage my-markdown-textarea { .homepage my-markdown-textarea {
@ -156,3 +157,7 @@ my-user-real-quota-info {
margin-top: 5px; margin-top: 5px;
font-size: 11px; font-size: 11px;
} }
my-actor-banner-edit {
max-width: $form-max-width;
}

View File

@ -8,6 +8,11 @@
</div> </div>
<div class="col-12 col-lg-8 col-xl-9"> <div class="col-12 col-lg-8 col-xl-9">
<my-actor-banner-edit
[previewImage]="false" class="d-block mb-4"
[bannerUrl]="instanceBannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
></my-actor-banner-edit>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceName">Name</label> <label i18n for="instanceName">Name</label>

View File

@ -1,25 +1,80 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model' import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input } 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 { ConfigService } from '../shared/config.service'
import { Notifier } from '@app/core'
import { HttpErrorResponse } from '@angular/common/http'
import { genericUploadErrorHandler } from '@app/helpers'
import { InstanceService } from '@app/shared/shared-instance'
@Component({ @Component({
selector: 'my-edit-instance-information', selector: 'my-edit-instance-information',
templateUrl: './edit-instance-information.component.html', templateUrl: './edit-instance-information.component.html',
styleUrls: [ './edit-custom-config.component.scss' ] styleUrls: [ './edit-custom-config.component.scss' ]
}) })
export class EditInstanceInformationComponent { export class EditInstanceInformationComponent implements OnInit {
@Input() form: FormGroup @Input() form: FormGroup
@Input() formErrors: any @Input() formErrors: any
@Input() languageItems: SelectOptionsItem[] = [] @Input() languageItems: SelectOptionsItem[] = []
@Input() categoryItems: SelectOptionsItem[] = [] @Input() categoryItems: SelectOptionsItem[] = []
constructor (private customMarkup: CustomMarkupService) { instanceBannerUrl: string
constructor (
private customMarkup: CustomMarkupService,
private configService: ConfigService,
private notifier: Notifier,
private instance: InstanceService
) {
}
ngOnInit () {
this.resetBannerUrl()
} }
getCustomMarkdownRenderer () { getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer() return this.customMarkup.getCustomMarkdownRenderer()
} }
onBannerChange (formData: FormData) {
this.configService.updateInstanceBanner(formData)
.subscribe({
next: data => {
this.notifier.success($localize`Banner changed.`)
this.resetBannerUrl()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier })
})
}
onBannerDelete () {
this.configService.deleteInstanceBanner()
.subscribe({
next: () => {
this.notifier.success($localize`Banner deleted.`)
this.resetBannerUrl()
},
error: err => this.notifier.error(err.message)
})
}
private resetBannerUrl () {
this.instance.getAbout()
.subscribe(about => {
const banners = about.instance.banners
if (banners.length === 0) {
this.instanceBannerUrl = undefined
return
}
this.instanceBannerUrl = banners[0].path
})
}
} }

View File

@ -67,4 +67,20 @@ export class ConfigService {
return this.authHttp.put<CustomConfig>(ConfigService.BASE_APPLICATION_URL + '/custom', data) return this.authHttp.put<CustomConfig>(ConfigService.BASE_APPLICATION_URL + '/custom', data)
.pipe(catchError(res => this.restExtractor.handleError(res))) .pipe(catchError(res => this.restExtractor.handleError(res)))
} }
// ---------------------------------------------------------------------------
updateInstanceBanner (formData: FormData) {
const url = ConfigService.BASE_APPLICATION_URL + '/instance-banner/pick'
return this.authHttp.post(url, formData)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
deleteInstanceBanner () {
const url = ConfigService.BASE_APPLICATION_URL + '/instance-banner'
return this.authHttp.delete(url)
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
} }

View File

@ -1,4 +1,4 @@
<div class="root pt-4 margin-content"> <div class="margin-content">
<my-custom-markup-container [content]="homepageContent"></my-custom-markup-container> <my-custom-markup-container [content]="homepageContent"></my-custom-markup-container>
</div> </div>

View File

@ -0,0 +1,10 @@
@use '_variables' as *;
@use '_mixins' as *;
.margin-content {
padding-top: 2rem;
::ng-deep .revert-home-padding-top {
margin-top: -2rem;
}
}

View File

@ -2,7 +2,8 @@ import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { CustomPageService } from '@app/shared/shared-main/custom-page' import { CustomPageService } from '@app/shared/shared-main/custom-page'
@Component({ @Component({
templateUrl: './home.component.html' templateUrl: './home.component.html',
styleUrls: [ './home.component.scss' ]
}) })
export class HomeComponent implements OnInit { export class HomeComponent implements OnInit {

View File

@ -12,7 +12,7 @@
<div class="col-12 col-lg-8 col-xl-9"> <div class="col-12 col-lg-8 col-xl-9">
<my-actor-banner-edit <my-actor-banner-edit
*ngIf="videoChannel" [previewImage]="isCreation()" class="d-block mb-4" *ngIf="videoChannel" [previewImage]="isCreation()" class="d-block mb-4"
[actor]="videoChannel" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()" [bannerUrl]="videoChannel?.bannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
></my-actor-banner-edit> ></my-actor-banner-edit>
<my-actor-avatar-edit <my-actor-avatar-edit

View File

@ -178,14 +178,6 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
}) })
} }
get maxAvatarSize () {
return this.serverConfig.avatar.file.size.max
}
get avatarExtensions () {
return this.serverConfig.avatar.file.extensions.join(',')
}
isCreation () { isCreation () {
return false return false
} }

View File

@ -1,7 +1,7 @@
<div class="root"> <div class="root">
<my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown> <my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown>
<div class="margin-content pb-5" [ngClass]="{ 'offset-content': !isBroadcastMessageDisplayed }"> <div class="margin-content pb-5" [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>
</div> </div>

View File

@ -1,7 +1,7 @@
<div class="root"> <div class="root">
<my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown> <my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown>
<div class="margin-content pb-5" [ngClass]="{ 'offset-content': !isBroadcastMessageDisplayed }"> <div class="margin-content pb-5" [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>
</div> </div>

View File

@ -11,10 +11,6 @@
--myGreyOwnerFontSize: 14px; --myGreyOwnerFontSize: 14px;
} }
.banner {
@include block-ratio('img', $banner-inverted-ratio);
}
.section-label { .section-label {
@include section-label-responsive; @include section-label-responsive;
} }

View File

@ -1,7 +1,7 @@
<div class="actor" *ngIf="actor"> <div class="actor">
<div class="actor-img-edit-container"> <div class="actor-img-edit-container">
<div class="banner-placeholder"> <div class="banner-placeholder">
<img *ngIf="hasBanner()" [src]="preview || actor.bannerUrl" alt="Banner" /> <img *ngIf="hasBanner()" [src]="preview || bannerUrl" alt="Banner" />
</div> </div>
<div *ngIf="!hasBanner()" class="actor-img-edit-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body"> <div *ngIf="!hasBanner()" class="actor-img-edit-button button-focus-within" [ngbTooltip]="bannerFormat" placement="right" container="body">

View File

@ -1,7 +1,6 @@
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
import { SafeResourceUrl } from '@angular/platform-browser' import { SafeResourceUrl } from '@angular/platform-browser'
import { Notifier, ServerService } from '@app/core' import { Notifier, ServerService } from '@app/core'
import { VideoChannel } from '@app/shared/shared-main'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { getBytes } from '@root-helpers/bytes' import { getBytes } from '@root-helpers/bytes'
import { imageToDataURL } from '@root-helpers/images' import { imageToDataURL } from '@root-helpers/images'
@ -18,7 +17,7 @@ export class ActorBannerEditComponent implements OnInit {
@ViewChild('bannerfileInput') bannerfileInput: ElementRef<HTMLInputElement> @ViewChild('bannerfileInput') bannerfileInput: ElementRef<HTMLInputElement>
@ViewChild('bannerPopover') bannerPopover: NgbPopover @ViewChild('bannerPopover') bannerPopover: NgbPopover
@Input() actor: VideoChannel @Input() bannerUrl: string
@Input() previewImage = false @Input() previewImage = false
@Output() bannerChange = new EventEmitter<FormData>() @Output() bannerChange = new EventEmitter<FormData>()
@ -69,6 +68,6 @@ export class ActorBannerEditComponent implements OnInit {
} }
hasBanner () { hasBanner () {
return !!this.preview || !!this.actor.bannerUrl return !!this.preview || !!this.bannerUrl
} }
} }

View File

@ -7,6 +7,7 @@ import {
ChannelMiniatureMarkupData, ChannelMiniatureMarkupData,
ContainerMarkupData, ContainerMarkupData,
EmbedMarkupData, EmbedMarkupData,
InstanceBannerMarkupData,
PlaylistMiniatureMarkupData, PlaylistMiniatureMarkupData,
VideoMiniatureMarkupData, VideoMiniatureMarkupData,
VideosListMarkupData VideosListMarkupData
@ -16,6 +17,7 @@ import {
ButtonMarkupComponent, ButtonMarkupComponent,
ChannelMiniatureMarkupComponent, ChannelMiniatureMarkupComponent,
EmbedMarkupComponent, EmbedMarkupComponent,
InstanceBannerMarkupComponent,
PlaylistMiniatureMarkupComponent, PlaylistMiniatureMarkupComponent,
VideoMiniatureMarkupComponent, VideoMiniatureMarkupComponent,
VideosListMarkupComponent VideosListMarkupComponent
@ -28,6 +30,7 @@ type HTMLBuilderFunction = (el: HTMLElement) => HTMLElement
@Injectable() @Injectable()
export class CustomMarkupService { export class CustomMarkupService {
private angularBuilders: { [ selector: string ]: AngularBuilderFunction } = { private angularBuilders: { [ selector: string ]: AngularBuilderFunction } = {
'peertube-instance-banner': el => this.instanceBannerBuilder(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'),
@ -160,6 +163,19 @@ export class CustomMarkupService {
return component return component
} }
private instanceBannerBuilder (el: HTMLElement) {
const data = el.dataset as InstanceBannerMarkupData
const component = this.dynamicElementService.createElement(InstanceBannerMarkupComponent)
const model = {
revertHomePaddingTop: this.buildBoolean(data.revertHomePaddingTop) ?? true
}
this.dynamicElementService.setModel(component, model)
return component
}
private videoMiniatureBuilder (el: HTMLElement) { private videoMiniatureBuilder (el: HTMLElement) {
const data = el.dataset as VideoMiniatureMarkupData const data = el.dataset as VideoMiniatureMarkupData
const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent) const component = 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-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'
export * from './videos-list-markup.component' export * from './videos-list-markup.component'

View File

@ -0,0 +1,3 @@
<div class="banner revert-margin-content" [ngClass]="{ 'revert-home-padding-top': revertHomePaddingTop }" *ngIf="instanceBannerUrl">
<img [src]="instanceBannerUrl" alt="Instance banner">
</div>

View File

@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
import { CustomMarkupComponent } from './shared'
import { InstanceService } from '@app/shared/shared-instance'
import { finalize } from 'rxjs'
/*
* Markup component that creates the img HTML element containing the instance banner
*/
@Component({
selector: 'my-instance-banner-markup',
templateUrl: 'instance-banner-markup.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class InstanceBannerMarkupComponent implements OnInit, CustomMarkupComponent {
@Input() revertHomePaddingTop: boolean
@Output() loaded = new EventEmitter<boolean>()
instanceBannerUrl: string
constructor (
private cd: ChangeDetectorRef,
private instance: InstanceService
) {}
ngOnInit () {
this.instance.getAbout()
.pipe(finalize(() => this.loaded.emit(true)))
.subscribe(about => {
if (about.instance.banners.length === 0) return
this.instanceBannerUrl = about.instance.banners[0].path
this.cd.markForCheck()
})
}
}

View File

@ -14,6 +14,7 @@ import {
ButtonMarkupComponent, ButtonMarkupComponent,
ChannelMiniatureMarkupComponent, ChannelMiniatureMarkupComponent,
EmbedMarkupComponent, EmbedMarkupComponent,
InstanceBannerMarkupComponent,
PlaylistMiniatureMarkupComponent, PlaylistMiniatureMarkupComponent,
VideoMiniatureMarkupComponent, VideoMiniatureMarkupComponent,
VideosListMarkupComponent VideosListMarkupComponent
@ -39,7 +40,8 @@ import {
VideosListMarkupComponent, VideosListMarkupComponent,
ButtonMarkupComponent, ButtonMarkupComponent,
CustomMarkupHelpComponent, CustomMarkupHelpComponent,
CustomMarkupContainerComponent CustomMarkupContainerComponent,
InstanceBannerMarkupComponent
], ],
exports: [ exports: [
@ -50,7 +52,8 @@ import {
EmbedMarkupComponent, EmbedMarkupComponent,
ButtonMarkupComponent, ButtonMarkupComponent,
CustomMarkupHelpComponent, CustomMarkupHelpComponent,
CustomMarkupContainerComponent CustomMarkupContainerComponent,
InstanceBannerMarkupComponent
], ],
providers: [ providers: [

View File

@ -140,7 +140,8 @@ code {
} }
.main-col { .main-col {
@include margin-left($menu-width); // Don't use rfs to get exact pixels
margin-inline-start: $menu-width;
width: calc(100% - #{$menu-width}); width: calc(100% - #{$menu-width});
outline: none; outline: none;
@ -150,6 +151,10 @@ code {
flex-grow: 1; flex-grow: 1;
} }
.revert-margin-content {
margin: 0 calc(#{pvar(--horizontalMarginContent)} * -1);
}
.sub-menu { .sub-menu {
background-color: pvar(--submenuBackgroundColor); background-color: pvar(--submenuBackgroundColor);
width: 100%; width: 100%;
@ -168,10 +173,14 @@ code {
} }
// Use an appropriate offset top when sub-menu fixed // Use an appropriate offset top when sub-menu fixed
.margin-content.offset-content { .sub-menu-offset-content {
padding-top: $sub-menu-height + $sub-menu-margin-bottom; padding-top: $sub-menu-height + $sub-menu-margin-bottom;
} }
.sub-menu.mb-0 + .sub-menu-offset-content {
padding-top: $sub-menu-height;
}
// Override some properties if the main content is expanded (no menu on the left) // Override some properties if the main content is expanded (no menu on the left)
&.expanded { &.expanded {
--horizontalMarginContent: #{$expanded-horizontal-margins}; --horizontalMarginContent: #{$expanded-horizontal-margins};
@ -271,7 +280,7 @@ my-global-icon[iconName=external-link] {
} }
// Use an appropriate offset top when sub-menu fixed // Use an appropriate offset top when sub-menu fixed
.margin-content.offset-content { .sub-menu-offset-content {
padding-top: $sub-menu-height + $sub-menu-margin-bottom-small-view; padding-top: $sub-menu-height + $sub-menu-margin-bottom-small-view;
} }

View File

@ -79,7 +79,7 @@
top: #{- ($header-height + 20px)}; top: #{- ($header-height + 20px)};
} }
.offset-content { .sub-menu-offset-content {
// if sub-menu fixed // if sub-menu fixed
.anchor { .anchor {

View File

@ -0,0 +1,10 @@
@use '_variables' as *;
@use '_mixins' as *;
.banner {
@include block-ratio('img', $banner-inverted-ratio);
}
.revert-margin-content.banner {
width: calc(100% + 2 * #{pvar(--horizontalMarginContent)});
}

View File

@ -2,5 +2,6 @@
@use './_common'; @use './_common';
@use './_custom-bootstrap-helpers'; @use './_custom-bootstrap-helpers';
@use './_forms'; @use './_forms';
@use './images';
@use './_menu'; @use './_menu';
@use './_text'; @use './_text';

View File

@ -1,3 +1,5 @@
type StringBoolean = 'true' | 'false'
export type EmbedMarkupData = { export type EmbedMarkupData = {
// Video or playlist uuid // Video or playlist uuid
uuid: string uuid: string
@ -7,7 +9,7 @@ export type VideoMiniatureMarkupData = {
// Video uuid // Video uuid
uuid: string uuid: string
onlyDisplayTitle?: string // boolean onlyDisplayTitle?: StringBoolean
} }
export type PlaylistMiniatureMarkupData = { export type PlaylistMiniatureMarkupData = {
@ -19,12 +21,12 @@ export type ChannelMiniatureMarkupData = {
// Channel name (username) // Channel name (username)
name: string name: string
displayLatestVideo?: string // boolean displayLatestVideo?: StringBoolean
displayDescription?: string // boolean displayDescription?: StringBoolean
} }
export type VideosListMarkupData = { export type VideosListMarkupData = {
onlyDisplayTitle?: string // boolean onlyDisplayTitle?: StringBoolean
maxRows?: string // number maxRows?: string // number
sort?: string sort?: string
@ -38,14 +40,14 @@ export type VideosListMarkupData = {
isLive?: string // number isLive?: string // number
onlyLocal?: string // boolean onlyLocal?: StringBoolean
} }
export type ButtonMarkupData = { export type ButtonMarkupData = {
theme: 'primary' | 'secondary' theme: 'primary' | 'secondary'
href: string href: string
label: string label: string
blankTarget?: string // boolean blankTarget?: StringBoolean
} }
export type ContainerMarkupData = { export type ContainerMarkupData = {
@ -56,3 +58,7 @@ export type ContainerMarkupData = {
justifyContent?: 'space-between' | 'normal' // default to 'space-between' justifyContent?: 'space-between' | 'normal' // default to 'space-between'
} }
export type InstanceBannerMarkupData = {
revertHomePaddingTop?: StringBoolean // default to 'true'
}

View File

@ -1,3 +1,5 @@
import { ActorImage } from '../index.js'
export interface About { export interface About {
instance: { instance: {
name: string name: string
@ -16,5 +18,7 @@ export interface About {
languages: string[] languages: string[]
categories: number[] categories: number[]
banners: ActorImage[]
} }
} }

View File

@ -295,6 +295,42 @@ export class ConfigCommand extends AbstractCommand {
}) })
} }
// ---------------------------------------------------------------------------
updateInstanceBanner (options: OverrideCommandOptions & {
fixture: string
}) {
const { fixture } = options
const path = `/api/v1/config/instance-banner/pick`
return this.updateImageRequest({
...options,
path,
fixture,
fieldname: 'bannerfile',
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
})
}
deleteInstanceBanner (options: OverrideCommandOptions = {}) {
const path = `/api/v1/config/instance-banner`
return this.deleteRequest({
...options,
path,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
})
}
// ---------------------------------------------------------------------------
getCustomConfig (options: OverrideCommandOptions = {}) { getCustomConfig (options: OverrideCommandOptions = {}) {
const path = '/api/v1/config/custom' const path = '/api/v1/config/custom'

View File

@ -8,9 +8,11 @@ import {
makeDeleteRequest, makeDeleteRequest,
makeGetRequest, makeGetRequest,
makePutBodyRequest, makePutBodyRequest,
makeUploadRequest,
PeerTubeServer, PeerTubeServer,
setAccessTokensToServers setAccessTokensToServers
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
describe('Test config API validators', function () { describe('Test config API validators', function () {
const path = '/api/v1/config/custom' const path = '/api/v1/config/custom'
@ -427,6 +429,82 @@ describe('Test config API validators', function () {
}) })
}) })
describe('Updating instance banner', function () {
const path = '/api/v1/config/instance-banner/pick'
it('Should fail with an incorrect input file', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('video_short.mp4') }
await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields: {}, attaches })
})
it('Should fail with a big file', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar-big.png') }
await makeUploadRequest({
url: server.url,
path,
token: server.accessToken,
fields: {},
attaches,
expectedStatus: HttpStatusCode.BAD_REQUEST_400
})
})
it('Should fail without token', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar.png') }
await makeUploadRequest({
url: server.url,
path,
fields: {},
attaches,
expectedStatus: HttpStatusCode.UNAUTHORIZED_401
})
})
it('Should fail without the appropriate rights', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar.png') }
await makeUploadRequest({
url: server.url,
path,
token: userAccessToken,
fields: {},
attaches,
expectedStatus: HttpStatusCode.FORBIDDEN_403
})
})
it('Should succeed with the correct params', async function () {
const attaches = { bannerfile: buildAbsoluteFixturePath('avatar.png') }
await makeUploadRequest({
url: server.url,
path,
token: server.accessToken,
fields: {},
attaches,
expectedStatus: HttpStatusCode.NO_CONTENT_204
})
})
})
describe('Deleting instance banner', function () {
it('Should fail without token', async function () {
await server.config.deleteInstanceBanner({ token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail without the appropriate rights', async function () {
await server.config.deleteInstanceBanner({ token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should succeed with the correct params', async function () {
await server.config.deleteInstanceBanner()
})
})
after(async function () { after(async function () {
await cleanupTests([ server ]) await cleanupTests([ server ])
}) })

View File

@ -11,6 +11,8 @@ import {
PeerTubeServer, PeerTubeServer,
setAccessTokensToServers setAccessTokensToServers
} from '@peertube/peertube-server-commands' } from '@peertube/peertube-server-commands'
import { testFileExistsOrNot, testImage } from '@tests/shared/checks.js'
import { basename } from 'path'
function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) { function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.instance.name).to.equal('PeerTube') expect(data.instance.name).to.equal('PeerTube')
@ -496,7 +498,8 @@ describe('Test static config', function () {
}) })
describe('Test config', function () { describe('Test config', function () {
let server: PeerTubeServer = null let server: PeerTubeServer
let bannerPath: string
before(async function () { before(async function () {
this.timeout(30000) this.timeout(30000)
@ -595,23 +598,47 @@ describe('Test config', function () {
}) })
it('Should fetch the about information', async function () { it('Should fetch the about information', async function () {
const data = await server.config.getAbout() const { instance } = await server.config.getAbout()
expect(data.instance.name).to.equal('PeerTube updated') expect(instance.name).to.equal('PeerTube updated')
expect(data.instance.shortDescription).to.equal('my short description') expect(instance.shortDescription).to.equal('my short description')
expect(data.instance.description).to.equal('my super description') expect(instance.description).to.equal('my super description')
expect(data.instance.terms).to.equal('my super terms') expect(instance.terms).to.equal('my super terms')
expect(data.instance.codeOfConduct).to.equal('my super coc') expect(instance.codeOfConduct).to.equal('my super coc')
expect(data.instance.creationReason).to.equal('my super creation reason') expect(instance.creationReason).to.equal('my super creation reason')
expect(data.instance.moderationInformation).to.equal('my super moderation information') expect(instance.moderationInformation).to.equal('my super moderation information')
expect(data.instance.administrator).to.equal('Kuja') expect(instance.administrator).to.equal('Kuja')
expect(data.instance.maintenanceLifetime).to.equal('forever') expect(instance.maintenanceLifetime).to.equal('forever')
expect(data.instance.businessModel).to.equal('my super business model') expect(instance.businessModel).to.equal('my super business model')
expect(data.instance.hardwareInformation).to.equal('2vCore 3GB RAM') expect(instance.hardwareInformation).to.equal('2vCore 3GB RAM')
expect(data.instance.languages).to.deep.equal([ 'en', 'es' ]) expect(instance.languages).to.deep.equal([ 'en', 'es' ])
expect(data.instance.categories).to.deep.equal([ 1, 2 ]) expect(instance.categories).to.deep.equal([ 1, 2 ])
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 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 () {

View File

@ -3,13 +3,25 @@ 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, CustomConfig, UserRight } from '@peertube/peertube-models' import { About, ActorImageType, 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'
import { ClientHtml } from '../../lib/html/client-html.js' import { ClientHtml } from '../../lib/html/client-html.js'
import { apiRateLimiter, asyncMiddleware, authenticate, ensureUserHasRight, openapiOperationDoc } from '../../middlewares/index.js' import {
apiRateLimiter,
asyncMiddleware,
authenticate,
ensureUserHasRight,
openapiOperationDoc,
updateBannerValidator
} from '../../middlewares/index.js'
import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js' import { customConfigUpdateValidator, ensureConfigIsEditable } from '../../middlewares/validators/config.js'
import { createReqFiles } from '@server/helpers/express-utils.js'
import { MIMETYPES } from '@server/initializers/constants.js'
import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '@server/lib/local-actor.js'
import { getServerActor } from '@server/models/application/application.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
const configRouter = express.Router() const configRouter = express.Router()
@ -24,7 +36,7 @@ configRouter.get('/',
configRouter.get('/about', configRouter.get('/about',
openapiOperationDoc({ operationId: 'getAbout' }), openapiOperationDoc({ operationId: 'getAbout' }),
getAbout asyncMiddleware(getAbout)
) )
configRouter.get('/custom', configRouter.get('/custom',
@ -51,13 +63,31 @@ configRouter.delete('/custom',
asyncMiddleware(deleteCustomConfig) asyncMiddleware(deleteCustomConfig)
) )
configRouter.post('/instance-banner/pick',
authenticate,
createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT),
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
updateBannerValidator,
asyncMiddleware(updateInstanceBanner)
)
configRouter.delete('/instance-banner',
authenticate,
ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
asyncMiddleware(deleteInstanceBanner)
)
// ---------------------------------------------------------------------------
async function getConfig (req: express.Request, res: express.Response) { async function getConfig (req: express.Request, res: express.Response) {
const json = await ServerConfigManager.Instance.getServerConfig(req.ip) const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
return res.json(json) return res.json(json)
} }
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 about: About = { const about: About = {
instance: { instance: {
name: CONFIG.INSTANCE.NAME, name: CONFIG.INSTANCE.NAME,
@ -75,7 +105,9 @@ function getAbout (req: express.Request, res: express.Response) {
businessModel: CONFIG.INSTANCE.BUSINESS_MODEL, businessModel: CONFIG.INSTANCE.BUSINESS_MODEL,
languages: CONFIG.INSTANCE.LANGUAGES, languages: CONFIG.INSTANCE.LANGUAGES,
categories: CONFIG.INSTANCE.CATEGORIES categories: CONFIG.INSTANCE.CATEGORIES,
banners: banners.map(b => b.toFormattedJSON())
} }
} }
@ -123,6 +155,23 @@ 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 accountServer = (await getServerActor()).Account
await updateLocalActorImageFiles(accountServer, bannerPhysicalFile, ActorImageType.BANNER)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
async function deleteInstanceBanner (req: express.Request, res: express.Response) {
const accountServer = (await getServerActor()).Account
await deleteLocalActorImageFile(accountServer, ActorImageType.BANNER)
return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {

View File

@ -18,10 +18,5 @@ const updateActorImageValidatorFactory = (fieldname: string) => ([
} }
]) ])
const updateAvatarValidator = updateActorImageValidatorFactory('avatarfile') export const updateAvatarValidator = updateActorImageValidatorFactory('avatarfile')
const updateBannerValidator = updateActorImageValidatorFactory('bannerfile') export const updateBannerValidator = updateActorImageValidatorFactory('bannerfile')
export {
updateAvatarValidator,
updateBannerValidator
}

View File

@ -1,7 +1,7 @@
import { ActivityIconObject, ActorImage, ActorImageType, type ActorImageType_Type } from '@peertube/peertube-models' import { ActivityIconObject, ActorImage, ActorImageType, type ActorImageType_Type } from '@peertube/peertube-models'
import { getLowercaseExtension } from '@peertube/peertube-node-utils' import { getLowercaseExtension } from '@peertube/peertube-node-utils'
import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { MActorImage, MActorImageFormattable } from '@server/types/models/index.js' import { MActorId, MActorImage, MActorImageFormattable } from '@server/types/models/index.js'
import { remove } from 'fs-extra/esm' import { remove } from 'fs-extra/esm'
import { join } from 'path' import { join } from 'path'
import { import {
@ -115,6 +115,17 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
return ActorImageModel.findOne(query) return ActorImageModel.findOne(query)
} }
static listByActor (actor: MActorId, type: ActorImageType_Type) {
const query = {
where: {
actorId: actor.id,
type
}
}
return ActorImageModel.findAll(query)
}
static getImageUrl (image: MActorImage) { static getImageUrl (image: MActorImage) {
if (!image) return undefined if (!image) return undefined

View File

@ -936,6 +936,60 @@ paths:
'200': '200':
description: successful operation description: successful operation
/api/v1/config/instance-banner/pick:
post:
summary: Update instance banner
security:
- OAuth2:
- admin
tags:
- Config
responses:
'200':
description: successful operation
content:
application/json:
schema:
type: object
properties:
banners:
type: array
items:
$ref: '#/components/schemas/ActorImage'
'413':
description: image file too large
headers:
X-File-Maximum-Size:
schema:
type: string
format: Nginx size
description: Maximum file size for the banner
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
bannerfile:
description: The file to upload.
type: string
format: binary
encoding:
bannerfile:
contentType: image/png, image/jpeg
'/api/v1/config/instance-banner':
delete:
summary: Delete instance banner
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