Redesign about pages

This commit is contained in:
Chocobozzz 2025-01-07 10:44:48 +01:00
parent fa7d8c0ed4
commit f4d6cecf10
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
81 changed files with 1819 additions and 935 deletions

View File

@ -0,0 +1,56 @@
<div class="margin-content mt-4">
<h3 class="fs-3 fw-semibold mb-3" i18n>Contact {{ instanceName }} administrators</h3>
@if (isContactFormEnabled()) {
@if (!success) {
<form novalidate [formGroup]="form" (ngSubmit)="sendForm()">
<div class="form-group">
<label i18n for="fromName">Your name</label>
<input
type="text" id="fromName" class="form-control"
formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
autocomplete="name"
>
<div *ngIf="formErrors.fromName" class="form-error" role="alert">{{ formErrors.fromName }}</div>
</div>
<div class="form-group">
<label i18n for="fromEmail">Your email</label>
<input
type="text" id="fromEmail" class="form-control"
formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }"
i18n-placeholder placeholder="Example: john@example.com" autocomplete="email"
>
<div *ngIf="formErrors.fromEmail" class="form-error" role="alert">{{ formErrors.fromEmail }}</div>
</div>
<div class="form-group">
<label i18n for="subject">Subject</label>
<input
type="text" id="subject" class="form-control"
formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }"
>
<div *ngIf="formErrors.subject" class="form-error" role="alert">{{ formErrors.subject }}</div>
</div>
<div class="form-group">
<label i18n for="body">Your message</label>
<textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }"></textarea>
<div *ngIf="formErrors.body" class="form-error" role="alert">{{ formErrors.body }}</div>
</div>
<my-alert *ngIf="error" type="danger">{{ error }}</my-alert>
<input type="submit" i18n-value value="Submit" class="peertube-button primary-button" [disabled]="!form.valid" />
</form>
} @else {
<my-alert type="success">{{ success }}</my-alert>
}
} @else {
<my-alert type="danger" i18n>The contact form is not enabled on this instance.</my-alert>
}
</div>

View File

@ -2,19 +2,10 @@
@use '_mixins' as *;
@use '_form-mixins' as *;
.modal-subtitle {
line-height: 1rem;
margin-bottom: 0;
}
.modal-body {
text-align: left;
}
input[type=text] {
@include peertube-input-text(340px);
}
textarea {
@include peertube-textarea(100%, 200px);
@include peertube-textarea(500px, 200px);
}

View File

@ -1,8 +1,8 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { Component, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import {
BODY_VALIDATOR,
FROM_EMAIL_VALIDATOR,
@ -13,10 +13,7 @@ import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { HTMLServerConfig, HttpStatusCode } from '@peertube/peertube-models'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
type Prefill = {
subject?: string
@ -24,27 +21,22 @@ type Prefill = {
}
@Component({
selector: 'my-contact-admin-modal',
templateUrl: './contact-admin-modal.component.html',
styleUrls: [ './contact-admin-modal.component.scss' ],
templateUrl: './about-contact.component.html',
styleUrls: [ './about-contact.component.scss' ],
standalone: true,
imports: [ GlobalIconComponent, NgIf, FormsModule, ReactiveFormsModule, NgClass, AlertComponent ]
imports: [ NgIf, FormsModule, ReactiveFormsModule, NgClass, AlertComponent ]
})
export class ContactAdminModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
export class AboutContactComponent extends FormReactive implements OnInit {
error: string
success: string
private openedModal: NgbModalRef
private serverConfig: HTMLServerConfig
constructor (
protected formReactiveService: FormReactiveService,
private router: Router,
private modalService: NgbModal,
private route: ActivatedRoute,
private instanceService: InstanceService,
private serverService: ServerService,
private notifier: Notifier
private serverService: ServerService
) {
super()
}
@ -62,27 +54,14 @@ export class ContactAdminModalComponent extends FormReactive implements OnInit {
subject: SUBJECT_VALIDATOR,
body: BODY_VALIDATOR
})
this.prefillForm(this.route.snapshot.queryParams)
}
isContactFormEnabled () {
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
}
show (prefill: Prefill = {}) {
this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
this.openedModal.shown.subscribe(() => this.prefillForm(prefill))
this.openedModal.result.finally(() => this.router.navigateByUrl('/about/instance'))
}
hide () {
this.form.reset()
this.error = undefined
this.openedModal.close()
this.openedModal = null
}
sendForm () {
const fromName = this.form.value['fromName']
const fromEmail = this.form.value['fromEmail']
@ -92,8 +71,7 @@ export class ContactAdminModalComponent extends FormReactive implements OnInit {
this.instanceService.contactAdministrator(fromEmail, fromName, subject, body)
.subscribe({
next: () => {
this.notifier.success($localize`Your message has been sent.`)
this.hide()
this.success = $localize`Your message has been sent.`
},
error: err => {

View File

@ -1,30 +1,81 @@
<div class="margin-content mt-4">
<div class="row">
<h1 class="visually-hidden" i18n>Follows</h1>
<div class="margin-content mt-5">
<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="subscriptions me-3 mb-3">
<div class="block-header mb-4 d-flex">
<div class="flex-grow-1 me-2">
<h3 i18n>{{ subscriptionsPagination.totalItems }} {subscriptionsPagination.totalItems, plural, =1 {subscription} other {subscriptions}}</h3>
<div i18n class="text-content">
This is content to which we have subscribed. This allows us to display their videos directly on {{ instanceName }}.
</div>
</div>
<my-subscription-image></my-subscription-image>
</div>
<div class="follows">
<div i18n class="no-results" *ngIf="subscriptionsPagination.totalItems === 0">{{ instanceName }} does not have subscriptions.</div>
<div *ngFor="let subscription of subscriptions" class="follow-block">
<my-actor-avatar [actor]="subscription" actorType="channel" size="32"></my-actor-avatar>
<div>
<a class="follow-name" [href]="subscription.url" target="_blank" rel="noopener noreferrer">{{ subscription.name }}</a>
</div>
</div>
</div>
<div class="text-center">
<my-button *ngIf="canLoadMoreSubscriptions()" class="mt-3 mx-auto" (click)="loadMoreSubscriptions()" theme="secondary" i18n>Show more subscriptions</my-button>
</div>
<div *ngIf="serverStats" class="stats mt-4">
<h4 i18n>Our network in figures</h4>
<div myPluginSelector pluginSelectorId="about-instance-network-statistics">
<div class="stat">
<strong>{{ serverStats.totalVideos | number }}</strong>
<a routerLink="/videos/browse" [queryParams]="{ scope: 'federated' }" i18n>total videos</a>
<my-global-icon iconName="videos"></my-global-icon>
</div>
<div class="stat">
<strong>{{ serverStats.totalVideoComments | number }}</strong>
<div i18n>total comments</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
</div>
</div>
</div>
<div class="followers">
<div class="block-header mb-4 d-flex">
<div class="flex-grow-1 me-2">
<h3 i18n>{{ followersPagination.totalItems }} {followersPagination.totalItems, plural, =1 {follower} other {followers}}</h3>
<div i18n class="text-content">
Our subscribers automatically display videos of {{ instanceName }} on their platforms.
</div>
</div>
<my-follower-image></my-follower-image>
</div>
<div class="follows">
<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>
<div *ngFor="let follower of followers" class="follow-block">
<my-actor-avatar [actor]="follower" actorType="channel" size="32"></my-actor-avatar>
<button i18n class="peertube-button-link secondary-button mt-1" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button>
<div>
<a class="follow-name" [href]="follower.url" target="_blank" rel="noopener noreferrer">{{ follower.name }}</a>
</div>
</div>
<div class="text-center">
<my-button *ngIf="canLoadMoreFollowers()" class="mt-3 mx-auto" (click)="loadMoreFollowers()" theme="secondary" i18n>Show more followers</my-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 secondary-button mt-1" *ngIf="!loadedAllFollowings && canLoadMoreFollowings()" (click)="loadAllFollowings()">Show full list</button>
</div>
</div>
</div>

View File

@ -1,13 +1,85 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_bootstrap-variables' as *;
@use '_components' as *;
a {
display: block;
width: fit-content;
margin-top: 3px;
.margin-content {
display: flex;
}
.no-results {
justify-content: flex-start;
align-items: flex-start;
.text-content {
color: pvar(--fg-300);
}
.stat {
@include stats-card;
}
.stats > div {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.followers,
.subscriptions {
flex-basis: 50%;
background-color: pvar(--bg-secondary-400);
padding: 1.5rem;
border-radius: 14px;
h3 {
font-weight: $font-bold;
color: pvar(--fg-400);
@include font-size(2rem);
}
h4 {
color: pvar(--fg-300);
font-weight: $font-bold;
@include font-size(1.25rem);
}
}
.follows {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.follow-block {
width: calc(50% - 1rem);
padding: 1rem;
border-radius: 8px;
background-color: pvar(--bg-secondary-450);
display: flex;
align-items: center;
my-actor-avatar {
@include margin-right(1rem);
}
}
.follow-name {
font-weight: $font-bold;
color: pvar(--fg-400);
}
@media screen and (max-width: #{breakpoint(xl)}) {
.margin-content {
flex-wrap: wrap;
}
.followers,
.subscriptions {
flex-basis: 100%;
}
}
@include on-small-main-col {
.follow-block {
width: 100%;
}
}

View File

@ -1,26 +1,41 @@
import { SortMeta } from 'primeng/api'
import { DecimalPipe, NgFor, NgIf } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { RouterLink } from '@angular/router'
import { ComponentPagination, hasMoreItems, Notifier, RestService, ServerService } from '@app/core'
import { Actor } from '@peertube/peertube-models'
import { NgIf, NgFor } from '@angular/common'
import { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { Actor, ServerStats } from '@peertube/peertube-models'
import { SortMeta } from 'primeng/api'
import { FollowerImageComponent } from './follower-image.component'
import { SubscriptionImageComponent } from './subscription-image.component'
@Component({
selector: 'my-about-follows',
templateUrl: './about-follows.component.html',
styleUrls: [ './about-follows.component.scss' ],
standalone: true,
imports: [ NgIf, NgFor ]
imports: [
NgIf,
NgFor,
ActorAvatarComponent,
ButtonComponent,
PluginSelectorDirective,
GlobalIconComponent,
DecimalPipe,
RouterLink,
SubscriptionImageComponent,
FollowerImageComponent
]
})
export class AboutFollowsComponent implements OnInit {
instanceName: string
followers: { name: string, url: string }[] = []
followings: { name: string, url: string }[] = []
loadedAllFollowers = false
loadedAllFollowings = false
followers: Actor[] = []
subscriptions: Actor[] = []
followersPagination: ComponentPagination = {
currentPage: 1,
@ -28,13 +43,18 @@ export class AboutFollowsComponent implements OnInit {
totalItems: 0
}
followingsPagination: ComponentPagination = {
subscriptionsPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 20,
totalItems: 0
}
sort: SortMeta = {
serverStats: ServerStats
private loadingFollowers = false
private loadingSubscriptions = false
private sort: SortMeta = {
field: 'createdAt',
order: -1
}
@ -47,41 +67,12 @@ export class AboutFollowsComponent implements OnInit {
) { }
ngOnInit () {
this.loadMoreFollowers()
this.loadMoreFollowings()
this.loadMoreFollowers(true)
this.loadMoreSubscriptions(true)
this.instanceName = this.server.getHTMLConfig().instance.name
}
loadAllFollowings () {
if (this.loadedAllFollowings) return
this.loadedAllFollowings = true
this.followingsPagination.itemsPerPage = 100
this.loadMoreFollowings(true)
while (hasMoreItems(this.followingsPagination)) {
this.followingsPagination.currentPage += 1
this.loadMoreFollowings()
}
}
loadAllFollowers () {
if (this.loadedAllFollowers) return
this.loadedAllFollowers = true
this.followersPagination.itemsPerPage = 100
this.loadMoreFollowers(true)
while (hasMoreItems(this.followersPagination)) {
this.followersPagination.currentPage += 1
this.loadMoreFollowers()
}
this.server.getServerStats().subscribe(stats => this.serverStats = stats)
}
buildLink (host: string) {
@ -89,14 +80,20 @@ export class AboutFollowsComponent implements OnInit {
}
canLoadMoreFollowers () {
return this.loadedAllFollowers || this.followersPagination.totalItems > this.followersPagination.itemsPerPage
return hasMoreItems(this.followersPagination)
}
canLoadMoreFollowings () {
return this.loadedAllFollowings || this.followingsPagination.totalItems > this.followingsPagination.itemsPerPage
canLoadMoreSubscriptions () {
return hasMoreItems(this.subscriptionsPagination)
}
private loadMoreFollowers (reset = false) {
loadMoreFollowers (reset = false) {
if (this.loadingFollowers) return
this.loadingFollowers = true
if (reset) this.followersPagination.currentPage = 1
else this.followersPagination.currentPage++
const pagination = this.restService.componentToRestPagination(this.followersPagination)
this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' })
@ -110,36 +107,46 @@ export class AboutFollowsComponent implements OnInit {
this.followersPagination.totalItems = resultList.total
},
error: err => this.notifier.error(err.message)
error: err => this.notifier.error(err.message),
complete: () => this.loadingFollowers = false
})
}
private loadMoreFollowings (reset = false) {
const pagination = this.restService.componentToRestPagination(this.followingsPagination)
loadMoreSubscriptions (reset = false) {
if (this.loadingSubscriptions) return
this.loadingSubscriptions = true
if (reset) this.subscriptionsPagination.currentPage = 1
else this.subscriptionsPagination.currentPage++
const pagination = this.restService.componentToRestPagination(this.subscriptionsPagination)
this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' })
.subscribe({
next: resultList => {
if (reset) this.followings = []
if (reset) this.subscriptions = []
const newFollowings = resultList.data.map(r => this.formatFollow(r.following))
this.followings = this.followings.concat(newFollowings)
this.subscriptions = this.subscriptions.concat(newFollowings)
this.followingsPagination.totalItems = resultList.total
this.subscriptionsPagination.totalItems = resultList.total
},
error: err => this.notifier.error(err.message)
error: err => this.notifier.error(err.message),
complete: () => this.loadingSubscriptions = false
})
}
private formatFollow (actor: Actor) {
return {
...actor,
// Instance follow, only display host
name: actor.name === 'peertube'
? actor.host
: actor.name + '@' + actor.host,
url: actor.url
: actor.name + '@' + actor.host
}
}
}

View File

@ -0,0 +1,51 @@
<div class="root" aria-hidden="true">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.2928 87.2622L10.5359 19.0679L28.4902 11.6038L89.4271 62.8128L42.2928 87.2622Z"
fill="url(#paint0_linear_1305_16041)" />
<path d="M57.3679 68.7467L87 26L89.4588 26L89.4588 77.6445L57.3679 68.7467Z"
fill="url(#paint1_linear_1305_16041)" />
<rect x="2.30959" y="14.776" width="37.2961" height="37.2961" rx="9" transform="rotate(-22.8223 2.30959 14.776)"
fill="var(--bg-secondary-400)" stroke="var(--secondary-icon-color)" stroke-width="2" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0393 26.7067L15.8565 16.767L25.4049 18.5988L20.0393 26.7067Z"
fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2229 36.6467L20.0401 26.7069L29.5885 28.5387L24.2229 36.6467Z"
fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.5872 28.5378L25.4043 18.598L34.9528 20.4299L29.5872 28.5378Z"
fill="var(--secondary-icon-color)" />
<path
d="M95.2828 29.4743L94.6821 27.4769C94.3634 26.4174 93.637 25.5279 92.6625 25.0041C91.6881 24.4802 90.5454 24.3649 89.4859 24.6835L83.4938 26.4856C82.4343 26.8043 81.5448 27.5307 81.0209 28.5052C80.4971 29.4796 80.3818 30.6223 80.7004 31.6818L81.3011 33.6792"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M85.2882 21.5899C87.4944 20.9264 88.745 18.6 88.0815 16.3937C87.418 14.1875 85.0916 12.9369 82.8854 13.6004C80.6791 14.2639 79.4285 16.5903 80.092 18.7965C80.7555 21.0028 83.0819 22.2534 85.2882 21.5899Z"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M68.0007 92.0002L22.5001 99.4999L18.0003 99.4996L52.5006 67L68.0007 92.0002Z"
fill="url(#paint2_linear_1305_16041)" />
<rect x="7.78809" y="86.0605" width="28.6783" height="28.6783" rx="6" transform="rotate(-6.5522 7.78809 86.0605)"
fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.5762 98.6849L17.6782 90.8661L23.993 94.1018L18.5762 98.6849Z"
fill="var(--bg)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.4756 106.504L18.5776 98.685L24.8924 101.921L19.4756 106.504Z"
fill="var(--bg)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.8916 101.92L23.9936 94.1015L30.3084 97.3371L24.8916 101.92Z"
fill="var(--bg)" />
<defs>
<linearGradient id="paint0_linear_1305_16041" x1="10.3339" y1="3.88906" x2="70.4056" y2="83.557"
gradientUnits="userSpaceOnUse">
<stop offset="0.39" stop-color="var(--bg)" />
<stop offset="0.905" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
<linearGradient id="paint1_linear_1305_16041" x1="87.5" y1="22.5" x2="75.6241" y2="83.0273"
gradientUnits="userSpaceOnUse">
<stop stop-color="var(--bg)" />
<stop offset="0.905" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
<linearGradient id="paint2_linear_1305_16041" x1="5.83634" y1="113.619" x2="65.326" y2="61.6047"
gradientUnits="userSpaceOnUse">
<stop stop-color="var(--bg)" />
<stop offset="0.905" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
<img [src]="avatarUrl" alt="">
</div>

View File

@ -0,0 +1,19 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_bootstrap-variables' as *;
@use '_components' as *;
.root {
position: relative;
}
img {
width: 30px;
height: 30px;
border: 1px solid pvar(--bg-secondary-400);
border-radius: 8px;
position: absolute;
right: 36px;
bottom: 25px;
transform: rotate(18deg);
}

View File

@ -0,0 +1,19 @@
import { Component, OnInit } from '@angular/core'
import { ServerService } from '@app/core'
import { Actor } from '@app/shared/shared-main/account/actor.model'
@Component({
selector: 'my-follower-image',
templateUrl: './follower-image.component.html',
styleUrls: [ './follower-image.component.scss' ],
standalone: true
})
export class FollowerImageComponent implements OnInit {
avatarUrl: string
constructor (private server: ServerService) {}
ngOnInit () {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.server.getHTMLConfig().instance, 30)
}
}

View File

@ -0,0 +1,42 @@
<div class="root" aria-hidden="true">
<img [src]= "avatarUrl" alt="">
<svg width="125" height="129" viewBox="0 0 125 129" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.4996 129L18.9946 27.2709L36.8651 13.7961L124.924 67.6037L43.4996 129Z"
fill="url(#paint0_linear_1305_16148)" />
<rect x="57.9766" y="33.5923" width="39.9544" height="39.9544" rx="10"
transform="rotate(-17.7787 57.9766 33.5923)" fill="var(--bg-secondary-450)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M76.1448 47.9191L72.7968 37.478L82.3039 40.1868L76.1448 47.9191Z" fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M79.4925 58.3605L76.1445 47.9194L85.6515 50.6282L79.4925 58.3605Z" fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M85.6507 50.6276L82.3027 40.1865L91.8097 42.8953L85.6507 50.6276Z" fill="var(--secondary-icon-color)" />
<g clip-path="url(#clip0_1305_16148)">
<path
d="M39.7866 86.6025L40.6519 83.7089C41.1109 82.1741 40.9414 80.5198 40.1807 79.11C39.4199 77.7002 38.1303 76.6503 36.5955 76.1913L27.915 73.5954C26.3801 73.1364 24.7259 73.3059 23.316 74.0666C21.9062 74.8273 20.8563 76.117 20.3973 77.6518L19.532 80.5453"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M33.9859 69.1059C37.182 70.0617 40.5478 68.2456 41.5036 65.0495C42.4594 61.8534 40.6433 58.4877 37.4472 57.5319C34.2511 56.5761 30.8853 58.3922 29.9295 61.5883C28.9737 64.7844 30.7899 68.1501 33.9859 69.1059Z"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</g>
<path
d="M64.0505 89.509C62.4526 89.3031 60.9752 90.5494 60.7506 92.2926L59.3273 103.34C59.1027 105.083 60.216 106.663 61.814 106.869L80.6204 109.292C82.2183 109.498 83.7001 108.218 83.9203 106.508"
stroke="var(--secondary-icon-color)" stroke-width="2" />
<path
d="M69.7048 83.8923L91.83 86.7428C93.0902 86.9051 94.0965 88.1833 93.8955 89.7441L92.235 102.632C92.0339 104.193 90.7364 105.175 89.4763 105.012L67.3511 102.162C66.0909 101.999 65.0845 100.721 65.2856 99.1604L66.9461 86.2721C67.1472 84.7112 68.4447 83.7299 69.7048 83.8923Z"
stroke="var(--secondary-icon-color)" stroke-width="2" />
<path d="M77.2559 89.7397L76.2523 97.5294L82.9857 94.4381L77.2559 89.7397Z" fill="var(--secondary-icon-color)" />
<defs>
<linearGradient id="paint0_linear_1305_16148" x1="27.7121" y1="20.698" x2="75.2879" y2="83.7937"
gradientUnits="userSpaceOnUse">
<stop offset="0.293252" stop-color="var(--bg)" />
<stop offset="1" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
<clipPath id="clip0_1305_16148">
<rect width="36.2416" height="36.2416" fill="var(--bg)"
transform="translate(21.3838 48) rotate(16.6494)" />
</clipPath>
</defs>
</svg>
</div>

View File

@ -0,0 +1,18 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_bootstrap-variables' as *;
@use '_components' as *;
.root {
position: relative;
}
img {
width: 30px;
height: 30px;
border: 1px solid pvar(--bg-secondary-400);
border-radius: 8px;
position: absolute;
top: 9px;
left: 15px;
}

View File

@ -0,0 +1,19 @@
import { Component, OnInit } from '@angular/core'
import { ServerService } from '@app/core'
import { Actor } from '@app/shared/shared-main/account/actor.model'
@Component({
selector: 'my-subscription-image',
templateUrl: './subscription-image.component.html',
styleUrls: [ './subscription-image.component.scss' ],
standalone: true
})
export class SubscriptionImageComponent implements OnInit {
avatarUrl: string
constructor (private server: ServerService) {}
ngOnInit () {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.server.getHTMLConfig().instance, 30)
}
}

View File

@ -1,233 +1,12 @@
<div class="margin-content mt-4">
<div class="banner mb-4" *ngIf="instanceBannerUrl">
<img class="rounded" [src]="instanceBannerUrl" alt="Instance banner">
</div>
<div class="margin-content">
<my-horizontal-menu [menuEntries]="menuEntries" areChildren="true"></my-horizontal-menu>
<div class="row ">
<div class="col-md-12 col-xl-6">
<div class="d-flex justify-content-between">
<h1 i18n class="fw-semibold fs-5">About {{ instanceName }}</h1>
<a routerLink="/about/contact" i18n *ngIf="isContactFormEnabled" class="peertube-button-link primary-button h-100 d-flex align-items-center">Contact us</a>
</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>
<span *ngFor="let language of languages" class="pt-badge badge-secondary">{{ language }}</span>
</div>
<div class="mt-2">
<div class="block">{{ shortDescription }}</div>
<div i18n *ngIf="isNSFW" class="block mt-4 fw-semibold">This instance is dedicated to sensitive/NSFW content.</div>
</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 class="block administrator" *ngIf="aboutHTML.administrator">
<div class="anchor" id="administrators"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="administrators"
#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">
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 class="content">
<div>
<router-outlet></router-outlet>
</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>
<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>
</div>
<my-instance-stat-rules [stats]="serverStats" [config]="serverConfig" [aboutHTML]="aboutHTML"></my-instance-stat-rules>
</div>
</div>
<my-contact-admin-modal #contactAdminModal></my-contact-admin-modal>

View File

@ -1,50 +1,23 @@
@use '_variables' as *;
@use '_bootstrap-variables' as *;
@use '_mixins' as *;
.pt-badge {
@include margin-right(5px);
}
.section-title {
font-weight: $font-semibold;
margin-bottom: 5px;
.content {
display: flex;
align-items: center;
font-size: 1rem;
@include rfs(4rem, gap);
}
.middle-title {
margin-top: 0;
text-transform: uppercase;
color: pvar(--fg);
font-weight: $font-bold;
@include font-size(22px);
@include margin-bottom(1.5rem);
my-instance-stat-rules {
min-width: 600px;
}
.block {
@include margin-bottom(4.5rem);
}
.anchor-link {
position: relative;
@include disable-outline;
&:hover,
&:active {
&::after {
content: '#';
display: inline-block;
@include margin-left(0.2em);
}
@media screen and (max-width: #{breakpoint(xl)}) {
.content {
flex-wrap: wrap;
}
.middle-title,
.section-title {
display: inline-block;
color: pvar(--fg-400);
my-instance-stat-rules {
min-width: 100%;
}
}

View File

@ -1,17 +1,10 @@
import { NgFor, NgIf, ViewportScroller } from '@angular/common'
import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, RouterOutlet } from '@angular/router'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { maxBy } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, ServerStats } from '@peertube/peertube-models'
import { copyToClipboard } from '@root-helpers/utils'
import { CustomMarkupContainerComponent } from '../../shared/shared-custom-markup/custom-markup-container.component'
import { InstanceFeaturesTableComponent } from '../../shared/shared-instance/instance-features-table.component'
import { PluginSelectorDirective } from '../../shared/shared-main/plugins/plugin-selector.directive'
import { ServerConfig, ServerStats } from '@peertube/peertube-models'
import { ResolverData } from './about-instance.resolver'
import { ContactAdminModalComponent } from './contact-admin-modal.component'
import { InstanceStatisticsComponent } from './instance-statistics.component'
import { InstanceStatRulesComponent } from './instance-stat-rules.component'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
@Component({
selector: 'my-about-instance',
@ -19,97 +12,61 @@ import { InstanceStatisticsComponent } from './instance-statistics.component'
styleUrls: [ './about-instance.component.scss' ],
standalone: true,
imports: [
NgIf,
RouterLink,
NgFor,
CustomMarkupContainerComponent,
PluginSelectorDirective,
InstanceFeaturesTableComponent,
InstanceStatisticsComponent,
ContactAdminModalComponent
InstanceStatRulesComponent,
HorizontalMenuComponent,
RouterOutlet
]
})
export class AboutInstanceComponent implements OnInit, AfterViewChecked {
export class AboutInstanceComponent implements OnInit {
@ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement>
@ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
instanceBannerUrl: string
languages: string[] = []
categories: string[] = []
shortDescription = ''
initialized = false
serverStats: ServerStats
private serverConfig: HTMLServerConfig
private lastScrollHash: string
serverConfig: ServerConfig
menuEntries: HorizontalMenuEntry[] = []
constructor (
private viewportScroller: ViewportScroller,
private route: ActivatedRoute,
private notifier: Notifier,
private serverService: ServerService
private route: ActivatedRoute
) {}
get instanceName () {
return this.serverConfig.instance.name
}
get isContactFormEnabled () {
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
}
get isNSFW () {
return this.serverConfig.instance.isNSFW
}
ngOnInit () {
const { about, languages, categories, aboutHTML, descriptionElement, serverStats }: ResolverData = this.route.snapshot.data.instanceData
const {
aboutHTML,
serverStats,
serverConfig
}: ResolverData = this.route.snapshot.data.instanceData
this.serverStats = serverStats
this.serverConfig = serverConfig
this.aboutHTML = aboutHTML
this.descriptionElement = descriptionElement
this.languages = languages
this.categories = categories
this.menuEntries = [
{
label: $localize`General`,
routerLink: '/about/instance/home'
}
]
this.shortDescription = about.instance.shortDescription
if (aboutHTML.administrator || aboutHTML.creationReason || aboutHTML.maintenanceLifetime || aboutHTML.businessModel) {
this.menuEntries.push({
label: $localize`Team`,
routerLink: '/about/instance/team'
})
}
this.instanceBannerUrl = about.instance.banners.length !== 0
? maxBy(about.instance.banners, 'width').path
: undefined
if (aboutHTML.moderationInformation || aboutHTML.codeOfConduct) {
this.menuEntries.push({
label: $localize`Moderation and code of conduct`,
routerLink: '/about/instance/moderation'
})
}
this.serverConfig = this.serverService.getHTMLConfig()
this.route.data.subscribe(data => {
if (!data?.isContact) return
const prefill = this.route.snapshot.queryParams
this.contactAdminModal.show(prefill)
})
this.initialized = true
}
ngAfterViewChecked () {
if (this.initialized && window.location.hash && window.location.hash !== this.lastScrollHash) {
this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
this.lastScrollHash = window.location.hash
if (aboutHTML.hardwareInformation) {
this.menuEntries.push({
label: $localize`Technical information`,
routerLink: '/about/instance/tech'
})
}
}
onClickCopyLink (anchor: HTMLAnchorElement) {
const link = anchor.href
copyToClipboard(link)
this.notifier.success(link, $localize`Link copied`)
}
}

View File

@ -2,11 +2,12 @@ import { forkJoin, Observable } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ServerService } from '@app/core'
import { About, ServerStats } from '@peertube/peertube-models'
import { About, ServerConfig, ServerStats } from '@peertube/peertube-models'
import { AboutHTML, InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
export type ResolverData = {
serverConfig: ServerConfig
serverStats: ServerStats
about: About
languages: string[]
@ -27,14 +28,17 @@ export class AboutInstanceResolver {
resolve (): Observable<ResolverData> {
return forkJoin([
this.buildInstanceAboutObservable(),
this.buildInstanceStatsObservable()
this.serverService.getServerStats(),
this.serverService.getConfig()
]).pipe(
map(([
[ about, languages, categories, aboutHTML, { rootElement } ],
serverStats
serverStats,
serverConfig
]) => {
return {
serverStats,
serverConfig,
about,
languages,
categories,
@ -59,8 +63,4 @@ export class AboutInstanceResolver {
})
)
}
private buildInstanceStatsObservable () {
return this.serverService.getServerStats()
}
}

View File

@ -0,0 +1,53 @@
import { Routes } from '@angular/router'
import { AboutInstanceComponent } from './about-instance.component'
import { AboutInstanceResolver } from './about-instance.resolver'
import { AboutInstanceHomeComponent } from './children/about-instance-home.component'
import { AboutInstanceModerationComponent } from './children/about-instance-moderation.component'
import { AboutInstanceTeamComponent } from './children/about-instance-team.component'
import { AboutInstanceTechComponent } from './children/about-instance-tech.component'
export const aboutInstanceRoutes: Routes = [
{
path: 'instance',
providers: [ AboutInstanceResolver ],
component: AboutInstanceComponent,
data: {
meta: {
title: $localize`About this instance`
}
},
resolve: {
instanceData: AboutInstanceResolver
},
children: [
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'home',
component: AboutInstanceHomeComponent
},
{
path: 'support',
component: AboutInstanceHomeComponent,
data: {
isSupport: true
}
},
{
path: 'team',
component: AboutInstanceTeamComponent
},
{
path: 'tech',
component: AboutInstanceTechComponent
},
{
path: 'moderation',
component: AboutInstanceModerationComponent
}
]
}
]

View File

@ -0,0 +1,17 @@
@use '_variables' as *;
@use '_mixins' as *;
h4 {
color: pvar(--fg-300);
font-size: 18px;
font-weight: $font-bold;
margin-bottom: 0.25rem;
}
.text-content {
color: pvar(--fg-200);
}
.block {
margin-bottom: 1rem;
}

View File

@ -0,0 +1,29 @@
<div class="block specifics" *ngIf="categories.length !== 0 || languages.length !== 0 || config.instance.isNSFW">
<h4 i18n>Specifics</h4>
<div *ngIf="languages.length !== 0" class="d-inline-block me-2">
<span class="text-content top-2px" i18n>Language: </span>
<span *ngFor="let language of languages" class="pt-badge badge-primary me-1">{{ language }}</span>
</div>
<div *ngIf="categories.length !== 0" class="d-inline-block mt-2">
<span class="text-content top-2px" i18n>Categories: </span>
<span *ngFor="let category of categories" class="pt-badge badge-secondary me-1">{{ category }}</span>
</div>
<div i18n *ngIf="config.instance.isNSFW" class="fw-bold text-content mt-3">{{ config.instance.name }} is dedicated to sensitive/NSFW content.</div>
</div>
<div class="block description">
<h4 i18n>Description</h4>
<my-custom-markup-container class="text-content" [content]="descriptionElement"></my-custom-markup-container>
</div>
<div class="block terms">
<h4 i18n class="section-title">Terms</h4>
<div class="text-content" [innerHTML]="aboutHTML.terms"></div>
</div>
<my-support-modal #supportModal [name]="config.instance.name" [content]="config.instance.support.text"></my-support-modal>

View File

@ -0,0 +1,65 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { CustomMarkupContainerComponent } from '../../../shared/shared-custom-markup/custom-markup-container.component'
import { ResolverData } from '../about-instance.resolver'
@Component({
templateUrl: './about-instance-home.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
standalone: true,
imports: [
NgIf,
NgFor,
CustomMarkupContainerComponent,
SupportModalComponent
]
})
export class AboutInstanceHomeComponent implements OnInit {
@ViewChild('supportModal') supportModal: SupportModalComponent
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
languages: string[] = []
categories: string[] = []
config: HTMLServerConfig
constructor (
private router: Router,
private route: ActivatedRoute,
private serverService: ServerService
) {}
ngOnInit () {
this.config = this.serverService.getHTMLConfig()
const {
languages,
categories,
aboutHTML,
descriptionElement
}: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
this.descriptionElement = descriptionElement
this.languages = languages
this.categories = categories
this.route.data.subscribe(data => {
if (!data?.isSupport) return
setTimeout(() => {
const modal = this.supportModal.show()
modal.hidden.subscribe(() => this.router.navigateByUrl('/about/instance/home'))
}, 0)
})
}
}

View File

@ -0,0 +1,13 @@
<div myPluginSelector pluginSelectorId="about-instance-moderation">
<div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
<h4 i18n class="section-title">Moderation information</h4>
<div [innerHTML]="aboutHTML.moderationInformation"></div>
</div>
<div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
<h4 i18n class="section-title">Code of conduct</h4>
<div [innerHTML]="aboutHTML.codeOfConduct"></div>
</div>
</div>

View File

@ -0,0 +1,32 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { ResolverData } from '../about-instance.resolver'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
@Component({
templateUrl: './about-instance-moderation.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
standalone: true,
imports: [ CommonModule, PluginSelectorDirective ]
})
export class AboutInstanceModerationComponent implements OnInit {
aboutHTML: AboutHTML
constructor (
private route: ActivatedRoute,
private serverService: ServerService
) {}
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
const { aboutHTML }: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
}
}

View File

@ -0,0 +1,19 @@
<div class="block administrator" *ngIf="aboutHTML.administrator">
<h4 i18n>Who we are</h4>
<div class="text-content" [innerHTML]="aboutHTML.administrator"></div>
</div>
<div class="block creation-reason" *ngIf="aboutHTML.creationReason">
<h4 i18n>Why we created {{ instanceName }}</h4>
<div class="text-content" [innerHTML]="aboutHTML.creationReason"></div>
</div>
<div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
<h4 i18n>How long we plan to maintain {{ instanceName }}</h4>
<div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
</div>
<div class="block business-model" *ngIf="aboutHTML.businessModel">
<h4 i18n>How we will pay for keeping {{ instanceName }} running</h4>
<div class="text-content" [innerHTML]="aboutHTML.businessModel"></div>
</div>

View File

@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { ResolverData } from '../about-instance.resolver'
@Component({
templateUrl: './about-instance-team.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
standalone: true,
imports: [ CommonModule ]
})
export class AboutInstanceTeamComponent implements OnInit {
aboutHTML: AboutHTML
constructor (
private route: ActivatedRoute,
private serverService: ServerService
) {}
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
const { aboutHTML }: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
}
}

View File

@ -0,0 +1,10 @@
<div myPluginSelector pluginSelectorId="about-instance-other-information">
<h4 i18n class="section-title">Hardware information</h4>
<div [innerHTML]="aboutHTML.hardwareInformation"></div>
</div>
<div myPluginSelector pluginSelectorId="about-instance-features">
<h4 class="visually-hidden" i18n>FEATURES</h4>
<my-instance-features-table></my-instance-features-table>
</div>

View File

@ -0,0 +1,33 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { ResolverData } from '../about-instance.resolver'
import { InstanceFeaturesTableComponent } from '@app/shared/shared-instance/instance-features-table.component'
@Component({
templateUrl: './about-instance-tech.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
standalone: true,
imports: [ CommonModule, PluginSelectorDirective, InstanceFeaturesTableComponent ]
})
export class AboutInstanceTechComponent implements OnInit {
aboutHTML: AboutHTML
constructor (
private route: ActivatedRoute,
private serverService: ServerService
) {}
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
const { aboutHTML }: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
}
}

View File

@ -1,63 +0,0 @@
<ng-template #modal>
<div class="modal-header">
<h1 i18n class="modal-title">Contact the administrator(s)<p class="modal-subtitle">{{ instanceName }}</p></h1>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body">
<form *ngIf="isContactFormEnabled()" novalidate [formGroup]="form" (ngSubmit)="sendForm()">
<div class="form-group">
<label i18n for="fromName">Your name</label>
<input
type="text" id="fromName" class="form-control"
formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
autocomplete="name"
>
<div *ngIf="formErrors.fromName" class="form-error" role="alert">{{ formErrors.fromName }}</div>
</div>
<div class="form-group">
<label i18n for="fromEmail">Your email</label>
<input
type="text" id="fromEmail" class="form-control"
formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }"
i18n-placeholder placeholder="Example: john@example.com" autocomplete="email"
>
<div *ngIf="formErrors.fromEmail" class="form-error" role="alert">{{ formErrors.fromEmail }}</div>
</div>
<div class="form-group">
<label i18n for="subject">Subject</label>
<input
type="text" id="subject" class="form-control"
formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }"
>
<div *ngIf="formErrors.subject" class="form-error" role="alert">{{ formErrors.subject }}</div>
</div>
<div class="form-group">
<label i18n for="body">Your message</label>
<textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }">
</textarea>
<div *ngIf="formErrors.body" class="form-error" role="alert">{{ formErrors.body }}</div>
</div>
<my-alert *ngIf="error" type="danger">{{ error }}</my-alert>
<div class="form-group inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button secondary-button"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" i18n-value value="Submit" class="peertube-button primary-button" [disabled]="!form.valid" />
</div>
</form>
<my-alert *ngIf="!isContactFormEnabled()" type="danger" i18n>The contact form is not enabled on this instance.</my-alert>
</div>
</ng-template>

View File

@ -0,0 +1,163 @@
<div class="root">
<div class="stats-block">
<h4 i18n>Our platform in figures</h4>
<div class="blocks" myPluginSelector pluginSelectorId="about-instance-statistics">
<div class="stat">
<strong>{{ stats.totalModerators + stats.totalAdmins | number }}</strong>
<div i18n>moderators</div>
<my-global-icon iconName="moderation"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalUsers | number }}</strong>
<div i18n>users</div>
<my-global-icon iconName="user"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalLocalVideos | number }}</strong>
<a routerLink="/videos/browse" [queryParams]="{ scope: 'local' }" i18n>videos</a>
<my-global-icon iconName="videos"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalLocalVideoViews | number }}</strong>
<div i18n>views</div>
<my-global-icon iconName="eye-open"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalLocalVideoComments | number }}</strong>
<div i18n>views</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalLocalVideoFilesSize | bytes:1 }}</strong>
<div i18n>hosted videos</div>
<my-global-icon iconName="film"></my-global-icon>
</div>
</div>
</div>
<div class="usage-rules-block">
<h4 i18n>Usage rules</h4>
<div class="blocks">
<div class="usage-rule">
<div class="icon-container">
<my-global-icon iconName="message-circle"></my-global-icon>
<div class="icon-status">
<div class="icon-info"></div>
</div>
</div>
<div *ngIf="config.instance.serverCountry">
<strong i18n>This platform has been created in {{ config.instance.serverCountry }}</strong>
<div class="rule-content">
<ng-container i18n>Your content (comments, videos...) must comply with the legislation in force in this country.</ng-container>
<ng-container *ngIf="aboutHTML.codeOfConduct" i18n> You must also follow our <a routerLink="/about/instance/moderation">code of conduct</a>.</ng-container>
</div>
</div>
</div>
<div class="usage-rule">
<div class="icon-container">
<my-global-icon iconName="user"></my-global-icon>
@if (config.signup.allowed && config.signup.allowedForCurrentIP) {
<div class="icon-status">
<my-global-icon iconName="tick"></my-global-icon>
</div>
} @else {
<div class="icon-status">
<my-global-icon iconName="cross"></my-global-icon>
</div>
}
</div>
<div>
@if (config.signup.allowed && config.signup.allowedForCurrentIP) {
@if (config.signup.requiresApproval) {
<strong i18n>You can <a routerLink="/signup">request an account</a> on our platform</strong>
@if (stats.averageRegistrationRequestResponseTimeMs) {
<div class="rule-content" i18n>Our moderator will validate it within a {{ stats.averageRegistrationRequestResponseTimeMs | myDaysDurationFormatter }}.</div>
} @else {
<div class="rule-content" i18n>Our moderator will validate it within a few days.</div>
}
} @else {
<strong i18n>You can <a routerLink="/signup">create an account</a> on our platform</strong>
}
} @else {
<strong i18n>Public registration on our platform is not allowed</strong>
}
</div>
</div>
<div class="usage-rule" *ngIf="config.federation.enabled">
<div class="icon-container">
<my-global-icon iconName="fediverse"></my-global-icon>
<div class="icon-status">
<my-global-icon iconName="tick"></my-global-icon>
</div>
</div>
<div>
<strong i18n>This platform is compatible with Mastodon, Lemmy, Misskey and other services from the Fediverse</strong>
<div class="rule-content" i18n>You can use these services to interact with our videos</div>
</div>
</div>
<div class="usage-rule">
@if (canUpload()) {
<div class="icon-container">
<my-global-icon iconName="upload"></my-global-icon>
<div class="icon-status">
<my-global-icon iconName="tick"></my-global-icon>
</div>
</div>
<div>
<strong i18n>Vous pouvez publier des vidéos</strong>
<div class="rule-content">
<ng-container i18n>By default, your account allows you to publish videos.</ng-container>
<ng-container *ngIf="canPublishLive()" i18n> You can also stream lives.</ng-container>
</div>
</div>
} @else {
<div class="icon-container">
<my-global-icon iconName="upload"></my-global-icon>
<div class="icon-status">
<my-global-icon iconName="cross"></my-global-icon>
</div>
</div>
<div>
@if (isContactFormEnabled()) {
<strong i18n>Contact us to publish videos</strong>
} @else {
<strong i18n>You can't publish videos</strong>
}
<div class="rule-content">
<ng-container i18n>By default, your account does not allow to publish videos.</ng-container>
<ng-container *ngIf="isContactFormEnabled()" i18n> If you want to publish videos, <a routerLink="/about/contact">contact us</a>.</ng-container>
</div>
</div>
}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,104 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_components' as *;
.root {
padding: 1.5rem;
border-radius: 14px;
background-color: pvar(--bg-secondary-400);
}
h4 {
font-size: 20px;
color: pvar(--fg-300);
font-weight: $font-bold;
}
.stats-block {
.blocks {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
}
.stat {
@include stats-card;
}
.usage-rules-block {
@include rfs(1.5rem, margin-top);
.blocks {
display: flex;
flex-direction: column;
gap: 1rem;
}
.usage-rule {
color: pvar(--fg-300);
border-radius: 8px;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.usage-rule:nth-child(2n + 1) {
background-color: pvar(--bg-secondary-450);
}
.usage-rule:nth-child(2n) {
border: 1px solid pvar(--border-secondary);
}
strong {
font-weight: $font-bold;
color: pvar(--fg-400);
}
.rule-content {
@include font-size(14px);
}
.icon-container {
position: relative;
> my-global-icon:first-child {
color: pvar(--secondary-icon-color);
@include global-icon-size(42px);
}
}
.icon-status {
background-color: pvar(--bg);
border-radius: 100%;
position: absolute;
right: -5px;
bottom: -5px;
text-align: center;
@include global-icon-size(18px);
my-global-icon {
@include global-icon-size(14px);
}
}
my-global-icon[iconName=tick] {
color: pvar(--green);
}
my-global-icon[iconName=cross] {
color: pvar(--red);
}
.icon-info::after {
content: '!';
display: block;
color: pvar(--fg-200);
font-size: 14px;
font-weight: $font-bold;
}
}

View File

@ -0,0 +1,56 @@
import { CommonModule, DecimalPipe, NgIf } from '@angular/common'
import { Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { BytesPipe } from '@app/shared/shared-main/common/bytes.pipe'
import { DaysDurationFormatterPipe } from '@app/shared/shared-main/date/days-duration-formatter.pipe'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { ServerConfig, ServerStats } from '@peertube/peertube-models'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
import { AuthService } from '@app/core'
@Component({
selector: 'my-instance-stat-rules',
templateUrl: './instance-stat-rules.component.html',
styleUrls: [ './instance-stat-rules.component.scss' ],
standalone: true,
imports: [
CommonModule,
NgIf,
GlobalIconComponent,
DecimalPipe,
DaysDurationFormatterPipe,
BytesPipe,
PluginSelectorDirective,
RouterLink
]
})
export class InstanceStatRulesComponent {
@Input({ required: true }) stats: ServerStats
@Input({ required: true }) config: ServerConfig
@Input({ required: true }) aboutHTML: AboutHTML
constructor (private auth: AuthService) {
}
canUpload () {
const user = this.auth.getUser()
if (user) {
if (user.videoQuota === 0 || user.videoQuotaDaily === 0) return false
return true
}
return this.config.user.videoQuota !== 0 && this.config.user.videoQuotaDaily !== 0
}
canPublishLive () {
return this.config.live.enabled
}
isContactFormEnabled () {
return this.config.email.enabled && this.config.contactForm.enabled
}
}

View File

@ -1,101 +0,0 @@
<p i18n *ngIf="null === serverStats">Loading instance statistics...</p>
<section *ngIf="null !== serverStats">
<h3 i18n>By users on this instance</h3>
<div class="row">
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalUsers | number }}</p>
<p class="stat-label" i18n>users</p>
</div>
<my-global-icon iconName="user"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideos | number }}</p>
<p class="stat-label" i18n>videos</p>
</div>
<my-global-icon iconName="film"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideoViews | number }}</p>
<p class="stat-label" i18n>views</p>
</div>
<my-global-icon iconName="eye-open"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideoComments | number }}</p>
<p class="stat-label" i18n>comments</p>
</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p>
<p class="stat-label" i18n>hosted video</p>
</div>
<my-global-icon iconName="home"></my-global-icon>
</div>
</div>
</div>
<h3 i18n>In this instance federation</h3>
<div class="row">
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalVideos | number }}</p>
<p class="stat-label" i18n>videos</p>
</div>
<my-global-icon iconName="film"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalVideoComments | number }}</p>
<p class="stat-label" i18n>comments</p>
</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalInstanceFollowers | number }}</p>
<p class="stat-label" i18n>followers</p>
</div>
<my-global-icon iconName="share"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalInstanceFollowing | number }}</p>
<p class="stat-label" i18n>following</p>
</div>
<my-global-icon iconName="globe"></my-global-icon>
</div>
</div>
</div>
</section>

View File

@ -1,40 +0,0 @@
@use '_variables' as *;
@use '_mixins' as *;
h3 {
font-size: 1.25rem;
}
.stat {
text-align: center;
margin-bottom: 1em;
overflow: hidden;
.stat-value {
font-size: 2.25em;
line-height: 1em;
margin: 0;
}
.stat-label {
font-size: 1.15em;
margin: 0;
}
.card-body {
z-index: 2;
}
}
my-global-icon {
opacity: 0.12;
position: absolute;
left: 16px;
top: -24px;
width: 110px;
height: 110px;
&.icon-bottom {
top: 4px;
}
}

View File

@ -1,16 +0,0 @@
import { Component, Input } from '@angular/core'
import { ServerStats } from '@peertube/peertube-models'
import { BytesPipe } from '../../shared/shared-main/common/bytes.pipe'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
import { NgIf, DecimalPipe } from '@angular/common'
@Component({
selector: 'my-instance-statistics',
templateUrl: './instance-statistics.component.html',
styleUrls: [ './instance-statistics.component.scss' ],
standalone: true,
imports: [ NgIf, GlobalIconComponent, DecimalPipe, BytesPipe ]
})
export class InstanceStatisticsComponent {
@Input() serverStats: ServerStats
}

View File

@ -1,7 +1,7 @@
<div class="margin-content mt-4">
<h1 i18n class="fs-3 text-center fw-semibold mb-3">
This website is powered by PeerTube
</h1>
<div class="margin-content mt-5">
<h3 i18n class="fs-3 text-center fw-semibold mb-3">
This platform is powered by PeerTube
</h3>
<img class="d-block my-4 mx-auto" width="121px" height="147px" src="/client/assets/images/mascot/default.svg" alt="mascot"/>
@ -58,102 +58,4 @@
</div>
</div>
</div>
<div class="d-flex flex-column">
<h2 class="mb-4 mt-5 text-center fs-5 fw-semibold">
<div class="anchor" id="privacy"></div> <!-- privacy anchor -->
<ng-container i18n>P2P & Privacy</ng-container>
</h2>
<p i18n>
PeerTube uses the BitTorrent protocol to share bandwidth between users by default to help lower the load on the server,
but ultimately leaves you the choice to switch back to regular streaming exclusively from the server of the video. What
follows applies only if you want to keep using the P2P mode of PeerTube.
</p>
<p i18n>
The main threat to your privacy induced by BitTorrent lies in your IP address being stored in the instance's BitTorrent
tracker as long as you download or watch the video.
</p>
<h3 i18n class="fs-5">What are the consequences?</h3>
<p i18n>
In theory, someone with enough technical skills could create a script that tracks which IP is downloading which video.
In practice, this is much more difficult because:
</p>
<ul>
<li i18n>
An HTTP request has to be sent on each tracker for each video to spy.
If we want to spy all PeerTube's videos, we have to send as many requests as there are videos (so potentially a lot)
</li>
<li i18n>
For each request sent, the tracker returns random peers at a limited number.
For instance, if there are 1000 peers in the swarm and the tracker sends only 20 peers for each request, there must be at least 50
requests sent to know every peer in the swarm
</li>
<li i18n>
Those requests have to be sent regularly to know who starts/stops watching a video. It is easy to detect that kind of behaviour
</li>
<li i18n>
If an IP address is stored in the tracker, it doesn't mean that the person behind the IP (if this person exists) has watched the
video
</li>
<li i18n>
The IP address is a vague information: usually, it regularly changes and can represent many persons or entities
</li>
<li i18n>
Web peers are not publicly accessible: because we use the websocket transport, the protocol is different from classic BitTorrent tracker.
When you are in a web browser, you send a signal containing your IP address to the tracker that will randomly choose other peers
to forward the information to.
See <a class="link-primary" href="https://github.com/yciabaud/webtorrent/blob/beps/bep_webrtc.rst">this document</a> for more information
</li>
</ul>
<p i18n>
The worst-case scenario of an average person spying on their friends is quite unlikely.
There are much more effective ways to get that kind of information.
</p>
<h3 i18n class="p2p-privacy-title">How does PeerTube compare with YouTube?</h3>
<p i18n>
The threats to privacy with YouTube are different from PeerTube's.
In YouTube's case, the platform gathers a huge amount of your personal information (not only your IP) to analyze them and track you.
Moreover, YouTube is owned by Google/Alphabet, a company that tracks you across many websites (via AdSense or Google Analytics).
</p>
<h3 i18n class="p2p-privacy-title">What can I do to limit the exposure of my IP address?</h3>
<p i18n>
Your IP address is public so every time you consult a website, there is a number of actors (in addition to the final website) seeing
your IP in their connection logs: ISP/routers/trackers/CDN and more.
PeerTube is transparent about it: we warn you that if you want to keep your IP private, you must use a VPN or Tor Browser.
Thinking that removing P2P from PeerTube will give you back anonymity doesn't make sense.
</p>
<h3 i18n class="p2p-privacy-title">What will be done to mitigate this problem?</h3>
<p i18n>
PeerTube wants to deliver the best countermeasures possible, to give you more choice
and render attacks less likely. Here is what we put in place so far:
</p>
<ul>
<li i18n>We set a limit to the number of peers sent by the tracker</li>
<li i18n>We set a limit on the request frequency received by the tracker</li>
<li i18n>Allow instance admins to disable P2P from the administration interface</li>
</ul>
<p i18n>
Ultimately, remember you can always disable P2P by toggling it in the video player, or just by disabling
WebRTC in your browser.
</p>
</div>
</div>

View File

@ -18,3 +18,7 @@
text-align: center;
margin-bottom: 1rem;
}
.card-body {
text-align: center;
}

View File

@ -1,7 +1,65 @@
<div>
<div class="margin-content">
<my-horizontal-menu [menuEntries]="menuEntries"></my-horizontal-menu>
<h1>
<my-global-icon iconName="help"></my-global-icon>
<ng-container i18n>About</ng-container>
</h1>
<div class="instance-info-container">
<div class="banner" *ngIf="bannerUrl">
<img [src]="bannerUrl" alt="">
</div>
<div class="instance-info">
<div class="avatar" *ngIf="avatarUrl">
<img [src]="avatarUrl" alt="">
</div>
<div>
<div class="instance-name">{{ config.instance.name }}</div>
<div class="instance-description">{{ config.instance.shortDescription }}</div>
</div>
<div class="ms-auto">
<div class="social-buttons d-flex flex-wrap justify-content-end">
<a
*ngIf="config.instance.social.mastodonLink"
class="media peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the Mastodon profile"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.mastodonLink"
>
<my-global-icon iconName="mastodon"></my-global-icon>
</a>
<a
*ngIf="config.instance.social.blueskyLink"
class="media peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the Bluesky profile"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.blueskyLink"
>
<my-global-icon iconName="bluesky"></my-global-icon>
</a>
<a
*ngIf="config.instance.social.externalLink"
class="external-link peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the external website"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.externalLink"
>
<my-global-icon iconName="link"></my-global-icon>
</a>
</div>
<div class="d-flex flex-wrap justify-content-end">
<my-button *ngIf="isContactFormEnabled()" class="ms-3" theme="primary" ptRouterLink="/about/contact" i18n>Contact us</my-button>
<my-button *ngIf="config.instance.support.text" class="ms-3" theme="secondary" ptRouterLink="/about/instance/support" i18n>Support</my-button>
</div>
</div>
</div>
</div>
<my-horizontal-menu [menuEntries]="menuEntries" withMarginBottom="false"></my-horizontal-menu>
</div>
<router-outlet></router-outlet>
</div>

View File

@ -0,0 +1,101 @@
@use '_variables' as *;
@use '_mixins' as *;
$container-radius: 14px;
h1 {
font-weight: $font-bold;
@include font-size(2rem);
@include rfs(1.5rem, margin-bottom);
my-global-icon {
@include margin-right(0.5rem);
@include global-icon-size(24px);
}
}
.instance-info-container {
background: pvar(--bg-secondary-400);
border-radius: $container-radius;
@include rfs(2rem, margin-bottom);
}
.instance-info {
display: flex;
flex-wrap: wrap;
@include rfs(1.25rem, gap);
@include rfs(1.75rem, padding);
}
.avatar img {
border-radius: 24px;
width: 110px;
height: 110px;
}
.banner {
@include fade-text(73%, #{pvar(--bg-secondary-400)});
img {
border-start-start-radius: $container-radius;
border-start-end-radius: $container-radius;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
}
.instance-name {
color: pvar(--fg-350);
font-weight: $font-bold;
line-height: 1;
margin-bottom: 0.5rem;
@include font-size(2.25rem);
}
.instance-description {
color: pvar(--fg-300);
@include font-size(1.25rem);
}
.social-buttons {
.peertube-button-link {
@include margin-left(0.5rem);
}
.media {
color: pvar(--fg-300);
background-color: pvar(--bg-secondary-450);
border: 1px solid pvar(--bg-secondary-450);
&:hover {
color: pvar(--fg-300);
background-color: pvar(--bg-secondary-400);
}
&:active {
background-color: pvar(--bg-secondary-350);
}
}
.external-link {
color: pvar(--bg-secondary-350);
background-color: pvar(--fg-350);
border: 1px solid pvar(--fg-350);
&:hover {
background-color: pvar(--fg-400);
color: pvar(--bg-secondary-400);
}
&:active {
background-color: pvar(--fg-450);
}
}
}

View File

@ -1,30 +1,68 @@
import { Component } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { ServerService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { Actor } from '@app/shared/shared-main/account/actor.model'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
import { maxBy } from '@peertube/peertube-core-utils'
import { HTMLServerConfig } from '@peertube/peertube-models'
@Component({
selector: 'my-about',
templateUrl: './about.component.html',
styleUrls: [ './about.component.scss' ],
standalone: true,
imports: [ RouterOutlet, HorizontalMenuComponent ]
imports: [ CommonModule, RouterOutlet, HorizontalMenuComponent, GlobalIconComponent, ButtonComponent, SupportModalComponent ]
})
export class AboutComponent {
menuEntries: HorizontalMenuEntry[] = [
{
label: $localize`Platform`,
routerLink: '/about/instance',
pluginSelectorId: 'about-menu-instance'
},
{
label: $localize`PeerTube`,
routerLink: '/about/peertube',
pluginSelectorId: 'about-menu-peertube'
},
{
label: $localize`Network`,
routerLink: '/about/follows',
pluginSelectorId: 'about-menu-network'
}
]
export class AboutComponent implements OnInit {
@ViewChild('supportModal') supportModal: SupportModalComponent
bannerUrl: string
avatarUrl: string
menuEntries: HorizontalMenuEntry[] = []
config: HTMLServerConfig
constructor (
private server: ServerService
) {
}
ngOnInit () {
this.config = this.server.getHTMLConfig()
this.bannerUrl = this.config.instance.banners.length !== 0
? maxBy(this.config.instance.banners, 'width').path
: undefined
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.config.instance, 110)
this.menuEntries = [
{
label: $localize`Platform`,
routerLink: '/about/instance/home',
pluginSelectorId: 'about-menu-instance'
},
{
label: $localize`PeerTube`,
routerLink: '/about/peertube',
pluginSelectorId: 'about-menu-peertube'
},
{
label: $localize`Network`,
routerLink: '/about/follows',
pluginSelectorId: 'about-menu-network'
}
]
}
isContactFormEnabled () {
return this.config.email.enabled && this.config.contactForm.enabled
}
}

View File

@ -1,19 +1,18 @@
import { Routes } from '@angular/router'
import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
import { AboutComponent } from './about.component'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { DynamicElementService } from '@app/shared/shared-custom-markup/dynamic-element.service'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
import { AboutContactComponent } from './about-contact/about-contact.component'
import { aboutInstanceRoutes } from './about-instance/about-instance.routes'
import { AboutComponent } from './about.component'
export default [
{
path: '',
component: AboutComponent,
providers: [
AboutInstanceResolver,
InstanceFollowService,
CustomMarkupService,
DynamicElementService
@ -24,31 +23,9 @@ export default [
redirectTo: 'instance',
pathMatch: 'full'
},
{
path: 'instance',
component: AboutInstanceComponent,
data: {
meta: {
title: $localize`About this instance`
}
},
resolve: {
instanceData: AboutInstanceResolver
}
},
{
path: 'contact',
component: AboutInstanceComponent,
data: {
meta: {
title: $localize`Contact`
},
isContact: true
},
resolve: {
instanceData: AboutInstanceResolver
}
},
...aboutInstanceRoutes,
{
path: 'peertube',
component: AboutPeertubeComponent,
@ -58,6 +35,7 @@ export default [
}
}
},
{
path: 'follows',
component: AboutFollowsComponent,
@ -66,6 +44,16 @@ export default [
title: $localize`About this instance's network`
}
}
},
{
path: 'contact',
component: AboutContactComponent,
data: {
meta: {
title: $localize`Contact`
}
}
}
]
}

View File

@ -5,13 +5,13 @@ import { ActivatedRoute, Router } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service'
import { Notifier } from '@app/core'
import { ServerService } from '@app/core/server/server.service'
import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators'
import {
ADMIN_EMAIL_VALIDATOR,
CACHE_SIZE_VALIDATOR,
CONCURRENCY_VALIDATOR,
EXPORT_EXPIRATION_VALIDATOR,
EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
INDEX_URL_VALIDATOR,
INSTANCE_NAME_VALIDATOR,
INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
MAX_INSTANCE_LIVES_VALIDATOR,
@ -19,7 +19,6 @@ import {
MAX_SYNC_PER_USER,
MAX_USER_LIVES_VALIDATOR,
MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
SEARCH_INDEX_URL_VALIDATOR,
SERVICES_TWITTER_USERNAME_VALIDATOR,
SIGNUP_LIMIT_VALIDATOR,
SIGNUP_MINIMUM_AGE_VALIDATOR,
@ -124,6 +123,16 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
categories: null,
languages: null,
serverCountry: null,
support: {
text: null
},
social: {
externalLink: URL_VALIDATOR,
mastodonLink: URL_VALIDATOR,
blueskyLink: URL_VALIDATOR
},
defaultClientRoute: null,
customizations: {
@ -312,7 +321,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
},
autoFollowIndex: {
enabled: null,
indexUrl: INDEX_URL_VALIDATOR
indexUrl: URL_VALIDATOR
}
}
},
@ -329,7 +338,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
},
searchIndex: {
enabled: null,
url: SEARCH_INDEX_URL_VALIDATOR,
url: URL_VALIDATOR,
disableLocalSearch: null,
isDefaultSearch: null
}

View File

@ -99,9 +99,81 @@
</div>
</div>
<div class="form-group">
<label i18n for="instanceServerCountry">Server country</label>
<div i18n class="label-small-info">PeerTube uses this setting to explain to your users which law they must follow in the "About" pages</div>
<input
type="text" id="instanceServerCountry" class="form-control"
formControlName="serverCountry" [ngClass]="{ 'input-error': formErrors.instance.serverCountry }"
>
<div *ngIf="formErrors.instance.serverCountry" class="form-error" role="alert">{{ formErrors.instance.serverCountry }}</div>
</div>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- social grid -->
<div class="title-col">
<h2 i18n>SOCIAL</h2>
<div i18n class="inner-form-description">
Social links and support information displayed in the <em>About</em> pages
</div>
</div>
<div class="content-col">
<div class="form-group" formGroupName="support">
<label i18n for="instanceSupportText">Support text</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">Explain to your users how to support your platform. If set, PeerTube will display a "Support" button in "About" instance pages</div>
<my-markdown-textarea
inputId="instanceSupportText" formControlName="text" markdownType="enhanced"
[formError]="formErrors['instance.support.text']"
></my-markdown-textarea>
</div>
<ng-container formGroupName="social">
<div class="form-group">
<label i18n for="instanceSocialExternalLink">External link</label>
<div i18n class="label-small-info">Link to your main website</div>
<input
type="text" id="instanceSocialExternalLink" class="form-control"
formControlName="externalLink" [ngClass]="{ 'input-error': formErrors.instance.social.externalLink }"
>
<div *ngIf="formErrors.instance.social.externalLink" class="form-error" role="alert">{{ formErrors.instance.social.externalLink }}</div>
</div>
<div class="form-group">
<label i18n for="instanceSocialMastodonLink">Mastodon link</label>
<input
type="text" id="instanceSocialMastodonLink" class="form-control"
formControlName="mastodonLink" [ngClass]="{ 'input-error': formErrors.instance.social.mastodonLink }"
>
<div *ngIf="formErrors.instance.social.mastodonLink" class="form-error" role="alert">{{ formErrors.instance.social.mastodonLink }}</div>
</div>
<div class="form-group">
<label i18n for="instanceSocialBlueskyLink">Bluesky link</label>
<input
type="text" id="instanceSocialBlueskyLink" class="form-control"
formControlName="blueskyLink" [ngClass]="{ 'input-error': formErrors.instance.social.blueskyLink }"
>
<div *ngIf="formErrors.instance.social.blueskyLink" class="form-error" role="alert">{{ formErrors.instance.social.blueskyLink }}</div>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- moderation & nsfw grid -->
<div class="title-col">
<h2 i18n>MODERATION & NSFW</h2>

View File

@ -16,12 +16,12 @@
@if (signupAllowed) {
<p i18n>
This instance allows registration. However, be careful to check the <a class="link-primary terms-anchor d-inline" (click)="onTermsClick($event, instanceInformation)" href="/about/instance#terms">Terms</a><a class="terms-link" target="_blank" routerLink="/about/instance" fragment="terms">Terms</a> before creating an account.
This instance allows registration. However, be careful to check the <a class="link-primary terms-anchor d-inline" (click)="onTermsClick($event, instanceInformation)" href="/about/instance#terms">Terms</a><a class="terms-link" target="_blank" routerLink="/about/instance">Terms</a> before creating an account.
You may also search for another instance to match your exact needs at: <a class="link-primary" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer">https://joinpeertube.org/instances</a>.
</p>
} @else {
<p i18n>
Currently this instance doesn't allow for user registration, you may check the <a class="link-primary terms-anchor d-inline" (click)="onTermsClick($event, instanceInformation)" href="/about/instance#terms">Terms</a><a class="terms-link" target="_blank" routerLink="/about/instance" fragment="terms">Terms</a> for more details or find an instance that gives you the possibility to sign up for an account and upload your videos there.
Currently this instance doesn't allow for user registration, you may check the <a class="link-primary terms-anchor d-inline" (click)="onTermsClick($event, instanceInformation)" href="/about/instance#terms">Terms</a><a class="terms-link" target="_blank" routerLink="/about/instance">Terms</a> for more details or find an instance that gives you the possibility to sign up for an account and upload your videos there.
Find yours among multiple instances at: <a class="link-primary" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer">https://joinpeertube.org/instances</a>.
</p>
}

View File

@ -123,4 +123,4 @@
<router-outlet></router-outlet>
</div>
<my-support-modal #supportModal [videoChannel]="videoChannel"></my-support-modal>
<my-support-modal #supportModal [name]="videoChannel.displayName" [content]="videoChannel.support"></my-support-modal>

View File

@ -68,6 +68,6 @@
</div>
<ng-container *ngIf="video">
<my-support-modal #supportModal [video]="video"></my-support-modal>
<my-support-modal #supportModal [name]="video.channel.displayName" [content]="video.support"></my-support-modal>
<my-video-share #videoShareModal [video]="video" [videoCaptions]="videoCaptions" [playlist]="playlist"></my-video-share>
</ng-container>

View File

@ -7,7 +7,7 @@
</ng-container>
</span>
<a class="link-primary" i18n i18n-title title="Get more information" target="_blank" rel="noopener noreferrer" href="/about/peertube#privacy">More information</a>
<a class="link-primary" i18n i18n-title title="Get more information" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/admin/privacy-guide#peertube-p2p-privacy">More information</a>
</div>
<button i18n class="ms-2 peertube-button primary-button" (click)="acceptedPrivacyConcern()">

View File

@ -227,6 +227,7 @@ export class ThemeService {
}
const mainColorHSL = toHSLA(parse(mainColor))
debugLogger(`Theme main variable ${mainColor} -> ${this.toHSLStr(mainColorHSL)}`)
// Inject in alphabetical order for easy debug
const toInject: { id: number, key: string, value: string }[] = [

View File

@ -15,8 +15,8 @@ import { NotificationDropdownComponent } from '@app/header/notification-dropdown
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
import { QuickSettingsModalComponent } from '@app/menu/quick-settings-modal.component'
import { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component'
import { InputSwitchComponent } from '@app/shared/shared-forms/input-switch.component'
import { PeertubeModalService } from '@app/shared/shared-main/peertube-modal/peertube-modal.service'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { LoginLinkComponent } from '@app/shared/shared-main/users/login-link.component'
import { SignupLabelComponent } from '@app/shared/shared-main/users/signup-label.component'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@ -35,7 +35,7 @@ import { SearchTypeaheadComponent } from './search-typeahead.component'
CommonModule,
NotificationDropdownComponent,
ActorAvatarComponent,
InputSwitchComponent,
PluginSelectorDirective,
SignupLabelComponent,
LoginLinkComponent,
LanguageChooserComponent,

View File

@ -81,12 +81,12 @@
</div>
</div>
<div *ngIf="!collapsed" class="mt-3 mx-4">
<div class="powered-by" *ngIf="!collapsed" class="mt-3 mx-4">
<div class="fs-8" i18n>
Platform powered by <a class="" href="https://joinpeertube.org" target="_blank" rel="noopener noreferrer">PeerTube</a>
Platform powered by <a class="fw-bold" href="https://joinpeertube.org" target="_blank" rel="noopener noreferrer">PeerTube</a>
</div>
<a class="d-block fs-8" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer" i18n>Discover more platforms</a>
<a class="d-block fs-8 fw-bold" href="https://joinpeertube.org/instances" target="_blank" rel="noopener noreferrer" i18n>Discover more platforms</a>
</div>
</div>

View File

@ -192,6 +192,10 @@
}
}
.powered-by {
color: pvar(--fg-200);
}
.collapsed {
.menu-block,
.toggle-menu-container,

View File

@ -7,3 +7,10 @@ export const REQUIRED_VALIDATOR: BuildFormValidator = {
required: $localize`This field is required.`
}
}
export const URL_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.pattern(/^https:\/\//) ],
MESSAGES: {
pattern: $localize`This field must be a URL`
}
}

View File

@ -123,20 +123,6 @@ export const CONCURRENCY_VALIDATOR: BuildFormValidator = {
}
}
export const INDEX_URL_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.pattern(/^https:\/\//) ],
MESSAGES: {
pattern: $localize`Index URL must be a URL`
}
}
export const SEARCH_INDEX_URL_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.pattern(/^https?:\/\//) ],
MESSAGES: {
pattern: $localize`Search index URL must be a URL`
}
}
export const EXPORT_EXPIRATION_VALIDATOR: BuildFormValidator = {
VALIDATORS: [ Validators.required, Validators.min(1) ],
MESSAGES: {

View File

@ -14,9 +14,13 @@ const icons = {
'following': require('../../../assets/images/misc/account-arrow-right.svg'), // material ui
'tip': require('../../../assets/images/misc/tip.svg'), // material ui
'flame': require('../../../assets/images/misc/flame.svg'),
'fediverse': require('../../../assets/images/misc/fediverse.svg'),
'mastodon': require('../../../assets/images/misc/mastodon.svg'),
'bluesky': require('../../../assets/images/misc/bluesky.svg'),
// feather/lucide icons
'menu': require('../../../assets/images/feather/menu.svg'),
'link': require('../../../assets/images/feather/link.svg'),
'history': require('../../../assets/images/feather/history.svg'),
'registry': require('../../../assets/images/feather/registry.svg'),
'subscriptions': require('../../../assets/images/feather/subscriptions.svg'),

View File

@ -1,13 +1,14 @@
import { Component, OnInit } from '@angular/core'
import { NgFor, NgIf } from '@angular/common'
import { Component, Input, OnInit } from '@angular/core'
import { ServerService } from '@app/core'
import { formatICU } from '@app/helpers'
import { ServerConfig, ServerStats } from '@peertube/peertube-models'
import { of } from 'rxjs'
import { HelpComponent } from '../shared-main/buttons/help.component'
import { BytesPipe } from '../shared-main/common/bytes.pipe'
import { PeerTubeTemplateDirective } from '../shared-main/common/peertube-template.directive'
import { HelpComponent } from '../shared-main/buttons/help.component'
import { FeatureBooleanComponent } from './feature-boolean.component'
import { NgIf, NgFor } from '@angular/common'
import { DaysDurationFormatterPipe } from '../shared-main/date/days-duration-formatter.pipe'
import { FeatureBooleanComponent } from './feature-boolean.component'
@Component({
selector: 'my-instance-features-table',
@ -17,9 +18,10 @@ import { DaysDurationFormatterPipe } from '../shared-main/date/days-duration-for
imports: [ NgIf, FeatureBooleanComponent, HelpComponent, PeerTubeTemplateDirective, NgFor, BytesPipe ]
})
export class InstanceFeaturesTableComponent implements OnInit {
@Input() serverConfig: ServerConfig
@Input() serverStats: ServerStats
quotaHelpIndication = ''
serverConfig: ServerConfig
serverStats: ServerStats
constructor (
private serverService: ServerService
@ -48,15 +50,19 @@ export class InstanceFeaturesTableComponent implements OnInit {
}
ngOnInit () {
this.serverService.getConfig()
.subscribe(config => {
this.serverConfig = config
const serverConfigObs = this.serverConfig
? of(this.serverConfig)
: this.serverService.getConfig()
this.buildQuotaHelpIndication()
})
serverConfigObs.subscribe(config => {
this.serverConfig = config
this.serverService.getServerStats()
.subscribe(stats => this.serverStats = stats)
this.buildQuotaHelpIndication()
})
if (!this.serverStats) {
this.serverService.getServerStats().subscribe(stats => this.serverStats = stats)
}
}
buildNSFWLabel () {

View File

@ -1,4 +1,4 @@
<div class="root">
<div class="root" [ngClass]="{ 'with-mb': withMarginBottom }">
<h1 *ngIf="h1">
<my-global-icon *ngIf="h1Icon" [iconName]="h1Icon"></my-global-icon>
@ -16,8 +16,8 @@
</a>
</ng-template>
<div class="parent-container">
<my-list-overflow [items]="menuEntries" [itemTemplate]="entryTemplate" hasBorder="true"></my-list-overflow>
<div [ngClass]="{ 'children-container': areChildren, 'parent-container': !areChildren }">
<my-list-overflow [items]="menuEntries" [itemTemplate]="entryTemplate" [hasBorder]="!areChildren"></my-list-overflow>
</div>
@if (children && children.length !== 0) {

View File

@ -4,7 +4,9 @@
.root {
width: 100%;
@include rfs(2.5rem, margin-bottom);
&.with-mb {
@include rfs(2.5rem, margin-bottom);
}
}
h1 {

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'
import { Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core'
import { booleanAttribute, Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, NavigationEnd, Router, RouterModule } from '@angular/router'
import { GlobalIconComponent, GlobalIconName } from '@app/shared/shared-icons/global-icon.component'
import { logger } from '@root-helpers/logger'
@ -47,6 +47,9 @@ export class HorizontalMenuComponent implements OnInit, OnChanges, OnDestroy {
@Input() h1: string
@Input() h1Icon: GlobalIconName
@Input({ transform: booleanAttribute }) areChildren = false
@Input({ transform: booleanAttribute }) withMarginBottom = true
activeParent: HorizontalMenuEntry
children: HorizontalMenuEntry[] = []

View File

@ -1,6 +1,6 @@
<ng-template #modal let-hide="close">
<div class="modal-header">
<h4 i18n class="modal-title">Support {{ displayName }}</h4>
<h4 i18n class="modal-title">Support {{ name }}</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>

View File

@ -1,8 +1,6 @@
import { Component, Input, ViewChild } from '@angular/core'
import { Component, Input, OnChanges, ViewChild } from '@angular/core'
import { MarkdownService } from '@app/core'
import { VideoDetails } from '@app/shared/shared-main/video/video-details.model'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { VideoChannel } from '@peertube/peertube-models'
import { GlobalIconComponent } from '../shared-icons/global-icon.component'
@Component({
@ -11,32 +9,25 @@ import { GlobalIconComponent } from '../shared-icons/global-icon.component'
standalone: true,
imports: [ GlobalIconComponent ]
})
export class SupportModalComponent {
@Input() video: VideoDetails = null
@Input() videoChannel: VideoChannel = null
export class SupportModalComponent implements OnChanges {
@Input({ required: true }) name: string
@Input({ required: true }) content: string
@ViewChild('modal', { static: true }) modal: NgbModal
htmlSupport = ''
displayName = ''
constructor (
private markdownService: MarkdownService,
private modalService: NgbModal
) { }
show () {
const modalRef = this.modalService.open(this.modal, { centered: true })
const support = this.video?.support || this.videoChannel.support
this.markdownService.enhancedMarkdownToHTML({ markdown: support, withEmoji: true, withHtml: true })
ngOnChanges () {
this.markdownService.enhancedMarkdownToHTML({ markdown: this.content, withEmoji: true, withHtml: true })
.then(r => this.htmlSupport = r)
}
this.displayName = this.video
? this.video.channel.displayName
: this.videoChannel.displayName
return modalRef
show () {
return this.modalService.open(this.modal, { centered: true })
}
}

View File

@ -49,7 +49,7 @@
i18n-labelText labelText="Help share videos being played"
>
<ng-container ngProjectAs="description">
<span i18n>The <a class="link-primary" routerLink="/about/peertube" fragment="privacy" target="_blank">sharing system</a> implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load.</span>
<span i18n>The <a class="link-primary" href="https://docs.joinpeertube.org/admin/privacy-guide#peertube-p2p-privacy" target="_blank">sharing system</a> implies that some technical information about your system (such as a public IP address) can be sent to other peers, but greatly helps to reduce server load.</span>
</ng-container>
</my-peertube-checkbox>
</div>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>

After

Width:  |  Height:  |  Size: 358 B

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<path
d="m 5.8043497,3.4268235 c 2.52225,1.89355 5.2350703,5.73305 6.2312303,7.7933105 0.99616,-2.0602605 3.70898,-5.8997605 6.23123,-7.7933105 1.81992,-1.36632 4.76877,-2.42349 4.76877,0.9405 0,0.67185 -0.38519,5.6438105 -0.61112,6.4510705 -0.7853,2.806279 -3.64685,3.522059 -6.19234,3.088829 4.44938,0.75726 5.58122,3.26557 3.13678,5.77388 -4.64246,4.76378 -6.67251,-1.19524 -7.19268,-2.72215 -0.0954,-0.27992 -0.13998,-0.41087 -0.14064,-0.29952 -6.6e-4,-0.11135 -0.0453,0.0196 -0.14064,0.29952 -0.52017,1.52691 -2.5502203,7.48593 -7.1926803,2.72215 -2.44444,-2.50831 -1.3126,-5.01662 3.13678,-5.77388 -2.54549,0.43323 -5.40704,-0.28255 -6.19234,-3.088829 -0.22593,-0.80726 -0.61112,-5.7792205 -0.61112,-6.4510705 0,-3.36399 2.94885,-2.30682 4.76877,-0.9405 z"
fill="currentColor"
id="path1"
style="stroke-width:0.0387324" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.009 0C11.4571 0 10.9828 0.189895 10.586 0.569684C10.1893 0.949473 9.97373 1.42421 9.93923 1.99389C9.90473 2.56358 10.0815 3.06421 10.4696 3.49579C10.8577 3.92737 11.3407 4.16042 11.9185 4.19495C12.4963 4.22947 13.0008 4.05253 13.432 3.6641C13.8632 3.27568 14.0961 2.79232 14.1306 2.214C14.1651 1.63568 13.9883 1.12642 13.6002 0.68621C13.2121 0.246 12.7248 0.0172632 12.1384 0H12.009ZM9.75812 2.46L3.75572 5.51558C4.10069 5.86084 4.31629 6.27516 4.40253 6.75852L10.3791 3.70295C10.0513 3.35768 9.84437 2.94337 9.75812 2.46ZM14.0788 3.15916C13.8546 3.59074 13.5269 3.91874 13.0957 4.14316L17.8303 8.90779C18.0545 8.47621 18.3909 8.14821 18.8393 7.92379L14.0788 3.15916ZM10.4049 3.72884L6.91216 10.5909L7.94705 11.6267L11.6468 4.37621C11.1639 4.28989 10.7499 4.0741 10.4049 3.72884ZM13.0957 4.14316C12.7162 4.33305 12.3195 4.41937 11.9055 4.4021L11.6986 4.37621L12.2419 7.79431L13.6907 8.02737L13.0957 4.14316ZM2.07402 5.02358C1.52207 5.02358 1.04774 5.21779 0.651034 5.60621C0.254324 5.99463 0.0387206 6.46937 0.00422407 7.03042C-0.0302725 7.59147 0.146522 8.0921 0.534609 8.53231C0.922695 8.97252 1.40996 9.20558 1.9964 9.23147C2.58284 9.25737 3.09167 9.0761 3.52287 8.68768C3.95408 8.29926 4.18262 7.81589 4.20849 7.23758C4.23436 6.65926 4.05326 6.15431 3.66517 5.72274C3.27708 5.29116 2.79844 5.0581 2.22925 5.02358H2.07402ZM4.40253 6.78442C4.41978 6.93979 4.42841 7.09516 4.42841 7.25052C4.41116 7.57852 4.32492 7.88063 4.16968 8.15684L7.58484 8.70063L8.25752 7.40589L4.40253 6.78442ZM10.2238 7.71663L9.55114 9.03726L17.5975 10.3061C17.5802 10.168 17.5716 10.0213 17.5716 9.86589C17.5888 9.53789 17.6751 9.22716 17.8303 8.93368L10.2238 7.71663ZM19.8484 7.872C19.2964 7.88926 18.8221 8.08779 18.4254 8.46758C18.0287 8.84737 17.8174 9.3221 17.7915 9.89179C17.7656 10.4615 17.9467 10.9621 18.3348 11.3937C18.7229 11.8253 19.2059 12.0583 19.7837 12.0928C20.3615 12.1274 20.866 11.9504 21.2972 11.562C21.7284 11.1736 21.9613 10.6859 21.9958 10.0989C22.0303 9.512 21.8535 9.00273 21.4654 8.57116C21.0773 8.13958 20.59 7.90652 20.0036 7.872H19.8484ZM4.16968 8.20863C3.94546 8.64021 3.60911 8.96821 3.16066 9.19263L8.59387 14.6305L9.91336 13.9832L4.16968 8.20863ZM3.13479 9.19263C2.77257 9.38252 2.39311 9.46884 1.9964 9.45158L1.76355 9.42568L2.79844 16.0547C3.16066 15.8821 3.54874 15.8044 3.9627 15.8217L4.16968 15.8476L3.13479 9.19263ZM12.5523 9.76231L13.7942 17.8156C14.1564 17.6429 14.5359 17.5653 14.9326 17.5825L15.1913 17.6084L14.0012 9.99537L12.5523 9.76231ZM17.5975 10.3579L14.5445 11.9116L14.7515 13.3617L18.2443 11.6008C17.8993 11.2556 17.6837 10.8413 17.5975 10.3579ZM18.2701 11.6267L15.1913 17.6084C15.6743 17.6947 16.0882 17.9105 16.4332 18.2558L19.512 12.2741C19.0291 12.1878 18.6151 11.972 18.2701 11.6267ZM5.98075 12.3777L4.22143 15.8476C4.70438 15.9339 5.11834 16.1497 5.4633 16.4949L7.01565 13.4135L5.98075 12.3777ZM12.7593 12.8179L5.48918 16.5208C5.81689 16.8661 6.02387 17.2804 6.11011 17.7638L12.9663 14.268L12.7593 12.8179ZM11.3363 15.4074L10.0168 16.0547L12.7593 18.8255C12.9836 18.3939 13.3199 18.0659 13.7683 17.8415L11.3363 15.4074ZM3.80747 16.0288C3.25552 16.0288 2.77688 16.223 2.37155 16.6115C1.96622 16.9999 1.75061 17.4746 1.72474 18.0357C1.69887 18.5967 1.87997 19.093 2.26806 19.5246C2.65615 19.9562 3.1391 20.1893 3.71692 20.2238C4.29473 20.2583 4.79924 20.0814 5.23045 19.6929C5.66166 19.3045 5.89451 18.8212 5.92901 18.2428C5.9635 17.6645 5.78671 17.1596 5.39862 16.728C5.01054 16.2964 4.52327 16.0634 3.93683 16.0288H3.80747ZM6.11011 17.7897C6.14461 17.945 6.15323 18.1004 6.13599 18.2558C6.11874 18.5838 6.04112 18.8859 5.90313 19.1621L12.5265 20.2238C12.5092 20.0684 12.5006 19.913 12.5006 19.7577C12.5178 19.4469 12.6041 19.1448 12.7593 18.8514L6.11011 17.7897ZM14.7774 17.7897C14.2254 17.7897 13.7511 17.9839 13.3544 18.3723C12.9577 18.7607 12.7464 19.2355 12.7205 19.7965C12.6946 20.3576 12.8714 20.8582 13.2509 21.2984C13.6304 21.7386 14.1133 21.9717 14.6998 21.9976C15.2862 22.0235 15.795 21.8422 16.2262 21.4538C16.6574 21.0654 16.8903 20.582 16.9248 20.0037C16.9593 19.4254 16.7825 18.9204 16.3944 18.4888C16.0063 18.0573 15.519 17.8242 14.9326 17.7897H14.7774Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-mastodon" viewBox="0 0 16 16">
<path d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a4 4 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522q0-1.288.66-2.046c.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764q.662.757.661 2.046z"/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

View File

@ -75,6 +75,7 @@ body {
--active-icon-bg: #{pvar(--primary)};
--border-primary: #{pvar(--primary)};
--border-secondary: #{pvar(--bg-secondary-450)};
--alert-primary-fg: #{pvar(--on-primary-200)};
--alert-primary-bg: #{pvar(--primary-200)};
@ -104,6 +105,7 @@ body {
--border-primary: #F2690D;
--fg: hsl(0 14% 2%);
--fg-200: hsl(0 14% 29%);
--bg: hsl(250 5% 96%);
--bg-secondary: hsl(0 12% 72%);

View File

@ -22,9 +22,8 @@
}
&.badge-secondary {
color: pvar(--bg);
background-color: pvar(--fg-300);
opacity: 0.7;
color: pvar(--fg-450);
background-color: pvar(--bg-secondary-400);
}
&.badge-banned,

View File

@ -143,7 +143,7 @@
@mixin peertube-button {
padding: pvar(--input-y-padding) pvar(--input-x-padding);
font-weight: $font-semibold;
font-weight: $font-bold;
border-radius: pvar(--input-border-radius);

View File

@ -0,0 +1,45 @@
@use 'sass:math';
@use '_variables' as *;
@use '_mixins' as *;
@mixin stats-card {
position: relative;
border: 1px solid pvar(--border-secondary);
border-radius: 4px;
min-width: 170px;
padding: 1rem 1.5rem;
text-align: center;
overflow: hidden;
strong,
div,
a {
position: relative;
z-index: 2;
line-height: 1.2;
}
strong {
color: pvar(--fg-400);
font-weight: $font-bold;
@include font-size(2rem);
}
div,
a {
font-size: 18px;
color: pvar(--fg-200);
display: block;
}
my-global-icon {
position: absolute;
left: 10px;
top: -20px;
color: pvar(--bg-secondary-450);
z-index: 1;
@include global-icon-size(60px);
}
}

View File

@ -199,6 +199,7 @@ $variables: (
--primary-50: var(--primary-50),
--border-primary: var(--border-primary),
--border-secondary: var(--border-secondary),
--alert-primary-fg: var(--alert-primary-fg),
--alert-primary-bg: var(--alert-primary-bg),

View File

@ -460,7 +460,7 @@ body .p-autocomplete-panel .p-autocomplete-items .p-autocomplete-item {
}
body .p-autocomplete-panel .p-autocomplete-items .p-autocomplete-item.p-highlight,
body .p-autocomplete-panel .p-autocomplete-items .p-autocomplete-item:hover {
color: #ffffff;
color: pvar(--on-primary);
background-color: pvar(--primary);
}
body .p-autocomplete-panel .p-autocomplete-items .p-autocomplete-group {
@ -648,7 +648,7 @@ p-chips.p-chips-clearable .p-chips-clear-icon {
margin-top: 0;
}
.p-multiselect-panel .p-multiselect-items .p-multiselect-item.p-highlight {
color: pvar(--bg);
color: pvar(--on-primary);
background: pvar(--primary);
}
.p-multiselect-panel .p-multiselect-items .p-multiselect-item.p-highlight.p-focus {
@ -1069,7 +1069,7 @@ p-tablecheckbox:hover div .p-checkbox-box {
.p-checkbox-box {
&.p-highlight {
color: pvar(--bg) !important;
color: pvar(--on-primary) !important;
background-color: pvar(--primary) !important;
border-color: pvar(--primary) !important;
}

View File

@ -948,13 +948,32 @@ instance:
# Could be overridden per user with a setting
default_nsfw_policy: 'do_not_list'
# PeerTube uses this setting to explain to your users which law they must follow in the "About" instance pages
server_country: '' # Example: "France", "United States", "España"
support:
# Explain to your users how to support your instance
# If set, PeerTube will display a "Support" button in "About" instance pages
text: '' # Supports Markdown
# If set, PeerTube will display buttons in "About" instance pages
social:
# Link to your main website
external_link: ''
# Mastodon
mastodon_link: ''
# Bluesky
bluesky_link: ''
customizations:
javascript: '' # Directly your JavaScript code (without <script> tags). Will be eval at runtime
css: '' # Directly your CSS code (without <style> tags). Will be injected at runtime
# Robot.txt rules. To disallow robots to crawl your instance and disallow indexation of your site, add `/` to `Disallow:`
robots: |
User-agent: *
Disallow:
# /.well-known/security.txt rules. This endpoint is cached, so you may have to wait a few hours before viewing your changes
# To discourage researchers from testing your instance and disable security.txt integration, set this to an empty string
securitytxt: |

View File

@ -958,13 +958,32 @@ instance:
# Could be overridden per user with a setting
default_nsfw_policy: 'do_not_list'
# PeerTube uses this setting to explain to your users which law they must follow in the "About" instance pages
server_country: '' # Example: "France", "United States", "España"
support:
# Explain to your users how to support your instance
# If set, PeerTube will display a "Support" button in "About" instance pages
text: '' # Supports Markdown
# If set, PeerTube will display buttons in "About" instance pages
social:
# Link to your main website
external_link: ''
# Mastodon
mastodon_link: ''
# Bluesky
bluesky_link: ''
customizations:
javascript: '' # Directly your JavaScript code (without <script> tags). Will be eval at runtime
css: '' # Directly your CSS code (without <style> tags). Will be injected at runtime
# Robot.txt rules. To disallow robots to crawl your instance and disallow indexation of your site, add `/` to `Disallow:`
robots: |
User-agent: *
Disallow:
# /.well-known/security.txt rules. This endpoint is cached, so you may have to wait a few hours before viewing your changes
# To discourage researchers from testing your instance and disable security.txt integration, set this to an empty string
securitytxt: |

View File

@ -3,8 +3,9 @@ export type PluginSelectorId =
'menu-user-dropdown-language-item' |
'about-instance-features' |
'about-instance-statistics' |
'about-instance-network-statistics' |
'about-instance-moderation' |
'about-instance-other-information' |
'about-menu-instance' |
'about-menu-peertube' |
'about-menu-network' |
'about-instance-other-information'
'about-menu-network'

View File

@ -34,6 +34,18 @@ export interface CustomConfig {
isNSFW: boolean
defaultNSFWPolicy: NSFWPolicyType
serverCountry: string
support: {
text: string
}
social: {
externalLink: string
mastodonLink: string
blueskyLink: string
}
defaultClientRoute: string
customizations: {

View File

@ -89,6 +89,19 @@ export interface ServerConfig {
shortDescription: string
isNSFW: boolean
defaultNSFWPolicy: NSFWPolicyType
serverCountry: string
support: {
text: string
}
social: {
externalLink: string
mastodonLink: string
blueskyLink: string
}
defaultClientRoute: string
customizations: {
javascript: string
@ -313,6 +326,10 @@ export interface ServerConfig {
}
}
federation: {
enabled: boolean
}
broadcastMessage: {
enabled: boolean
message: string

View File

@ -30,6 +30,11 @@ function checkInitialConfig (server: PeerTubeServer, data: CustomConfig) {
expect(data.instance.maintenanceLifetime).to.be.empty
expect(data.instance.businessModel).to.be.empty
expect(data.instance.hardwareInformation).to.be.empty
expect(data.instance.serverCountry).to.be.empty
expect(data.instance.support.text).to.be.empty
expect(data.instance.social.externalLink).to.be.empty
expect(data.instance.social.blueskyLink).to.be.empty
expect(data.instance.social.mastodonLink).to.be.empty
expect(data.instance.languages).to.have.lengthOf(0)
expect(data.instance.categories).to.have.lengthOf(0)
@ -165,6 +170,16 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
isNSFW: true,
defaultNSFWPolicy: 'blur' as 'blur',
serverCountry: 'France',
support: {
text: 'My support text'
},
social: {
externalLink: 'https://joinpeertube.org/',
mastodonLink: 'https://framapiaf.org/@peertube',
blueskyLink: 'https://bsky.app/profile/joinpeertube.org'
},
defaultClientRoute: '/videos/recently-added',
customizations: {

View File

@ -251,6 +251,18 @@ function customConfig (): CustomConfig {
isNSFW: CONFIG.INSTANCE.IS_NSFW,
defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
serverCountry: CONFIG.INSTANCE.SERVER_COUNTRY,
support: {
text: CONFIG.INSTANCE.SUPPORT.TEXT
},
social: {
blueskyLink: CONFIG.INSTANCE.SOCIAL.BLUESKY,
mastodonLink: CONFIG.INSTANCE.SOCIAL.MASTODON_LINK,
externalLink: CONFIG.INSTANCE.SOCIAL.EXTERNAL_LINK
},
defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
customizations: {

View File

@ -52,6 +52,8 @@ function checkMissedConfig () {
'defaults.publish.download_enabled', 'defaults.publish.comments_policy', 'defaults.publish.privacy', 'defaults.publish.licence',
'instance.name', 'instance.short_description', 'instance.description', 'instance.terms', 'instance.default_client_route',
'instance.is_nsfw', 'instance.default_nsfw_policy', 'instance.robots', 'instance.securitytxt',
'instance.server_country', 'instance.support.text', 'instance.social.external_link', 'instance.social.mastodon_link',
'instance.social.bluesky_link',
'services.twitter.username',
'followers.instance.enabled', 'followers.instance.manual_approval',
'tracker.enabled', 'tracker.private', 'tracker.reject_too_many_announces',

View File

@ -630,6 +630,18 @@ const CONFIG = {
get IS_NSFW () { return config.get<boolean>('instance.is_nsfw') },
get DEFAULT_NSFW_POLICY () { return config.get<NSFWPolicyType>('instance.default_nsfw_policy') },
get SERVER_COUNTRY () { return config.get<string>('instance.server_country') },
SUPPORT: {
get TEXT () { return config.get<string>('instance.support.text') }
},
SOCIAL: {
get EXTERNAL_LINK () { return config.get<string>('instance.social.external_link') },
get MASTODON_LINK () { return config.get<string>('instance.social.mastodon_link') },
get BLUESKY () { return config.get<string>('instance.social.bluesky_link') }
},
get DEFAULT_CLIENT_ROUTE () { return config.get<string>('instance.default_client_route') },
CUSTOMIZATIONS: {

View File

@ -104,6 +104,15 @@ class ServerConfigManager {
isNSFW: CONFIG.INSTANCE.IS_NSFW,
defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
serverCountry: CONFIG.INSTANCE.SERVER_COUNTRY,
support: {
text: CONFIG.INSTANCE.SUPPORT.TEXT
},
social: {
blueskyLink: CONFIG.INSTANCE.SOCIAL.BLUESKY,
mastodonLink: CONFIG.INSTANCE.SOCIAL.MASTODON_LINK,
externalLink: CONFIG.INSTANCE.SOCIAL.EXTERNAL_LINK
},
customizations: {
javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
@ -291,6 +300,10 @@ class ServerConfigManager {
}
},
federation: {
enabled: CONFIG.FEDERATION.ENABLED
},
broadcastMessage: {
enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
message: CONFIG.BROADCAST_MESSAGE.MESSAGE,

View File

@ -7,12 +7,30 @@ import { isThemeNameValid } from '../../helpers/custom-validators/plugins.js'
import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users.js'
import { isThemeRegistered } from '../../lib/plugins/theme-utils.js'
import { areValidationErrors } from './shared/index.js'
import { isNumberArray, isStringArray } from '@server/helpers/custom-validators/search.js'
const customConfigUpdateValidator = [
body('instance.name').exists(),
body('instance.shortDescription').exists(),
body('instance.description').exists(),
body('instance.terms').exists(),
body('instance.codeOfConduct').exists(),
body('instance.creationReason').exists(),
body('instance.moderationInformation').exists(),
body('instance.administrator').exists(),
body('instance.maintenanceLifetime').exists(),
body('instance.businessModel').exists(),
body('instance.hardwareInformation').exists(),
body('instance.serverCountry').exists(),
body('instance.support.text').exists(),
body('instance.social.externalLink').exists(),
body('instance.social.mastodonLink').exists(),
body('instance.social.blueskyLink').exists(),
body('instance.isNSFW').isBoolean(),
body('instance.languages').custom(isStringArray),
body('instance.categories').custom(isNumberArray),
body('instance.defaultNSFWPolicy').custom(isUserNSFWPolicyValid),
body('instance.defaultClientRoute').exists(),
body('instance.customizations.css').exists(),

View File

@ -9033,6 +9033,22 @@ components:
type: boolean
defaultNSFWPolicy:
type: string
serverCountry:
type: string
support:
type: object
properties:
text:
type: string
social:
type: object
properties:
externalLink:
type: string
mastodonLink:
type: string
blueskyLink:
type: string
customizations:
type: object
properties:
@ -9251,6 +9267,11 @@ components:
indexUrl:
type: string
format: url
federation:
type: object
properties:
enabled:
type: boolean
homepage:
type: object
properties:
@ -9459,12 +9480,50 @@ components:
type: string
terms:
type: string
defaultClientRoute:
codeOfConduct:
type: string
creationReason:
type: string
moderationInformation:
type: string
administrator:
type: string
maintenanceLifetime:
type: string
businessModel:
type: string
hardwareInformation:
type: string
languages:
type: array
items:
type: string
categories:
type: array
items:
type: number
isNSFW:
type: boolean
defaultNSFWPolicy:
type: string
serverCountry:
type: string
support:
type: object
properties:
text:
type: string
social:
type: object
properties:
externalLink:
type: string
mastodonLink:
type: string
blueskyLink:
type: string
defaultClientRoute:
type: string
customizations:
type: object
properties: