diff --git a/client/src/app/+about/about-contact/about-contact.component.html b/client/src/app/+about/about-contact/about-contact.component.html new file mode 100644 index 000000000..64940ce80 --- /dev/null +++ b/client/src/app/+about/about-contact/about-contact.component.html @@ -0,0 +1,56 @@ +
+

Contact {{ instanceName }} administrators

+ + @if (isContactFormEnabled()) { + @if (!success) { +
+
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+ + {{ error }} + + +
+ } @else { + {{ success }} + } + } @else { + The contact form is not enabled on this instance. + } +
diff --git a/client/src/app/+about/about-instance/contact-admin-modal.component.scss b/client/src/app/+about/about-contact/about-contact.component.scss similarity index 50% rename from client/src/app/+about/about-instance/contact-admin-modal.component.scss rename to client/src/app/+about/about-contact/about-contact.component.scss index dc4010c8a..8d49ab185 100644 --- a/client/src/app/+about/about-instance/contact-admin-modal.component.scss +++ b/client/src/app/+about/about-contact/about-contact.component.scss @@ -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); } diff --git a/client/src/app/+about/about-instance/contact-admin-modal.component.ts b/client/src/app/+about/about-contact/about-contact.component.ts similarity index 60% rename from client/src/app/+about/about-instance/contact-admin-modal.component.ts rename to client/src/app/+about/about-contact/about-contact.component.ts index facde4aab..6d471a3c1 100644 --- a/client/src/app/+about/about-instance/contact-admin-modal.component.ts +++ b/client/src/app/+about/about-contact/about-contact.component.ts @@ -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 => { diff --git a/client/src/app/+about/about-follows/about-follows.component.html b/client/src/app/+about/about-follows/about-follows.component.html index c4bda1944..222db1b7a 100644 --- a/client/src/app/+about/about-follows/about-follows.component.html +++ b/client/src/app/+about/about-follows/about-follows.component.html @@ -1,30 +1,81 @@ -
-
-

Follows

+
-
-

Followers of {{ instanceName }} ({{ followersPagination.totalItems }})

+
+
+
+

{{ subscriptionsPagination.totalItems }} {subscriptionsPagination.totalItems, plural, =1 {subscription} other {subscriptions}}

+
+ This is content to which we have subscribed. This allows us to display their videos directly on {{ instanceName }}. +
+
+ + +
+ +
+
{{ instanceName }} does not have subscriptions.
+ + + +
+ +
+ Show more subscriptions +
+ +
+

Our network in figures

+ +
+
+ {{ serverStats.totalVideos | number }} + total videos + +
+ +
+ {{ serverStats.totalVideoComments | number }} +
total comments
+ +
+
+
+
+ +
+
+
+

{{ followersPagination.totalItems }} {followersPagination.totalItems, plural, =1 {follower} other {followers}}

+ +
+ Our subscribers automatically display videos of {{ instanceName }} on their platforms. +
+
+ + +
+ +
{{ instanceName }} does not have followers.
- - {{ follower.name }} - + + +
+ Show more followers +
- -
-

Subscriptions of {{ instanceName }} ({{ followingsPagination.totalItems }})

- -
{{ instanceName }} does not have subscriptions.
- - - {{ following.name }} - - - -
-
diff --git a/client/src/app/+about/about-follows/about-follows.component.scss b/client/src/app/+about/about-follows/about-follows.component.scss index 25c8ac70a..e45d57ab9 100644 --- a/client/src/app/+about/about-follows/about-follows.component.scss +++ b/client/src/app/+about/about-follows/about-follows.component.scss @@ -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%; + } } diff --git a/client/src/app/+about/about-follows/about-follows.component.ts b/client/src/app/+about/about-follows/about-follows.component.ts index 5093fb8f9..608b04acf 100644 --- a/client/src/app/+about/about-follows/about-follows.component.ts +++ b/client/src/app/+about/about-follows/about-follows.component.ts @@ -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 } } } diff --git a/client/src/app/+about/about-follows/follower-image.component.html b/client/src/app/+about/about-follows/follower-image.component.html new file mode 100644 index 000000000..9ae7292c5 --- /dev/null +++ b/client/src/app/+about/about-follows/follower-image.component.html @@ -0,0 +1,51 @@ + diff --git a/client/src/app/+about/about-follows/follower-image.component.scss b/client/src/app/+about/about-follows/follower-image.component.scss new file mode 100644 index 000000000..ef4b37f05 --- /dev/null +++ b/client/src/app/+about/about-follows/follower-image.component.scss @@ -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); +} diff --git a/client/src/app/+about/about-follows/follower-image.component.ts b/client/src/app/+about/about-follows/follower-image.component.ts new file mode 100644 index 000000000..2920402a0 --- /dev/null +++ b/client/src/app/+about/about-follows/follower-image.component.ts @@ -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) + } +} diff --git a/client/src/app/+about/about-follows/subscription-image.component.html b/client/src/app/+about/about-follows/subscription-image.component.html new file mode 100644 index 000000000..16fa58c59 --- /dev/null +++ b/client/src/app/+about/about-follows/subscription-image.component.html @@ -0,0 +1,42 @@ + diff --git a/client/src/app/+about/about-follows/subscription-image.component.scss b/client/src/app/+about/about-follows/subscription-image.component.scss new file mode 100644 index 000000000..8e853b863 --- /dev/null +++ b/client/src/app/+about/about-follows/subscription-image.component.scss @@ -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; +} diff --git a/client/src/app/+about/about-follows/subscription-image.component.ts b/client/src/app/+about/about-follows/subscription-image.component.ts new file mode 100644 index 000000000..738b32c7f --- /dev/null +++ b/client/src/app/+about/about-follows/subscription-image.component.ts @@ -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) + } +} diff --git a/client/src/app/+about/about-instance/about-instance.component.html b/client/src/app/+about/about-instance/about-instance.component.html index 5754aa7dc..49d041211 100644 --- a/client/src/app/+about/about-instance/about-instance.component.html +++ b/client/src/app/+about/about-instance/about-instance.component.html @@ -1,233 +1,12 @@ -
- +
+ -
-
- -
-

About {{ instanceName }}

- - Contact us -
- -
- {{ category }} - - {{ language }} -
- -
-
{{ shortDescription }}
- -
This instance is dedicated to sensitive/NSFW content.
-
- -
- -

- ADMINISTRATORS & SUSTAINABILITY -

-
- - - - - - - - - -
- -

- INFORMATION -

-
- - - - - - +
+
+
-
-

FEATURES

- -
- -
-
- - -

STATISTICS

-
- - -
+
- diff --git a/client/src/app/+about/about-instance/about-instance.component.scss b/client/src/app/+about/about-instance/about-instance.component.scss index cb7324f06..ab296d307 100644 --- a/client/src/app/+about/about-instance/about-instance.component.scss +++ b/client/src/app/+about/about-instance/about-instance.component.scss @@ -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%; } } diff --git a/client/src/app/+about/about-instance/about-instance.component.ts b/client/src/app/+about/about-instance/about-instance.component.ts index e0a9a361d..93835c900 100644 --- a/client/src/app/+about/about-instance/about-instance.component.ts +++ b/client/src/app/+about/about-instance/about-instance.component.ts @@ -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 - @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`) - } } diff --git a/client/src/app/+about/about-instance/about-instance.resolver.ts b/client/src/app/+about/about-instance/about-instance.resolver.ts index 84284bb74..ed1054c3c 100644 --- a/client/src/app/+about/about-instance/about-instance.resolver.ts +++ b/client/src/app/+about/about-instance/about-instance.resolver.ts @@ -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 { 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() - } } diff --git a/client/src/app/+about/about-instance/about-instance.routes.ts b/client/src/app/+about/about-instance/about-instance.routes.ts new file mode 100644 index 000000000..5aab91301 --- /dev/null +++ b/client/src/app/+about/about-instance/about-instance.routes.ts @@ -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 + } + ] + } +] diff --git a/client/src/app/+about/about-instance/children/about-instance-common.component.scss b/client/src/app/+about/about-instance/children/about-instance-common.component.scss new file mode 100644 index 000000000..9b21d8664 --- /dev/null +++ b/client/src/app/+about/about-instance/children/about-instance-common.component.scss @@ -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; +} diff --git a/client/src/app/+about/about-instance/children/about-instance-home.component.html b/client/src/app/+about/about-instance/children/about-instance-home.component.html new file mode 100644 index 000000000..c71d358b8 --- /dev/null +++ b/client/src/app/+about/about-instance/children/about-instance-home.component.html @@ -0,0 +1,29 @@ +
+

Specifics

+ +
+ Language: + {{ language }} +
+ +
+ Categories: + {{ category }} +
+ +
{{ config.instance.name }} is dedicated to sensitive/NSFW content.
+
+ +
+

Description

+ + +
+ +
+

Terms

+ +
+
+ + diff --git a/client/src/app/+about/about-instance/children/about-instance-home.component.ts b/client/src/app/+about/about-instance/children/about-instance-home.component.ts new file mode 100644 index 000000000..5db1089b3 --- /dev/null +++ b/client/src/app/+about/about-instance/children/about-instance-home.component.ts @@ -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) + }) + } +} diff --git a/client/src/app/+about/about-instance/children/about-instance-moderation.component.html b/client/src/app/+about/about-instance/children/about-instance-moderation.component.html new file mode 100644 index 000000000..e166c3dcf --- /dev/null +++ b/client/src/app/+about/about-instance/children/about-instance-moderation.component.html @@ -0,0 +1,13 @@ +
+ +
+

Moderation information

+
+
+ +
+

Code of conduct

+
+
+ +
diff --git a/client/src/app/+about/about-instance/children/about-instance-moderation.component.ts b/client/src/app/+about/about-instance/children/about-instance-moderation.component.ts new file mode 100644 index 000000000..25d3ff632 --- /dev/null +++ b/client/src/app/+about/about-instance/children/about-instance-moderation.component.ts @@ -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 + } +} diff --git a/client/src/app/+about/about-instance/children/about-instance-team.component.html b/client/src/app/+about/about-instance/children/about-instance-team.component.html new file mode 100644 index 000000000..c655eca83 --- /dev/null +++ b/client/src/app/+about/about-instance/children/about-instance-team.component.html @@ -0,0 +1,19 @@ +
+

Who we are

+
+
+ +
+

Why we created {{ instanceName }}

+
+
+ +
+

How long we plan to maintain {{ instanceName }}

+
+
+ +
+

How we will pay for keeping {{ instanceName }} running

+
+
diff --git a/client/src/app/+about/about-instance/children/about-instance-team.component.ts b/client/src/app/+about/about-instance/children/about-instance-team.component.ts new file mode 100644 index 000000000..23fe7eae1 --- /dev/null +++ b/client/src/app/+about/about-instance/children/about-instance-team.component.ts @@ -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 + } +} diff --git a/client/src/app/+about/about-instance/children/about-instance-tech.component.html b/client/src/app/+about/about-instance/children/about-instance-tech.component.html new file mode 100644 index 000000000..afcb201e1 --- /dev/null +++ b/client/src/app/+about/about-instance/children/about-instance-tech.component.html @@ -0,0 +1,10 @@ +
+

Hardware information

+
+
+ +
+

FEATURES

+ +
+ diff --git a/client/src/app/+about/about-instance/children/about-instance-tech.component.ts b/client/src/app/+about/about-instance/children/about-instance-tech.component.ts new file mode 100644 index 000000000..6681d67ee --- /dev/null +++ b/client/src/app/+about/about-instance/children/about-instance-tech.component.ts @@ -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 + } +} diff --git a/client/src/app/+about/about-instance/contact-admin-modal.component.html b/client/src/app/+about/about-instance/contact-admin-modal.component.html deleted file mode 100644 index 8f414d168..000000000 --- a/client/src/app/+about/about-instance/contact-admin-modal.component.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - diff --git a/client/src/app/+about/about-instance/instance-stat-rules.component.html b/client/src/app/+about/about-instance/instance-stat-rules.component.html new file mode 100644 index 000000000..07a9d5da9 --- /dev/null +++ b/client/src/app/+about/about-instance/instance-stat-rules.component.html @@ -0,0 +1,163 @@ +
+ +
+

Our platform in figures

+ +
+
+ {{ stats.totalModerators + stats.totalAdmins | number }} +
moderators
+ +
+ +
+ {{ stats.totalUsers | number }} +
users
+ +
+ +
+ {{ stats.totalLocalVideos | number }} + videos + +
+ +
+ {{ stats.totalLocalVideoViews | number }} +
views
+ +
+ +
+ {{ stats.totalLocalVideoComments | number }} +
views
+ +
+ +
+ {{ stats.totalLocalVideoFilesSize | bytes:1 }} +
hosted videos
+ +
+
+
+ +
+

Usage rules

+ +
+ +
+
+ + +
+
+
+
+ +
+ This platform has been created in {{ config.instance.serverCountry }} +
+ Your content (comments, videos...) must comply with the legislation in force in this country. + You must also follow our code of conduct. +
+
+
+ +
+
+ + + @if (config.signup.allowed && config.signup.allowedForCurrentIP) { +
+ +
+ } @else { +
+ +
+ } +
+ +
+ @if (config.signup.allowed && config.signup.allowedForCurrentIP) { + @if (config.signup.requiresApproval) { + You can request an account on our platform + + @if (stats.averageRegistrationRequestResponseTimeMs) { +
Our moderator will validate it within a {{ stats.averageRegistrationRequestResponseTimeMs | myDaysDurationFormatter }}.
+ } @else { +
Our moderator will validate it within a few days.
+ } + } @else { + You can create an account on our platform + } + } @else { + Public registration on our platform is not allowed + } +
+
+ +
+
+ + +
+ +
+
+ +
+ This platform is compatible with Mastodon, Lemmy, Misskey and other services from the Fediverse + +
You can use these services to interact with our videos
+
+
+ +
+ @if (canUpload()) { +
+ + +
+ +
+
+ +
+ Vous pouvez publier des vidéos + +
+ By default, your account allows you to publish videos. + + You can also stream lives. +
+
+ } @else { +
+ + +
+ +
+
+ +
+ @if (isContactFormEnabled()) { + Contact us to publish videos + } @else { + You can't publish videos + } + +
+ By default, your account does not allow to publish videos. + + If you want to publish videos, contact us. +
+
+ } +
+
+
+
diff --git a/client/src/app/+about/about-instance/instance-stat-rules.component.scss b/client/src/app/+about/about-instance/instance-stat-rules.component.scss new file mode 100644 index 000000000..6a8c6ce94 --- /dev/null +++ b/client/src/app/+about/about-instance/instance-stat-rules.component.scss @@ -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; + } +} diff --git a/client/src/app/+about/about-instance/instance-stat-rules.component.ts b/client/src/app/+about/about-instance/instance-stat-rules.component.ts new file mode 100644 index 000000000..ec1a7695d --- /dev/null +++ b/client/src/app/+about/about-instance/instance-stat-rules.component.ts @@ -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 + } +} diff --git a/client/src/app/+about/about-instance/instance-statistics.component.html b/client/src/app/+about/about-instance/instance-statistics.component.html deleted file mode 100644 index 68b209990..000000000 --- a/client/src/app/+about/about-instance/instance-statistics.component.html +++ /dev/null @@ -1,101 +0,0 @@ -

Loading instance statistics...

- -
-

By users on this instance

- -
-
-
-
-

{{ serverStats.totalUsers | number }}

-

users

-
- -
-
- -
-
-
-

{{ serverStats.totalLocalVideos | number }}

-

videos

-
- -
-
- -
-
-
-

{{ serverStats.totalLocalVideoViews | number }}

-

views

-
- -
-
- -
-
-
-

{{ serverStats.totalLocalVideoComments | number }}

-

comments

-
- -
-
- -
-
-
-

{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}

-

hosted video

-
- -
-
-
- -

In this instance federation

- -
-
-
-
-

{{ serverStats.totalVideos | number }}

-

videos

-
- -
-
- -
-
-
-

{{ serverStats.totalVideoComments | number }}

-

comments

-
- -
-
- -
-
-
-

{{ serverStats.totalInstanceFollowers | number }}

-

followers

-
- -
-
- -
-
-
-

{{ serverStats.totalInstanceFollowing | number }}

-

following

-
- -
-
-
-
diff --git a/client/src/app/+about/about-instance/instance-statistics.component.scss b/client/src/app/+about/about-instance/instance-statistics.component.scss deleted file mode 100644 index c2f8ec730..000000000 --- a/client/src/app/+about/about-instance/instance-statistics.component.scss +++ /dev/null @@ -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; - } -} diff --git a/client/src/app/+about/about-instance/instance-statistics.component.ts b/client/src/app/+about/about-instance/instance-statistics.component.ts deleted file mode 100644 index bc257803f..000000000 --- a/client/src/app/+about/about-instance/instance-statistics.component.ts +++ /dev/null @@ -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 -} diff --git a/client/src/app/+about/about-peertube/about-peertube.component.html b/client/src/app/+about/about-peertube/about-peertube.component.html index 73ea2bd75..595510785 100644 --- a/client/src/app/+about/about-peertube/about-peertube.component.html +++ b/client/src/app/+about/about-peertube/about-peertube.component.html @@ -1,7 +1,7 @@ -
-

- This website is powered by PeerTube -

+
+

+ This platform is powered by PeerTube +

mascot @@ -58,102 +58,4 @@
- -
-

-
- P2P & Privacy -

- -

- 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. -

- -

- 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. -

- -

What are the consequences?

- -

- 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: -

- -
    -
  • - 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) -
  • - -
  • - 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 -
  • - -
  • - Those requests have to be sent regularly to know who starts/stops watching a video. It is easy to detect that kind of behaviour -
  • - -
  • - 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 -
  • - -
  • - The IP address is a vague information: usually, it regularly changes and can represent many persons or entities -
  • - -
  • - 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 this document for more information -
  • -
- -

- 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. -

- -

How does PeerTube compare with YouTube?

- -

- 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). -

- -

What can I do to limit the exposure of my IP address?

- -

- 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. -

- -

What will be done to mitigate this problem?

- -

- 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: -

- -
    -
  • We set a limit to the number of peers sent by the tracker
  • -
  • We set a limit on the request frequency received by the tracker
  • -
  • Allow instance admins to disable P2P from the administration interface
  • -
- -

- Ultimately, remember you can always disable P2P by toggling it in the video player, or just by disabling - WebRTC in your browser. -

-
diff --git a/client/src/app/+about/about-peertube/about-peertube.component.scss b/client/src/app/+about/about-peertube/about-peertube.component.scss index ffa8fe1d5..2761ac2c2 100644 --- a/client/src/app/+about/about-peertube/about-peertube.component.scss +++ b/client/src/app/+about/about-peertube/about-peertube.component.scss @@ -18,3 +18,7 @@ text-align: center; margin-bottom: 1rem; } + +.card-body { + text-align: center; +} diff --git a/client/src/app/+about/about.component.html b/client/src/app/+about/about.component.html index 2241fe716..f2f6ee61d 100644 --- a/client/src/app/+about/about.component.html +++ b/client/src/app/+about/about.component.html @@ -1,7 +1,65 @@
- +

+ + + About +

+ +
+ + +
+
+ +
+ +
+
{{ config.instance.name }}
+
{{ config.instance.shortDescription }}
+
+ +
+ + +
+ Contact us + + Support +
+
+
+
+ +
+ diff --git a/client/src/app/+about/about.component.scss b/client/src/app/+about/about.component.scss new file mode 100644 index 000000000..996a18827 --- /dev/null +++ b/client/src/app/+about/about.component.scss @@ -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); + } + } +} diff --git a/client/src/app/+about/about.component.ts b/client/src/app/+about/about.component.ts index 01672ba6a..d3e033e22 100644 --- a/client/src/app/+about/about.component.ts +++ b/client/src/app/+about/about.component.ts @@ -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 + } } diff --git a/client/src/app/+about/routes.ts b/client/src/app/+about/routes.ts index 88d56306f..cfe5a24e1 100644 --- a/client/src/app/+about/routes.ts +++ b/client/src/app/+about/routes.ts @@ -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` + } + } } ] } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index 492c56842..96d5990cc 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts @@ -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 } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html index d05742376..470bee56a 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html @@ -99,9 +99,81 @@
+
+ +
PeerTube uses this setting to explain to your users which law they must follow in the "About" pages
+ + + + +
+
+
+
+

SOCIAL

+
+ Social links and support information displayed in the About pages +
+
+ +
+ +
+ +
Explain to your users how to support your platform. If set, PeerTube will display a "Support" button in "About" instance pages
+ + +
+ + +
+ +
Link to your main website
+ + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+
+ +
+ +
+

MODERATION & NSFW

diff --git a/client/src/app/+login/login.component.html b/client/src/app/+login/login.component.html index 6fc650694..41719d86b 100644 --- a/client/src/app/+login/login.component.html +++ b/client/src/app/+login/login.component.html @@ -16,12 +16,12 @@ @if (signupAllowed) {

- This instance allows registration. However, be careful to check the TermsTerms before creating an account. + This instance allows registration. However, be careful to check the TermsTerms before creating an account. You may also search for another instance to match your exact needs at: https://joinpeertube.org/instances.

} @else {

- Currently this instance doesn't allow for user registration, you may check the TermsTerms 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 TermsTerms 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: https://joinpeertube.org/instances.

} diff --git a/client/src/app/+video-channels/video-channels.component.html b/client/src/app/+video-channels/video-channels.component.html index 8e20136fd..f4d6defba 100644 --- a/client/src/app/+video-channels/video-channels.component.html +++ b/client/src/app/+video-channels/video-channels.component.html @@ -123,4 +123,4 @@
- + diff --git a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html index 493ebeb61..dd1992b22 100644 --- a/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html +++ b/client/src/app/+videos/+video-watch/shared/action-buttons/action-buttons.component.html @@ -68,6 +68,6 @@
- + diff --git a/client/src/app/+videos/+video-watch/shared/information/privacy-concerns.component.html b/client/src/app/+videos/+video-watch/shared/information/privacy-concerns.component.html index eef543b9f..32dd2fd6a 100644 --- a/client/src/app/+videos/+video-watch/shared/information/privacy-concerns.component.html +++ b/client/src/app/+videos/+video-watch/shared/information/privacy-concerns.component.html @@ -7,7 +7,7 @@ - More information + More information diff --git a/client/src/app/shared/shared-support-modal/support-modal.component.ts b/client/src/app/shared/shared-support-modal/support-modal.component.ts index 6ec2284a4..109cfb611 100644 --- a/client/src/app/shared/shared-support-modal/support-modal.component.ts +++ b/client/src/app/shared/shared-support-modal/support-modal.component.ts @@ -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 }) } } diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.html b/client/src/app/shared/shared-user-settings/user-video-settings.component.html index bdfea2f02..9e5c94813 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.html +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.html @@ -49,7 +49,7 @@ i18n-labelText labelText="Help share videos being played" > - The sharing system 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. + The sharing system 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. diff --git a/client/src/assets/images/feather/link.svg b/client/src/assets/images/feather/link.svg new file mode 100644 index 000000000..d74317afb --- /dev/null +++ b/client/src/assets/images/feather/link.svg @@ -0,0 +1 @@ + diff --git a/client/src/assets/images/misc/bluesky.svg b/client/src/assets/images/misc/bluesky.svg new file mode 100644 index 000000000..5a1a78c5b --- /dev/null +++ b/client/src/assets/images/misc/bluesky.svg @@ -0,0 +1,18 @@ + + + + + diff --git a/client/src/assets/images/misc/fediverse.svg b/client/src/assets/images/misc/fediverse.svg new file mode 100644 index 000000000..acf43deb8 --- /dev/null +++ b/client/src/assets/images/misc/fediverse.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/assets/images/misc/mastodon.svg b/client/src/assets/images/misc/mastodon.svg new file mode 100644 index 000000000..71797c364 --- /dev/null +++ b/client/src/assets/images/misc/mastodon.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss index e4c57d925..0a7b21506 100644 --- a/client/src/sass/application.scss +++ b/client/src/sass/application.scss @@ -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%); diff --git a/client/src/sass/include/_badges.scss b/client/src/sass/include/_badges.scss index 36b537be3..424e18fbc 100644 --- a/client/src/sass/include/_badges.scss +++ b/client/src/sass/include/_badges.scss @@ -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, diff --git a/client/src/sass/include/_button-mixins.scss b/client/src/sass/include/_button-mixins.scss index 2f3465233..908b4818c 100644 --- a/client/src/sass/include/_button-mixins.scss +++ b/client/src/sass/include/_button-mixins.scss @@ -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); diff --git a/client/src/sass/include/_components.scss b/client/src/sass/include/_components.scss new file mode 100644 index 000000000..fbb2f7f4c --- /dev/null +++ b/client/src/sass/include/_components.scss @@ -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); + } +} diff --git a/client/src/sass/include/_variables.scss b/client/src/sass/include/_variables.scss index c5316ace3..b88c1306d 100644 --- a/client/src/sass/include/_variables.scss +++ b/client/src/sass/include/_variables.scss @@ -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), diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss index 290f0ec04..6cf271e4c 100644 --- a/client/src/sass/primeng-custom.scss +++ b/client/src/sass/primeng-custom.scss @@ -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; } diff --git a/config/default.yaml b/config/default.yaml index e4496c15a..c54c0e5d2 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -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