Add ability to set a banner to the instance

pull/6215/head
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">
<h1 class="visually-hidden" i18n>Follows</h1>
<div class="margin-content mt-4">
<div class="row">
<h1 class="visually-hidden" i18n>Follows</h1>
<div class="col-xl-6 col-md-12">
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Followers of {{ instanceName }} ({{ followersPagination.totalItems }})</h2>
<div class="col-xl-6 col-md-12">
<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">
{{ follower.name }}
</a>
<a *ngFor="let follower of followers" [href]="follower.url" target="_blank" rel="noopener noreferrer">
{{ follower.name }}
</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 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>

View File

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

View File

@ -20,6 +20,8 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
instanceBannerUrl: string
languages: string[] = []
categories: string[] = []
shortDescription = ''
@ -64,6 +66,10 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
this.shortDescription = about.instance.shortDescription
this.instanceBannerUrl = about.instance.banners.length !== 0
? about.instance.banners[0].path
: undefined
this.serverConfig = this.serverService.getHTMLConfig()
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">
This website is powered by PeerTube
</h1>

View File

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

View File

@ -1,5 +1,5 @@
<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-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>
</div>
<div class="margin-content" [ngClass]="{ 'offset-content': !isBroadcastMessageDisplayed }">
<div [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
<router-outlet></router-outlet>
</div>
</div>

View File

@ -1,7 +1,7 @@
<div class="root">
<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>
</div>
</div>

View File

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

View File

@ -8,6 +8,11 @@
</div>
<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">
<label i18n for="instanceName">Name</label>

View File

@ -1,25 +1,80 @@
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 { 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({
selector: 'my-edit-instance-information',
templateUrl: './edit-instance-information.component.html',
styleUrls: [ './edit-custom-config.component.scss' ]
})
export class EditInstanceInformationComponent {
export class EditInstanceInformationComponent implements OnInit {
@Input() form: FormGroup
@Input() formErrors: any
@Input() languageItems: 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 () {
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)
.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>
</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'
@Component({
templateUrl: './home.component.html'
templateUrl: './home.component.html',
styleUrls: [ './home.component.scss' ]
})
export class HomeComponent implements OnInit {

View File

@ -12,7 +12,7 @@
<div class="col-12 col-lg-8 col-xl-9">
<my-actor-banner-edit
*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-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 () {
return false
}

View File

@ -1,7 +1,7 @@
<div class="root">
<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>
</div>
</div>

View File

@ -1,7 +1,7 @@
<div class="root">
<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>
</div>
</div>

View File

@ -11,10 +11,6 @@
--myGreyOwnerFontSize: 14px;
}
.banner {
@include block-ratio('img', $banner-inverted-ratio);
}
.section-label {
@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="banner-placeholder">
<img *ngIf="hasBanner()" [src]="preview || actor.bannerUrl" alt="Banner" />
<img *ngIf="hasBanner()" [src]="preview || bannerUrl" alt="Banner" />
</div>
<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 { SafeResourceUrl } from '@angular/platform-browser'
import { Notifier, ServerService } from '@app/core'
import { VideoChannel } from '@app/shared/shared-main'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { getBytes } from '@root-helpers/bytes'
import { imageToDataURL } from '@root-helpers/images'
@ -18,7 +17,7 @@ export class ActorBannerEditComponent implements OnInit {
@ViewChild('bannerfileInput') bannerfileInput: ElementRef<HTMLInputElement>
@ViewChild('bannerPopover') bannerPopover: NgbPopover
@Input() actor: VideoChannel
@Input() bannerUrl: string
@Input() previewImage = false
@Output() bannerChange = new EventEmitter<FormData>()
@ -69,6 +68,6 @@ export class ActorBannerEditComponent implements OnInit {
}
hasBanner () {
return !!this.preview || !!this.actor.bannerUrl
return !!this.preview || !!this.bannerUrl
}
}

View File

@ -7,6 +7,7 @@ import {
ChannelMiniatureMarkupData,
ContainerMarkupData,
EmbedMarkupData,
InstanceBannerMarkupData,
PlaylistMiniatureMarkupData,
VideoMiniatureMarkupData,
VideosListMarkupData
@ -16,6 +17,7 @@ import {
ButtonMarkupComponent,
ChannelMiniatureMarkupComponent,
EmbedMarkupComponent,
InstanceBannerMarkupComponent,
PlaylistMiniatureMarkupComponent,
VideoMiniatureMarkupComponent,
VideosListMarkupComponent
@ -28,6 +30,7 @@ type HTMLBuilderFunction = (el: HTMLElement) => HTMLElement
@Injectable()
export class CustomMarkupService {
private angularBuilders: { [ selector: string ]: AngularBuilderFunction } = {
'peertube-instance-banner': el => this.instanceBannerBuilder(el),
'peertube-button': el => this.buttonBuilder(el),
'peertube-video-embed': el => this.embedBuilder(el, 'video'),
'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'),
@ -160,6 +163,19 @@ export class CustomMarkupService {
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) {
const data = el.dataset as VideoMiniatureMarkupData
const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent)

View File

@ -1,6 +1,7 @@
export * from './button-markup.component'
export * from './channel-miniature-markup.component'
export * from './embed-markup.component'
export * from './instance-banner-markup.component'
export * from './playlist-miniature-markup.component'
export * from './video-miniature-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,
ChannelMiniatureMarkupComponent,
EmbedMarkupComponent,
InstanceBannerMarkupComponent,
PlaylistMiniatureMarkupComponent,
VideoMiniatureMarkupComponent,
VideosListMarkupComponent
@ -39,7 +40,8 @@ import {
VideosListMarkupComponent,
ButtonMarkupComponent,
CustomMarkupHelpComponent,
CustomMarkupContainerComponent
CustomMarkupContainerComponent,
InstanceBannerMarkupComponent
],
exports: [
@ -50,7 +52,8 @@ import {
EmbedMarkupComponent,
ButtonMarkupComponent,
CustomMarkupHelpComponent,
CustomMarkupContainerComponent
CustomMarkupContainerComponent,
InstanceBannerMarkupComponent
],
providers: [

View File

@ -140,7 +140,8 @@ code {
}
.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});
outline: none;
@ -150,6 +151,10 @@ code {
flex-grow: 1;
}
.revert-margin-content {
margin: 0 calc(#{pvar(--horizontalMarginContent)} * -1);
}
.sub-menu {
background-color: pvar(--submenuBackgroundColor);
width: 100%;
@ -168,10 +173,14 @@ code {
}
// 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;
}
.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)
&.expanded {
--horizontalMarginContent: #{$expanded-horizontal-margins};
@ -271,7 +280,7 @@ my-global-icon[iconName=external-link] {
}
// 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;
}

View File

@ -79,7 +79,7 @@
top: #{- ($header-height + 20px)};
}
.offset-content {
.sub-menu-offset-content {
// if sub-menu fixed
.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 './_custom-bootstrap-helpers';
@use './_forms';
@use './images';
@use './_menu';
@use './_text';

View File

@ -1,3 +1,5 @@
type StringBoolean = 'true' | 'false'
export type EmbedMarkupData = {
// Video or playlist uuid
uuid: string
@ -7,7 +9,7 @@ export type VideoMiniatureMarkupData = {
// Video uuid
uuid: string
onlyDisplayTitle?: string // boolean
onlyDisplayTitle?: StringBoolean
}
export type PlaylistMiniatureMarkupData = {
@ -19,12 +21,12 @@ export type ChannelMiniatureMarkupData = {
// Channel name (username)
name: string
displayLatestVideo?: string // boolean
displayDescription?: string // boolean
displayLatestVideo?: StringBoolean
displayDescription?: StringBoolean
}
export type VideosListMarkupData = {
onlyDisplayTitle?: string // boolean
onlyDisplayTitle?: StringBoolean
maxRows?: string // number
sort?: string
@ -38,14 +40,14 @@ export type VideosListMarkupData = {
isLive?: string // number
onlyLocal?: string // boolean
onlyLocal?: StringBoolean
}
export type ButtonMarkupData = {
theme: 'primary' | 'secondary'
href: string
label: string
blankTarget?: string // boolean
blankTarget?: StringBoolean
}
export type ContainerMarkupData = {
@ -56,3 +58,7 @@ export type ContainerMarkupData = {
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 {
instance: {
name: string
@ -16,5 +18,7 @@ export interface About {
languages: string[]
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 = {}) {
const path = '/api/v1/config/custom'

View File

@ -8,9 +8,11 @@ import {
makeDeleteRequest,
makeGetRequest,
makePutBodyRequest,
makeUploadRequest,
PeerTubeServer,
setAccessTokensToServers
} from '@peertube/peertube-server-commands'
import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils'
describe('Test config API validators', function () {
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 () {
await cleanupTests([ server ])
})

View File

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

View File

@ -3,13 +3,25 @@ import { remove, writeJSON } from 'fs-extra/esm'
import snakeCase from 'lodash-es/snakeCase.js'
import validator from 'validator'
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 { objectConverter } from '../../helpers/core-utils.js'
import { CONFIG, reloadConfig } from '../../initializers/config.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 { 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()
@ -24,7 +36,7 @@ configRouter.get('/',
configRouter.get('/about',
openapiOperationDoc({ operationId: 'getAbout' }),
getAbout
asyncMiddleware(getAbout)
)
configRouter.get('/custom',
@ -51,13 +63,31 @@ configRouter.delete('/custom',
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) {
const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
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 = {
instance: {
name: CONFIG.INSTANCE.NAME,
@ -75,7 +105,9 @@ function getAbout (req: express.Request, res: express.Response) {
businessModel: CONFIG.INSTANCE.BUSINESS_MODEL,
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)
}
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 {

View File

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

View File

@ -1,7 +1,7 @@
import { ActivityIconObject, ActorImage, ActorImageType, type ActorImageType_Type } from '@peertube/peertube-models'
import { getLowercaseExtension } from '@peertube/peertube-node-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 { join } from 'path'
import {
@ -115,6 +115,17 @@ export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageMode
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) {
if (!image) return undefined

View File

@ -936,6 +936,60 @@ paths:
'200':
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:
get:
summary: Get instance custom homepage