1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 09:49:20 +02:00

Redesign admin config and add theme customization

This commit is contained in:
Chocobozzz 2025-05-28 09:58:56 +02:00
parent 03425e10d3
commit a6b89bde2b
No known key found for this signature in database
GPG key ID: 583A612D890159BE
98 changed files with 3928 additions and 2525 deletions

View file

@ -214,7 +214,6 @@
"escape-string-regexp", "escape-string-regexp",
"is-plain-object", "is-plain-object",
"parse-srcset", "parse-srcset",
"deepmerge",
"core-js/features/reflect", "core-js/features/reflect",
"hammerjs", "hammerjs",
"jschannel" "jschannel"

View file

@ -150,6 +150,7 @@ export default defineConfig([
'no-return-assign': 'off', 'no-return-assign': 'off',
'@typescript-eslint/unbound-method': 'off', '@typescript-eslint/unbound-method': 'off',
'import/no-named-default': 'off', 'import/no-named-default': 'off',
'@typescript-eslint/prefer-reduce-type-parameter': 'off',
"@typescript-eslint/no-deprecated": [ 'error', { "@typescript-eslint/no-deprecated": [ 'error', {
allow: [ allow: [

View file

@ -0,0 +1,9 @@
<div class="root">
<div>
<my-lateral-menu [config]="menuConfig"></my-lateral-menu>
</div>
<div class="flex-grow-1">
<router-outlet></router-outlet>
</div>
</div>

View file

@ -0,0 +1,14 @@
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
@import "bootstrap/scss/mixins";
.root {
display: flex;
}
@media screen and (max-width: $medium-view) {
.root {
margin-bottom: 150px;
}
}

View file

@ -0,0 +1,67 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { RouterModule } from '@angular/router'
import { LateralMenuComponent, LateralMenuConfig } from '../../shared/shared-main/menu/lateral-menu.component'
@Component({
selector: 'my-admin-config',
styleUrls: [ './admin-config.component.scss' ],
templateUrl: './admin-config.component.html',
imports: [
CommonModule,
RouterModule,
LateralMenuComponent
]
})
export class AdminConfigComponent implements OnInit {
menuConfig: LateralMenuConfig
ngOnInit (): void {
this.menuConfig = {
title: $localize`Configuration`,
entries: [
{
type: 'link',
label: $localize`Information`,
routerLink: 'information'
},
{
type: 'link',
label: $localize`General`,
routerLink: 'general'
},
{
type: 'link',
label: $localize`Homepage`,
routerLink: 'homepage'
},
{
type: 'link',
label: $localize`Customization`,
routerLink: 'customization'
},
{ type: 'separator' },
{
type: 'link',
label: $localize`VOD`,
routerLink: 'vod'
},
{
type: 'link',
label: $localize`Live`,
routerLink: 'live'
},
{ type: 'separator' },
{
type: 'link',
label: $localize`Advanced`,
routerLink: 'advanced'
}
]
}
}
}

View file

@ -1,7 +1,37 @@
import { Routes } from '@angular/router' import { inject } from '@angular/core'
import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config' import { ActivatedRouteSnapshot, ResolveFn, RouterStateSnapshot, Routes } from '@angular/router'
import { UserRightGuard } from '@app/core' import { CanDeactivateGuard, ServerService, UserRightGuard } from '@app/core'
import { UserRight } from '@peertube/peertube-models' import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
import { CustomConfig, UserRight, VideoConstant } from '@peertube/peertube-models'
import { map } from 'rxjs'
import { AdminConfigComponent } from './admin-config.component'
import {
AdminConfigAdvancedComponent,
AdminConfigGeneralComponent,
AdminConfigHomepageComponent,
AdminConfigInformationComponent,
AdminConfigLiveComponent,
AdminConfigVODComponent
} from './pages'
import { AdminConfigCustomizationComponent } from './pages/admin-config-customization.component'
import { AdminConfigService } from './shared/admin-config.service'
export const customConfigResolver: ResolveFn<CustomConfig> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(AdminConfigService).getCustomConfig()
}
export const homepageResolver: ResolveFn<string> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(CustomPageService).getInstanceHomepage()
.pipe(map(({ content }) => content))
}
export const categoriesResolver: ResolveFn<VideoConstant<number>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(ServerService).getVideoCategories()
}
export const languagesResolver: ResolveFn<VideoConstant<string>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(ServerService).getVideoLanguages()
}
export const configRoutes: Routes = [ export const configRoutes: Routes = [
{ {
@ -10,18 +40,96 @@ export const configRoutes: Routes = [
data: { data: {
userRight: UserRight.MANAGE_CONFIGURATION userRight: UserRight.MANAGE_CONFIGURATION
}, },
resolve: {
customConfig: customConfigResolver
},
component: AdminConfigComponent,
children: [ children: [
{ {
path: '', // Old path with PeerTube < 7.3
redirectTo: 'edit-custom', path: 'edit-custom',
redirectTo: 'information',
pathMatch: 'full' pathMatch: 'full'
}, },
{ {
path: 'edit-custom', path: '',
component: EditCustomConfigComponent, redirectTo: 'information',
pathMatch: 'full'
},
{
path: 'homepage',
component: AdminConfigHomepageComponent,
canDeactivate: [ CanDeactivateGuard ],
resolve: {
homepageContent: homepageResolver
},
data: { data: {
meta: { meta: {
title: $localize`Edit custom configuration` title: $localize`Edit your platform homepage`
}
}
},
{
path: 'customization',
component: AdminConfigCustomizationComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`Platform customization`
}
}
},
{
path: 'information',
component: AdminConfigInformationComponent,
canDeactivate: [ CanDeactivateGuard ],
resolve: {
categories: categoriesResolver,
languages: languagesResolver
},
data: {
meta: {
title: $localize`Platform information`
}
}
},
{
path: 'general',
component: AdminConfigGeneralComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`General configuration`
}
}
},
{
path: 'vod',
component: AdminConfigVODComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`VOD configuration`
}
}
},
{
path: 'live',
component: AdminConfigLiveComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`Live configuration`
}
}
},
{
path: 'advanced',
component: AdminConfigAdvancedComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`Advanced configuration`
} }
} }
} }

View file

@ -1,135 +0,0 @@
<ng-container [formGroup]="form()">
<div class="pt-two-cols mt-5"> <!-- cache grid -->
<div class="title-col">
<h2 i18n>CACHE</h2>
<div i18n class="inner-form-description">
Some files are not federated, and fetched when necessary. Define their caching policies.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="cache">
<div class="form-group" formGroupName="previews">
<label i18n for="cachePreviewsSize">Number of previews to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cachePreviewsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.previews.size'] }"
>
<span i18n>{getCacheSize('previews'), plural, =1 {cached image} other {cached images}}</span>
</div>
<div *ngIf="formErrors().cache.previews.size" class="form-error" role="alert">{{ formErrors().cache.previews.size }}</div>
</div>
<div class="form-group" formGroupName="captions">
<label i18n for="cacheCaptionsSize">Number of video captions to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheCaptionsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.captions.size'] }"
>
<span i18n>{getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}}</span>
</div>
<div *ngIf="formErrors().cache.captions.size" class="form-error" role="alert">{{ formErrors().cache.captions.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
<label i18n for="cacheTorrentsSize">Number of video torrents to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheTorrentsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.torrents.size'] }"
>
<span i18n>{getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}}</span>
</div>
<div *ngIf="formErrors().cache.torrents.size" class="form-error" role="alert">{{ formErrors().cache.torrents.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
<label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheStoryboardsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.storyboards.size'] }"
>
<span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
</div>
<div *ngIf="formErrors().cache.storyboards.size" class="form-error" role="alert">{{ formErrors().cache.storyboards.size }}</div>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- cache grid -->
<div class="title-col">
<div class="anchor" id="customizations"></div> <!-- customizations anchor -->
<h2 i18n>CUSTOMIZATIONS</h2>
<div i18n class="inner-form-description">
Slight modifications to your PeerTube instance for when creating a plugin or theme is overkill.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="instance">
<ng-container formGroupName="customizations">
<div class="form-group">
<label i18n for="customizationJavascript">JavaScript</label>
<my-help>
<ng-container i18n>
<p class="mb-2">Write JavaScript code directly. Example:</p>
<pre>console.log('my instance is amazing');</pre>
</ng-container>
</my-help>
<textarea
id="customizationJavascript" formControlName="javascript" class="form-control" dir="ltr"
[ngClass]="{ 'input-error': formErrors()['instance.customizations.javascript'] }"
></textarea>
<div *ngIf="formErrors().instance.customizations.javascript" class="form-error" role="alert">{{ formErrors().instance.customizations.javascript }}</div>
</div>
<div class="form-group">
<label for="customizationCSS">CSS</label>
<my-help>
<ng-container i18n>
<p class="mb-2">Write CSS code directly. Example:</p>
<pre>
#custom-css {{ '{' }}
color: red;
{{ '}' }}
</pre>
<p class="mb-2">Prepend with <em>#custom-css</em> to override styles. Example:</p>
<pre>
#custom-css .logged-in-email {{ '{' }}
color: red;
{{ '}' }}
</pre>
</ng-container>
</my-help>
<textarea
id="customizationCSS" formControlName="css" class="form-control" dir="ltr"
[ngClass]="{ 'input-error': formErrors()['instance.customizations.css'] }"
></textarea>
<div *ngIf="formErrors().instance.customizations.css" class="form-error" role="alert">{{ formErrors().instance.customizations.css }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container>

View file

@ -1,19 +0,0 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
@Component({
selector: 'my-edit-advanced-configuration',
templateUrl: './edit-advanced-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [ FormsModule, ReactiveFormsModule, NgClass, NgIf, HelpComponent ]
})
export class EditAdvancedConfigurationComponent {
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
return this.form().value['cache'][type]['size']
}
}

View file

@ -1,215 +0,0 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { ThemeService } from '@app/core'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component'
import { ConfigService } from '../shared/config.service'
@Component({
selector: 'my-edit-basic-configuration',
templateUrl: './edit-basic-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
RouterLink,
SelectCustomValueComponent,
NgIf,
PeertubeCheckboxComponent,
HelpComponent,
MarkdownTextareaComponent,
NgClass,
UserRealQuotaInfoComponent,
SelectOptionsComponent,
AlertComponent
]
})
export class EditBasicConfigurationComponent implements OnInit, OnChanges {
private configService = inject(ConfigService)
private themeService = inject(ThemeService)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly serverConfig = input<HTMLServerConfig>(undefined)
signupAlertMessage: string
defaultLandingPageOptions: SelectOptionsItem[] = []
availableThemes: SelectOptionsItem[]
exportExpirationOptions: SelectOptionsItem[] = []
exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = []
ngOnInit () {
this.buildLandingPageOptions()
this.checkSignupField()
this.checkImportSyncField()
this.availableThemes = [
this.themeService.getDefaultThemeItem(),
...this.themeService.buildAvailableThemes()
]
this.exportExpirationOptions = [
{ id: 1000 * 3600 * 24, label: $localize`1 day` },
{ id: 1000 * 3600 * 24 * 2, label: $localize`2 days` },
{ id: 1000 * 3600 * 24 * 7, label: $localize`7 days` },
{ id: 1000 * 3600 * 24 * 30, label: $localize`30 days` }
]
this.exportMaxUserVideoQuotaOptions = this.configService.videoQuotaOptions.filter(o => (o.id as number) >= 1)
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.buildLandingPageOptions()
}
}
countExternalAuth () {
return this.serverConfig().plugin.registeredExternalAuths.length
}
getVideoQuotaOptions () {
return this.configService.videoQuotaOptions
}
getVideoQuotaDailyOptions () {
return this.configService.videoQuotaDailyOptions
}
doesTrendingVideosAlgorithmsEnabledInclude (algorithm: string) {
const enabled = this.form().value['trending']['videos']['algorithms']['enabled']
if (!Array.isArray(enabled)) return false
return !!enabled.find((e: string) => e === algorithm)
}
getUserVideoQuota () {
return this.form().value['user']['videoQuota']
}
isExportUsersEnabled () {
return this.form().value['export']['users']['enabled'] === true
}
getDisabledExportUsersClass () {
return { 'disabled-checkbox-extra': !this.isExportUsersEnabled() }
}
isSignupEnabled () {
return this.form().value['signup']['enabled'] === true
}
getDisabledSignupClass () {
return { 'disabled-checkbox-extra': !this.isSignupEnabled() }
}
isImportVideosHttpEnabled (): boolean {
return this.form().value['import']['videos']['http']['enabled'] === true
}
importSynchronizationChecked () {
return this.isImportVideosHttpEnabled() && this.form().value['import']['videoChannelSynchronization']['enabled']
}
hasUnlimitedSignup () {
return this.form().value['signup']['limit'] === -1
}
isSearchIndexEnabled () {
return this.form().value['search']['searchIndex']['enabled'] === true
}
getDisabledSearchIndexClass () {
return { 'disabled-checkbox-extra': !this.isSearchIndexEnabled() }
}
// ---------------------------------------------------------------------------
isTranscriptionEnabled () {
return this.form().value['videoTranscription']['enabled'] === true
}
getTranscriptionRunnerDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscriptionEnabled() }
}
// ---------------------------------------------------------------------------
isAutoFollowIndexEnabled () {
return this.form().value['followings']['instance']['autoFollowIndex']['enabled'] === true
}
buildLandingPageOptions () {
let links: { label: string, path: string }[] = []
if (this.serverConfig().homepage.enabled) {
links.push({ label: $localize`Home`, path: '/home' })
}
links = links.concat([
{ label: $localize`Discover`, path: '/videos/overview' },
{ label: $localize`Browse all videos`, path: '/videos/browse' },
{ label: $localize`Browse local videos`, path: '/videos/browse?scope=local' }
])
this.defaultLandingPageOptions = links.map(o => ({
id: o.path,
label: o.label,
description: o.path
}))
}
private checkImportSyncField () {
const importSyncControl = this.form().get('import.videoChannelSynchronization.enabled')
const importVideosHttpControl = this.form().get('import.videos.http.enabled')
importVideosHttpControl.valueChanges
.subscribe(httpImportEnabled => {
importSyncControl.setValue(httpImportEnabled && importSyncControl.value)
if (httpImportEnabled) {
importSyncControl.enable()
} else {
importSyncControl.disable()
}
})
}
private checkSignupField () {
const signupControl = this.form().get('signup.enabled')
signupControl.valueChanges
.pipe(pairwise())
.subscribe(([ oldValue, newValue ]) => {
if (oldValue === false && newValue === true) {
this.signupAlertMessage =
// eslint-disable-next-line max-len
$localize`You enabled signup: we automatically enabled the "Block new videos automatically" checkbox of the "Videos" section just below.`
this.form().patchValue({
autoBlacklist: {
videos: {
ofUsers: {
enabled: true
}
}
}
})
}
})
signupControl.updateValueAndValidity()
}
}

View file

@ -1,106 +0,0 @@
import { Injectable } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { formatICU } from '@app/helpers'
export type ResolutionOption = {
id: string
label: string
description?: string
}
@Injectable()
export class EditConfigurationService {
getTranscodingResolutions () {
return [
{
id: '0p',
label: $localize`Audio-only`,
// eslint-disable-next-line max-len
description: $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users`
},
{
id: '144p',
label: $localize`144p`
},
{
id: '240p',
label: $localize`240p`
},
{
id: '360p',
label: $localize`360p`
},
{
id: '480p',
label: $localize`480p`
},
{
id: '720p',
label: $localize`720p`
},
{
id: '1080p',
label: $localize`1080p`
},
{
id: '1440p',
label: $localize`1440p`
},
{
id: '2160p',
label: $localize`2160p`
}
]
}
isTranscodingEnabled (form: FormGroup) {
return form.value['transcoding']['enabled'] === true
}
isHLSEnabled (form: FormGroup) {
return form.value['transcoding']['hls']['enabled'] === true
}
isRemoteRunnerVODEnabled (form: FormGroup) {
return form.value['transcoding']['remoteRunners']['enabled'] === true
}
isRemoteRunnerLiveEnabled (form: FormGroup) {
return form.value['live']['transcoding']['remoteRunners']['enabled'] === true
}
isStudioEnabled (form: FormGroup) {
return form.value['videoStudio']['enabled'] === true
}
isLiveEnabled (form: FormGroup) {
return form.value['live']['enabled'] === true
}
isLiveTranscodingEnabled (form: FormGroup) {
return form.value['live']['transcoding']['enabled'] === true
}
getTotalTranscodingThreads (form: FormGroup) {
const transcodingEnabled = form.value['transcoding']['enabled']
const transcodingThreads = form.value['transcoding']['threads']
const liveTranscodingEnabled = form.value['live']['transcoding']['enabled']
const liveTranscodingThreads = form.value['live']['transcoding']['threads']
// checks whether all enabled method are on fixed values and not on auto (= 0)
let noneOnAuto = !transcodingEnabled || +transcodingThreads > 0
noneOnAuto &&= !liveTranscodingEnabled || +liveTranscodingThreads > 0
// count total of fixed value, repalcing auto by a single thread (knowing it will display "at least")
let value = 0
if (transcodingEnabled) value += +transcodingThreads || 1
if (liveTranscodingEnabled) value += +liveTranscodingThreads || 1
return {
value,
atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible
unit: formatICU($localize`{value, plural, =1 {thread} other {threads}}`, { value })
}
}
}

View file

@ -1,96 +0,0 @@
<h1 class="visually-hidden" i18n>Configuration</h1>
<my-alert type="warning" *ngIf="!isUpdateAllowed()" i18n>
Updating instance configuration from the web interface is disabled by the system administrator.
</my-alert>
<form [formGroup]="form">
<div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
<ng-container ngbNavItem="instance-homepage">
<a ngbNavLink i18n>Homepage</a>
<ng-template ngbNavContent>
<my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage>
</ng-template>
</ng-container>
<ng-container ngbNavItem="instance-information">
<a ngbNavLink i18n>Information</a>
<ng-template ngbNavContent>
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
</my-edit-instance-information>
</ng-template>
</ng-container>
<ng-container ngbNavItem="basic-configuration">
<a ngbNavLink i18n>Basic</a>
<ng-template ngbNavContent>
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
</my-edit-basic-configuration>
</ng-template>
</ng-container>
<ng-container ngbNavItem="transcoding">
<a ngbNavLink i18n>VOD Transcoding</a>
<ng-template ngbNavContent>
<my-edit-vod-transcoding [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
</my-edit-vod-transcoding>
</ng-template>
</ng-container>
<ng-container ngbNavItem="live">
<a ngbNavLink i18n>Live streaming</a>
<ng-template ngbNavContent>
<my-edit-live-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
</my-edit-live-configuration>
</ng-template>
</ng-container>
<ng-container ngbNavItem="advanced-configuration">
<a ngbNavLink i18n>Advanced</a>
<ng-template ngbNavContent>
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">
</my-edit-advanced-configuration>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>
<div class="row mt-4"> <!-- submit placement block -->
<div class="col-md-7 col-xl-5"></div>
<div class="col-md-5 col-xl-5">
<div role="alert" class="form-error submit-error" i18n *ngIf="!form.valid && isUpdateAllowed()">
There are errors in the form:
<ul>
<li *ngFor="let error of grabAllErrors()">
{{ error }}
</li>
</ul>
</div>
<span role="alert" class="form-error submit-error" i18n *ngIf="!hasLiveAllowReplayConsistentOptions()">
You cannot allow live replay if you don't enable transcoding.
</span>
<span i18n *ngIf="!isUpdateAllowed()">
You cannot change the server configuration because it's managed externally.
</span>
<input
class="peertube-button primary-button"
(click)="formValidated()" type="submit" i18n-value value="Update configuration"
[disabled]="!form.valid || !hasConsistentOptions() || !isUpdateAllowed()"
>
</div>
</div>
</form>

View file

@ -1,144 +0,0 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_form-mixins' as *;
$form-base-input-width: 340px;
$form-max-width: 500px;
form {
padding-bottom: 1.5rem;
}
my-markdown-textarea {
display: block;
max-width: $form-max-width;
}
.homepage my-markdown-textarea {
display: block;
max-width: 90%;
::ng-deep textarea {
height: 300px !important;
}
}
input[type=text],
input[type=number] {
@include peertube-input-text($form-base-input-width);
}
.number-with-unit {
position: relative;
width: fit-content;
input[type=number] + span {
position: absolute;
top: 0.4em;
right: 3em;
@media screen and (max-width: $mobile-view) {
display: none;
}
}
input[disabled] {
opacity: 0.8;
pointer-events: none;
}
}
input[type=checkbox] {
@include peertube-checkbox;
}
.peertube-select-container {
@include peertube-select-container($form-base-input-width);
}
my-select-checkbox,
my-select-options,
my-select-custom-value {
display: block;
@include responsive-width($form-base-input-width);
}
input[type=submit] {
display: flex;
@include margin-left(auto);
+ .form-error {
display: inline;
@include margin-left(5px);
}
}
.inner-form-description {
font-size: 15px;
margin-bottom: 15px;
}
textarea {
max-width: 100%;
display: block;
@include peertube-textarea(500px, 150px);
&.small {
height: 75px;
}
}
.label-small-info {
font-style: italic;
margin-bottom: 10px;
font-size: 14px;
}
.disabled-checkbox-extra {
&,
::ng-deep label {
opacity: .5;
pointer-events: none;
}
}
input[disabled] {
opacity: 0.5;
}
ngb-tabset:not(.previews) ::ng-deep {
.nav-link {
font-size: 105%;
}
}
.submit-error {
margin-bottom: 20px;
}
.alert-signup {
width: fit-content;
margin-top: 10px;
}
.callout-container {
position: absolute;
display: flex;
height: 0;
width: 100%;
justify-content: right;
}
my-actor-banner-edit {
max-width: $form-max-width;
}
h4 {
font-weight: $font-bold;
margin-bottom: 0.5rem;
font-size: 1rem;
}

View file

@ -1,479 +0,0 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
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,
INSTANCE_NAME_VALIDATOR,
INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
MAX_INSTANCE_LIVES_VALIDATOR,
MAX_LIVE_DURATION_VALIDATOR,
MAX_SYNC_PER_USER,
MAX_USER_LIVES_VALIDATOR,
MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
SERVICES_TWITTER_USERNAME_VALIDATOR,
SIGNUP_LIMIT_VALIDATOR,
SIGNUP_MINIMUM_AGE_VALIDATOR,
TRANSCODING_MAX_FPS_VALIDATOR,
TRANSCODING_THREADS_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
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 { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models'
import merge from 'lodash-es/merge'
import omit from 'lodash-es/omit'
import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component'
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
import { EditConfigurationService } from './edit-configuration.service'
import { EditHomepageComponent } from './edit-homepage.component'
import { EditInstanceInformationComponent } from './edit-instance-information.component'
import { EditLiveConfigurationComponent } from './edit-live-configuration.component'
import { EditVODTranscodingComponent } from './edit-vod-transcoding.component'
type ComponentCustomConfig = CustomConfig & {
instanceCustomHomepage: CustomPage
}
@Component({
selector: 'my-edit-custom-config',
templateUrl: './edit-custom-config.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
NgIf,
FormsModule,
ReactiveFormsModule,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavContent,
EditHomepageComponent,
EditInstanceInformationComponent,
EditBasicConfigurationComponent,
EditVODTranscodingComponent,
EditLiveConfigurationComponent,
EditAdvancedConfigurationComponent,
NgbNavOutlet,
NgFor,
AlertComponent
]
})
export class EditCustomConfigComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private router = inject(Router)
private route = inject(ActivatedRoute)
private notifier = inject(Notifier)
private configService = inject(ConfigService)
private customPage = inject(CustomPageService)
private serverService = inject(ServerService)
private editConfigurationService = inject(EditConfigurationService)
activeNav: string
customConfig: ComponentCustomConfig
serverConfig: HTMLServerConfig
homepage: CustomPage
languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = []
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
const formGroupData: { [key in keyof ComponentCustomConfig]: any } = {
instance: {
name: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
description: null,
isNSFW: false,
defaultNSFWPolicy: null,
terms: null,
codeOfConduct: null,
creationReason: null,
moderationInformation: null,
administrator: null,
maintenanceLifetime: null,
businessModel: null,
hardwareInformation: null,
categories: null,
languages: null,
serverCountry: null,
support: {
text: null
},
social: {
externalLink: URL_VALIDATOR,
mastodonLink: URL_VALIDATOR,
blueskyLink: URL_VALIDATOR
},
defaultClientRoute: null,
customizations: {
javascript: null,
css: null
}
},
theme: {
default: null
},
services: {
twitter: {
username: SERVICES_TWITTER_USERNAME_VALIDATOR
}
},
client: {
videos: {
miniature: {
preferAuthorDisplayName: null
}
},
menu: {
login: {
redirectOnSingleExternalAuth: null
}
}
},
cache: {
previews: {
size: CACHE_SIZE_VALIDATOR
},
captions: {
size: CACHE_SIZE_VALIDATOR
},
torrents: {
size: CACHE_SIZE_VALIDATOR
},
storyboards: {
size: CACHE_SIZE_VALIDATOR
}
},
signup: {
enabled: null,
limit: SIGNUP_LIMIT_VALIDATOR,
requiresApproval: null,
requiresEmailVerification: null,
minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
},
import: {
videos: {
concurrency: CONCURRENCY_VALIDATOR,
http: {
enabled: null
},
torrent: {
enabled: null
}
},
videoChannelSynchronization: {
enabled: null,
maxPerUser: MAX_SYNC_PER_USER
},
users: {
enabled: null
}
},
export: {
users: {
enabled: null,
maxUserVideoQuota: EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
exportExpiration: EXPORT_EXPIRATION_VALIDATOR
}
},
trending: {
videos: {
algorithms: {
enabled: null,
default: null
}
}
},
admin: {
email: ADMIN_EMAIL_VALIDATOR
},
contactForm: {
enabled: null
},
user: {
history: {
videos: {
enabled: null
}
},
videoQuota: USER_VIDEO_QUOTA_VALIDATOR,
videoQuotaDaily: USER_VIDEO_QUOTA_DAILY_VALIDATOR
},
videoChannels: {
maxPerUser: MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR
},
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
allowAdditionalExtensions: null,
allowAudioFiles: null,
profile: null,
concurrency: CONCURRENCY_VALIDATOR,
resolutions: {},
alwaysTranscodeOriginalResolution: null,
originalFile: {
keep: null
},
hls: {
enabled: null,
splitAudioAndVideo: null
},
webVideos: {
enabled: null
},
remoteRunners: {
enabled: null
},
fps: {
max: TRANSCODING_MAX_FPS_VALIDATOR
}
},
live: {
enabled: null,
maxDuration: MAX_LIVE_DURATION_VALIDATOR,
maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR,
maxUserLives: MAX_USER_LIVES_VALIDATOR,
allowReplay: null,
latencySetting: {
enabled: null
},
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
profile: null,
resolutions: {},
alwaysTranscodeOriginalResolution: null,
remoteRunners: {
enabled: null
},
fps: {
max: TRANSCODING_MAX_FPS_VALIDATOR
}
}
},
videoStudio: {
enabled: null,
remoteRunners: {
enabled: null
}
},
videoTranscription: {
enabled: null,
remoteRunners: {
enabled: null
}
},
videoFile: {
update: {
enabled: null
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: null
}
}
},
followers: {
instance: {
enabled: null,
manualApproval: null
}
},
followings: {
instance: {
autoFollowBack: {
enabled: null
},
autoFollowIndex: {
enabled: null,
indexUrl: URL_VALIDATOR
}
}
},
broadcastMessage: {
enabled: null,
level: null,
dismissable: null,
message: null
},
search: {
remoteUri: {
users: null,
anonymous: null
},
searchIndex: {
enabled: null,
url: URL_VALIDATOR,
disableLocalSearch: null,
isDefaultSearch: null
}
},
instanceCustomHomepage: {
content: null
},
storyboards: {
enabled: null
}
}
const defaultValues = {
transcoding: {
resolutions: {} as { [id: string]: string }
},
live: {
transcoding: {
resolutions: {} as { [id: string]: string }
}
}
}
for (const resolution of this.editConfigurationService.getTranscodingResolutions()) {
defaultValues.transcoding.resolutions[resolution.id] = 'false'
formGroupData.transcoding.resolutions[resolution.id] = null
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
formGroupData.live.transcoding.resolutions[resolution.id] = null
}
this.buildForm(formGroupData)
if (this.route.snapshot.fragment) {
this.onNavChange(this.route.snapshot.fragment)
}
this.loadConfigAndUpdateForm()
this.loadCategoriesAndLanguages()
if (!this.isUpdateAllowed()) {
this.form.disable()
}
}
formValidated () {
this.forceCheck()
if (!this.form.valid) return
const value: ComponentCustomConfig = merge(this.customConfig, this.form.getRawValue())
forkJoin([
this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')),
this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content)
])
.subscribe({
next: ([ resConfig ]) => {
const instanceCustomHomepage = { content: value.instanceCustomHomepage.content }
this.customConfig = { ...resConfig, instanceCustomHomepage }
// Reload general configuration
this.serverService.resetConfig()
.subscribe(config => {
this.serverConfig = config
})
this.updateForm()
this.notifier.success($localize`Configuration updated.`)
},
error: err => this.notifier.error(err.message)
})
}
isUpdateAllowed () {
return this.serverConfig.webadmin.configuration.edition.allowed === true
}
hasConsistentOptions () {
if (this.hasLiveAllowReplayConsistentOptions()) return true
return false
}
hasLiveAllowReplayConsistentOptions () {
if (
this.editConfigurationService.isTranscodingEnabled(this.form) === false &&
this.editConfigurationService.isLiveEnabled(this.form) &&
this.form.value['live']['allowReplay'] === true
) {
return false
}
return true
}
onNavChange (newActiveNav: string) {
this.activeNav = newActiveNav
this.router.navigate([], { fragment: this.activeNav })
}
grabAllErrors () {
return this.formReactiveService.grabAllErrors(this.formErrors)
}
private updateForm () {
this.form.patchValue(this.customConfig)
}
private loadConfigAndUpdateForm () {
forkJoin([
this.configService.getCustomConfig(),
this.customPage.getInstanceHomepage()
]).subscribe({
next: ([ config, homepage ]) => {
this.customConfig = { ...config, instanceCustomHomepage: homepage }
this.updateForm()
this.markAllAsDirty()
},
error: err => this.notifier.error(err.message)
})
}
private loadCategoriesAndLanguages () {
forkJoin([
this.serverService.getVideoLanguages(),
this.serverService.getVideoCategories()
]).subscribe({
next: ([ languages, categories ]) => {
this.languageItems = languages.map(l => ({ label: l.label, id: l.id }))
this.categoryItems = categories.map(l => ({ label: l.label, id: l.id }))
},
error: err => this.notifier.error(err.message)
})
}
}

View file

@ -1,32 +0,0 @@
<ng-container [formGroup]="form()">
<ng-container formGroupName="instanceCustomHomepage">
<div class="homepage pt-two-cols mt-5"> <!-- homepage grid -->
<div class="title-col">
<h2 i18n>INSTANCE HOMEPAGE</h2>
</div>
<div class="content-col">
<div class="form-group">
<label i18n for="instanceCustomHomepageContent">Homepage</label>
<div class="label-small-info">
<my-custom-markup-help></my-custom-markup-help>
</div>
<my-markdown-textarea
inputId="instanceCustomHomepageContent" formControlName="content"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors()['instanceCustomHomepage.content']"
dir="ltr" monospace="true"
></my-markdown-textarea>
<div *ngIf="formErrors().instanceCustomHomepage.content" class="form-error" role="alert">{{ formErrors().instanceCustomHomepage.content }}</div>
</div>
</div>
</div>
</ng-container>
</ng-container>

View file

@ -1,25 +0,0 @@
import { Component, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIf } from '@angular/common'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
@Component({
selector: 'my-edit-homepage',
templateUrl: './edit-homepage.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [ FormsModule, ReactiveFormsModule, CustomMarkupHelpComponent, MarkdownTextareaComponent, NgIf ]
})
export class EditHomepageComponent {
private customMarkup = inject(CustomMarkupService)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
customMarkdownRenderer: (text: string) => Promise<HTMLElement>
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
}

View file

@ -1,158 +0,0 @@
import { CommonModule } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http'
import { Component, OnInit, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import { genericUploadErrorHandler } from '@app/helpers'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component'
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { maxBy } from '@peertube/peertube-core-utils'
import { ActorImage, HTMLServerConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { ActorAvatarEditComponent } from '../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../../../shared/shared-actor-image-edit/actor-banner-edit.component'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/select-checkbox.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
@Component({
selector: 'my-edit-instance-information',
templateUrl: './edit-instance-information.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
ActorAvatarEditComponent,
ActorBannerEditComponent,
SelectRadioComponent,
CommonModule,
CustomMarkupHelpComponent,
MarkdownTextareaComponent,
SelectCheckboxComponent,
RouterLink,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
HelpComponent
]
})
export class EditInstanceInformationComponent implements OnInit {
private customMarkup = inject(CustomMarkupService)
private notifier = inject(Notifier)
private instanceService = inject(InstanceService)
private server = inject(ServerService)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly languageItems = input<SelectOptionsItem[]>([])
readonly categoryItems = input<SelectOptionsItem[]>([])
instanceBannerUrl: string
instanceAvatars: ActorImage[] = []
nsfwItems: SelectOptionsItem[] = [
{
id: 'do_not_list',
label: $localize`Hide`
},
{
id: 'warn',
label: $localize`Warn`
},
{
id: 'blur',
label: $localize`Blur`
},
{
id: 'display',
label: $localize`Display`
}
]
private serverConfig: HTMLServerConfig
get instanceName () {
return this.server.getHTMLConfig().instance.name
}
ngOnInit () {
this.serverConfig = this.server.getHTMLConfig()
this.updateActorImages()
}
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
onBannerChange (formData: FormData) {
this.instanceService.updateInstanceBanner(formData)
.subscribe({
next: () => {
this.notifier.success($localize`Banner changed.`)
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier })
})
}
onBannerDelete () {
this.instanceService.deleteInstanceBanner()
.subscribe({
next: () => {
this.notifier.success($localize`Banner deleted.`)
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
onAvatarChange (formData: FormData) {
this.instanceService.updateInstanceAvatar(formData)
.subscribe({
next: () => {
this.notifier.success($localize`Avatar changed.`)
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`avatar`, notifier: this.notifier })
})
}
onAvatarDelete () {
this.instanceService.deleteInstanceAvatar()
.subscribe({
next: () => {
this.notifier.success($localize`Avatar deleted.`)
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
private updateActorImages () {
this.instanceBannerUrl = maxBy(this.serverConfig.instance.banners, 'width')?.path
this.instanceAvatars = this.serverConfig.instance.avatars
}
private resetActorImages () {
this.server.resetConfig()
.subscribe(config => {
this.serverConfig = config
this.updateActorImages()
})
}
}

View file

@ -1,112 +0,0 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { RouterLink } from '@angular/router'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { NgClass, NgIf, NgFor } from '@angular/common'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
@Component({
selector: 'my-edit-live-configuration',
templateUrl: './edit-live-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
NgClass,
NgIf,
SelectOptionsComponent,
NgFor,
RouterLink,
SelectCustomValueComponent
]
})
export class EditLiveConfigurationComponent implements OnInit, OnChanges {
private configService = inject(ConfigService)
private editConfigurationService = inject(EditConfigurationService)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly serverConfig = input<HTMLServerConfig>(undefined)
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
liveMaxDurationOptions: SelectOptionsItem[] = []
liveResolutions: ResolutionOption[] = []
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.liveMaxDurationOptions = [
{ id: -1, label: $localize`No limit` },
{ id: 1000 * 3600, label: $localize`1 hour` },
{ id: 1000 * 3600 * 3, label: $localize`3 hours` },
{ id: 1000 * 3600 * 5, label: $localize`5 hours` },
{ id: 1000 * 3600 * 10, label: $localize`10 hours` }
]
this.liveResolutions = this.editConfigurationService.getTranscodingResolutions()
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.transcodingProfiles = this.buildAvailableTranscodingProfile()
}
}
buildAvailableTranscodingProfile () {
const profiles = this.serverConfig().live.transcoding.availableProfiles
return profiles.map(p => {
if (p === 'default') {
return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` }
}
return { id: p, label: p }
})
}
getResolutionKey (resolution: string) {
return 'live.transcoding.resolutions.' + resolution
}
getLiveRTMPPort () {
return this.serverConfig().live.rtmp.port
}
isLiveEnabled () {
return this.editConfigurationService.isLiveEnabled(this.form())
}
isRemoteRunnerLiveEnabled () {
return this.editConfigurationService.isRemoteRunnerLiveEnabled(this.form())
}
getDisabledLiveClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() }
}
getDisabledLiveTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() }
}
getDisabledLiveLocalTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() || this.isRemoteRunnerLiveEnabled() }
}
isLiveTranscodingEnabled () {
return this.editConfigurationService.isLiveTranscodingEnabled(this.form())
}
getTotalTranscodingThreads () {
return this.editConfigurationService.getTotalTranscodingThreads(this.form())
}
}

View file

@ -1,159 +0,0 @@
import { NgClass, NgFor, NgIf } from '@angular/common'
import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier } from '@app/core'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
@Component({
selector: 'my-edit-vod-transcoding',
templateUrl: './edit-vod-transcoding.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
NgClass,
NgFor,
NgIf,
RouterLink,
SelectCustomValueComponent,
SelectOptionsComponent
]
})
export class EditVODTranscodingComponent implements OnInit, OnChanges {
private configService = inject(ConfigService)
private editConfigurationService = inject(EditConfigurationService)
private notifier = inject(Notifier)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly serverConfig = input<HTMLServerConfig>(undefined)
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
resolutions: ResolutionOption[] = []
additionalVideoExtensions = ''
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.editConfigurationService.getTranscodingResolutions()
this.checkTranscodingFields()
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.transcodingProfiles = this.buildAvailableTranscodingProfile()
this.additionalVideoExtensions = this.serverConfig().video.file.extensions.join(' ')
}
}
buildAvailableTranscodingProfile () {
const profiles = this.serverConfig().transcoding.availableProfiles
return profiles.map(p => {
if (p === 'default') {
return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` }
}
return { id: p, label: p }
})
}
getResolutionKey (resolution: string) {
return 'transcoding.resolutions.' + resolution
}
isRemoteRunnerVODEnabled () {
return this.editConfigurationService.isRemoteRunnerVODEnabled(this.form())
}
isTranscodingEnabled () {
return this.editConfigurationService.isTranscodingEnabled(this.form())
}
isHLSEnabled () {
return this.editConfigurationService.isHLSEnabled(this.form())
}
isStudioEnabled () {
return this.editConfigurationService.isStudioEnabled(this.form())
}
getTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
}
getHLSDisabledClass () {
return { 'disabled-checkbox-extra': !this.isHLSEnabled() }
}
getLocalTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
}
getStudioDisabledClass () {
return { 'disabled-checkbox-extra': !this.isStudioEnabled() }
}
getTotalTranscodingThreads () {
return this.editConfigurationService.getTotalTranscodingThreads(this.form())
}
private checkTranscodingFields () {
const transcodingControl = this.form().get('transcoding.enabled')
const videoStudioControl = this.form().get('videoStudio.enabled')
const hlsControl = this.form().get('transcoding.hls.enabled')
const webVideosControl = this.form().get('transcoding.webVideos.enabled')
webVideosControl.valueChanges
.subscribe(newValue => {
if (newValue === false && hlsControl.value === false) {
hlsControl.setValue(true)
this.notifier.info(
$localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})
hlsControl.valueChanges
.subscribe(newValue => {
if (newValue === false && webVideosControl.value === false) {
webVideosControl.setValue(true)
this.notifier.info(
// eslint-disable-next-line max-len
$localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})
transcodingControl.valueChanges
.subscribe(newValue => {
if (newValue === false) {
videoStudioControl.setValue(false)
}
})
transcodingControl.updateValueAndValidity()
webVideosControl.updateValueAndValidity()
videoStudioControl.updateValueAndValidity()
hlsControl.updateValueAndValidity()
}
}

View file

@ -1,8 +0,0 @@
export * from './edit-advanced-configuration.component'
export * from './edit-basic-configuration.component'
export * from './edit-configuration.service'
export * from './edit-custom-config.component'
export * from './edit-homepage.component'
export * from './edit-instance-information.component'
export * from './edit-live-configuration.component'
export * from './edit-vod-transcoding.component'

View file

@ -1,2 +1,2 @@
export * from './edit-custom-config' export * from './pages'
export * from './config.routes' export * from './config.routes'

View file

@ -0,0 +1,112 @@
<my-admin-save-bar i18n-title title="Advanced configuration" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<ng-container [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col">
<h2 i18n>CACHE</h2>
<div i18n class="inner-form-description">
Some files are not federated, and fetched when necessary. Define their caching policies.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="cache">
<div class="form-group" formGroupName="previews">
<label i18n for="cachePreviewsSize">Number of previews to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cachePreviewsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors.cache.previews.size }"
>
<span i18n>{getCacheSize('previews'), plural, =1 {cached image} other {cached images}}</span>
</div>
<div *ngIf="formErrors.cache.previews.size" class="form-error" role="alert">{{ formErrors.cache.previews.size }}</div>
</div>
<div class="form-group" formGroupName="captions">
<label i18n for="cacheCaptionsSize">Number of video captions to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheCaptionsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors.cache.captions.size }"
>
<span i18n>{getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}}</span>
</div>
<div *ngIf="formErrors.cache.captions.size" class="form-error" role="alert">{{ formErrors.cache.captions.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
<label i18n for="cacheTorrentsSize">Number of video torrents to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheTorrentsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors.cache.torrents.size }"
>
<span i18n>{getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}}</span>
</div>
<div *ngIf="formErrors.cache.torrents.size" class="form-error" role="alert">{{ formErrors.cache.torrents.size }}</div>
</div>
<div class="form-group" formGroupName="storyboards">
<label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheStoryboardsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors.cache.storyboards.size }"
>
<span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
</div>
<div *ngIf="formErrors.cache.storyboards.size" class="form-error" role="alert">{{ formErrors.cache.storyboards.size }}</div>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>TWITTER/X</h2>
<div i18n class="inner-form-description">
Extra configuration required by Twitter/X. All other social media (Facebook, Mastodon, etc.) are supported out of the box.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="services">
<ng-container formGroupName="twitter">
<div class="form-group">
<label for="servicesTwitterUsername" i18n>Your Twitter/X username</label>
<div class="form-group-description">
<p i18n class="mb-0">Indicates the Twitter/X account for the website or platform where the content was published.</p>
<p i18n>This is just an extra information injected in PeerTube HTML that is required by Twitter/X. If you don't have a Twitter/X account, just leave the default value.</p>
</div>
<input
type="text" id="servicesTwitterUsername" class="form-control"
formControlName="username" [ngClass]="{ 'input-error': formErrors.services.twitter.username }"
>
<div *ngIf="formErrors.services.twitter.username" class="form-error" role="alert">{{ formErrors.services.twitter.username }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container>

View file

@ -0,0 +1,116 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { CanComponentDeactivate } from '@app/core'
import { CACHE_SIZE_VALIDATOR, SERVICES_TWITTER_USERNAME_VALIDATOR } from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomConfig } from '@peertube/peertube-models'
import { AdminConfigService } from '../shared/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
services: FormGroup<{
twitter: FormGroup<{
username: FormControl<string>
}>
}>
cache: FormGroup<{
previews: FormGroup<{
size: FormControl<number>
}>
captions: FormGroup<{
size: FormControl<number>
}>
torrents: FormGroup<{
size: FormControl<number>
}>
storyboards: FormGroup<{
size: FormControl<number>
}>
}>
}
@Component({
selector: 'my-admin-config-advanced',
templateUrl: './admin-config-advanced.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [ CommonModule, FormsModule, ReactiveFormsModule, AdminSaveBarComponent ]
})
export class AdminConfigAdvancedComponent implements OnInit, CanComponentDeactivate {
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
private customConfig: CustomConfig
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.buildForm()
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
services: {
twitter: {
username: SERVICES_TWITTER_USERNAME_VALIDATOR
}
},
cache: {
previews: {
size: CACHE_SIZE_VALIDATOR
},
captions: {
size: CACHE_SIZE_VALIDATOR
},
torrents: {
size: CACHE_SIZE_VALIDATOR
},
storyboards: {
size: CACHE_SIZE_VALIDATOR
}
}
}
const defaultValues: FormDefaultTyped<Form> = this.customConfig
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
return this.form.value.cache[type].size
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`Advanced configuration updated.`
})
}
}

View file

@ -0,0 +1,80 @@
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
$form-base-input-width: 340px;
$form-max-width: 500px;
form {
padding-bottom: 1.5rem;
}
my-markdown-textarea {
display: block;
max-width: $form-max-width;
}
.homepage my-markdown-textarea {
display: block;
max-width: 100%;
::ng-deep textarea {
height: 300px !important;
}
}
input[type="text"],
input[type="number"] {
@include peertube-input-text($form-base-input-width);
}
input[type="checkbox"] {
@include peertube-checkbox;
}
.peertube-select-container {
@include peertube-select-container($form-base-input-width);
}
my-select-checkbox,
my-select-options,
my-select-custom-value {
display: block;
@include responsive-width($form-base-input-width);
}
.inner-form-description {
font-size: 14px;
margin-bottom: 1rem;
color: pvar(--fg-300);
}
textarea {
max-width: 100%;
display: block;
@include peertube-textarea(500px, 150px);
&.small {
height: 75px;
}
}
.disabled-checkbox-extra {
&,
::ng-deep label {
opacity: 0.5;
pointer-events: none;
}
}
my-actor-banner-edit {
max-width: $form-max-width;
}
h4 {
font-weight: $font-bold;
margin-bottom: 0.5rem;
font-size: 1rem;
}

View file

@ -0,0 +1,178 @@
<my-admin-save-bar i18n-title title="Platform customization" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<div class="pt-two-cols" [formGroup]="form">
<div class="title-col">
<h2 i18n>APPEARANCE</h2>
</div>
<div class="content-col">
<ng-container formGroupName="theme">
<div class="form-group">
<label i18n for="themeDefault">Theme</label>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div>
</ng-container>
<ng-container formGroupName="client">
<ng-container formGroupName="videos">
<ng-container formGroupName="miniature">
<div class="form-group">
<my-peertube-checkbox
inputName="clientVideosMiniaturePreferAuthorDisplayName"
formControlName="preferAuthorDisplayName"
i18n-labelText
labelText="Prefer author display name in video miniature"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4" [formGroup]="form">
<div class="title-col">
<h2 i18n>CUSTOMIZATION</h2>
<div i18n class="inner-form-description">
Use <a class="link-primary" routerLink="/admin/settings/plugins">plugins & themes</a> for more involved changes
</div>
</div>
<div class="content-col">
<ng-template #alertIntro>
<div i18n>UI customization only applies if the user is using the default platform theme.</div>
</ng-template>
@if (getCurrentThemeName() !== getDefaultThemeName()) {
<my-alert type="warning">
<ng-template *ngTemplateOutlet="alertIntro"></ng-template>
<div i18n>You can't preview the changes because you aren't using your platform's default theme.</div>
<div i18n>Current theme: <strong>{{ getCurrentThemeLabel() }}</strong></div>
<div i18n>Platform theme: <strong>{{ getDefaultThemeLabel() }}</strong>.</div>
</my-alert>
} @else {
<my-alert type="info">
<ng-template *ngTemplateOutlet="alertIntro"></ng-template>
<div i18n>You can preview your UI customization but <strong>don't forget to save your changes</strong> once you are happy with the results.</div>
</my-alert>
}
<div class="form-group" formGroupName="theme">
<ng-container formGroupName="customization">
@for (field of customizationFormFields; track field.name) {
<div class="form-group">
<label [for]="field.inputId">{{ field.label }}</label>
<button
*ngIf="!hasDefaultCustomizationValue(field.name)"
type="button"
i18n
class="reset-button reset-button-small"
(click)="resetCustomizationField(field.name)"
>
Reset
</button>
<div *ngIf="field.description" class="form-group-description">{{ field.description }}</div>
@if (field.type === 'color') {
<p-colorpicker class="d-block" [inputId]="field.inputId" [formControlName]="field.name" />
} @else if (field.type === 'pixels' && isCustomizationFieldNumber(field.name)) {
<div class="number-with-unit">
<input
type="number"
[id]="field.inputId"
[name]="field.inputId"
class="form-control"
[formControlName]="field.name"
[ngClass]="{ 'input-error': formErrors.theme.customization[field.name]}"
/>
<span>pixels</span>
</div>
} @else {
<input
type="text"
[id]="field.inputId"
[name]="field.inputId"
class="form-control"
[formControlName]="field.name"
[ngClass]="{ 'input-error': formErrors.theme.customization[field.name]}"
>
}
</div>
}
</ng-container>
</div>
</div>
</div>
<div class="pt-two-cols mt-4" [formGroup]="form">
<div class="title-col">
<div class="anchor" id="customizations"></div>
<!-- customizations anchor -->
<h2 i18n>Advanced</h2>
<div i18n class="inner-form-description">
Advanced modifications to your PeerTube platform if creating a plugin or a theme is overkill.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="instance">
<ng-container formGroupName="customizations">
<div class="form-group">
<label i18n for="customizationJavascript">JavaScript</label>
<my-help>
<ng-container i18n>
<p class="mb-2">Write JavaScript code directly. Example:</p>
<pre>console.log('my instance is amazing');</pre>
</ng-container>
</my-help>
<textarea
id="customizationJavascript"
formControlName="javascript"
class="form-control"
dir="ltr"
[ngClass]="{ 'input-error': formErrors.instance.customizations.javascript }"
></textarea>
<div *ngIf="formErrors.instance.customizations.javascript" class="form-error" role="alert">{{ formErrors.instance.customizations.javascript }}</div>
</div>
<div class="form-group">
<label for="customizationCSS">CSS</label>
<my-help>
<ng-container i18n>
<p class="mb-2">Write CSS code directly. Example:</p>
<pre>
#custom-css {{ '{' }}
color: red;
{{ '}' }}
</pre>
<p class="mb-2">Prepend with <em>#custom-css</em> to override styles. Example:</p>
<pre>
#custom-css .logged-in-email {{ '{' }}
color: red;
{{ '}' }}
</pre>
</ng-container>
</my-help>
<textarea
id="customizationCSS"
formControlName="css"
class="form-control"
dir="ltr"
[ngClass]="{ 'input-error': formErrors.instance.customizations.css }"
></textarea>
<div *ngIf="formErrors.instance.customizations.css" class="form-error" role="alert">{{ formErrors.instance.customizations.css }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>

View file

@ -0,0 +1,363 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, ValueChangeEvent } from '@angular/forms'
import { ActivatedRoute, RouterModule } from '@angular/router'
import { CanComponentDeactivate, ServerService, ThemeService } from '@app/core'
import { BuildFormArgumentTyped, FormDefaultTyped, FormReactiveMessagesTyped } from '@app/shared/form-validators/form-validator.model'
import { FormReactiveErrorsTyped, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { objectKeysTyped } from '@peertube/peertube-core-utils'
import { CustomConfig } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger'
import { capitalizeFirstLetter } from '@root-helpers/string'
import { ColorPaletteThemeConfig, ThemeCustomizationKey } from '@root-helpers/theme-manager'
import { formatHEX, parse } from 'color-bits'
import debug from 'debug'
import { ColorPickerModule } from 'primeng/colorpicker'
import { debounceTime } from 'rxjs'
import { SelectOptionsItem } from 'src/types'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { AlertComponent } from '../../../shared/shared-main/common/alert.component'
import { AdminConfigService } from '../shared/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
const debugLogger = debug('peertube:config')
type Form = {
instance: FormGroup<{
customizations: FormGroup<{
css: FormControl<string>
javascript: FormControl<string>
}>
}>
client: FormGroup<{
videos: FormGroup<{
miniature: FormGroup<{
preferAuthorDisplayName: FormControl<boolean>
}>
}>
}>
theme: FormGroup<{
default: FormControl<string>
customization: FormGroup<{
primaryColor: FormControl<string>
foregroundColor: FormControl<string>
backgroundColor: FormControl<string>
backgroundSecondaryColor: FormControl<string>
menuForegroundColor: FormControl<string>
menuBackgroundColor: FormControl<string>
menuBorderRadius: FormControl<string>
headerForegroundColor: FormControl<string>
headerBackgroundColor: FormControl<string>
inputBorderRadius: FormControl<string>
}>
}>
}
@Component({
selector: 'my-admin-config-customization',
templateUrl: './admin-config-customization.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
RouterModule,
ReactiveFormsModule,
AdminSaveBarComponent,
ColorPickerModule,
AlertComponent,
SelectOptionsComponent,
HelpComponent,
PeertubeCheckboxComponent
]
})
export class AdminConfigCustomizationComponent implements OnInit, CanComponentDeactivate {
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
private serverService = inject(ServerService)
private themeService = inject(ThemeService)
private route = inject(ActivatedRoute)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
customizationFormFields: {
label: string
inputId: string
name: ThemeCustomizationKey
description?: string
type: 'color' | 'pixels'
}[] = []
availableThemes: SelectOptionsItem[]
private customizationResetFields = new Set<ThemeCustomizationKey>()
private customConfig: CustomConfig
private readonly formFieldsObject: Record<ThemeCustomizationKey, { label: string, description?: string, type: 'color' | 'pixels' }> = {
primaryColor: { label: $localize`Primary color`, type: 'color' },
foregroundColor: { label: $localize`Foreground color`, type: 'color' },
backgroundColor: { label: $localize`Background color`, type: 'color' },
backgroundSecondaryColor: {
label: $localize`Secondary background color`,
description: $localize`Used as a background for inputs, overlays...`,
type: 'color'
},
menuForegroundColor: { label: $localize`Menu foreground color`, type: 'color' },
menuBackgroundColor: { label: $localize`Menu background color`, type: 'color' },
menuBorderRadius: { label: $localize`Menu border radius`, type: 'pixels' },
headerForegroundColor: { label: $localize`Header foreground color`, type: 'color' },
headerBackgroundColor: { label: $localize`Header background color`, type: 'color' },
inputBorderRadius: { label: $localize`Input border radius`, type: 'pixels' }
}
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.availableThemes = [
this.themeService.getDefaultThemeItem(),
...this.themeService.buildAvailableThemes()
]
this.buildForm()
this.subscribeToCustomizationChanges()
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
private subscribeToCustomizationChanges () {
let currentAnimationFrame: number
this.form.get('theme.customization').valueChanges.pipe(debounceTime(250)).subscribe(formValues => {
if (currentAnimationFrame) {
cancelAnimationFrame(currentAnimationFrame)
currentAnimationFrame = null
}
currentAnimationFrame = requestAnimationFrame(() => {
this.themeService.updateColorPalette({
...this.customConfig.theme,
customization: this.buildNewCustomization(formValues)
})
})
})
for (const [ key, control ] of Object.entries((this.form.get('theme.customization') as FormGroup).controls)) {
control.events.subscribe(event => {
if (event instanceof ValueChangeEvent) {
debugLogger(`Deleting "${key}" from reset fields`)
this.customizationResetFields.delete(key as ThemeCustomizationKey)
}
})
}
}
private buildForm () {
for (const [ untypedName, info ] of Object.entries(this.formFieldsObject)) {
const name = untypedName as ThemeCustomizationKey
this.customizationFormFields.push({
label: info.label,
type: info.type,
inputId: `themeCustomization${capitalizeFirstLetter(name)}`,
name
})
if (!this.customConfig.theme.customization[name]) {
this.customizationResetFields.add(name)
}
}
const obj: BuildFormArgumentTyped<Form> = {
client: {
videos: {
miniature: {
preferAuthorDisplayName: null
}
}
},
instance: {
customizations: {
css: null,
javascript: null
}
},
theme: {
default: null,
customization: {
primaryColor: null,
foregroundColor: null,
backgroundColor: null,
backgroundSecondaryColor: null,
menuForegroundColor: null,
menuBackgroundColor: null,
menuBorderRadius: null,
headerForegroundColor: null,
headerBackgroundColor: null,
inputBorderRadius: null
}
}
}
const defaultValues: FormDefaultTyped<Form> = {
...this.customConfig,
theme: {
default: this.customConfig.theme.default,
customization: this.getDefaultCustomization()
}
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
getCurrentThemeName () {
return this.themeService.getCurrentThemeName()
}
getCurrentThemeLabel () {
return this.availableThemes.find(t => t.id === this.themeService.getCurrentThemeName())?.label
}
getDefaultThemeName () {
return this.serverService.getHTMLConfig().theme.default
}
getDefaultThemeLabel () {
return this.availableThemes.find(t => t.id === this.getDefaultThemeName())?.label
}
hasDefaultCustomizationValue (field: ThemeCustomizationKey) {
return this.customizationResetFields.has(field)
}
resetCustomizationField (field: ThemeCustomizationKey) {
this.customizationResetFields.add(field)
this.themeService.updateColorPalette({
...this.customConfig.theme,
customization: this.buildNewCustomization(this.form.get('theme.customization').value)
})
const value = this.formatCustomizationFieldForForm(field, this.themeService.getCSSConfigValue(field))
const control = this.getCustomizationControl(field)
control.patchValue(value, { emitEvent: false })
control.markAsDirty()
}
save () {
const formValues = this.form.value
formValues.theme.customization = this.buildNewCustomization(formValues.theme.customization)
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`Platform customization updated.`
})
}
private getCustomizationControl (field: ThemeCustomizationKey) {
return this.form.get('theme.customization').get(field)
}
private getDefaultCustomization () {
const config = this.customConfig.theme.customization
return objectKeysTyped(this.formFieldsObject).reduce((acc, field) => {
acc[field] = config[field]
? this.formatCustomizationFieldForForm(field, config[field])
: this.formatCustomizationFieldForForm(field, this.themeService.getCSSConfigValue(field))
return acc
}, {} as Record<ThemeCustomizationKey, string>)
}
isCustomizationFieldNumber (field: ThemeCustomizationKey) {
return this.isNumber(this.getCustomizationControl(field).value)
}
private isNumber (value: string | number) {
return typeof value === 'number' || /^\d+$/.test(value)
}
// ---------------------------------------------------------------------------
private formatCustomizationFieldForForm (field: ThemeCustomizationKey, value: string) {
if (this.formFieldsObject[field].type === 'pixels') {
return this.formatPixelsForForm(value)
}
if (this.formFieldsObject[field].type === 'color') {
return this.formatColorForForm(value)
}
return value
}
private formatPixelsForForm (value: string) {
if (typeof value === 'number') return value + ''
if (typeof value !== 'string') return null
const result = parseInt(value.replace(/px$/, ''))
if (isNaN(result)) return null
return result + ''
}
private formatColorForForm (value: string) {
if (!value) return null
try {
return formatHEX(parse(value))
} catch (err) {
logger.warn(`Error parsing color value "${value}"`, err)
return null
}
}
// ---------------------------------------------------------------------------
private buildNewCustomization (formValues: any) {
return objectKeysTyped(this.customConfig.theme.customization).reduce(
(acc: ColorPaletteThemeConfig['customization'], field) => {
acc[field] = this.formatCustomizationFieldForTheme(field, formValues[field])
return acc
},
{} as ColorPaletteThemeConfig['customization']
)
}
private formatCustomizationFieldForTheme (field: ThemeCustomizationKey, value: string) {
if (this.customizationResetFields.has(field)) return null
if (this.formFieldsObject[field].type === 'pixels' && this.isNumber(value)) {
value = value + 'px'
}
return value
}
}

View file

@ -1,23 +1,13 @@
<ng-container [formGroup]="form()"> <my-admin-save-bar i18n-title title="General configuration" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<div class="pt-two-cols mt-5"> <!-- appearance grid -->
<div class="title-col">
<h2 i18n>APPEARANCE</h2>
<div i18n class="inner-form-description"> <ng-container [formGroup]="form">
Use <a class="link-primary" routerLink="/admin/settings/plugins">plugins & themes</a> for more involved changes, or add slight <a class="link-primary" routerLink="/admin/settings/config/edit-custom" fragment="advanced-configuration">customizations</a>. <div class="pt-two-cols">
</div> <div class="title-col">
<h2 i18n>BEHAVIOR</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="theme">
<div class="form-group">
<label i18n for="themeDefault">Theme</label>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div>
</ng-container>
<div class="form-group" formGroupName="instance"> <div class="form-group" formGroupName="instance">
<label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label> <label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label>
@ -30,7 +20,7 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().instance.defaultClientRoute" class="form-error" role="alert">{{ formErrors().instance.defaultClientRoute }}</div> <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error" role="alert">{{ formErrors.instance.defaultClientRoute }}</div>
</div> </div>
<div class="form-group" formGroupName="trending"> <div class="form-group" formGroupName="trending">
@ -47,24 +37,13 @@
</select> </select>
</div> </div>
<div *ngIf="formErrors().trending.videos.algorithms.default" class="form-error" role="alert">{{ formErrors().trending.videos.algorithms.default }}</div> <div *ngIf="formErrors.trending.videos.algorithms.default" class="form-error" role="alert">{{ formErrors.trending.videos.algorithms.default }}</div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
<ng-container formGroupName="client"> <ng-container formGroupName="client">
<ng-container formGroupName="videos">
<ng-container formGroupName="miniature">
<div class="form-group">
<my-peertube-checkbox
inputName="clientVideosMiniaturePreferAuthorDisplayName" formControlName="preferAuthorDisplayName"
i18n-labelText labelText="Prefer author display name in video miniature"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="menu"> <ng-container formGroupName="menu">
<ng-container formGroupName="login"> <ng-container formGroupName="login">
<div class="form-group"> <div class="form-group">
@ -73,8 +52,11 @@
i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu" i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span *ngIf="countExternalAuth() === 0" i18n>⚠️ You don't have any external auth plugin enabled.</span> @if (countExternalAuth() === 0) {
<span *ngIf="countExternalAuth() > 1" i18n>⚠️ You have multiple external auth plugins enabled.</span> <span *ngIf="" i18n>⚠️ You don't have any external auth plugin enabled.</span>
} @else if (countExternalAuth() > 1) {
<span i18n>⚠️ You have multiple external auth plugins enabled.</span>
}
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -85,11 +67,11 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- broadcast grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>BROADCAST MESSAGE</h2> <h2 i18n>BROADCAST MESSAGE</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
Display a message on your instance Display a message on your platform
</div> </div>
</div> </div>
@ -122,7 +104,7 @@
</select> </select>
</div> </div>
<div *ngIf="formErrors().broadcastMessage.level" class="form-error" role="alert">{{ formErrors().broadcastMessage.level }}</div> <div *ngIf="formErrors.broadcastMessage.level" class="form-error" role="alert">{{ formErrors.broadcastMessage.level }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -130,10 +112,10 @@
<my-markdown-textarea <my-markdown-textarea
inputId="broadcastMessageMessage" formControlName="message" inputId="broadcastMessageMessage" formControlName="message"
[formError]="formErrors()['broadcastMessage.message']" markdownType="to-unsafe-html" [formError]="formErrors.broadcastMessage.message" markdownType="to-unsafe-html"
></my-markdown-textarea> ></my-markdown-textarea>
<div *ngIf="formErrors().broadcastMessage.message" class="form-error" role="alert">{{ formErrors().broadcastMessage.message }}</div> <div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div>
</div> </div>
</ng-container> </ng-container>
@ -144,9 +126,6 @@
<div class="pt-two-cols mt-4"> <!-- new users grid --> <div class="pt-two-cols mt-4"> <!-- new users grid -->
<div class="title-col"> <div class="title-col">
<h2 i18n>NEW USERS</h2> <h2 i18n>NEW USERS</h2>
<div i18n class="inner-form-description">
Manage <a class="link-primary" routerLink="/admin/overview/users">users</a> to set their quota individually.
</div>
</div> </div>
<div class="content-col"> <div class="content-col">
@ -160,7 +139,7 @@
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span> <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
<my-alert type="primary" class="alert-signup" *ngIf="signupAlertMessage">{{ signupAlertMessage }}</my-alert> <my-alert type="primary" class="d-block mt-2" *ngIf="signupAlertMessage">{{ signupAlertMessage }}</my-alert>
</ng-container> </ng-container>
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
@ -180,17 +159,17 @@
<div [ngClass]="getDisabledSignupClass()"> <div [ngClass]="getDisabledSignupClass()">
<label i18n for="signupLimit">Signup limit</label> <label i18n for="signupLimit">Signup limit</label>
<span i18n class="small muted ms-1">When the total number of users in your instance reaches this limit, registrations are disabled. -1 == unlimited</span> <span i18n class="small muted ms-1">When the total number of users in your platform reaches this limit, registrations are disabled. -1 == unlimited</span>
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="-1" id="signupLimit" class="form-control" type="number" min="-1" id="signupLimit" class="form-control"
formControlName="limit" [ngClass]="{ 'input-error': formErrors()['signup.limit'] }" formControlName="limit" [ngClass]="{ 'input-error': formErrors.signup.limit }"
> >
<span i18n>{form().value['signup']['limit'], plural, =1 {user} other {users}}</span> <span i18n>{form.value.signup.limit, plural, =1 {user} other {users}}</span>
</div> </div>
<div *ngIf="formErrors().signup.limit" class="form-error" role="alert">{{ formErrors().signup.limit }}</div> <div *ngIf="formErrors.signup.limit" class="form-error" role="alert">{{ formErrors.signup.limit }}</div>
<small i18n *ngIf="hasUnlimitedSignup()" class="muted small">Signup won't be limited to a fixed number of users.</small> <small i18n *ngIf="hasUnlimitedSignup()" class="muted small">Signup won't be limited to a fixed number of users.</small>
</div> </div>
@ -201,12 +180,12 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="signupMinimumAge" class="form-control" type="number" min="1" id="signupMinimumAge" class="form-control"
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors()['signup.minimumAge'] }" formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors.signup.minimumAge }"
> >
<span i18n>{form().value['signup']['minimumAge'], plural, =1 {year old} other {years old}}</span> <span i18n>{form.value.signup.minimumAge, plural, =1 {year old} other {years old}}</span>
</div> </div>
<div *ngIf="formErrors().signup.minimumAge" class="form-error" role="alert">{{ formErrors().signup.minimumAge }}</div> <div *ngIf="formErrors.signup.minimumAge" class="form-error" role="alert">{{ formErrors.signup.minimumAge }}</div>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
@ -215,7 +194,7 @@
<ng-container formGroupName="user"> <ng-container formGroupName="user">
<div class="form-group"> <div class="form-group">
<label i18n id="userVideoQuotaLabel" for="userVideoQuota">Default video quota per user</label> <label i18n id="userVideoQuotaLabel" for="userVideoQuota">Default video quota for a new user</label>
<my-select-custom-value <my-select-custom-value
labelId="userVideoQuotaLabel" labelId="userVideoQuotaLabel"
@ -228,11 +207,11 @@
<my-user-real-quota-info class="mt-2 d-block small muted" [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info> <my-user-real-quota-info class="mt-2 d-block small muted" [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
<div *ngIf="formErrors().user.videoQuota" class="form-error" role="alert">{{ formErrors().user.videoQuota }}</div> <div *ngIf="formErrors.user.videoQuota" class="form-error" role="alert">{{ formErrors.user.videoQuota }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n id="userVideoQuotaDaily" for="userVideoQuotaDaily">Default daily upload limit per user</label> <label i18n id="userVideoQuotaDaily" for="userVideoQuotaDaily">Default daily upload limit for a new user</label>
<my-select-custom-value <my-select-custom-value
labelId="userVideoQuotaDailyLabel" labelId="userVideoQuotaDailyLabel"
@ -243,14 +222,14 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().user.videoQuotaDaily" class="form-error" role="alert">{{ formErrors().user.videoQuotaDaily }}</div> <div *ngIf="formErrors.user.videoQuotaDaily" class="form-error" role="alert">{{ formErrors.user.videoQuotaDaily }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<ng-container formGroupName="history"> <ng-container formGroupName="history">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videosHistoryEnabled" formControlName="enabled" inputName="videosHistoryEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically enable video history for new users" i18n-labelText labelText="Automatically enable video history for a new user"
> >
</my-peertube-checkbox> </my-peertube-checkbox>
</ng-container> </ng-container>
@ -261,9 +240,9 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- videos grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>VIDEOS</h2> <h2 i18n>VIDEO IMPORTS</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
@ -281,7 +260,7 @@
<span i18n>jobs in parallel</span> <span i18n>jobs in parallel</span>
</div> </div>
<div *ngIf="formErrors().import.concurrency" class="form-error" role="alert">{{ formErrors().import.concurrency }}</div> <div *ngIf="formErrors.import.videos.concurrency" class="form-error" role="alert">{{ formErrors.import.videos.concurrency }}</div>
</div> </div>
<div class="form-group" formGroupName="http"> <div class="form-group" formGroupName="http">
@ -328,16 +307,25 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="videoChannelSynchronizationMaxPerUser" class="form-control" type="number" min="1" id="videoChannelSynchronizationMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors()['import']['videoChannelSynchronization']['maxPerUser'] }" formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['import']['videoChannelSynchronization']['maxPerUser'] }"
> >
<span i18n>{form().value['import']['videoChannelSynchronization']['maxPerUser'], plural, =1 {sync} other {syncs}}</span> <span i18n>{form.value.import.videoChannelSynchronization.maxPerUser, plural, =1 {sync} other {syncs}}</span>
</div> </div>
<div *ngIf="formErrors().import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">{{ formErrors().import.videoChannelSynchronization.maxPerUser }}</div> <div *ngIf="formErrors.import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">{{ formErrors.import.videoChannelSynchronization.maxPerUser }}</div>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>VIDEOS</h2>
</div>
<div class="content-col">
<ng-container formGroupName="autoBlacklist"> <ng-container formGroupName="autoBlacklist">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
@ -414,7 +402,7 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- video channels grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>VIDEO CHANNELS</h2> <h2 i18n>VIDEO CHANNELS</h2>
</div> </div>
@ -426,17 +414,17 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="videoChannelsMaxPerUser" class="form-control" type="number" min="1" id="videoChannelsMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors()['videoChannels.maxPerUser'] }" formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors.videoChannels.maxPerUser }"
> >
<span i18n>{form().value['videoChannels']['maxPerUser'], plural, =1 {channel} other {channels}}</span> <span i18n>{form.value.videoChannels.maxPerUser, plural, =1 {channel} other {channels}}</span>
</div> </div>
<div *ngIf="formErrors().videoChannels.maxPerUser" class="form-error" role="alert">{{ formErrors().videoChannels.maxPerUser }}</div> <div *ngIf="formErrors.videoChannels.maxPerUser" class="form-error" role="alert">{{ formErrors.videoChannels.maxPerUser }}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- search grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>SEARCH</h2> <h2 i18n>SEARCH</h2>
</div> </div>
@ -452,7 +440,7 @@
i18n-labelText labelText="Allow users to do remote URI/handle search" i18n-labelText labelText="Allow users to do remote URI/handle search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your instance</span> <span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your platform</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -463,7 +451,7 @@
i18n-labelText labelText="Allow anonymous to do remote URI/handle search" i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your instance</span> <span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your platform</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -477,23 +465,23 @@
i18n-labelText labelText="Enable global search" i18n-labelText labelText="Enable global search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality depends heavily on the moderation of instances followed by the search index you select.</div> <div i18n>⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select.</div>
</ng-container> </ng-container>
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
<div [ngClass]="getDisabledSearchIndexClass()"> <div [ngClass]="getDisabledSearchIndexClass()">
<label i18n for="searchIndexUrl">Search index URL</label> <label i18n for="searchIndexUrl">Search index URL</label>
<div i18n class="label-small-info"> <div i18n class="form-group-description">
Use your <a class="link-primary" target="_blank" href="https://framagit.org/framasoft/peertube/search-index">your own search index</a> or choose the official one, <a class="link-primary" target="_blank" href="https://sepiasearch.org">https://sepiasearch.org</a>, that is not moderated. Use your <a class="link-primary" target="_blank" href="https://framagit.org/framasoft/peertube/search-index">your own search index</a> or choose the official one, <a class="link-primary" target="_blank" href="https://sepiasearch.org">https://sepiasearch.org</a>, that is not moderated.
</div> </div>
<input <input
type="text" id="searchIndexUrl" class="form-control" type="text" id="searchIndexUrl" class="form-control"
formControlName="url" [ngClass]="{ 'input-error': formErrors()['search.searchIndex.url'] }" formControlName="url" [ngClass]="{ 'input-error': formErrors.search.searchIndex.url }"
> >
<div *ngIf="formErrors().search.searchIndex.url" class="form-error" role="alert">{{ formErrors().search.searchIndex.url }}</div> <div *ngIf="formErrors.search.searchIndex.url" class="form-error" role="alert">{{ formErrors.search.searchIndex.url }}</div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
@ -509,7 +497,7 @@
i18n-labelText labelText="Search bar uses the global search index by default" i18n-labelText labelText="Search bar uses the global search index by default"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Otherwise the local search stays used by default</span> <span i18n>Otherwise, the local search will be used by default</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -525,7 +513,7 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- import/export grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>USER IMPORT/EXPORT</h2> <h2 i18n>USER IMPORT/EXPORT</h2>
</div> </div>
@ -577,7 +565,7 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().export.users.maxUserVideoQuota" class="form-error" role="alert">{{ formErrors().export.users.maxUserVideoQuota }}</div> <div *ngIf="formErrors.export.users.maxUserVideoQuota" class="form-error" role="alert">{{ formErrors.export.users.maxUserVideoQuota }}</div>
</div> </div>
<div class="form-group" [ngClass]="getDisabledExportUsersClass()"> <div class="form-group" [ngClass]="getDisabledExportUsersClass()">
@ -587,7 +575,7 @@
<div i18n class="mt-1 small muted">The archive file is deleted after this period.</div> <div i18n class="mt-1 small muted">The archive file is deleted after this period.</div>
<div *ngIf="formErrors().export.users.exportExpiration" class="form-error" role="alert">{{ formErrors().export.users.exportExpiration }}</div> <div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
</div> </div>
</ng-container> </ng-container>
@ -599,11 +587,11 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- federation grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>FEDERATION</h2> <h2 i18n>FEDERATION</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
Manage <a class="link-primary" routerLink="/admin/settings/follows">relations</a> with other instances. Manage <a class="link-primary" routerLink="/admin/settings/follows">relations</a> with other platforms.
</div> </div>
</div> </div>
@ -615,14 +603,14 @@
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled" inputName="followersInstanceEnabled" formControlName="enabled"
i18n-labelText labelText="Other instances can follow yours" i18n-labelText labelText="Remote actors can follow your platform"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval" inputName="followersInstanceManualApproval" formControlName="manualApproval"
i18n-labelText labelText="Manually approve new instance followers" i18n-labelText labelText="Manually approve new followers that follow your platform"
></my-peertube-checkbox> ></my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
@ -635,7 +623,7 @@
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled" inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow back instances" i18n-labelText labelText="Automatically follow back followers that follow your platform"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span> <span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
@ -648,7 +636,7 @@
<div class="form-group"> <div class="form-group">
<my-peertube-checkbox <my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled" inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow instances of a public index" i18n-labelText labelText="Automatically follow platforms of a public index"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div> <div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
@ -663,9 +651,9 @@
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label> <label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input <input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control" type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors()['followings.instance.autoFollowIndex.indexUrl'] }" formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }"
> >
<div *ngIf="formErrors().followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors().followings.instance.autoFollowIndex.indexUrl }}</div> <div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
@ -678,68 +666,4 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- administrators grid -->
<div class="title-col">
<h2 i18n>ADMINISTRATORS</h2>
</div>
<div class="content-col">
<div class="form-group" formGroupName="admin">
<label i18n for="adminEmail">Admin email</label>
<input
type="text" id="adminEmail" class="form-control"
formControlName="email" [ngClass]="{ 'input-error': formErrors()['admin.email'] }"
>
<div *ngIf="formErrors().admin.email" class="form-error" role="alert">{{ formErrors().admin.email }}</div>
</div>
<div class="form-group" formGroupName="contactForm">
<my-peertube-checkbox
inputName="enableContactForm" formControlName="enabled"
i18n-labelText labelText="Enable contact form"
></my-peertube-checkbox>
</div>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- Twitter grid -->
<div class="title-col">
<h2 i18n>TWITTER/X</h2>
<div i18n class="inner-form-description">
Extra configuration required by Twitter/X. All other social media (Facebook, Mastodon, etc.) are supported out of the box.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="services">
<ng-container formGroupName="twitter">
<div class="form-group">
<label for="servicesTwitterUsername" i18n>Your Twitter/X username</label>
<div class="label-small-info">
<p i18n class="mb-0">Indicates the Twitter/X account for the website or platform where the content was published.</p>
<p i18n>This is just an extra information injected in PeerTube HTML that is required by Twitter/X. If you don't have a Twitter/X account, just leave the default value.</p>
</div>
<input
type="text" id="servicesTwitterUsername" class="form-control"
formControlName="username" [ngClass]="{ 'input-error': formErrors()['services.twitter.username'] }"
>
<div *ngIf="formErrors().services.twitter.username" class="form-error" role="alert">{{ formErrors().services.twitter.username }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container> </ng-container>

View file

@ -0,0 +1,517 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { FormArray, FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { getVideoQuotaDailyOptions, getVideoQuotaOptions } from '@app/+admin/shared/user-quota-options'
import { CanComponentDeactivate, ServerService } from '@app/core'
import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators'
import {
CONCURRENCY_VALIDATOR,
EXPORT_EXPIRATION_VALIDATOR,
EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
MAX_SYNC_PER_USER,
MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
SIGNUP_LIMIT_VALIDATOR,
SIGNUP_MINIMUM_AGE_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { BroadcastMessageLevel, CustomConfig } from '@peertube/peertube-models'
import { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component'
import { AdminConfigService } from '../shared/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
instance: FormGroup<{
defaultClientRoute: FormControl<string>
}>
client: FormGroup<{
menu: FormGroup<{
login: FormGroup<{
redirectOnSingleExternalAuth: FormControl<boolean>
}>
}>
}>
signup: FormGroup<{
enabled: FormControl<boolean>
limit: FormControl<number>
requiresApproval: FormControl<boolean>
requiresEmailVerification: FormControl<boolean>
minimumAge: FormControl<number>
}>
import: FormGroup<{
videos: FormGroup<{
concurrency: FormControl<number>
http: FormGroup<{
enabled: FormControl<boolean>
}>
torrent: FormGroup<{
enabled: FormControl<boolean>
}>
}>
videoChannelSynchronization: FormGroup<{
enabled: FormControl<boolean>
maxPerUser: FormControl<number>
}>
users: FormGroup<{
enabled: FormControl<boolean>
}>
}>
export: FormGroup<{
users: FormGroup<{
enabled: FormControl<boolean>
maxUserVideoQuota: FormControl<number>
exportExpiration: FormControl<number>
}>
}>
trending: FormGroup<{
videos: FormGroup<{
algorithms: FormGroup<{
enabled: FormArray<FormControl<string>>
default: FormControl<string>
}>
}>
}>
user: FormGroup<{
history: FormGroup<{
videos: FormGroup<{
enabled: FormControl<boolean>
}>
}>
videoQuota: FormControl<number>
videoQuotaDaily: FormControl<number>
}>
videoChannels: FormGroup<{
maxPerUser: FormControl<number>
}>
videoTranscription: FormGroup<{
enabled: FormControl<boolean>
remoteRunners: FormGroup<{
enabled: FormControl<boolean>
}>
}>
videoFile: FormGroup<{
update: FormGroup<{
enabled: FormControl<boolean>
}>
}>
autoBlacklist: FormGroup<{
videos: FormGroup<{
ofUsers: FormGroup<{
enabled: FormControl<boolean>
}>
}>
}>
followers: FormGroup<{
instance: FormGroup<{
enabled: FormControl<boolean>
manualApproval: FormControl<boolean>
}>
}>
followings: FormGroup<{
instance: FormGroup<{
autoFollowBack: FormGroup<{
enabled: FormControl<boolean>
}>
autoFollowIndex: FormGroup<{
enabled: FormControl<boolean>
indexUrl: FormControl<string>
}>
}>
}>
broadcastMessage: FormGroup<{
enabled: FormControl<boolean>
level: FormControl<BroadcastMessageLevel>
dismissable: FormControl<boolean>
message: FormControl<string>
}>
search: FormGroup<{
remoteUri: FormGroup<{
users: FormControl<boolean>
anonymous: FormControl<boolean>
}>
searchIndex: FormGroup<{
enabled: FormControl<boolean>
url: FormControl<string>
disableLocalSearch: FormControl<boolean>
isDefaultSearch: FormControl<boolean>
}>
}>
storyboards: FormGroup<{
enabled: FormControl<boolean>
}>
}
@Component({
selector: 'my-admin-config-general',
templateUrl: './admin-config-general.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
RouterLink,
SelectCustomValueComponent,
PeertubeCheckboxComponent,
HelpComponent,
MarkdownTextareaComponent,
UserRealQuotaInfoComponent,
SelectOptionsComponent,
AlertComponent,
AdminSaveBarComponent
]
})
export class AdminConfigGeneralComponent implements OnInit, CanComponentDeactivate {
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
signupAlertMessage: string
defaultLandingPageOptions: SelectOptionsItem[] = []
exportExpirationOptions: SelectOptionsItem[] = []
exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = []
private customConfig: CustomConfig
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.buildLandingPageOptions()
this.exportExpirationOptions = [
{ id: 1000 * 3600 * 24, label: $localize`1 day` },
{ id: 1000 * 3600 * 24 * 2, label: $localize`2 days` },
{ id: 1000 * 3600 * 24 * 7, label: $localize`7 days` },
{ id: 1000 * 3600 * 24 * 30, label: $localize`30 days` }
]
this.exportMaxUserVideoQuotaOptions = this.getVideoQuotaOptions().filter(o => (o.id as number) >= 1)
this.buildForm()
this.subscribeToSignupChanges()
this.subscribeToImportSyncChanges()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
instance: {
defaultClientRoute: null
},
client: {
menu: {
login: {
redirectOnSingleExternalAuth: null
}
}
},
signup: {
enabled: null,
limit: SIGNUP_LIMIT_VALIDATOR,
requiresApproval: null,
requiresEmailVerification: null,
minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
},
import: {
videos: {
concurrency: CONCURRENCY_VALIDATOR,
http: {
enabled: null
},
torrent: {
enabled: null
}
},
videoChannelSynchronization: {
enabled: null,
maxPerUser: MAX_SYNC_PER_USER
},
users: {
enabled: null
}
},
export: {
users: {
enabled: null,
maxUserVideoQuota: EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
exportExpiration: EXPORT_EXPIRATION_VALIDATOR
}
},
trending: {
videos: {
algorithms: {
enabled: null,
default: null
}
}
},
user: {
history: {
videos: {
enabled: null
}
},
videoQuota: USER_VIDEO_QUOTA_VALIDATOR,
videoQuotaDaily: USER_VIDEO_QUOTA_DAILY_VALIDATOR
},
videoChannels: {
maxPerUser: MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR
},
videoTranscription: {
enabled: null,
remoteRunners: {
enabled: null
}
},
videoFile: {
update: {
enabled: null
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: null
}
}
},
followers: {
instance: {
enabled: null,
manualApproval: null
}
},
followings: {
instance: {
autoFollowBack: {
enabled: null
},
autoFollowIndex: {
enabled: null,
indexUrl: URL_VALIDATOR
}
}
},
broadcastMessage: {
enabled: null,
level: null,
dismissable: null,
message: null
},
search: {
remoteUri: {
users: null,
anonymous: null
},
searchIndex: {
enabled: null,
url: URL_VALIDATOR,
disableLocalSearch: null,
isDefaultSearch: null
}
},
storyboards: {
enabled: null
}
}
const defaultValues: FormDefaultTyped<Form> = this.customConfig
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
countExternalAuth () {
return this.server.getHTMLConfig().plugin.registeredExternalAuths.length
}
getVideoQuotaOptions () {
return getVideoQuotaOptions()
}
getVideoQuotaDailyOptions () {
return getVideoQuotaDailyOptions()
}
doesTrendingVideosAlgorithmsEnabledInclude (algorithm: string) {
const enabled = this.form.value.trending.videos.algorithms.enabled
if (!Array.isArray(enabled)) return false
return !!enabled.find((e: string) => e === algorithm)
}
getUserVideoQuota () {
return this.form.value.user.videoQuota
}
isExportUsersEnabled () {
return this.form.value.export.users.enabled === true
}
getDisabledExportUsersClass () {
return { 'disabled-checkbox-extra': !this.isExportUsersEnabled() }
}
isSignupEnabled () {
return this.form.value.signup.enabled === true
}
getDisabledSignupClass () {
return { 'disabled-checkbox-extra': !this.isSignupEnabled() }
}
isImportVideosHttpEnabled (): boolean {
return this.form.value.import.videos.http.enabled === true
}
importSynchronizationChecked () {
return this.isImportVideosHttpEnabled() && this.form.value.import.videoChannelSynchronization.enabled
}
hasUnlimitedSignup () {
return this.form.value.signup.limit === -1
}
isSearchIndexEnabled () {
return this.form.value.search.searchIndex.enabled === true
}
getDisabledSearchIndexClass () {
return { 'disabled-checkbox-extra': !this.isSearchIndexEnabled() }
}
// ---------------------------------------------------------------------------
isTranscriptionEnabled () {
return this.form.value.videoTranscription.enabled === true
}
getTranscriptionRunnerDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscriptionEnabled() }
}
// ---------------------------------------------------------------------------
isAutoFollowIndexEnabled () {
return this.form.value.followings.instance.autoFollowIndex.enabled === true
}
buildLandingPageOptions () {
let links: { label: string, path: string }[] = []
if (this.server.getHTMLConfig().homepage.enabled) {
links.push({ label: $localize`Home`, path: '/home' })
}
links = links.concat([
{ label: $localize`Discover`, path: '/videos/overview' },
{ label: $localize`Browse all videos`, path: '/videos/browse' },
{ label: $localize`Browse local videos`, path: '/videos/browse?scope=local' }
])
this.defaultLandingPageOptions = links.map(o => ({
id: o.path,
label: o.label,
description: o.path
}))
}
private subscribeToImportSyncChanges () {
const controls = this.form.controls
const importSyncControl = controls.import.controls.videoChannelSynchronization.controls.enabled
const importVideosHttpControl = controls.import.controls.videos.controls.http.controls.enabled
importVideosHttpControl.valueChanges
.subscribe(httpImportEnabled => {
importSyncControl.setValue(httpImportEnabled && importSyncControl.value)
if (httpImportEnabled) importSyncControl.enable()
else importSyncControl.disable()
})
}
private subscribeToSignupChanges () {
const signupControl = this.form.controls.signup.controls.enabled
signupControl.valueChanges
.pipe(pairwise())
.subscribe(([ oldValue, newValue ]) => {
if (oldValue === false && newValue === true) {
this.signupAlertMessage =
// eslint-disable-next-line max-len
$localize`You enabled signup: we automatically enabled the "Block new videos automatically" checkbox of the "Videos" section just below.`
this.form.patchValue({
autoBlacklist: {
videos: {
ofUsers: {
enabled: true
}
}
}
})
}
})
signupControl.updateValueAndValidity()
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`Live configuration updated.`
})
}
}

View file

@ -0,0 +1,28 @@
<my-admin-save-bar i18n-title title="Edit your homepage" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<div class="homepage pt-two-cols" [formGroup]="form">
<div class="title-col">
<h2 i18n>HOMEPAGE</h2>
</div>
<div class="content-col">
<div class="form-group">
<label i18n for="homepageContent">Homepage content</label>
<div class="form-group-description">
<my-custom-markup-help></my-custom-markup-help>
</div>
<my-markdown-textarea
inputId="homepageContent"
formControlName="homepageContent"
[customMarkdownRenderer]="getCustomMarkdownRenderer()"
[debounceTime]="500"
[formError]="formErrors['homepageContent']"
dir="ltr"
monospace="true"
></my-markdown-textarea>
<div *ngIf="formErrors.homepageContent" class="form-error" role="alert">{{ formErrors.homepageContent }}</div>
</div>
</div>
</div>

View file

@ -0,0 +1,83 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { CanComponentDeactivate, Notifier } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { FormReactiveErrors, FormReactiveMessages, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
homepageContent: FormControl<string>
}
@Component({
selector: 'my-admin-config-homepage',
templateUrl: './admin-config-homepage.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
CustomMarkupHelpComponent,
MarkdownTextareaComponent,
AdminSaveBarComponent
]
})
export class AdminConfigHomepageComponent implements OnInit, CanComponentDeactivate {
private formReactiveService = inject(FormReactiveService)
private notifier = inject(Notifier)
private route = inject(ActivatedRoute)
private customMarkup = inject(CustomMarkupService)
private customPage = inject(CustomPageService)
form: FormGroup<Form>
formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveMessages = {}
ngOnInit () {
this.buildForm()
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
save () {
this.customPage.updateInstanceHomepage(this.form.value.homepageContent)
.subscribe({
next: () => {
this.form.markAsPristine()
this.notifier.success($localize`Homepage updated.`)
},
error: err => this.notifier.error(err.message)
})
}
private buildForm () {
const obj: BuildFormArgument = {
homepageContent: null
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, { homepageContent: this.route.snapshot.data['homepageContent'] })
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
}

View file

@ -1,17 +1,47 @@
<ng-container [formGroup]="form()"> <my-admin-save-bar i18n-title title="Platform information" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<ng-container [formGroup]="form">
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>ADMINISTRATORS</h2>
</div>
<div class="content-col">
<div class="form-group" formGroupName="admin">
<label i18n for="adminEmail">Admin email</label>
<input
type="text" id="adminEmail" class="form-control"
formControlName="email" [ngClass]="{ 'input-error': formErrors.admin.email }"
>
<div *ngIf="formErrors.admin.email" class="form-error" role="alert">{{ formErrors.admin.email }}</div>
</div>
<div class="form-group" formGroupName="contactForm">
<my-peertube-checkbox
inputName="enableContactForm" formControlName="enabled"
i18n-labelText labelText="Enable contact form"
></my-peertube-checkbox>
</div>
</div>
</div>
<ng-container formGroupName="instance"> <ng-container formGroupName="instance">
<div class="pt-two-cols mt-5"> <!-- instance grid --> <div class="pt-two-cols">
<div class="title-col"> <div class="title-col">
<h2 i18n>INSTANCE</h2> <h2 i18n>PLATFORM</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
<div class="form-group"> <div class="form-group">
<label i18n for="avatarfile">Square icon</label> <label i18n for="avatarfile">Square icon</label>
<div class="label-small-info"> <div class="form-group-description">
<p i18n class="mb-0">Square icon can be used on your custom homepage.</p> <p i18n class="mb-0">Square icon can be used on your custom homepage.</p>
</div> </div>
@ -25,7 +55,7 @@
<div class="form-group"> <div class="form-group">
<label i18n for="bannerfile">Banner</label> <label i18n for="bannerfile">Banner</label>
<div class="label-small-info"> <div class="form-group-description">
<p i18n class="mb-0">Banner is displayed in the about, login and registration pages and be used on your custom homepage.</p> <p i18n class="mb-0">Banner is displayed in the about, login and registration pages and be used on your custom homepage.</p>
<p i18n>It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances">JoinPeerTube.org</a>.</p> <p i18n>It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances">JoinPeerTube.org</a>.</p>
</div> </div>
@ -41,10 +71,10 @@
<input <input
type="text" id="instanceName" class="form-control" type="text" id="instanceName" class="form-control"
formControlName="name" [ngClass]="{ 'input-error': formErrors().instance.name }" formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }"
> >
<div *ngIf="formErrors().instance.name" class="form-error" role="alert">{{ formErrors().instance.name }}</div> <div *ngIf="formErrors.instance.name" class="form-error" role="alert">{{ formErrors.instance.name }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -52,22 +82,22 @@
<textarea <textarea
id="instanceShortDescription" formControlName="shortDescription" class="form-control small" id="instanceShortDescription" formControlName="shortDescription" class="form-control small"
[ngClass]="{ 'input-error': formErrors()['instance.shortDescription'] }" [ngClass]="{ 'input-error': formErrors.instance.shortDescription }"
></textarea> ></textarea>
<div *ngIf="formErrors().instance.shortDescription" class="form-error" role="alert">{{ formErrors().instance.shortDescription }}</div> <div *ngIf="formErrors.instance.shortDescription" class="form-error" role="alert">{{ formErrors.instance.shortDescription }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceDescription">Description</label> <label i18n for="instanceDescription">Description</label>
<div class="label-small-info"> <div class="form-group-description">
<my-custom-markup-help supportRelMe="true"></my-custom-markup-help> <my-custom-markup-help supportRelMe="true"></my-custom-markup-help>
</div> </div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceDescription" formControlName="description" inputId="instanceDescription" formControlName="description"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500" [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors()['instance.description']" [formError]="formErrors.instance.description"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
@ -77,7 +107,7 @@
<div> <div>
<my-select-checkbox <my-select-checkbox
inputId="instanceCategories" inputId="instanceCategories"
formControlName="categories" [availableItems]="categoryItems()" formControlName="categories" [availableItems]="categoryItems"
[selectableGroup]="false" [selectableGroup]="false"
i18n-placeholder placeholder="Add a new category" i18n-placeholder placeholder="Add a new category"
> >
@ -91,7 +121,7 @@
<div> <div>
<my-select-checkbox <my-select-checkbox
inputId="instanceLanguages" inputId="instanceLanguages"
formControlName="languages" [availableItems]="languageItems()" formControlName="languages" [availableItems]="languageItems"
[selectableGroup]="false" [selectableGroup]="false"
i18n-placeholder placeholder="Add a new language" i18n-placeholder placeholder="Add a new language"
> >
@ -101,20 +131,20 @@
<div class="form-group"> <div class="form-group">
<label i18n for="instanceServerCountry">Server country</label> <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> <div i18n class="form-group-description">PeerTube uses this setting to explain to your users which law they must follow in the "About" pages</div>
<input <input
type="text" id="instanceServerCountry" class="form-control" type="text" id="instanceServerCountry" class="form-control"
formControlName="serverCountry" [ngClass]="{ 'input-error': formErrors().instance.serverCountry }" formControlName="serverCountry" [ngClass]="{ 'input-error': formErrors.instance.serverCountry }"
> >
<div *ngIf="formErrors().instance.serverCountry" class="form-error" role="alert">{{ formErrors().instance.serverCountry }}</div> <div *ngIf="formErrors.instance.serverCountry" class="form-error" role="alert">{{ formErrors.instance.serverCountry }}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- social grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>SOCIAL</h2> <h2 i18n>SOCIAL</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
@ -126,25 +156,25 @@
<div class="form-group" formGroupName="support"> <div class="form-group" formGroupName="support">
<label i18n for="instanceSupportText">Support text</label><my-help helpType="markdownText"></my-help> <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> <div i18n class="form-group-description">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 <my-markdown-textarea
inputId="instanceSupportText" formControlName="text" markdownType="enhanced" inputId="instanceSupportText" formControlName="text" markdownType="enhanced"
[formError]="formErrors()['instance.support.text']" [formError]="formErrors.instance.support.text"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<ng-container formGroupName="social"> <ng-container formGroupName="social">
<div class="form-group"> <div class="form-group">
<label i18n for="instanceSocialExternalLink">External link</label> <label i18n for="instanceSocialExternalLink">External link</label>
<div i18n class="label-small-info">Link to your main website</div> <div i18n class="form-group-description">Link to your main website</div>
<input <input
type="text" id="instanceSocialExternalLink" class="form-control" type="text" id="instanceSocialExternalLink" class="form-control"
formControlName="externalLink" [ngClass]="{ 'input-error': formErrors().instance.social.externalLink }" 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 *ngIf="formErrors.instance.social.externalLink" class="form-error" role="alert">{{ formErrors.instance.social.externalLink }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -152,10 +182,10 @@
<input <input
type="text" id="instanceSocialMastodonLink" class="form-control" type="text" id="instanceSocialMastodonLink" class="form-control"
formControlName="mastodonLink" [ngClass]="{ 'input-error': formErrors().instance.social.mastodonLink }" 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 *ngIf="formErrors.instance.social.mastodonLink" class="form-error" role="alert">{{ formErrors.instance.social.mastodonLink }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -163,10 +193,10 @@
<input <input
type="text" id="instanceSocialBlueskyLink" class="form-control" type="text" id="instanceSocialBlueskyLink" class="form-control"
formControlName="blueskyLink" [ngClass]="{ 'input-error': formErrors().instance.social.blueskyLink }" 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 *ngIf="formErrors.instance.social.blueskyLink" class="form-error" role="alert">{{ formErrors.instance.social.blueskyLink }}</div>
</div> </div>
</ng-container> </ng-container>
@ -174,7 +204,7 @@
</div> </div>
<div class="pt-two-cols mt-4"> <!-- moderation grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>MODERATION & SENSITIVE CONTENT</h2> <h2 i18n>MODERATION & SENSITIVE CONTENT</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
@ -205,7 +235,7 @@
formControlName="defaultNSFWPolicy" formControlName="defaultNSFWPolicy"
></my-select-radio> ></my-select-radio>
<div *ngIf="formErrors().instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors().instance.defaultNSFWPolicy }}</div> <div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors.instance.defaultNSFWPolicy }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -213,7 +243,7 @@
<my-markdown-textarea <my-markdown-textarea
inputId="instanceTerms" formControlName="terms" markdownType="enhanced" inputId="instanceTerms" formControlName="terms" markdownType="enhanced"
[formError]="formErrors()['instance.terms']" [formError]="formErrors.instance.terms"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
@ -222,74 +252,74 @@
<my-markdown-textarea <my-markdown-textarea
inputId="instanceCodeOfConduct" formControlName="codeOfConduct" markdownType="enhanced" inputId="instanceCodeOfConduct" formControlName="codeOfConduct" markdownType="enhanced"
[formError]="formErrors()['instance.codeOfConduct']" [formError]="formErrors.instance.codeOfConduct"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">Who moderates the instance? What is the policy regarding sensitive content? Political videos? etc</div> <div i18n class="form-group-description">Who moderates the instance? What is the policy regarding sensitive content? Political videos? etc</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced" inputId="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced"
[formError]="formErrors()['instance.moderationInformation']" [formError]="formErrors.instance.moderationInformation"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- you and your instance grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>YOU AND YOUR INSTANCE</h2> <h2 i18n>YOU AND YOUR PLATFORM</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
<div class="form-group"> <div class="form-group">
<label i18n for="instanceAdministrator">Who is behind the instance?</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceAdministrator">Who is behind the instance?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">A single person? A non-profit? A company?</div> <div i18n class="form-group-description">A single person? A non-profit? A company?</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceAdministrator" formControlName="administrator" markdownType="enhanced" inputId="instanceAdministrator" formControlName="administrator" markdownType="enhanced"
[formError]="formErrors()['instance.administrator']" [formError]="formErrors.instance.administrator"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceCreationReason">Why did you create this instance?</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceCreationReason">Why did you create this instance?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">To share your personal videos? To open registrations and allow people to upload what they want?</div> <div i18n class="form-group-description">To share your personal videos? To open registrations and allow people to upload what they want?</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceCreationReason" formControlName="creationReason" markdownType="enhanced" inputId="instanceCreationReason" formControlName="creationReason" markdownType="enhanced"
[formError]="formErrors()['instance.creationReason']" [formError]="formErrors.instance.creationReason"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">It's important to know for users who want to register on your instance</div> <div i18n class="form-group-description">It's important to know for users who want to register on your instance</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" markdownType="enhanced" inputId="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" markdownType="enhanced"
[formError]="formErrors()['instance.maintenanceLifetime']" [formError]="formErrors.instance.maintenanceLifetime"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">With your own funds? With user donations? Advertising?</div> <div i18n class="form-group-description">With your own funds? With user donations? Advertising?</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceBusinessModel" formControlName="businessModel" markdownType="enhanced" inputId="instanceBusinessModel" formControlName="businessModel" markdownType="enhanced"
[formError]="formErrors()['instance.businessModel']" [formError]="formErrors.instance.businessModel"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- other information grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>OTHER INFORMATION</h2> <h2 i18n>OTHER INFORMATION</h2>
</div> </div>
@ -298,11 +328,11 @@
<div class="form-group"> <div class="form-group">
<label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label> <label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label>
<div i18n class="label-small-info">i.e. 2vCore 2GB RAM, a direct the link to the server you rent, etc.</div> <div i18n class="form-group-description">i.e. 2vCore 2GB RAM, a direct the link to the server you rent, etc.</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceHardwareInformation" formControlName="hardwareInformation" markdownType="enhanced" inputId="instanceHardwareInformation" formControlName="hardwareInformation" markdownType="enhanced"
[formError]="formErrors()['instance.hardwareInformation']" [formError]="formErrors.instance.hardwareInformation"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>

View file

@ -0,0 +1,299 @@
import { CommonModule } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http'
import { Component, OnInit, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { CanComponentDeactivate, Notifier, ServerService } from '@app/core'
import { genericUploadErrorHandler } from '@app/helpers'
import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators'
import {
ADMIN_EMAIL_VALIDATOR,
INSTANCE_NAME_VALIDATOR,
INSTANCE_SHORT_DESCRIPTION_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component'
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { maxBy } from '@peertube/peertube-core-utils'
import { ActorImage, CustomConfig, HTMLServerConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { ActorAvatarEditComponent } from '../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../../../shared/shared-actor-image-edit/actor-banner-edit.component'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/select-checkbox.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { AdminConfigService } from '../shared/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
admin: FormGroup<{
email: FormControl<string>
}>
contactForm: FormGroup<{
enabled: FormControl<boolean>
}>
instance: FormGroup<{
name: FormControl<string>
shortDescription: FormControl<string>
description: FormControl<string>
categories: FormControl<number[]>
languages: FormControl<string[]>
serverCountry: FormControl<string>
support: FormGroup<{
text: FormControl<string>
}>
social: FormGroup<{
externalLink: FormControl<string>
mastodonLink: FormControl<string>
blueskyLink: FormControl<string>
}>
isNSFW: FormControl<boolean>
defaultNSFWPolicy: FormControl<NSFWPolicyType>
terms: FormControl<string>
codeOfConduct: FormControl<string>
moderationInformation: FormControl<string>
administrator: FormControl<string>
creationReason: FormControl<string>
maintenanceLifetime: FormControl<string>
businessModel: FormControl<string>
hardwareInformation: FormControl<string>
}>
}
@Component({
selector: 'my-admin-config-information',
templateUrl: './admin-config-information.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
ActorAvatarEditComponent,
ActorBannerEditComponent,
SelectRadioComponent,
CommonModule,
CustomMarkupHelpComponent,
MarkdownTextareaComponent,
SelectCheckboxComponent,
RouterLink,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
HelpComponent,
AdminSaveBarComponent
]
})
export class AdminConfigInformationComponent implements OnInit, CanComponentDeactivate {
private customMarkup = inject(CustomMarkupService)
private notifier = inject(Notifier)
private instanceService = inject(InstanceService)
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = []
instanceBannerUrl: string
instanceAvatars: ActorImage[] = []
nsfwItems: SelectOptionsItem[] = [
{
id: 'do_not_list',
label: $localize`Hide`
},
{
id: 'warn',
label: $localize`Warn`
},
{
id: 'blur',
label: $localize`Blur`
},
{
id: 'display',
label: $localize`Display`
}
]
private serverConfig: HTMLServerConfig
private customConfig: CustomConfig
get instanceName () {
return this.server.getHTMLConfig().instance.name
}
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
const data = this.route.snapshot.data as {
languages: VideoConstant<string>[]
categories: VideoConstant<number>[]
}
this.languageItems = data.languages.map(l => ({ label: l.label, id: l.id }))
this.categoryItems = data.categories.map(l => ({ label: l.label, id: l.id }))
this.serverConfig = this.server.getHTMLConfig()
this.updateActorImages()
this.buildForm()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
admin: {
email: ADMIN_EMAIL_VALIDATOR
},
contactForm: {
enabled: null
},
instance: {
name: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
description: null,
isNSFW: null,
defaultNSFWPolicy: null,
terms: null,
codeOfConduct: null,
creationReason: null,
moderationInformation: null,
administrator: null,
maintenanceLifetime: null,
businessModel: null,
hardwareInformation: null,
categories: null,
languages: null,
serverCountry: null,
support: {
text: null
},
social: {
externalLink: URL_VALIDATOR,
mastodonLink: URL_VALIDATOR,
blueskyLink: URL_VALIDATOR
}
}
}
const defaultValues: FormDefaultTyped<Form> = this.customConfig
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
onBannerChange (formData: FormData) {
this.instanceService.updateInstanceBanner(formData)
.subscribe({
next: () => {
this.notifier.success($localize`Banner changed.`)
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier })
})
}
onBannerDelete () {
this.instanceService.deleteInstanceBanner()
.subscribe({
next: () => {
this.notifier.success($localize`Banner deleted.`)
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
onAvatarChange (formData: FormData) {
this.instanceService.updateInstanceAvatar(formData)
.subscribe({
next: () => {
this.notifier.success($localize`Avatar changed.`)
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`avatar`, notifier: this.notifier })
})
}
onAvatarDelete () {
this.instanceService.deleteInstanceAvatar()
.subscribe({
next: () => {
this.notifier.success($localize`Avatar deleted.`)
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
private updateActorImages () {
this.instanceBannerUrl = maxBy(this.serverConfig.instance.banners, 'width')?.path
this.instanceAvatars = this.serverConfig.instance.avatars
}
private resetActorImages () {
this.server.resetConfig()
.subscribe(config => {
this.serverConfig = config
this.updateActorImages()
})
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`Information updated.`
})
}
}

View file

@ -1,11 +1,13 @@
<ng-container [formGroup]="form()"> <my-admin-save-bar i18n-title title="Live configuration" (save)="save()" [form]="form" [formErrors]="formErrors" [inconsistentOptions]="checkTranscodingConsistentOptions()"></my-admin-save-bar>
<div class="pt-two-cols mt-5"> <ng-container [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col"> <div class="title-col">
<h2 i18n>LIVE</h2> <h2 i18n>LIVE</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
Enable users of your instance to stream live. Enable users of your platform to stream a live.
</div> </div>
</div> </div>
@ -46,16 +48,16 @@
</div> </div>
<div class="form-group" [ngClass]="getDisabledLiveClass()"> <div class="form-group" [ngClass]="getDisabledLiveClass()">
<label i18n for="liveMaxInstanceLives">Max simultaneous lives created on your instance</label> <label i18n for="liveMaxInstanceLives">Max simultaneous lives created on your platform</label>
<span i18n class="ms-2 small muted">(-1 for "unlimited")</span> <span i18n class="ms-2 small muted">(-1 for "unlimited")</span>
<div class="number-with-unit"> <div class="number-with-unit">
<input type="number" id="liveMaxInstanceLives" formControlName="maxInstanceLives" /> <input type="number" id="liveMaxInstanceLives" formControlName="maxInstanceLives" />
<span i18n>{form().value['live']['maxInstanceLives'], plural, =1 {live} other {lives}}</span> <span i18n>{form.value.live.maxInstanceLives, plural, =1 {live} other {lives}}</span>
</div> </div>
<div *ngIf="formErrors().live.maxInstanceLives" class="form-error" role="alert">{{ formErrors().live.maxInstanceLives }}</div> <div *ngIf="formErrors.live.maxInstanceLives" class="form-error" role="alert">{{ formErrors.live.maxInstanceLives }}</div>
</div> </div>
<div class="form-group" [ngClass]="getDisabledLiveClass()"> <div class="form-group" [ngClass]="getDisabledLiveClass()">
@ -64,10 +66,10 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input type="number" id="liveMaxUserLives" formControlName="maxUserLives" /> <input type="number" id="liveMaxUserLives" formControlName="maxUserLives" />
<span i18n>{form().value['live']['maxUserLives'], plural, =1 {live} other {lives}}</span> <span i18n>{form.value.live.maxUserLives, plural, =1 {live} other {lives}}</span>
</div> </div>
<div *ngIf="formErrors().live.maxUserLives" class="form-error" role="alert">{{ formErrors().live.maxUserLives }}</div> <div *ngIf="formErrors.live.maxUserLives" class="form-error" role="alert">{{ formErrors.live.maxUserLives }}</div>
</div> </div>
<div class="form-group" [ngClass]="getDisabledLiveClass()"> <div class="form-group" [ngClass]="getDisabledLiveClass()">
@ -75,7 +77,7 @@
<my-select-options inputId="liveMaxDuration" [items]="liveMaxDurationOptions" formControlName="maxDuration"></my-select-options> <my-select-options inputId="liveMaxDuration" [items]="liveMaxDurationOptions" formControlName="maxDuration"></my-select-options>
<div *ngIf="formErrors().live.maxDuration" class="form-error" role="alert">{{ formErrors().live.maxDuration }}</div> <div *ngIf="formErrors.live.maxDuration" class="form-error" role="alert">{{ formErrors.live.maxDuration }}</div>
</div> </div>
</ng-container> </ng-container>
@ -123,7 +125,7 @@
<span>FPS</span> <span>FPS</span>
</div> </div>
<div *ngIf="formErrors().live.transcoding.fps.max" class="form-error" role="alert">{{ formErrors().live.transcoding.fps.max }}</div> <div *ngIf="formErrors.live.transcoding.fps.max" class="form-error" role="alert">{{ formErrors.live.transcoding.fps.max }}</div>
</div> </div>
<div class="ms-2 mt-3"> <div class="ms-2 mt-3">
@ -193,7 +195,7 @@
formControlName="threads" formControlName="threads"
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().live.transcoding.threads" class="form-error" role="alert">{{ formErrors().live.transcoding.threads }}</div> <div *ngIf="formErrors.live.transcoding.threads" class="form-error" role="alert">{{ formErrors.live.transcoding.threads }}</div>
</div> </div>
<div class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()"> <div class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()">
@ -202,7 +204,7 @@
<my-select-options inputId="liveTranscodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options> <my-select-options inputId="liveTranscodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options>
<div *ngIf="formErrors().live.transcoding.profile" class="form-error" role="alert">{{ formErrors().live.transcoding.profile }}</div> <div *ngIf="formErrors.live.transcoding.profile" class="form-error" role="alert">{{ formErrors.live.transcoding.profile }}</div>
</div> </div>
</ng-container> </ng-container>

View file

@ -0,0 +1,224 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { CanComponentDeactivate, ServerService } from '@app/core'
import {
MAX_INSTANCE_LIVES_VALIDATOR,
MAX_LIVE_DURATION_VALIDATOR,
MAX_USER_LIVES_VALIDATOR,
TRANSCODING_MAX_FPS_VALIDATOR,
TRANSCODING_THREADS_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { AdminConfigService, FormResolutions, ResolutionOption } from '../shared/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
live: FormGroup<{
enabled: FormControl<boolean>
allowReplay: FormControl<boolean>
latencySetting: FormGroup<{
enabled: FormControl<boolean>
}>
maxInstanceLives: FormControl<number>
maxUserLives: FormControl<number>
maxDuration: FormControl<number>
transcoding: FormGroup<{
enabled: FormControl<boolean>
fps: FormGroup<{
max: FormControl<number>
}>
resolutions: FormGroup<FormResolutions>
alwaysTranscodeOriginalResolution: FormControl<boolean>
remoteRunners: FormGroup<{
enabled: FormControl<boolean>
}>
threads: FormControl<number>
profile: FormControl<string>
}>
}>
}
@Component({
selector: 'my-admin-config-live',
templateUrl: './admin-config-live.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
SelectOptionsComponent,
RouterLink,
SelectCustomValueComponent,
AdminSaveBarComponent
]
})
export class AdminConfigLiveComponent implements OnInit, CanComponentDeactivate {
private configService = inject(AdminConfigService)
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
liveMaxDurationOptions: SelectOptionsItem[] = []
liveResolutions: ResolutionOption[] = []
private customConfig: CustomConfig
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.liveMaxDurationOptions = [
{ id: -1, label: $localize`No limit` },
{ id: 1000 * 3600, label: $localize`1 hour` },
{ id: 1000 * 3600 * 3, label: $localize`3 hours` },
{ id: 1000 * 3600 * 5, label: $localize`5 hours` },
{ id: 1000 * 3600 * 10, label: $localize`10 hours` }
]
this.liveResolutions = this.adminConfigService.transcodingResolutionOptions
this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(
this.server.getHTMLConfig().live.transcoding.availableProfiles
)
this.buildForm()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
live: {
enabled: null,
allowReplay: null,
maxDuration: MAX_LIVE_DURATION_VALIDATOR,
maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR,
maxUserLives: MAX_USER_LIVES_VALIDATOR,
latencySetting: {
enabled: null
},
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
profile: null,
resolutions: this.adminConfigService.buildFormResolutions(),
alwaysTranscodeOriginalResolution: null,
remoteRunners: {
enabled: null
},
fps: {
max: TRANSCODING_MAX_FPS_VALIDATOR
}
}
}
}
const defaultValues: FormDefaultTyped<Form> = this.customConfig
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
getResolutionKey (resolution: string) {
return 'live.transcoding.resolutions.' + resolution
}
getLiveRTMPPort () {
return this.server.getHTMLConfig().live.rtmp.port
}
isLiveEnabled () {
return this.form.value.live.enabled === true
}
isRemoteRunnerLiveEnabled () {
return this.form.value.live.transcoding.remoteRunners.enabled === true
}
getDisabledLiveClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() }
}
getDisabledLiveTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() }
}
getDisabledLiveLocalTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() || this.isRemoteRunnerLiveEnabled() }
}
isLiveTranscodingEnabled () {
return this.form.value.live.transcoding.enabled === true
}
getTotalTranscodingThreads () {
return this.adminConfigService.getTotalTranscodingThreads({
transcoding: this.customConfig.transcoding,
live: {
transcoding: {
enabled: this.form.value.live.transcoding.enabled,
threads: this.form.value.live.transcoding.threads
}
}
})
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`Live configuration updated.`
})
}
checkTranscodingConsistentOptions () {
return this.adminConfigService.checkTranscodingConsistentOptions({
transcoding: this.customConfig.transcoding,
live: {
enabled: this.form.value.live.enabled,
allowReplay: this.form.value.live.allowReplay
}
})
}
}

View file

@ -1,10 +1,18 @@
<ng-container [formGroup]="form()"> <my-admin-save-bar i18n-title title="VOD configuration" (save)="save()" [form]="form" [formErrors]="formErrors" [inconsistentOptions]="checkTranscodingConsistentOptions()"></my-admin-save-bar>
<ng-container [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col">
<h2 i18n>TRANSCODING</h2>
<div i18n class="inner-form-description">
Process uploaded videos so that they are streamable on any device. Although this is costly in terms of resources, it is a critical part of PeerTube, so proceed with caution.
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col"></div>
<div class="content-col"> <div class="content-col">
<div class="callout callout-primary mb-4">
<div class="callout callout-primary">
<span i18n> <span i18n>
Estimating a server's capacity to transcode and stream videos isn't easy and we can't tune PeerTube automatically. Estimating a server's capacity to transcode and stream videos isn't easy and we can't tune PeerTube automatically.
</span> </span>
@ -15,20 +23,6 @@
However, you may want to read <a class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/admin/configuration#vod-transcoding">our guidelines</a> before tweaking the following values. However, you may want to read <a class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/admin/configuration#vod-transcoding">our guidelines</a> before tweaking the following values.
</span> </span>
</div> </div>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>TRANSCODING</h2>
<div i18n class="inner-form-description">
Process uploaded videos so that they are in a streamable form that any device can play. Though costly in
resources, this is a critical part of PeerTube, so tread carefully.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="transcoding"> <ng-container formGroupName="transcoding">
@ -151,7 +145,7 @@
<span>FPS</span> <span>FPS</span>
</div> </div>
<div *ngIf="formErrors().transcoding.fps.max" class="form-error" role="alert">{{ formErrors().transcoding.fps.max }}</div> <div *ngIf="formErrors.transcoding.fps.max" class="form-error" role="alert">{{ formErrors.transcoding.fps.max }}</div>
</div> </div>
<div class="form-group" [ngClass]="getTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
@ -220,7 +214,7 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().transcoding.threads" class="form-error" role="alert">{{ formErrors().transcoding.threads }}</div> <div *ngIf="formErrors.transcoding.threads" class="form-error" role="alert">{{ formErrors.transcoding.threads }}</div>
</div> </div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
@ -232,7 +226,7 @@
<span i18n>jobs in parallel</span> <span i18n>jobs in parallel</span>
</div> </div>
<div *ngIf="formErrors().transcoding.concurrency" class="form-error" role="alert">{{ formErrors().transcoding.concurrency }}</div> <div *ngIf="formErrors.transcoding.concurrency" class="form-error" role="alert">{{ formErrors.transcoding.concurrency }}</div>
</div> </div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
@ -241,7 +235,7 @@
<my-select-options inputId="transcodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options> <my-select-options inputId="transcodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options>
<div *ngIf="formErrors().transcoding.profile" class="form-error" role="alert">{{ formErrors().transcoding.profile }}</div> <div *ngIf="formErrors.transcoding.profile" class="form-error" role="alert">{{ formErrors.transcoding.profile }}</div>
</div> </div>
</ng-container> </ng-container>

View file

@ -0,0 +1,295 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { CanComponentDeactivate, Notifier, ServerService } from '@app/core'
import {
CONCURRENCY_VALIDATOR,
TRANSCODING_MAX_FPS_VALIDATOR,
TRANSCODING_THREADS_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { AdminConfigService, FormResolutions, ResolutionOption } from '../shared/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
transcoding: FormGroup<{
enabled: FormControl<boolean>
allowAdditionalExtensions: FormControl<boolean>
allowAudioFiles: FormControl<boolean>
originalFile: FormGroup<{
keep: FormControl<boolean>
}>
webVideos: FormGroup<{
enabled: FormControl<boolean>
}>
hls: FormGroup<{
enabled: FormControl<boolean>
splitAudioAndVideo: FormControl<boolean>
}>
fps: FormGroup<{
max: FormControl<number>
}>
resolutions: FormGroup<FormResolutions>
alwaysTranscodeOriginalResolution: FormControl<boolean>
remoteRunners: FormGroup<{
enabled: FormControl<boolean>
}>
threads: FormControl<number>
profile: FormControl<string>
concurrency: FormControl<number>
}>
videoStudio: FormGroup<{
enabled: FormControl<boolean>
remoteRunners: FormGroup<{
enabled: FormControl<boolean>
}>
}>
}
@Component({
selector: 'my-admin-config-vod',
templateUrl: './admin-config-vod.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
RouterLink,
SelectCustomValueComponent,
SelectOptionsComponent,
AdminSaveBarComponent
]
})
export class AdminConfigVODComponent implements OnInit, CanComponentDeactivate {
private configService = inject(AdminConfigService)
private notifier = inject(Notifier)
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
resolutions: ResolutionOption[] = []
additionalVideoExtensions = ''
private customConfig: CustomConfig
ngOnInit () {
const serverConfig = this.server.getHTMLConfig()
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.adminConfigService.transcodingResolutionOptions
this.additionalVideoExtensions = serverConfig.video.file.extensions.join(' ')
this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(serverConfig.transcoding.availableProfiles)
this.buildForm()
this.subscribeToTranscodingChanges()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
transcoding: {
enabled: null,
allowAdditionalExtensions: null,
allowAudioFiles: null,
originalFile: {
keep: null
},
webVideos: {
enabled: null
},
hls: {
enabled: null,
splitAudioAndVideo: null
},
fps: {
max: TRANSCODING_MAX_FPS_VALIDATOR
},
resolutions: this.adminConfigService.buildFormResolutions(),
alwaysTranscodeOriginalResolution: null,
remoteRunners: {
enabled: null
},
threads: TRANSCODING_THREADS_VALIDATOR,
profile: null,
concurrency: CONCURRENCY_VALIDATOR
},
videoStudio: {
enabled: null,
remoteRunners: {
enabled: null
}
}
}
const defaultValues: FormDefaultTyped<Form> = this.customConfig
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
getResolutionKey (resolution: string) {
return 'transcoding.resolutions.' + resolution
}
isRemoteRunnerVODEnabled () {
return this.form.value.transcoding.remoteRunners.enabled === true
}
isTranscodingEnabled () {
return this.form.value.transcoding.enabled === true
}
isHLSEnabled () {
return this.form.value.transcoding.hls.enabled === true
}
isStudioEnabled () {
return this.form.value.videoStudio.enabled === true
}
getTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
}
getHLSDisabledClass () {
return { 'disabled-checkbox-extra': !this.isHLSEnabled() }
}
getLocalTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
}
getStudioDisabledClass () {
return { 'disabled-checkbox-extra': !this.isStudioEnabled() }
}
getTotalTranscodingThreads () {
return this.adminConfigService.getTotalTranscodingThreads({
live: this.customConfig.live,
transcoding: {
enabled: this.form.value.transcoding.enabled,
threads: this.form.value.transcoding.threads
}
})
}
private subscribeToTranscodingChanges () {
const controls = this.form.controls
const transcodingControl = controls.transcoding.controls.enabled
const videoStudioControl = controls.videoStudio.controls.enabled
const hlsControl = controls.transcoding.controls.hls.controls.enabled
const webVideosControl = controls.transcoding.controls.webVideos.controls.enabled
webVideosControl.valueChanges
.subscribe(newValue => {
if (newValue === false && hlsControl.value === false) {
hlsControl.setValue(true)
this.notifier.info(
$localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})
hlsControl.valueChanges
.subscribe(newValue => {
if (newValue === false && webVideosControl.value === false) {
webVideosControl.setValue(true)
this.notifier.info(
// eslint-disable-next-line max-len
$localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})
transcodingControl.valueChanges
.subscribe(newValue => {
if (newValue === false) {
videoStudioControl.setValue(false)
}
})
transcodingControl.updateValueAndValidity()
webVideosControl.updateValueAndValidity()
videoStudioControl.updateValueAndValidity()
hlsControl.updateValueAndValidity()
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`VOD configuration updated.`
})
}
checkTranscodingConsistentOptions () {
return this.adminConfigService.checkTranscodingConsistentOptions({
transcoding: {
enabled: this.form.value.transcoding.enabled
},
live: this.customConfig.live
})
}
}

View file

@ -0,0 +1,6 @@
export * from './admin-config-advanced.component'
export * from './admin-config-general.component'
export * from './admin-config-homepage.component'
export * from './admin-config-information.component'
export * from './admin-config-live.component'
export * from './admin-config-vod.component'

View file

@ -0,0 +1,210 @@
import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { Notifier, RestExtractor, ServerService } from '@app/core'
import { formatICU } from '@app/helpers'
import { BuildFormValidator } from '@app/shared/form-validators/form-validator.model'
import { CustomConfig } from '@peertube/peertube-models'
import { DeepPartial } from '@peertube/peertube-typescript-utils'
import merge from 'lodash-es/merge'
import { catchError, map, switchMap } from 'rxjs/operators'
import { environment } from '../../../../environments/environment'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
export type FormResolutions = {
'0p': FormControl<boolean>
'144p': FormControl<boolean>
'240p': FormControl<boolean>
'360p': FormControl<boolean>
'480p': FormControl<boolean>
'720p': FormControl<boolean>
'1080p': FormControl<boolean>
'1440p': FormControl<boolean>
'2160p': FormControl<boolean>
}
export type ResolutionOption = { id: keyof FormResolutions, label: string, description?: string }
@Injectable()
export class AdminConfigService {
private authHttp = inject(HttpClient)
private restExtractor = inject(RestExtractor)
private notifier = inject(Notifier)
private serverService = inject(ServerService)
private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config'
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingResolutionOptions: ResolutionOption[] = []
constructor () {
this.transcodingThreadOptions = [
{ id: 0, label: $localize`Auto (via ffmpeg)` },
{ id: 1, label: '1' },
{ id: 2, label: '2' },
{ id: 4, label: '4' },
{ id: 8, label: '8' },
{ id: 12, label: '12' },
{ id: 16, label: '16' },
{ id: 32, label: '32' }
]
this.transcodingResolutionOptions = [
{
id: '0p',
label: $localize`Audio-only`,
description:
$localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users`
},
{
id: '144p',
label: $localize`144p`
},
{
id: '240p',
label: $localize`240p`
},
{
id: '360p',
label: $localize`360p`
},
{
id: '480p',
label: $localize`480p`
},
{
id: '720p',
label: $localize`720p`
},
{
id: '1080p',
label: $localize`1080p`
},
{
id: '1440p',
label: $localize`1440p`
},
{
id: '2160p',
label: $localize`2160p`
}
]
}
// ---------------------------------------------------------------------------
getCustomConfig () {
return this.authHttp.get<CustomConfig>(AdminConfigService.BASE_APPLICATION_URL + '/custom')
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
updateCustomConfig (partialConfig: DeepPartial<CustomConfig>) {
return this.getCustomConfig()
.pipe(
switchMap(customConfig => {
const newConfig = merge(customConfig, partialConfig)
return this.authHttp.put<CustomConfig>(AdminConfigService.BASE_APPLICATION_URL + '/custom', newConfig)
.pipe(map(() => newConfig))
}),
catchError(res => this.restExtractor.handleError(res))
)
}
saveAndUpdateCurrent (options: {
currentConfig: CustomConfig
form: FormGroup
formConfig: DeepPartial<CustomConfig>
success: string
}) {
const { currentConfig, form, formConfig, success } = options
this.updateCustomConfig(formConfig)
.pipe(switchMap(() => this.serverService.resetConfig()))
.subscribe({
next: newConfig => {
Object.assign(currentConfig, newConfig)
form.markAsPristine()
this.notifier.success(success)
},
error: err => this.notifier.error(err.message)
})
}
// ---------------------------------------------------------------------------
buildFormResolutions () {
const formResolutions = {} as Record<keyof FormResolutions, BuildFormValidator>
for (const resolution of this.transcodingResolutionOptions) {
formResolutions[resolution.id] = null
}
return formResolutions
}
buildTranscodingProfiles (profiles: string[]) {
return profiles.map(p => {
if (p === 'default') {
return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` }
}
return { id: p, label: p }
})
}
getTotalTranscodingThreads (options: {
transcoding: {
enabled: boolean
threads: number
}
live: {
transcoding: {
enabled: boolean
threads: number
}
}
}) {
const transcodingEnabled = options.transcoding.enabled
const transcodingThreads = options.transcoding.threads
const liveTranscodingEnabled = options.live.transcoding.enabled
const liveTranscodingThreads = options.live.transcoding.threads
// checks whether all enabled method are on fixed values and not on auto (= 0)
let noneOnAuto = !transcodingEnabled || +transcodingThreads > 0
noneOnAuto &&= !liveTranscodingEnabled || +liveTranscodingThreads > 0
// count total of fixed value, repalcing auto by a single thread (knowing it will display "at least")
let value = 0
if (transcodingEnabled) value += +transcodingThreads || 1
if (liveTranscodingEnabled) value += +liveTranscodingThreads || 1
return {
value,
atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible
unit: formatICU($localize`{value, plural, =1 {thread} other {threads}}`, { value })
}
}
checkTranscodingConsistentOptions (options: {
transcoding: {
enabled: boolean
}
live: {
enabled: boolean
allowReplay: boolean
}
}) {
if (
options.transcoding.enabled === false &&
options.live.enabled === true && options.live.allowReplay === true
) {
return $localize`You cannot allow live replay if you don't enable transcoding.`
}
return undefined
}
}

View file

@ -0,0 +1,28 @@
<div class="root">
<div class="root-bar">
<h2>{{ title() }}</h2>
<my-button
theme="primary" class="save-button" icon="circle-tick"
[disabled]="!canUpdate()" (click)="onSave($event)" i18n
>Save</my-button>
</div>
@if (!isUpdateAllowed()) {
<my-alert type="primary" i18n class="d-block mt-3">
Updating platform configuration from the web interface is disabled by the system administrator.
</my-alert>
} @else if (displayFormErrors && !form().valid) {
<my-alert type="danger" class="d-block mt-3">
<ng-container i18n>There are errors in the configuration:</ng-container>
<ul class="mb-0">
<li *ngFor="let error of grabAllErrors()">{{ error }}</li>
</ul>
</my-alert>
}
@if (inconsistentOptions()) {
<my-alert type="danger" class="d-block mt-3">{{ inconsistentOptions() }}</my-alert>
}
</div>

View file

@ -0,0 +1,55 @@
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
@import "bootstrap/scss/mixins";
.root {
position: sticky;
top: pvar(--header-height);
z-index: 11;
background-color: pvar(--bg);
@include rfs(3rem, margin-bottom);
}
.root-bar {
display: flex;
gap: 0.5rem;
width: 100%;
min-width: 0;
justify-content: center;
border-radius: 14px;
background-color: pvar(--bg-secondary-350);
@include rfs(1.5rem, padding);
}
.save-button {
@include margin-left(auto);
}
h2 {
flex-shrink: 1;
color: pvar(--fg-350);
font-weight: $font-bold;
margin-bottom: 0;
line-height: normal;
@include margin-left(auto);
@include font-size(1.5rem);
@include ellipsis;
}
@media screen and (max-width: $small-view) {
.root-bar {
flex-direction: column;
align-items: center;
padding: 0.5rem;
}
.save-button,
h2 {
@include margin-left(0);
}
}

View file

@ -0,0 +1,74 @@
import { CommonModule } from '@angular/common'
import { Component, inject, input, OnDestroy, OnInit, output } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { ScreenService, ServerService } from '@app/core'
import { HeaderService } from '@app/header/header.service'
import { FormReactiveErrors, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { AlertComponent } from '../../../shared/shared-main/common/alert.component'
@Component({
selector: 'my-admin-save-bar',
styleUrls: [ './admin-save-bar.component.scss' ],
templateUrl: './admin-save-bar.component.html',
imports: [
CommonModule,
RouterModule,
ButtonComponent,
AlertComponent
]
})
export class AdminSaveBarComponent implements OnInit, OnDestroy {
private formReactiveService = inject(FormReactiveService)
private server = inject(ServerService)
private headerService = inject(HeaderService)
private screenService = inject(ScreenService)
readonly title = input.required<string>()
readonly form = input.required<FormGroup>()
readonly formErrors = input.required<FormReactiveErrors>()
readonly inconsistentOptions = input<string>()
readonly save = output()
displayFormErrors = false
ngOnInit () {
if (this.screenService.isInMobileView()) {
this.headerService.setSearchHidden(true)
}
}
ngOnDestroy () {
this.headerService.setSearchHidden(false)
}
isUpdateAllowed () {
return this.server.getHTMLConfig().webadmin.configuration.edition.allowed === true
}
canUpdate () {
if (!this.isUpdateAllowed()) return false
if (this.inconsistentOptions()) return false
return this.form().dirty
}
grabAllErrors () {
return this.formReactiveService.grabAllErrors(this.formErrors())
}
onSave (event: Event) {
this.displayFormErrors = false
if (this.form().valid) {
this.save.emit()
return
}
event.preventDefault()
this.displayFormErrors = true
}
}

View file

@ -1,70 +0,0 @@
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { RestExtractor } from '@app/core'
import { CustomConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { environment } from '../../../../environments/environment'
@Injectable()
export class ConfigService {
private authHttp = inject(HttpClient)
private restExtractor = inject(RestExtractor)
private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config'
videoQuotaOptions: SelectOptionsItem[] = []
videoQuotaDailyOptions: SelectOptionsItem[] = []
transcodingThreadOptions: SelectOptionsItem[] = []
constructor () {
this.videoQuotaOptions = [
{ id: -1, label: $localize`Unlimited` },
{ id: 0, label: $localize`None - no upload possible` },
{ id: 100 * 1024 * 1024, label: $localize`100MB` },
{ id: 500 * 1024 * 1024, label: $localize`500MB` },
{ id: 1024 * 1024 * 1024, label: $localize`1GB` },
{ id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` },
{ id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` },
{ id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` },
{ id: 100 * 1024 * 1024 * 1024, label: $localize`100GB` },
{ id: 200 * 1024 * 1024 * 1024, label: $localize`200GB` },
{ id: 500 * 1024 * 1024 * 1024, label: $localize`500GB` }
]
this.videoQuotaDailyOptions = [
{ id: -1, label: $localize`Unlimited` },
{ id: 0, label: $localize`None - no upload possible` },
{ id: 10 * 1024 * 1024, label: $localize`10MB` },
{ id: 50 * 1024 * 1024, label: $localize`50MB` },
{ id: 100 * 1024 * 1024, label: $localize`100MB` },
{ id: 500 * 1024 * 1024, label: $localize`500MB` },
{ id: 2 * 1024 * 1024 * 1024, label: $localize`2GB` },
{ id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` },
{ id: 10 * 1024 * 1024 * 1024, label: $localize`10GB` },
{ id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` },
{ id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` }
]
this.transcodingThreadOptions = [
{ id: 0, label: $localize`Auto (via ffmpeg)` },
{ id: 1, label: '1' },
{ id: 2, label: '2' },
{ id: 4, label: '4' },
{ id: 8, label: '8' },
{ id: 12, label: '12' },
{ id: 16, label: '16' },
{ id: 32, label: '32' }
]
}
getCustomConfig () {
return this.authHttp.get<CustomConfig>(ConfigService.BASE_APPLICATION_URL + '/custom')
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
updateCustomConfig (data: CustomConfig) {
return this.authHttp.put<CustomConfig>(ConfigService.BASE_APPLICATION_URL + '/custom', data)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
}

View file

@ -2,7 +2,7 @@ import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router' import { Router, RouterLink } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service' import { AdminConfigService } from '@app/+admin/config/shared/admin-config.service'
import { AuthService, Notifier, ScreenService, ServerService } from '@app/core' import { AuthService, Notifier, ScreenService, ServerService } from '@app/core'
import { import {
USER_CHANNEL_NAME_VALIDATOR, USER_CHANNEL_NAME_VALIDATOR,
@ -54,7 +54,7 @@ import { UserPasswordComponent } from './user-password.component'
export class UserCreateComponent extends UserEdit implements OnInit { export class UserCreateComponent extends UserEdit implements OnInit {
protected serverService = inject(ServerService) protected serverService = inject(ServerService)
protected formReactiveService = inject(FormReactiveService) protected formReactiveService = inject(FormReactiveService)
protected configService = inject(ConfigService) protected configService = inject(AdminConfigService)
protected screenService = inject(ScreenService) protected screenService = inject(ScreenService)
protected auth = inject(AuthService) protected auth = inject(AuthService)
private router = inject(Router) private router = inject(Router)

View file

@ -1,10 +1,11 @@
import { Directive, OnInit } from '@angular/core' import { Directive, OnInit } from '@angular/core'
import { ConfigService } from '@app/+admin/config/shared/config.service' import { AdminConfigService } from '@app/+admin/config/shared/admin-config.service'
import { getVideoQuotaDailyOptions, getVideoQuotaOptions } from '@app/+admin/shared/user-quota-options'
import { AuthService, ScreenService, ServerService, User } from '@app/core' import { AuthService, ScreenService, ServerService, User } from '@app/core'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { peertubeTranslate, USER_ROLE_LABELS } from '@peertube/peertube-core-utils' import { peertubeTranslate, USER_ROLE_LABELS } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, UserAdminFlag, UserRole } from '@peertube/peertube-models' import { HTMLServerConfig, UserAdminFlag, UserRole } from '@peertube/peertube-models'
import { SelectOptionsItem } from '../../../../../types/select-options-item.model' import { SelectOptionsItem } from '../../../../../types/select-options-item.model'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
@Directive() @Directive()
export abstract class UserEdit extends FormReactive implements OnInit { export abstract class UserEdit extends FormReactive implements OnInit {
@ -18,7 +19,7 @@ export abstract class UserEdit extends FormReactive implements OnInit {
protected serverConfig: HTMLServerConfig protected serverConfig: HTMLServerConfig
protected abstract serverService: ServerService protected abstract serverService: ServerService
protected abstract configService: ConfigService protected abstract configService: AdminConfigService
protected abstract screenService: ScreenService protected abstract screenService: ScreenService
protected abstract auth: AuthService protected abstract auth: AuthService
abstract isCreation (): boolean abstract isCreation (): boolean
@ -88,7 +89,7 @@ export abstract class UserEdit extends FormReactive implements OnInit {
} }
protected buildQuotaOptions () { protected buildQuotaOptions () {
this.videoQuotaOptions = this.configService.videoQuotaOptions this.videoQuotaOptions = getVideoQuotaOptions()
this.videoQuotaDailyOptions = this.configService.videoQuotaDailyOptions this.videoQuotaDailyOptions = getVideoQuotaDailyOptions()
} }
} }

View file

@ -2,7 +2,7 @@ import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterLink } from '@angular/router' import { ActivatedRoute, Router, RouterLink } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service' import { AdminConfigService } from '@app/+admin/config/shared/admin-config.service'
import { AuthService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core' import { AuthService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
import { import {
USER_EMAIL_VALIDATOR, USER_EMAIL_VALIDATOR,
@ -52,7 +52,7 @@ import { UserPasswordComponent } from './user-password.component'
export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
protected formReactiveService = inject(FormReactiveService) protected formReactiveService = inject(FormReactiveService)
protected serverService = inject(ServerService) protected serverService = inject(ServerService)
protected configService = inject(ConfigService) protected configService = inject(AdminConfigService)
protected screenService = inject(ScreenService) protected screenService = inject(ScreenService)
protected auth = inject(AuthService) protected auth = inject(AuthService)
private route = inject(ActivatedRoute) private route = inject(ActivatedRoute)

View file

@ -1,5 +1,5 @@
import { Route, Routes, UrlSegment } from '@angular/router' import { Route, Routes, UrlSegment } from '@angular/router'
import { configRoutes, EditConfigurationService } from '@app/+admin/config' import { configRoutes } from '@app/+admin/config'
import { moderationRoutes } from '@app/+admin/moderation/moderation.routes' import { moderationRoutes } from '@app/+admin/moderation/moderation.routes'
import { pluginsRoutes } from '@app/+admin/plugins/plugins.routes' import { pluginsRoutes } from '@app/+admin/plugins/plugins.routes'
import { DebugService, JobService, LogsService, RunnerService, systemRoutes } from '@app/+admin/system' import { DebugService, JobService, LogsService, RunnerService, systemRoutes } from '@app/+admin/system'
@ -21,7 +21,7 @@ import { WatchedWordsListService } from '@app/shared/standalone-watched-words/wa
import { AdminModerationComponent } from './admin-moderation.component' import { AdminModerationComponent } from './admin-moderation.component'
import { AdminOverviewComponent } from './admin-overview.component' import { AdminOverviewComponent } from './admin-overview.component'
import { AdminSettingsComponent } from './admin-settings.component' import { AdminSettingsComponent } from './admin-settings.component'
import { ConfigService } from './config/shared/config.service' import { AdminConfigService } from './config/shared/admin-config.service'
import { followsRoutes } from './follows' import { followsRoutes } from './follows'
import { AdminRegistrationService } from './moderation/registration-list' import { AdminRegistrationService } from './moderation/registration-list'
import { overviewRoutes, VideoAdminService } from './overview' import { overviewRoutes, VideoAdminService } from './overview'
@ -37,7 +37,6 @@ const commonConfig = {
CustomMarkupService, CustomMarkupService,
CustomPageService, CustomPageService,
DebugService, DebugService,
EditConfigurationService,
InstanceFollowService, InstanceFollowService,
JobService, JobService,
LogsService, LogsService,
@ -48,7 +47,7 @@ const commonConfig = {
VideoAdminService, VideoAdminService,
VideoBlockService, VideoBlockService,
VideoCommentService, VideoCommentService,
ConfigService, AdminConfigService,
AbuseService, AbuseService,
DynamicElementService, DynamicElementService,
FindInBulkService, FindInBulkService,

View file

@ -0,0 +1,33 @@
import { SelectOptionsItem } from '../../../types/select-options-item.model'
export function getVideoQuotaOptions (): SelectOptionsItem[] {
return [
{ id: -1, label: $localize`Unlimited` },
{ id: 0, label: $localize`None - no upload possible` },
{ id: 100 * 1024 * 1024, label: $localize`100MB` },
{ id: 500 * 1024 * 1024, label: $localize`500MB` },
{ id: 1024 * 1024 * 1024, label: $localize`1GB` },
{ id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` },
{ id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` },
{ id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` },
{ id: 100 * 1024 * 1024 * 1024, label: $localize`100GB` },
{ id: 200 * 1024 * 1024 * 1024, label: $localize`200GB` },
{ id: 500 * 1024 * 1024 * 1024, label: $localize`500GB` }
]
}
export function getVideoQuotaDailyOptions (): SelectOptionsItem[] {
return [
{ id: -1, label: $localize`Unlimited` },
{ id: 0, label: $localize`None - no upload possible` },
{ id: 10 * 1024 * 1024, label: $localize`10MB` },
{ id: 50 * 1024 * 1024, label: $localize`50MB` },
{ id: 100 * 1024 * 1024, label: $localize`100MB` },
{ id: 500 * 1024 * 1024, label: $localize`500MB` },
{ id: 2 * 1024 * 1024 * 1024, label: $localize`2GB` },
{ id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` },
{ id: 10 * 1024 * 1024 * 1024, label: $localize`10GB` },
{ id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` },
{ id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` }
]
}

View file

@ -2,7 +2,7 @@ import { NgClass, NgFor, NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormArray, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' import { FormArray, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'
import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators' import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators'
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service' import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import debug from 'debug' import debug from 'debug'
@ -52,7 +52,7 @@ export class VideoChaptersComponent implements OnInit, OnDestroy {
form: FormGroup<Form> form: FormGroup<Form>
formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {} formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {}
validationMessages: FormReactiveValidationMessages = {} validationMessages: FormReactiveMessages = {}
videoEdit: VideoEdit videoEdit: VideoEdit

View file

@ -4,7 +4,7 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angul
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
import { VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR } from '@app/shared/form-validators/video-validators' import { VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR } from '@app/shared/form-validators/video-validators'
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { HTMLServerConfig } from '@peertube/peertube-models' import { HTMLServerConfig } from '@peertube/peertube-models'
import debug from 'debug' import debug from 'debug'
import { DatePickerModule } from 'primeng/datepicker' import { DatePickerModule } from 'primeng/datepicker'
@ -44,7 +44,7 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
form: FormGroup<Form> form: FormGroup<Form>
formErrors: FormReactiveErrors = {} formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveValidationMessages = {} validationMessages: FormReactiveMessages = {}
videoEdit: VideoEdit videoEdit: VideoEdit

View file

@ -3,7 +3,7 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { VideoService } from '@app/shared/shared-main/video/video.service' import { VideoService } from '@app/shared/shared-main/video/video.service'
import { import {
@ -68,7 +68,7 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
form: FormGroup<Form> form: FormGroup<Form>
formErrors: FormReactiveErrors = {} formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveValidationMessages = {} validationMessages: FormReactiveMessages = {}
videoEdit: VideoEdit videoEdit: VideoEdit

View file

@ -18,7 +18,7 @@ import {
VIDEO_TAGS_ARRAY_VALIDATOR VIDEO_TAGS_ARRAY_VALIDATOR
} from '@app/shared/form-validators/video-validators' } from '@app/shared/form-validators/video-validators'
import { DynamicFormFieldComponent } from '@app/shared/shared-forms/dynamic-form-field.component' import { DynamicFormFieldComponent } from '@app/shared/shared-forms/dynamic-form-field.component'
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service' import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service'
import { InputTextComponent } from '@app/shared/shared-forms/input-text.component' import { InputTextComponent } from '@app/shared/shared-forms/input-text.component'
import { MarkdownTextareaComponent } from '@app/shared/shared-forms/markdown-textarea.component' import { MarkdownTextareaComponent } from '@app/shared/shared-forms/markdown-textarea.component'
@ -120,7 +120,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy {
form: FormGroup<Form> form: FormGroup<Form>
formErrors: FormReactiveErrors = {} formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveValidationMessages = {} validationMessages: FormReactiveMessages = {}
forbidScheduledPublication: boolean forbidScheduledPublication: boolean
hideWaitTranscoding: boolean hideWaitTranscoding: boolean
@ -337,7 +337,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy {
const { pluginData } = this.videoEdit.toCommonFormPatch() const { pluginData } = this.videoEdit.toCommonFormPatch()
const pluginObj: { [id: string]: BuildFormValidator } = {} const pluginObj: { [id: string]: BuildFormValidator } = {}
const pluginValidationMessages: FormReactiveValidationMessages = {} const pluginValidationMessages: FormReactiveMessages = {}
const pluginFormErrors: FormReactiveErrors = {} const pluginFormErrors: FormReactiveErrors = {}
const pluginDefaults: Record<string, string | boolean> = {} const pluginDefaults: Record<string, string | boolean> = {}

View file

@ -5,7 +5,7 @@ import { RouterLink } from '@angular/router'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
import { VIDEO_NSFW_SUMMARY_VALIDATOR } from '@app/shared/form-validators/video-validators' import { VIDEO_NSFW_SUMMARY_VALIDATOR } from '@app/shared/form-validators/video-validators'
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { HTMLServerConfig, VideoCommentPolicyType, VideoConstant } from '@peertube/peertube-models' import { HTMLServerConfig, VideoCommentPolicyType, VideoConstant } from '@peertube/peertube-models'
import debug from 'debug' import debug from 'debug'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
@ -51,7 +51,7 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
form: FormGroup<Form> form: FormGroup<Form>
formErrors: FormReactiveErrors = {} formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveValidationMessages = {} validationMessages: FormReactiveMessages = {}
commentPolicies: VideoConstant<VideoCommentPolicyType>[] = [] commentPolicies: VideoConstant<VideoCommentPolicyType>[] = []
serverConfig: HTMLServerConfig serverConfig: HTMLServerConfig

View file

@ -3,7 +3,7 @@ import { Component, inject, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import debug from 'debug' import debug from 'debug'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { ReactiveFileComponent } from '../../../shared/shared-forms/reactive-file.component' import { ReactiveFileComponent } from '../../../shared/shared-forms/reactive-file.component'
@ -46,7 +46,7 @@ export class VideoReplaceFileComponent implements OnInit, OnDestroy {
form: FormGroup<Form> form: FormGroup<Form>
formErrors: FormReactiveErrors = {} formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveValidationMessages = {} validationMessages: FormReactiveMessages = {}
videoEdit: VideoEdit videoEdit: VideoEdit

View file

@ -51,9 +51,9 @@ type Card = { label: string, value: string | number, moreInfo?: string, help?: s
const isBarGraph = (graphId: ActiveGraphId): graphId is BarGraphs => BAR_GRAPHS.some(graph => graph === graphId) const isBarGraph = (graphId: ActiveGraphId): graphId is BarGraphs => BAR_GRAPHS.some(graph => graph === graphId)
ChartJSDefaults.backgroundColor = getComputedStyle(document.body).getPropertyValue('--bg') ChartJSDefaults.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--bg')
ChartJSDefaults.borderColor = getComputedStyle(document.body).getPropertyValue('--bg-secondary-500') ChartJSDefaults.borderColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-secondary-500')
ChartJSDefaults.color = getComputedStyle(document.body).getPropertyValue('--fg') ChartJSDefaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg')
@Component({ @Component({
templateUrl: './video-stats.component.html', templateUrl: './video-stats.component.html',
@ -654,7 +654,7 @@ export class VideoStatsComponent implements OnInit {
} }
private buildChartColor () { private buildChartColor () {
return getComputedStyle(document.body).getPropertyValue('--border-primary') return getComputedStyle(document.documentElement).getPropertyValue('--border-primary')
} }
private formatXTick (options: { private formatXTick (options: {

View file

@ -2,7 +2,7 @@ import { NgFor, NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { ReactiveFileComponent } from '@app/shared/shared-forms/reactive-file.component' import { ReactiveFileComponent } from '@app/shared/shared-forms/reactive-file.component'
import { TimestampInputComponent } from '@app/shared/shared-forms/timestamp-input.component' import { TimestampInputComponent } from '@app/shared/shared-forms/timestamp-input.component'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component' import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
@ -49,7 +49,7 @@ export class VideoStudioEditComponent implements OnInit, OnDestroy {
form: FormGroup<Form> form: FormGroup<Form>
formErrors: FormReactiveErrors = {} formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveValidationMessages = {} validationMessages: FormReactiveMessages = {}
isRunningEdit = false isRunningEdit = false

View file

@ -1,141 +0,0 @@
<div class="menu">
<h1 i18n>MANAGE MY VIDEO</h1>
<ul class="ul-unstyle">
<li>
<a routerLink="." queryParamsHandling="merge" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
<div class="global-icon-wrapper">
<my-global-icon iconName="film"></my-global-icon>
</div>
<span i18n>Main information</span>
</a>
</li>
<li *ngIf="getVideo().isLive">
<a routerLink="live-settings" queryParamsHandling="merge" routerLinkActive="active">
<div class="global-icon-wrapper">
<my-global-icon iconName="live"></my-global-icon>
</div>
<span i18n>Live settings</span>
</a>
</li>
<div class="separator">
<div></div>
</div>
<li>
<a routerLink="customization" queryParamsHandling="merge" routerLinkActive="active">
<div class="global-icon-wrapper">
<my-global-icon iconName="cog"></my-global-icon>
</div>
<span i18n>Customization</span>
</a>
</li>
<li>
<a routerLink="moderation" queryParamsHandling="merge" routerLinkActive="active">
<div class="global-icon-wrapper">
<my-global-icon iconName="moderation"></my-global-icon>
</div>
<span i18n>Moderation</span>
</a>
</li>
@if (!getVideo().isLive) {
<li>
<a routerLink="captions" queryParamsHandling="merge" routerLinkActive="active">
<div class="global-icon-wrapper">
<my-global-icon iconName="captions"></my-global-icon>
</div>
<span i18n>Captions</span>
</a>
</li>
<li>
<a routerLink="chapters" queryParamsHandling="merge" routerLinkActive="active">
<div class="global-icon-wrapper">
<my-global-icon iconName="chapters"></my-global-icon>
</div>
<span i18n>Chapters</span>
</a>
</li>
<div class="separator">
<div></div>
</div>
<li>
<ng-template #iconStudio>
<div class="global-icon-wrapper">
<my-global-icon iconName="studio"></my-global-icon>
</div>
</ng-template>
<ng-template #labelStudio>
<span i18n>Studio</span>
</ng-template>
@if (studioUnavailable()) {
<my-unavailable-menu-entry [help]="studioUnavailable()">
<span class="icon" *ngTemplateOutlet="iconStudio"></span>
<span class="label" *ngTemplateOutlet="labelStudio"></span>
</my-unavailable-menu-entry>
} @else {
<a routerLink="studio" queryParamsHandling="merge" routerLinkActive="active">
<ng-container *ngTemplateOutlet="iconStudio"></ng-container>
<ng-container *ngTemplateOutlet="labelStudio"></ng-container>
</a>
}
</li>
<li>
<ng-template #iconReplaceFile>
<div class="global-icon-wrapper">
<my-global-icon iconName="upload"></my-global-icon>
</div>
</ng-template>
<ng-template #labelReplaceFile>
<span i18n>Replace file</span>
</ng-template>
@if (replaceFileUnavailable()) {
<my-unavailable-menu-entry [help]="replaceFileUnavailable()">
<span class="icon" *ngTemplateOutlet="iconReplaceFile"></span>
<span class="label" *ngTemplateOutlet="labelReplaceFile"></span>
</my-unavailable-menu-entry>
} @else {
<a routerLink="replace-file" queryParamsHandling="merge" routerLinkActive="active">
<ng-container *ngTemplateOutlet="iconReplaceFile"></ng-container>
<ng-container *ngTemplateOutlet="labelReplaceFile"></ng-container>
</a>
}
</li>
}
@if (canWatch()) {
<div class="separator">
<div></div>
</div>
<li>
<a routerLink="stats" queryParamsHandling="merge" routerLinkActive="active">
<div class="global-icon-wrapper">
<my-global-icon iconName="stats"></my-global-icon>
</div>
<span i18n>Statistics</span>
</a>
</li>
}
</ul>
</div>
<div class="menu-placeholder"></div>

View file

@ -1,27 +1,17 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { booleanAttribute, Component, inject, input, OnInit } from '@angular/core' import { booleanAttribute, Component, inject, input, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { ServerService } from '@app/core' import { ServerService } from '@app/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { LateralMenuComponent, LateralMenuConfig } from '@app/shared/shared-main/menu/lateral-menu.component'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
import { getReplaceFileUnavailability, getStudioUnavailability } from './common/unavailable-features' import { getReplaceFileUnavailability, getStudioUnavailability } from './common/unavailable-features'
import { VideoEdit } from './common/video-edit.model' import { VideoEdit } from './common/video-edit.model'
import { UnavailableMenuEntryComponent } from './unavailable-menu-entry.component'
import { VideoManageController } from './video-manage-controller.service' import { VideoManageController } from './video-manage-controller.service'
@Component({ @Component({
selector: 'my-video-manage-menu', selector: 'my-video-manage-menu',
styleUrls: [ './video-manage-menu.component.scss' ], template: '<my-lateral-menu [config]="menuConfig" />',
templateUrl: './video-manage-menu.component.html',
imports: [ imports: [
CommonModule, CommonModule,
RouterModule, LateralMenuComponent
FormsModule,
ReactiveFormsModule,
NgbTooltipModule,
GlobalIconComponent,
UnavailableMenuEntryComponent
] ]
}) })
export class VideoManageMenuComponent implements OnInit { export class VideoManageMenuComponent implements OnInit {
@ -30,6 +20,8 @@ export class VideoManageMenuComponent implements OnInit {
readonly canWatch = input.required<boolean, string | boolean>({ transform: booleanAttribute }) readonly canWatch = input.required<boolean, string | boolean>({ transform: booleanAttribute })
menuConfig: LateralMenuConfig
private videoEdit: VideoEdit private videoEdit: VideoEdit
private replaceFileEnabled: boolean private replaceFileEnabled: boolean
private studioEnabled: boolean private studioEnabled: boolean
@ -43,6 +35,89 @@ export class VideoManageMenuComponent implements OnInit {
const { videoEdit } = this.manageController.getStore() const { videoEdit } = this.manageController.getStore()
this.videoEdit = videoEdit this.videoEdit = videoEdit
this.menuConfig = {
title: $localize``,
entries: [
{
type: 'link',
label: $localize`Main information`,
routerLinkActiveOptions: { exact: true },
icon: 'film',
routerLink: '.'
},
{
type: 'link',
isDisplayed: () => this.getVideo().isLive,
label: $localize`Live settings`,
icon: 'live',
routerLink: 'live-settings'
},
{
type: 'separator'
},
{
type: 'link',
label: $localize`Customization`,
icon: 'cog',
routerLink: 'customization'
},
{
type: 'link',
label: $localize`Moderation`,
icon: 'moderation',
routerLink: 'moderation'
},
{
type: 'link',
isDisplayed: () => !this.getVideo().isLive,
label: $localize`Captions`,
icon: 'captions',
routerLink: 'captions'
},
{
type: 'link',
isDisplayed: () => !this.getVideo().isLive,
label: $localize`Chapters`,
icon: 'chapters',
routerLink: 'chapters'
},
{
type: 'separator'
},
{
type: 'link',
label: $localize`Studio`,
icon: 'studio',
routerLink: 'studio',
unavailableText: () => this.studioUnavailable()
},
{
type: 'link',
label: $localize`Replace file`,
icon: 'upload',
routerLink: 'replace-file',
unavailableText: () => this.replaceFileUnavailable()
},
{
type: 'separator'
},
{
type: 'link',
isDisplayed: () => this.canWatch(),
label: $localize`Statistics`,
icon: 'stats',
routerLink: 'stats'
}
]
}
} }
getVideo () { getVideo () {

View file

@ -1,7 +1,5 @@
import { Observable, of, Subject } from 'rxjs'
import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Injectable, LOCALE_ID, inject } from '@angular/core' import { inject, Injectable, LOCALE_ID } from '@angular/core'
import { getDevLocale, isOnDevLocale } from '@app/helpers' import { getDevLocale, isOnDevLocale } from '@app/helpers'
import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@peertube/peertube-core-utils' import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@peertube/peertube-core-utils'
import { import {
@ -14,6 +12,8 @@ import {
VideoPrivacyType VideoPrivacyType
} from '@peertube/peertube-models' } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger' import { logger } from '@root-helpers/logger'
import { Observable, of, Subject } from 'rxjs'
import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
@Injectable() @Injectable()
@ -37,8 +37,6 @@ export class ServerService {
private videoLanguagesObservable: Observable<VideoConstant<string>[]> private videoLanguagesObservable: Observable<VideoConstant<string>[]>
private configObservable: Observable<ServerConfig> private configObservable: Observable<ServerConfig>
private configReset = false
private configLoaded = false private configLoaded = false
private config: ServerConfig private config: ServerConfig
private htmlConfig: HTMLServerConfig private htmlConfig: HTMLServerConfig
@ -68,13 +66,14 @@ export class ServerService {
resetConfig () { resetConfig () {
this.configLoaded = false this.configLoaded = false
this.configReset = true
// Notify config update // Notify config update
return this.getConfig() return this.getConfig({ isReset: true })
} }
getConfig () { getConfig (options: {
isReset?: boolean
} = {}) {
if (this.configLoaded) return of(this.config) if (this.configLoaded) return of(this.config)
if (!this.configObservable) { if (!this.configObservable) {
@ -86,9 +85,8 @@ export class ServerService {
this.configLoaded = true this.configLoaded = true
}), }),
tap(config => { tap(config => {
if (this.configReset) { if (options.isReset) {
this.configReloaded.next(config) this.configReloaded.next(config)
this.configReset = false
} }
}), }),
share() share()

View file

@ -179,7 +179,7 @@ export default {
overlay: { overlay: {
select: { select: {
background: 'var(--bg)', background: 'var(--bg)',
borderColor: 'var---input-border-color)', borderColor: 'var(--input-border-color)',
color: 'var(--fg)' color: 'var(--fg)'
}, },
popover: { popover: {

View file

@ -0,0 +1,34 @@
import { ColorPickerDesignTokens } from '@primeng/themes/types/colorpicker'
export default {
root: {
transitionDuration: '{transition.duration}'
},
preview: {
width: '100%',
height: '1.5rem',
borderRadius: '{form.field.border.radius}',
focusRing: {
width: '{focus.ring.width}',
style: '{focus.ring.style}',
color: '{focus.ring.color}',
offset: '{focus.ring.offset}',
shadow: '{focus.ring.shadow}'
}
},
panel: {
shadow: '{overlay.popover.shadow}',
borderRadius: '{overlay.popover.borderRadius}'
},
colorScheme: {
light: {
panel: {
background: 'var(--bg-secondary-400)',
borderColor: 'var(--bg-secondary-450)'
},
handle: {
color: 'var(--fg)'
}
}
}
} as ColorPickerDesignTokens

View file

@ -2,6 +2,7 @@ import base from './base'
import autocomplete from './components/autocomplete' import autocomplete from './components/autocomplete'
import checkbox from './components/checkbox' import checkbox from './components/checkbox'
import chip from './components/chip' import chip from './components/chip'
import colorpicker from './components/colorpicker'
import datatable from './components/datatable' import datatable from './components/datatable'
import datepicker from './components/datepicker' import datepicker from './components/datepicker'
import inputchips from './components/inputchips' import inputchips from './components/inputchips'
@ -18,6 +19,7 @@ export const PTPrimeTheme = {
select, select,
inputchips, inputchips,
chip, chip,
colorpicker,
datepicker, datepicker,
inputtext, inputtext,
toast, toast,

View file

@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core'
import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models' import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models'
import { logger } from '@root-helpers/logger' import { logger } from '@root-helpers/logger'
import { capitalizeFirstLetter } from '@root-helpers/string' import { capitalizeFirstLetter } from '@root-helpers/string'
import { ThemeManager } from '@root-helpers/theme-manager' import { ColorPaletteThemeConfig, ThemeCustomizationKey, ThemeManager } from '@root-helpers/theme-manager'
import { UserLocalStorageKeys } from '@root-helpers/users' import { UserLocalStorageKeys } from '@root-helpers/users'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
import { AuthService } from '../auth' import { AuthService } from '../auth'
@ -72,6 +72,14 @@ export class ThemeService {
] ]
} }
updateColorPalette (config: ColorPaletteThemeConfig = this.serverConfig.theme) {
this.themeManager.injectColorPalette({ currentTheme: this.getCurrentThemeName(), config })
}
getCSSConfigValue (configKey: ThemeCustomizationKey) {
return this.themeManager.getCSSConfigValue(configKey)
}
private injectThemes (themes: ServerConfigTheme[], fromLocalStorage = false) { private injectThemes (themes: ServerConfigTheme[], fromLocalStorage = false) {
this.themes = themes this.themes = themes
@ -89,7 +97,7 @@ export class ThemeService {
} }
} }
private getCurrentThemeName () { getCurrentThemeName () {
if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name
const theme = this.auth.isLoggedIn() const theme = this.auth.isLoggedIn()
@ -137,7 +145,7 @@ export class ThemeService {
this.localStorageService.removeItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, false) this.localStorageService.removeItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, false)
} }
this.themeManager.injectCoreColorPalette() this.themeManager.injectColorPalette({ currentTheme: currentThemeName, config: this.serverConfig.theme })
this.oldThemeName = currentThemeName this.oldThemeName = currentThemeName
} }

View file

@ -1,8 +1,8 @@
@use 'sass:math'; @use "sass:math";
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_button-mixins' as *; @use "_button-mixins" as *;
@use '_bootstrap-variables' as *; @use "_bootstrap-variables" as *;
.mobile-msg { .mobile-msg {
display: flex; display: flex;
@ -29,7 +29,8 @@
--co-logo-size: 34px; --co-logo-size: 34px;
--co-root-padding: 1.5rem; --co-root-padding: 1.5rem;
background-color: pvar(--bg); color: pvar(--header-fg);
background-color: pvar(--header-bg);
padding: var(--co-root-padding); padding: var(--co-root-padding);
width: 100%; width: 100%;
@ -96,7 +97,7 @@ my-search-typeahead {
} }
.dropdown { .dropdown {
z-index: #{z('header') + 1} !important; z-index: #{z("header") + 1} !important;
} }
.dropdown-item { .dropdown-item {
@ -119,7 +120,7 @@ my-search-typeahead {
.logged-in-container { .logged-in-container {
border-radius: 25px; border-radius: 25px;
transition: all .1s ease-in-out; transition: all 0.1s ease-in-out;
cursor: pointer; cursor: pointer;
max-width: 250px; max-width: 250px;
height: 100%; height: 100%;
@ -173,7 +174,7 @@ my-actor-avatar {
@include margin-right(0.5rem); @include margin-right(0.5rem);
} }
.margin-button[theme=tertiary] { .margin-button[theme="tertiary"] {
@include margin-right(5px); @include margin-right(5px);
} }
@ -238,7 +239,7 @@ my-actor-avatar {
} }
.peertube-title { .peertube-title {
@include margin-right(5px) @include margin-right(5px);
} }
.instance-name { .instance-name {

View file

@ -132,8 +132,8 @@ export class HeaderComponent implements OnInit, OnDestroy {
this.getSearchHiddenSub = this.headerService.getSearchHiddenObs() this.getSearchHiddenSub = this.headerService.getSearchHiddenObs()
.subscribe(hidden => { .subscribe(hidden => {
if (hidden) document.body.classList.add('global-search-hidden') if (hidden) document.documentElement.classList.add('global-search-hidden')
else document.body.classList.remove('global-search-hidden') else document.documentElement.classList.remove('global-search-hidden')
this.searchHidden = hidden this.searchHidden = hidden
}) })
@ -167,7 +167,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
if (!isAndroid() && !isIphone()) return if (!isAndroid() && !isIphone()) return
this.mobileMsg = true this.mobileMsg = true
document.body.classList.add('mobile-app-msg') document.documentElement.classList.add('mobile-app-msg')
const host = window.location.host const host = window.location.host
const intentConfig = this.htmlConfig.client.openInApp.android.intent const intentConfig = this.htmlConfig.client.openInApp.android.intent
@ -228,7 +228,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
hideMobileMsg () { hideMobileMsg () {
this.mobileMsg = false this.mobileMsg = false
document.body.classList.remove('mobile-app-msg') document.documentElement.classList.remove('mobile-app-msg')
peertubeLocalStorage.setItem(HeaderComponent.LS_HIDE_MOBILE_MSG, 'true') peertubeLocalStorage.setItem(HeaderComponent.LS_HIDE_MOBILE_MSG, 'true')
} }

View file

@ -1,7 +1,7 @@
@use 'sass:math'; @use "sass:math";
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_button-mixins' as *; @use "_button-mixins" as *;
.menu-container { .menu-container {
--co-menu-x-padding: 1.5rem; --co-menu-x-padding: 1.5rem;
@ -81,7 +81,7 @@
position: relative; position: relative;
padding-top: 1.5rem; padding-top: 1.5rem;
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
border-radius: 14px; border-radius: pvar(--menu-border-radius);
background-color: pvar(--menu-bg); background-color: pvar(--menu-bg);
} }
@ -99,7 +99,7 @@
.collapsed .toggle-menu-container, .collapsed .toggle-menu-container,
.about-top { .about-top {
&::after { &::after {
content: ''; content: "";
display: block; display: block;
height: 2px; height: 2px;
margin: 1rem var(--co-menu-x-padding); margin: 1rem var(--co-menu-x-padding);
@ -123,7 +123,7 @@
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;
transition: background-color .1s ease-in-out; transition: background-color 0.1s ease-in-out;
width: 100%; width: 100%;
padding-top: 0.5rem; padding-top: 0.5rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@ -245,7 +245,7 @@
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
opacity: 0.75; opacity: 0.75;
content: ''; content: "";
display: none; display: none;
position: fixed; position: fixed;
z-index: z(overlay); z-index: z(overlay);

View file

@ -1,4 +1,5 @@
import { AsyncValidatorFn, ValidatorFn } from '@angular/forms' import { AsyncValidatorFn, FormArray, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
import { PartialDeep } from 'type-fest'
export type BuildFormValidator = { export type BuildFormValidator = {
VALIDATORS: ValidatorFn[] VALIDATORS: ValidatorFn[]
@ -11,6 +12,48 @@ export type BuildFormArgument = {
[id: string]: BuildFormValidator | BuildFormArgument [id: string]: BuildFormValidator | BuildFormArgument
} }
export type BuildFormDefaultValues = { export type BuildFormArgumentTyped<Form> = ReplaceForm<Form, BuildFormValidator>
[name: string]: Blob | Date | boolean | number | string | string[] | BuildFormDefaultValues
// ---------------------------------------------------------------------------
export type FormDefault = {
[name: string]: Blob | Date | boolean | number | number[] | string | string[] | FormDefault
} }
export type FormDefaultTyped<Form> = PartialDeep<UnwrapForm<Form>>
// ---------------------------------------------------------------------------
export type FormReactiveMessages = {
[id: string]: { [name: string]: string } | FormReactiveMessages | FormReactiveMessages[]
}
export type FormReactiveMessagesTyped<Form> = Partial<ReplaceForm<Form, string>>
// ---------------------------------------------------------------------------
export type FormReactiveErrors = { [id: string]: string | FormReactiveErrors | FormReactiveErrors[] }
export type FormReactiveErrorsTyped<Form> = Partial<ReplaceForm<Form, string>>
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
export type UnwrapForm<Form> = {
[K in keyof Form]: _UnwrapForm<Form[K]>
}
type _UnwrapForm<T> = T extends FormGroup<infer U> ? UnwrapForm<U> :
T extends FormArray<infer U> ? _UnwrapForm<U>[] :
T extends FormControl<infer U> ? U
: never
// ---------------------------------------------------------------------------
export type ReplaceForm<Form, By> = {
[K in keyof Form]: _ReplaceForm<Form[K], By>
}
type _ReplaceForm<T, By> = T extends FormGroup<infer U> ? ReplaceForm<U, By> :
T extends FormArray<infer U> ? _ReplaceForm<U, By> :
T extends FormControl ? By
: never

View file

@ -1,8 +1,8 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_form-mixins' as *; @use "_form-mixins" as *;
input:not([type=submit]) { input:not([type="submit"]) {
max-width: 340px; max-width: 340px;
width: 100%; width: 100%;
@ -21,10 +21,6 @@ textarea {
@include peertube-select-container(340px); @include peertube-select-container(340px);
} }
my-peertube-checkbox + .label-small-info {
margin-top: 5px;
}
my-markdown-textarea { my-markdown-textarea {
display: block; display: block;
max-width: 500px; max-width: 500px;

View file

@ -1,19 +1,16 @@
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { AbstractControl, FormGroup, StatusChangeEvent } from '@angular/forms' import { AbstractControl, FormGroup, StatusChangeEvent } from '@angular/forms'
import { filter, firstValueFrom } from 'rxjs' import { filter, firstValueFrom } from 'rxjs'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' import { BuildFormArgument, FormDefault, FormReactiveErrors, FormReactiveMessages } from '../form-validators/form-validator.model'
import { FormValidatorService } from './form-validator.service' import { FormValidatorService } from './form-validator.service'
export type FormReactiveErrors = { [id: string]: string | FormReactiveErrors | FormReactiveErrors[] } export * from '../form-validators/form-validator.model'
export type FormReactiveValidationMessages = {
[id: string]: { [name: string]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[]
}
@Injectable() @Injectable()
export class FormReactiveService { export class FormReactiveService {
private formValidatorService = inject(FormValidatorService) private formValidatorService = inject(FormValidatorService)
buildForm<T = any> (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { buildForm<T = any> (obj: BuildFormArgument, defaultValues: FormDefault = {}) {
const { formErrors, validationMessages, form } = this.formValidatorService.internalBuildForm<T>(obj, defaultValues) const { formErrors, validationMessages, form } = this.formValidatorService.internalBuildForm<T>(obj, defaultValues)
form.events form.events
@ -44,7 +41,7 @@ export class FormReactiveService {
} }
} }
forceCheck (form: FormGroup, formErrors: any, validationMessages: FormReactiveValidationMessages) { forceCheck (form: FormGroup, formErrors: any, validationMessages: FormReactiveMessages) {
this.onStatusChanged({ form, formErrors, validationMessages, onlyDirty: false }) this.onStatusChanged({ form, formErrors, validationMessages, onlyDirty: false })
} }
@ -76,7 +73,7 @@ export class FormReactiveService {
private onStatusChanged (options: { private onStatusChanged (options: {
form: FormGroup form: FormGroup
formErrors: FormReactiveErrors formErrors: FormReactiveErrors
validationMessages: FormReactiveValidationMessages validationMessages: FormReactiveMessages
onlyDirty?: boolean // default true onlyDirty?: boolean // default true
}) { }) {
const { form, formErrors, validationMessages, onlyDirty = true } = options const { form, formErrors, validationMessages, onlyDirty = true } = options
@ -86,7 +83,7 @@ export class FormReactiveService {
this.onStatusChanged({ this.onStatusChanged({
form: form.controls[field] as FormGroup, form: form.controls[field] as FormGroup,
formErrors: formErrors[field] as FormReactiveErrors, formErrors: formErrors[field] as FormReactiveErrors,
validationMessages: validationMessages[field] as FormReactiveValidationMessages, validationMessages: validationMessages[field] as FormReactiveMessages,
onlyDirty onlyDirty
}) })
@ -99,7 +96,7 @@ export class FormReactiveService {
if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
const staticMessages = validationMessages[field] as FormReactiveValidationMessages const staticMessages = validationMessages[field] as FormReactiveMessages
for (const key of Object.keys(control.errors)) { for (const key of Object.keys(control.errors)) {
const formErrorValue = control.errors[key] const formErrorValue = control.errors[key]

View file

@ -1,6 +1,6 @@
import { FormGroup } from '@angular/forms' import { FormGroup } from '@angular/forms'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' import { BuildFormArgument, FormDefault } from '../form-validators/form-validator.model'
import { FormReactiveService, FormReactiveValidationMessages } from './form-reactive.service' import { FormReactiveService, FormReactiveMessages } from './form-reactive.service'
export abstract class FormReactive { export abstract class FormReactive {
protected abstract formReactiveService: FormReactiveService protected abstract formReactiveService: FormReactiveService
@ -8,9 +8,9 @@ export abstract class FormReactive {
form: FormGroup form: FormGroup
formErrors: any // To avoid casting in template because of string | FormReactiveErrors formErrors: any // To avoid casting in template because of string | FormReactiveErrors
validationMessages: FormReactiveValidationMessages validationMessages: FormReactiveMessages
buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { buildForm (obj: BuildFormArgument, defaultValues: FormDefault = {}) {
const { formErrors, validationMessages, form } = this.formReactiveService.buildForm(obj, defaultValues) const { formErrors, validationMessages, form } = this.formReactiveService.buildForm(obj, defaultValues)
this.form = form this.form = form

View file

@ -1,16 +1,16 @@
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
import { objectKeysTyped } from '@peertube/peertube-core-utils' import { objectKeysTyped } from '@peertube/peertube-core-utils'
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' import { BuildFormArgument, FormDefault } from '../form-validators/form-validator.model'
import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service' import { FormReactiveErrors, FormReactiveMessages } from './form-reactive.service'
@Injectable() @Injectable()
export class FormValidatorService { export class FormValidatorService {
private formBuilder = inject(FormBuilder) private formBuilder = inject(FormBuilder)
internalBuildForm<T = any> (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { internalBuildForm<T = any> (obj: BuildFormArgument, defaultValues: FormDefault = {}) {
const formErrors: FormReactiveErrors = {} const formErrors: FormReactiveErrors = {}
const validationMessages: FormReactiveValidationMessages = {} const validationMessages: FormReactiveMessages = {}
const group: { [key: string]: any } = {} const group: { [key: string]: any } = {}
for (const name of Object.keys(obj)) { for (const name of Object.keys(obj)) {
@ -18,7 +18,7 @@ export class FormValidatorService {
const field = obj[name] const field = obj[name]
if (this.isRecursiveField(field)) { if (this.isRecursiveField(field)) {
const result = this.internalBuildForm(field as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues) const result = this.internalBuildForm(field as BuildFormArgument, defaultValues[name] as FormDefault)
group[name] = result.form group[name] = result.form
formErrors[name] = result.formErrors formErrors[name] = result.formErrors
validationMessages[name] = result.validationMessages validationMessages[name] = result.validationMessages
@ -41,9 +41,9 @@ export class FormValidatorService {
updateFormGroup ( updateFormGroup (
form: FormGroup, form: FormGroup,
formErrors: FormReactiveErrors, formErrors: FormReactiveErrors,
validationMessages: FormReactiveValidationMessages, validationMessages: FormReactiveMessages,
formToBuild: BuildFormArgument, formToBuild: BuildFormArgument,
defaultValues: BuildFormDefaultValues = {} defaultValues: FormDefault = {}
) { ) {
for (const name of objectKeysTyped(formToBuild)) { for (const name of objectKeysTyped(formToBuild)) {
const field = formToBuild[name] const field = formToBuild[name]
@ -55,9 +55,9 @@ export class FormValidatorService {
// FIXME: typings // FIXME: typings
(form as any)[name], (form as any)[name],
formErrors[name], formErrors[name],
validationMessages[name] as FormReactiveValidationMessages, validationMessages[name] as FormReactiveMessages,
formToBuild[name] as BuildFormArgument, formToBuild[name] as BuildFormArgument,
defaultValues[name] as BuildFormDefaultValues defaultValues[name] as FormDefault
) )
continue continue
} }
@ -77,11 +77,11 @@ export class FormValidatorService {
addControlInFormArray (options: { addControlInFormArray (options: {
formErrors: FormReactiveErrors formErrors: FormReactiveErrors
validationMessages: FormReactiveValidationMessages validationMessages: FormReactiveMessages
formArray: FormArray formArray: FormArray
controlName: string controlName: string
formToBuild: BuildFormArgument formToBuild: BuildFormArgument
defaultValues?: BuildFormDefaultValues defaultValues?: FormDefault
}) { }) {
const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options
@ -90,7 +90,7 @@ export class FormValidatorService {
if (!validationMessages[controlName]) validationMessages[controlName] = [] if (!validationMessages[controlName]) validationMessages[controlName] = []
const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] const formArrayValidationMessages = validationMessages[controlName] as FormReactiveMessages[]
const totalControls = formArray.controls.length const totalControls = formArray.controls.length
formArrayErrors.push({}) formArrayErrors.push({})
@ -109,7 +109,7 @@ export class FormValidatorService {
removeControlFromFormArray (options: { removeControlFromFormArray (options: {
formErrors: FormReactiveErrors formErrors: FormReactiveErrors
validationMessages: FormReactiveValidationMessages validationMessages: FormReactiveMessages
index: number index: number
formArray: FormArray formArray: FormArray
controlName: string controlName: string
@ -117,7 +117,7 @@ export class FormValidatorService {
const { formArray, formErrors, validationMessages, index, controlName } = options const { formArray, formErrors, validationMessages, index, controlName } = options
const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] const formArrayValidationMessages = validationMessages[controlName] as FormReactiveMessages[]
formArrayErrors.splice(index, 1) formArrayErrors.splice(index, 1)
formArrayValidationMessages.splice(index, 1) formArrayValidationMessages.splice(index, 1)

View file

@ -32,7 +32,7 @@
<div *ngIf="recommended()" class="ms-2 pt-badge badge-secondary" i18n>Recommended</div> <div *ngIf="recommended()" class="ms-2 pt-badge badge-secondary" i18n>Recommended</div>
</div> </div>
<div class="d-flex flex-column extra-container ms-4"> <div class="d-flex flex-column extra-container">
<div class="wrapper form-group-description" [id]="inputName() + '-description'"> <div class="wrapper form-group-description" [id]="inputName() + '-description'">
<ng-content select="description"></ng-content> <ng-content select="description"></ng-content>
</div> </div>

View file

@ -1,6 +1,6 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use 'form-mixins' as *; @use "form-mixins" as *;
.root { .root {
display: flex; display: flex;
@ -35,3 +35,7 @@
.pt-badge { .pt-badge {
height: fit-content; height: fit-content;
} }
.extra-container {
@include margin-left(28px);
}

View file

@ -1,9 +1,9 @@
import { of } from 'rxjs'
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core' import { Injectable, inject } from '@angular/core'
import { RestExtractor } from '@app/core' import { RestExtractor } from '@app/core'
import { CustomPage } from '@peertube/peertube-models' import { CustomPage } from '@peertube/peertube-models'
import { Observable, of } from 'rxjs'
import { catchError } from 'rxjs/operators'
import { environment } from '../../../../environments/environment' import { environment } from '../../../../environments/environment'
@Injectable() @Injectable()
@ -13,7 +13,7 @@ export class CustomPageService {
static BASE_INSTANCE_HOMEPAGE_URL = environment.apiUrl + '/api/v1/custom-pages/homepage/instance' static BASE_INSTANCE_HOMEPAGE_URL = environment.apiUrl + '/api/v1/custom-pages/homepage/instance'
getInstanceHomepage () { getInstanceHomepage (): Observable<CustomPage> {
return this.authHttp.get<CustomPage>(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL) return this.authHttp.get<CustomPage>(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL)
.pipe( .pipe(
catchError(err => { catchError(err => {

View file

@ -0,0 +1,37 @@
<div class="menu">
<h1>{{ config().title }}</h1>
<ul class="ul-unstyle">
@for (entry of config().entries; track entry) {
@if (entry.type === 'separator') {
<div class="separator">
<div></div>
</div>
} @else if (entry.type === 'link' && isDisplayed(entry)) {
<ng-template #icon>
<div *ngIf="entry.icon" class="global-icon-wrapper">
<my-global-icon [iconName]="entry.icon"></my-global-icon>
</div>
</ng-template>
<ng-template #label>
<span>{{ entry.label }}</span>
</ng-template>
@if (isUnavailable(entry)) {
<my-unavailable-menu-entry [help]="entry.unavailableText()">
<span class="icon" *ngTemplateOutlet="icon"></span>
<span class="label" *ngTemplateOutlet="label"></span>
</my-unavailable-menu-entry>
} @else {
<a [routerLink]="entry.routerLink" queryParamsHandling="merge" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
<ng-container *ngTemplateOutlet="icon"></ng-container>
<ng-container *ngTemplateOutlet="label"></ng-container>
</a>
}
}
}
</ul>
</div>
<div class="menu-placeholder"></div>

View file

@ -1,7 +1,7 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_form-mixins' as *; @use "_form-mixins" as *;
@import 'bootstrap/scss/mixins'; @import "bootstrap/scss/mixins";
h1 { h1 {
color: pvar(--fg-200); color: pvar(--fg-200);
@ -105,6 +105,10 @@ a {
@include padding-right(1.5rem); @include padding-right(1.5rem);
&:last-child {
display: none;
}
> div { > div {
height: 2px; height: 2px;
width: 100%; width: 100%;
@ -132,7 +136,7 @@ a {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
top: unset; top: unset;
width: calc(100vw - #{pvar(--menu-width)} - (#{pvar(--x-margin-content)} * 2)); width: calc(100vw - #{pvar(--menu-width)} - #{pvar(--x-margin-content)} * 2);
padding: 0.75rem 0.5rem; padding: 0.75rem 0.5rem;
border: 1px solid pvar(--bg-secondary-450); border: 1px solid pvar(--bg-secondary-450);
border-bottom: 0; border-bottom: 0;

View file

@ -0,0 +1,56 @@
import { CommonModule } from '@angular/common'
import { Component, input } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { GlobalIconComponent, GlobalIconName } from '../../shared-icons/global-icon.component'
import { UnavailableMenuEntryComponent } from './unavailable-menu-entry.component'
type LateralMenuLinkEntry = {
type: 'link'
label: string
routerLink: string
routerLinkActiveOptions?: { exact: boolean }
icon?: GlobalIconName
isDisplayed?: () => boolean
unavailableText?: () => string
}
export type LateralMenuConfig = {
title: string
entries: ({ type: 'separator' } | LateralMenuLinkEntry)[]
}
@Component({
selector: 'my-lateral-menu',
styleUrls: [ './lateral-menu.component.scss' ],
templateUrl: './lateral-menu.component.html',
imports: [
CommonModule,
RouterModule,
FormsModule,
ReactiveFormsModule,
NgbTooltipModule,
GlobalIconComponent,
UnavailableMenuEntryComponent,
GlobalIconComponent
]
})
export class LateralMenuComponent {
config = input.required<LateralMenuConfig>()
isDisplayed (entry: LateralMenuLinkEntry) {
if (!entry.isDisplayed) return true
return entry.isDisplayed()
}
isUnavailable (entry: LateralMenuLinkEntry) {
if (!entry.unavailableText) return false
return !!entry.unavailableText()
}
}

View file

@ -3,7 +3,7 @@ import { Component, input } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { HelpComponent } from '../../shared/shared-main/buttons/help.component' import { HelpComponent } from '../buttons/help.component'
@Component({ @Component({
selector: 'my-unavailable-menu-entry', selector: 'my-unavailable-menu-entry',

View file

@ -2,7 +2,7 @@ import { NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, input } from '@angular/core' import { Component, OnDestroy, OnInit, inject, input } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service'
import { NSFWFlag, NSFWFlagType, NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models' import { NSFWFlag, NSFWFlagType, NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models'
import { pick } from 'lodash-es' import { pick } from 'lodash-es'
import { Subject, Subscription } from 'rxjs' import { Subject, Subscription } from 'rxjs'
@ -55,7 +55,7 @@ export class UserVideoSettingsComponent implements OnInit, OnDestroy {
form: FormGroup<Form> form: FormGroup<Form>
formErrors: FormReactiveErrors = {} formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveValidationMessages = {} validationMessages: FormReactiveMessages = {}
nsfwItems: SelectOptionsItem[] = [ nsfwItems: SelectOptionsItem[] = [
{ {

View file

@ -1,13 +1,87 @@
import { sortBy } from '@peertube/peertube-core-utils' import { sortBy } from '@peertube/peertube-core-utils'
import { getLuminance, parse, toHSLA } from 'color-bits' import { getLuminance, parse, toHSLA } from 'color-bits'
import { ServerConfigTheme } from '@peertube/peertube-models' import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models'
import { logger } from './logger' import { logger } from './logger'
import debug from 'debug' import debug from 'debug'
const debugLogger = debug('peertube:theme') const debugLogger = debug('peertube:theme')
type ConfigCSSVariableMap = Record<keyof ServerConfig['theme']['customization'], string>
export type ThemeCustomizationKey = keyof ConfigCSSVariableMap
export type ColorPaletteThemeConfig = Pick<HTMLServerConfig['theme'], 'default' | 'customization'>
export class ThemeManager { export class ThemeManager {
private oldInjectedProperties: string[] = [] private configVariablesStyle: HTMLStyleElement
private colorPaletteStyle: HTMLStyleElement
private configuredCSSVariables = new Set<string>()
private readonly configCSSVariableMap: ConfigCSSVariableMap = {
primaryColor: '--primary',
foregroundColor: '--fg',
backgroundColor: '--bg',
backgroundSecondaryColor: '--bg-secondary',
menuForegroundColor: '--menu-fg',
menuBackgroundColor: '--menu-bg',
menuBorderRadius: '--menu-border-radius',
headerForegroundColor: '--header-fg',
headerBackgroundColor: '--header-bg',
inputBorderRadius: '--input-border-radius'
}
private defaultConfigValue: Record<keyof ConfigCSSVariableMap, string>
getCSSConfigValue (configKey: ThemeCustomizationKey) {
const cssVariable = this.configCSSVariableMap[configKey]
return getComputedStyle(document.documentElement).getPropertyValue(cssVariable)
}
injectConfigVariables (options: {
currentTheme: string
config: ColorPaletteThemeConfig
}) {
const { currentTheme, config } = options
if (!this.configVariablesStyle) {
this.configVariablesStyle = document.createElement('style')
this.configVariablesStyle.setAttribute('type', 'text/css')
this.configVariablesStyle.dataset.ptStyleId = 'config-variables'
document.head.appendChild(this.configVariablesStyle)
}
this.configuredCSSVariables.clear()
this.configVariablesStyle.textContent = ''
// Only inject config variables for the default theme
if (currentTheme !== config.default) return
const computedStyle = getComputedStyle(document.documentElement)
let configStyleContent = ''
this.defaultConfigValue = {} as any
for (const [ configKey, configValue ] of Object.entries(config.customization) as ([keyof ConfigCSSVariableMap, string][])) {
const cssVariable = this.configCSSVariableMap[configKey]
this.defaultConfigValue[configKey] = computedStyle.getPropertyValue(cssVariable)
if (!configValue) continue
if (!cssVariable) {
logger.error(`Unknown UI config variable "${configKey}" with value "${configValue}"`)
continue
}
configStyleContent += ` ${cssVariable}: ${configValue};\n`
this.configuredCSSVariables.add(cssVariable)
}
if (configStyleContent) {
this.configVariablesStyle.textContent = `:root[data-pt-theme=${currentTheme}] {\n${configStyleContent} }`
}
}
injectTheme (theme: ServerConfigTheme, apiUrl: string) { injectTheme (theme: ServerConfigTheme, apiUrl: string) {
const head = this.getHeadElement() const head = this.getHeadElement()
@ -42,7 +116,7 @@ export class ThemeManager {
link.disabled = link.getAttribute('title') !== name link.disabled = link.getAttribute('title') !== name
if (!link.disabled) { if (!link.disabled) {
link.onload = () => this.injectColorPalette() link.onload = () => this._injectColorPalette()
} else { } else {
link.onload = undefined link.onload = undefined
} }
@ -52,7 +126,10 @@ export class ThemeManager {
document.documentElement.dataset.ptTheme = name document.documentElement.dataset.ptTheme = name
} }
injectCoreColorPalette (iteration = 0) { injectColorPalette (options: {
config: ColorPaletteThemeConfig
currentTheme: string
}, iteration = 0) {
if (iteration > 100) { if (iteration > 100) {
logger.error('Too many iteration when checking color palette injection. The theme may be missing the --is-dark CSS variable') logger.error('Too many iteration when checking color palette injection. The theme may be missing the --is-dark CSS variable')
@ -61,10 +138,14 @@ export class ThemeManager {
} }
if (!this.canInjectCoreColorPalette()) { if (!this.canInjectCoreColorPalette()) {
return setTimeout(() => this.injectCoreColorPalette(iteration + 1), Math.floor(iteration / 10)) return setTimeout(() => this.injectColorPalette(options, iteration + 1), Math.floor(iteration / 10))
} }
return this.injectColorPalette() debugLogger(`Update color palette`, options.config)
this.injectConfigVariables(options)
return this._injectColorPalette()
} }
removeThemeLink (linkEl: HTMLLinkElement) { removeThemeLink (linkEl: HTMLLinkElement) {
@ -78,18 +159,19 @@ export class ThemeManager {
return isDark === '0' || isDark === '1' return isDark === '0' || isDark === '1'
} }
private injectColorPalette () { private _injectColorPalette () {
console.log(`Injecting color palette`) try {
if (!this.colorPaletteStyle) {
const rootStyle = document.documentElement.style this.colorPaletteStyle = document.createElement('style')
const computedStyle = getComputedStyle(document.documentElement) this.colorPaletteStyle.setAttribute('type', 'text/css')
this.colorPaletteStyle.dataset.ptStyleId = 'color-palette'
// FIXME: Remove previously injected properties document.head.appendChild(this.colorPaletteStyle)
for (const property of this.oldInjectedProperties) {
rootStyle.removeProperty(property)
} }
this.oldInjectedProperties = [] let paletteStyleContent = ''
const computedStyle = getComputedStyle(document.documentElement)
this.colorPaletteStyle.textContent = ''
const isGlobalDarkTheme = () => { const isGlobalDarkTheme = () => {
return this.isDarkTheme({ return this.isDarkTheme({
@ -133,7 +215,7 @@ export class ThemeManager {
// Use trim for some web browsers: https://github.com/Chocobozzz/PeerTube/issues/6952 // Use trim for some web browsers: https://github.com/Chocobozzz/PeerTube/issues/6952
const mainColorHSL = toHSLA(parse(mainColor.trim())) const mainColorHSL = toHSLA(parse(mainColor.trim()))
debugLogger(`Theme main variable ${mainColor} -> ${this.toHSLStr(mainColorHSL)}`) debugLogger(`Theme main variable --${prefix}: ${mainColor} -> ${this.toHSLStr(mainColorHSL)}`)
// Inject in alphabetical order for easy debug // Inject in alphabetical order for easy debug
const toInject: { id: number, key: string, value: string }[] = [ const toInject: { id: number, key: string, value: string }[] = [
@ -147,7 +229,11 @@ export class ThemeManager {
const suffix = 500 + (50 * i * j) const suffix = 500 + (50 * i * j)
const key = `--${prefix}-${suffix}` const key = `--${prefix}-${suffix}`
const existingValue = computedStyle.getPropertyValue(key) // Override all our variables if the CSS variable has been configured by the admin
const existingValue = this.configuredCSSVariables.has(`--${prefix}`)
? '0'
: computedStyle.getPropertyValue(key)
if (!existingValue || existingValue === '0') { if (!existingValue || existingValue === '0') {
const newLuminance = this.buildNewLuminance(lastColorHSL, j * step, darkInverter) const newLuminance = this.buildNewLuminance(lastColorHSL, j * step, darkInverter)
const newColorHSL = { ...lastColorHSL, l: newLuminance } const newColorHSL = { ...lastColorHSL, l: newLuminance }
@ -170,14 +256,23 @@ export class ThemeManager {
} }
for (const { key, value } of sortBy(toInject, 'id')) { for (const { key, value } of sortBy(toInject, 'id')) {
rootStyle.setProperty(key, value) paletteStyleContent += ` ${key}: ${value};\n`
this.oldInjectedProperties.push(key) }
if (paletteStyleContent) {
// To override default variables
document.documentElement.className = 'color-palette'
this.colorPaletteStyle.textContent = `:root.color-palette {\n${paletteStyleContent} }`
} }
} }
document.documentElement.dataset.bsTheme = isGlobalDarkTheme() document.documentElement.dataset.bsTheme = isGlobalDarkTheme()
? 'dark' ? 'dark'
: '' : ''
} catch (err) {
logger.error('Cannot inject color palette', err)
}
} }
private buildNewLuminance (base: { l: number }, factor: number, darkInverter: number) { private buildNewLuminance (base: { l: number }, factor: number, darkInverter: number) {

View file

@ -69,7 +69,7 @@ strong {
input[readonly] { input[readonly] {
// Force blank on readonly inputs // Force blank on readonly inputs
background-color: pvar(--input-bg) !important; background-color: pvar(--input-bg);
} }
input, input,

View file

@ -1,47 +1,47 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_button-mixins' as *; @use "_button-mixins" as *;
@import './_bootstrap-variables'; @import "./_bootstrap-variables";
@import 'bootstrap/scss/functions'; @import "bootstrap/scss/functions";
@import 'bootstrap/scss/variables'; @import "bootstrap/scss/variables";
@import 'bootstrap/scss/maps'; @import "bootstrap/scss/maps";
@import 'bootstrap/scss/mixins'; @import "bootstrap/scss/mixins";
@import 'bootstrap/scss/utilities'; @import "bootstrap/scss/utilities";
@import 'bootstrap/scss/root'; @import "bootstrap/scss/root";
@import 'bootstrap/scss/reboot'; @import "bootstrap/scss/reboot";
@import 'bootstrap/scss/type'; @import "bootstrap/scss/type";
@import 'bootstrap/scss/grid'; @import "bootstrap/scss/grid";
@import 'bootstrap/scss/forms'; @import "bootstrap/scss/forms";
@import 'bootstrap/scss/buttons'; @import "bootstrap/scss/buttons";
@import 'bootstrap/scss/transitions'; @import "bootstrap/scss/transitions";
@import 'bootstrap/scss/dropdown'; @import "bootstrap/scss/dropdown";
@import 'bootstrap/scss/button-group'; @import "bootstrap/scss/button-group";
@import 'bootstrap/scss/nav'; @import "bootstrap/scss/nav";
@import 'bootstrap/scss/card'; @import "bootstrap/scss/card";
@import 'bootstrap/scss/accordion'; @import "bootstrap/scss/accordion";
@import 'bootstrap/scss/alert'; @import "bootstrap/scss/alert";
@import 'bootstrap/scss/close'; @import "bootstrap/scss/close";
@import 'bootstrap/scss/modal'; @import "bootstrap/scss/modal";
@import 'bootstrap/scss/tooltip'; @import "bootstrap/scss/tooltip";
@import 'bootstrap/scss/popover'; @import "bootstrap/scss/popover";
@import 'bootstrap/scss/spinners'; @import "bootstrap/scss/spinners";
/* stylelint-disable-next-line at-rule-empty-line-before */ /* stylelint-disable-next-line at-rule-empty-line-before */
@import 'bootstrap/scss/helpers/clearfix'; @import "bootstrap/scss/helpers/clearfix";
@import 'bootstrap/scss/helpers/color-bg'; @import "bootstrap/scss/helpers/color-bg";
// @import 'bootstrap/scss/helpers/colored-links'; // @import 'bootstrap/scss/helpers/colored-links';
@import 'bootstrap/scss/helpers/focus-ring'; @import "bootstrap/scss/helpers/focus-ring";
@import 'bootstrap/scss/helpers/icon-link'; @import "bootstrap/scss/helpers/icon-link";
@import 'bootstrap/scss/helpers/ratio'; @import "bootstrap/scss/helpers/ratio";
@import 'bootstrap/scss/helpers/position'; @import "bootstrap/scss/helpers/position";
@import 'bootstrap/scss/helpers/stacks'; @import "bootstrap/scss/helpers/stacks";
@import 'bootstrap/scss/helpers/visually-hidden'; @import "bootstrap/scss/helpers/visually-hidden";
@import 'bootstrap/scss/helpers/stretched-link'; @import "bootstrap/scss/helpers/stretched-link";
@import 'bootstrap/scss/helpers/text-truncation'; @import "bootstrap/scss/helpers/text-truncation";
@import 'bootstrap/scss/helpers/vr'; @import "bootstrap/scss/helpers/vr";
/* stylelint-disable-next-line at-rule-empty-line-before */ /* stylelint-disable-next-line at-rule-empty-line-before */
@import 'bootstrap/scss/utilities/api'; @import "bootstrap/scss/utilities/api";
body { body {
--bs-border-color-translucent: #{pvar(--input-border-color)}; --bs-border-color-translucent: #{pvar(--input-border-color)};
@ -166,7 +166,7 @@ body {
@media screen and (min-width: #{breakpoint(md)}) { @media screen and (min-width: #{breakpoint(md)}) {
.modal::before { .modal::before {
vertical-align: middle; vertical-align: middle;
content: ' '; content: " ";
height: 100%; height: 100%;
} }
@ -217,7 +217,6 @@ body {
} }
} }
// On desktop browsers, make the content and header horizontally sticked to right not move when modal open and close // On desktop browsers, make the content and header horizontally sticked to right not move when modal open and close
.modal-open { .modal-open {
overflow-y: scroll !important; // Make sure vertical scroll bar is always visible on desktop browsers to get disabled scrollbar effect overflow-y: scroll !important; // Make sure vertical scroll bar is always visible on desktop browsers to get disabled scrollbar effect
@ -299,12 +298,6 @@ body {
font-size: $button-font-size; font-size: $button-font-size;
} }
.form-control {
color: pvar(--fg);
background-color: pvar(--input-bg);
outline: none;
}
.input-group { .input-group {
> .btn, > .btn,
> .input-group-text { > .input-group-text {
@ -342,7 +335,7 @@ body {
.form-control-clear { .form-control-clear {
position: absolute; position: absolute;
right: .5rem; right: 0.5rem;
top: 0; top: 0;
bottom: 0; bottom: 0;
opacity: 0.4; opacity: 0.4;
@ -363,12 +356,11 @@ body {
vertical-align: top; vertical-align: top;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// RTL compatibility // RTL compatibility
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
:root[dir=rtl] .modal .modal-header .modal-title { :root[dir="rtl"] .modal .modal-header .modal-title {
margin-inline-end: auto; margin-inline-end: auto;
margin-right: unset; margin-right: unset;
} }

View file

@ -82,8 +82,29 @@ label,
} }
label + .form-group-description, label + .form-group-description,
label + my-help + .form-group-description,
.label + .form-group-description, .label + .form-group-description,
.label-container + .form-group-description { .label-container + .form-group-description {
margin-bottom: 10px; margin-bottom: 10px;
margin-top: -0.5rem; margin-top: -0.4rem;
}
.number-with-unit {
position: relative;
width: fit-content;
input[type="number"] + span {
position: absolute;
top: 0.4em;
right: 3em;
@media screen and (max-width: $mobile-view) {
display: none;
}
}
input[disabled] {
opacity: 0.8;
pointer-events: none;
}
} }

View file

@ -48,10 +48,6 @@
min-width: 0; min-width: 0;
} }
.max-width-300px {
max-width: 300px;
}
.d-none-mw { .d-none-mw {
@include on-mobile-main-col { @include on-mobile-main-col {
display: none !important; display: none !important;

View file

@ -1,5 +1,5 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
.sub-menu-entry { .sub-menu-entry {
border: 0; border: 0;
@ -50,14 +50,6 @@
} }
} }
.admin-sub-header {
display: flex;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.pt-breadcrumb { .pt-breadcrumb {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -83,7 +75,7 @@
&::before { &::before {
display: inline-block; display: inline-block;
content: '/'; content: "/";
@include padding-right(0.5rem); @include padding-right(0.5rem);
} }

View file

@ -1,5 +1,5 @@
@use 'sass:map'; @use "sass:map";
@use '_variables' as *; @use "_variables" as *;
$modal-footer-border-width: 0; $modal-footer-border-width: 0;
$modal-md: 600px; $modal-md: 600px;
@ -17,7 +17,6 @@ $grid-breakpoints: (
// Extra large screens / wide desktops // Extra large screens / wide desktops
xl: 1200px, xl: 1200px,
xxl: 1600px, xxl: 1600px,
// SCREEN GROUP // SCREEN GROUP
fhd: 1800px, fhd: 1800px,
qhd: 2560px, qhd: 2560px,
@ -43,6 +42,11 @@ $input-btn-focus-width: 0;
$input-btn-focus-color: inherit; $input-btn-focus-color: inherit;
$input-focus-border-color: pvar(--input-border-color); $input-focus-border-color: pvar(--input-border-color);
$input-focus-box-shadow: #{$focus-box-shadow-form}; $input-focus-box-shadow: #{$focus-box-shadow-form};
$input-padding-y: pvar(--input-y-padding);
$input-padding-x: pvar(--input-x-padding);
$input-border-radius: pvar(--input-border-radius);
$input-border-width: pvar(--input-border-width);
$input-border-color: pvar(--input-border-color);
$input-group-addon-color: pvar(--fg); $input-group-addon-color: pvar(--fg);
$input-group-addon-bg: pvar(--bg-secondary-500); $input-group-addon-bg: pvar(--bg-secondary-500);

View file

@ -1,5 +1,5 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@mixin define-css-variables() { @mixin define-css-variables() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -10,6 +10,7 @@
--menu-fg: var(--menuForegroundColor); --menu-fg: var(--menuForegroundColor);
--menu-margin-left: #{$menu-margin-left}; --menu-margin-left: #{$menu-margin-left};
--menu-width: #{$menu-width}; --menu-width: #{$menu-width};
--menu-border-radius: #{$menu-border-radius};
--fg: var(--mainForegroundColor, #000); --fg: var(--mainForegroundColor, #000);
@ -33,6 +34,7 @@
--input-placeholder: var(--inputPlaceholderColor, #{pvar(--fg-50)}); --input-placeholder: var(--inputPlaceholderColor, #{pvar(--fg-50)});
--input-border-color: var(--inputBorderColor, #{pvar(--input-bg)}); --input-border-color: var(--inputBorderColor, #{pvar(--input-bg)});
--input-border-width: 1px;
--input-check-active-fg: #{pvar(--on-primary)}; --input-check-active-fg: #{pvar(--on-primary)};
--input-check-active-bg: #{pvar(--primary)}; --input-check-active-bg: #{pvar(--primary)};
@ -70,6 +72,9 @@
--menu-fg: #{pvar(--fg-400)}; --menu-fg: #{pvar(--fg-400)};
--menu-bg: #{pvar(--bg-secondary-400)}; --menu-bg: #{pvar(--bg-secondary-400)};
--header-fg: #{pvar(--fg)};
--header-bg: #{pvar(--bg)};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
--tmp-header-height: #{$header-height}; --tmp-header-height: #{$header-height};
@ -95,8 +100,8 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Light theme // Light theme
&[data-pt-theme=peertube-core-light-beige], &[data-pt-theme="peertube-core-light-beige"],
&[data-pt-theme=default] { &[data-pt-theme="default"] {
--is-dark: 0; --is-dark: 0;
--primary: #FF8F37; --primary: #FF8F37;
@ -128,7 +133,7 @@
} }
// Brown // Brown
&[data-pt-theme=peertube-core-dark-brown] { &[data-pt-theme="peertube-core-dark-brown"] {
--is-dark: 1; --is-dark: 1;
--primary: #FD9C50; --primary: #FD9C50;

View file

@ -12,7 +12,7 @@
max-width: $width; max-width: $width;
color: pvar(--input-fg); color: pvar(--input-fg);
background-color: pvar(--input-bg); background-color: pvar(--input-bg);
border: 1px solid pvar(--input-border-color); border: pvar(--input-border-width) solid pvar(--input-border-color);
border-radius: pvar(--input-border-radius); border-radius: pvar(--input-border-radius);
@include rounded-line-height-1-5($font-size); @include rounded-line-height-1-5($font-size);
@ -84,7 +84,7 @@
padding: pvar(--input-y-padding) calc(#{pvar(--input-x-padding)} + 23px) pvar(--input-y-padding) pvar(--input-x-padding); padding: pvar(--input-y-padding) calc(#{pvar(--input-x-padding)} + 23px) pvar(--input-y-padding) pvar(--input-x-padding);
position: relative; position: relative;
border: 1px solid var(--input-border-color) !important; border: pvar(--input-border-width) solid var(--input-border-color) !important;
appearance: none; appearance: none;
text-overflow: ellipsis; text-overflow: ellipsis;

View file

@ -1,7 +1,7 @@
@use 'sass:math'; @use "sass:math";
@use 'sass:color'; @use "sass:color";
@use '_variables' as *; @use "_variables" as *;
@import '_bootstrap-mixins'; @import "_bootstrap-mixins";
@mixin underline-primary { @mixin underline-primary {
text-decoration: underline !important; text-decoration: underline !important;
@ -67,7 +67,7 @@
overflow: hidden; overflow: hidden;
&::after { &::after {
content: ''; content: "";
pointer-events: none; pointer-events: none;
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -134,7 +134,7 @@
@mixin responsive-width($width) { @mixin responsive-width($width) {
width: $width; width: $width;
@media screen and (max-width: #{$width - 30px}) { @media screen and (max-width: #{$width + 30px}) {
width: 100%; width: 100%;
} }
} }
@ -145,7 +145,7 @@
align-items: center; align-items: center;
> *:not(:last-child)::after { > *:not(:last-child)::after {
content: ''; content: "";
margin: 0 $separator-margin; margin: 0 $separator-margin;
color: pvar(--primary); color: pvar(--primary);
} }
@ -179,7 +179,7 @@
my-global-icon { my-global-icon {
width: 22px; width: 22px;
opacity: .7; opacity: 0.7;
position: relative; position: relative;
top: -2px; top: -2px;
@ -189,23 +189,23 @@
@mixin divider($color: pvar(--bg-secondary-400), $background: pvar(--bg)) { @mixin divider($color: pvar(--bg-secondary-400), $background: pvar(--bg)) {
width: 95%; width: 95%;
border-top: .05rem solid $color; border-top: 0.05rem solid $color;
height: .05rem; height: 0.05rem;
text-align: center; text-align: center;
display: block; display: block;
position: relative; position: relative;
&[data-content] { &[data-content] {
margin: .8rem 0; margin: 0.8rem 0;
&::after { &::after {
background: $background; background: $background;
color: $color; color: $color;
content: attr(data-content); content: attr(data-content);
display: inline-block; display: inline-block;
font-size: .7rem; font-size: 0.7rem;
padding: 0 .4rem; padding: 0 0.4rem;
transform: translateY(-.65rem); transform: translateY(-0.65rem);
} }
} }
} }
@ -213,7 +213,7 @@
// applies ratio (default to 16:9) to a child element (using $selector) only using // applies ratio (default to 16:9) to a child element (using $selector) only using
// an immediate's parent size. This allows to set a ratio without explicit // an immediate's parent size. This allows to set a ratio without explicit
// dimensions, as width/height cannot be computed from each other. // dimensions, as width/height cannot be computed from each other.
@mixin block-ratio ($selector: 'div', $inverted-ratio: math.div(9, 16)) { @mixin block-ratio($selector: "div", $inverted-ratio: math.div(9, 16)) {
$padding-percent: math.percentage($inverted-ratio); $padding-percent: math.percentage($inverted-ratio);
position: relative; position: relative;
@ -318,7 +318,6 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* *
* inset-inline properties are not supported by iOS < 14.5 * inset-inline properties are not supported by iOS < 14.5
@ -335,7 +334,6 @@
} }
} }
@mixin left($value) { @mixin left($value) {
@supports (inset-inline-start: $value) { @supports (inset-inline-start: $value) {
inset-inline-start: $value; inset-inline-start: $value;
@ -345,4 +343,3 @@
left: $value; left: $value;
} }
} }

View file

@ -1,12 +1,12 @@
@use 'sass:math'; @use "sass:math";
@use 'sass:color'; @use "sass:color";
@use 'sass:map'; @use "sass:map";
$medium-view: 1000px; $medium-view: 1000px;
$small-view: 800px; $small-view: 800px;
$mobile-view: 500px; $mobile-view: 500px;
$main-fonts: 'Source Sans Pro', sans-serif; $main-fonts: "Source Sans Pro", sans-serif;
$font-regular: 400; $font-regular: 400;
$font-semibold: 600; $font-semibold: 600;
$font-bold: 700; $font-bold: 700;
@ -28,6 +28,7 @@ $header-height-mobile-view-without-search: 80px;
$header-mobile-msg-height: 48px; $header-mobile-msg-height: 48px;
$menu-width: 248px; $menu-width: 248px;
$menu-border-radius: 14px;
$menu-collapsed-width: 50px; $menu-collapsed-width: 50px;
$menu-margin-left: 2rem; $menu-margin-left: 2rem;
$menu-overlay-view: 1200px; $menu-overlay-view: 1200px;
@ -74,7 +75,7 @@ $player-portrait-bottom-space: 50px;
$sub-menu-margin-bottom: 30px; $sub-menu-margin-bottom: 30px;
$sub-menu-margin-bottom-small-view: 10px; $sub-menu-margin-bottom-small-view: 10px;
$focus-box-shadow-dimensions: 0 0 0 .2rem; $focus-box-shadow-dimensions: 0 0 0 0.2rem;
$form-input-font-size: 16px; $form-input-font-size: 16px;
@ -88,46 +89,37 @@ $variables: (
--x-margin-content: var(--x-margin-content), --x-margin-content: var(--x-margin-content),
--tmp-header-height: var(--tmp-header-height), --tmp-header-height: var(--tmp-header-height),
--header-height: var(--header-height), --header-height: var(--header-height),
--header-fg: var(--header-fg),
--header-bg: var(--header-bg),
--fg: var(--fg), --fg: var(--fg),
--bg: var(--bg), --bg: var(--bg),
--red: var(--red), --red: var(--red),
--green: var(--green), --green: var(--green),
--input-fg: var(--input-fg), --input-fg: var(--input-fg),
--input-bg: var(--input-bg), --input-bg: var(--input-bg),
--input-bg-550: var(--input-bg-550), --input-bg-550: var(--input-bg-550),
--input-bg-600: var(--input-bg-600), --input-bg-600: var(--input-bg-600),
--input-bg-in-secondary: var(--input-bg-in-secondary), --input-bg-in-secondary: var(--input-bg-in-secondary),
--input-danger-fg: var(--input-danger-fg), --input-danger-fg: var(--input-danger-fg),
--input-danger-bg: var(--input-danger-bg), --input-danger-bg: var(--input-danger-bg),
--input-placeholder: var(--input-placeholder), --input-placeholder: var(--input-placeholder),
--input-border-color: var(--input-border-color), --input-border-color: var(--input-border-color),
--input-border-radius: var(--input-border-radius), --input-border-radius: var(--input-border-radius),
--input-border-width: var(--input-border-width),
--input-check-active-fg: var(--input-check-active-fg), --input-check-active-fg: var(--input-check-active-fg),
--input-check-active-bg: var(--input-check-active-bg), --input-check-active-bg: var(--input-check-active-bg),
--input-x-padding: var(--input-x-padding), --input-x-padding: var(--input-x-padding),
--input-y-padding: var(--input-y-padding), --input-y-padding: var(--input-y-padding),
--textarea-x-padding: var(--textarea-x-padding), --textarea-x-padding: var(--textarea-x-padding),
--textarea-y-padding: var(--textarea-y-padding), --textarea-y-padding: var(--textarea-y-padding),
--textarea-fg: var(--textarea-fg), --textarea-fg: var(--textarea-fg),
--textarea-bg: var(--textarea-bg), --textarea-bg: var(--textarea-bg),
--support-btn-bg: var(--support-btn-bg), --support-btn-bg: var(--support-btn-bg),
--support-btn-fg: var(--support-btn-fg), --support-btn-fg: var(--support-btn-fg),
--support-btn-heart-bg: var(--support-btn-heart-bg), --support-btn-heart-bg: var(--support-btn-heart-bg),
--secondary-icon-color: var(--secondary-icon-color), --secondary-icon-color: var(--secondary-icon-color),
--active-icon-color: var(--active-icon-color), --active-icon-color: var(--active-icon-color),
--active-icon-bg: var(--active-icon-bg), --active-icon-bg: var(--active-icon-bg),
--fg-500: var(--fg-500), --fg-500: var(--fg-500),
--fg-450: var(--fg-450), --fg-450: var(--fg-450),
--fg-400: var(--fg-400), --fg-400: var(--fg-400),
@ -138,7 +130,6 @@ $variables: (
--fg-150: var(--fg-150), --fg-150: var(--fg-150),
--fg-100: var(--fg-100), --fg-100: var(--fg-100),
--fg-50: var(--fg-50), --fg-50: var(--fg-50),
--bg-secondary-600: var(--bg-secondary-600), --bg-secondary-600: var(--bg-secondary-600),
--bg-secondary-550: var(--bg-secondary-550), --bg-secondary-550: var(--bg-secondary-550),
--bg-secondary-500: var(--bg-secondary-500), --bg-secondary-500: var(--bg-secondary-500),
@ -148,7 +139,6 @@ $variables: (
--bg-secondary-300: var(--bg-secondary-300), --bg-secondary-300: var(--bg-secondary-300),
--bg-secondary-250: var(--bg-secondary-250), --bg-secondary-250: var(--bg-secondary-250),
--bg-secondary-200: var(--bg-secondary-200), --bg-secondary-200: var(--bg-secondary-200),
--menu-fg: var(--menu-fg), --menu-fg: var(--menu-fg),
--menu-fg-600: var(--menu-fg-600), --menu-fg-600: var(--menu-fg-600),
--menu-fg-550: var(--menu-fg-550), --menu-fg-550: var(--menu-fg-550),
@ -162,7 +152,6 @@ $variables: (
--menu-fg-150: var(--menu-fg-150), --menu-fg-150: var(--menu-fg-150),
--menu-fg-100: var(--menu-fg-100), --menu-fg-100: var(--menu-fg-100),
--menu-fg-50: var(--menu-fg-50), --menu-fg-50: var(--menu-fg-50),
--menu-bg: var(--menu-bg), --menu-bg: var(--menu-bg),
--menu-bg-600: var(--menu-bg-600), --menu-bg-600: var(--menu-bg-600),
--menu-bg-550: var(--menu-bg-550), --menu-bg-550: var(--menu-bg-550),
@ -173,10 +162,9 @@ $variables: (
--menu-bg-300: var(--menu-bg-300), --menu-bg-300: var(--menu-bg-300),
--menu-bg-250: var(--menu-bg-250), --menu-bg-250: var(--menu-bg-250),
--menu-bg-200: var(--menu-bg-200), --menu-bg-200: var(--menu-bg-200),
--menu-margin-left: var(--menu-margin-left), --menu-margin-left: var(--menu-margin-left),
--menu-width: var(--menu-width), --menu-width: var(--menu-width),
--menu-border-radius: var(--menu-border-radius),
--on-primary: var(--on-primary), --on-primary: var(--on-primary),
--on-primary-700: var(--on-primary-700), --on-primary-700: var(--on-primary-700),
--on-primary-650: var(--on-primary-650), --on-primary-650: var(--on-primary-650),
@ -192,7 +180,6 @@ $variables: (
--on-primary-150: var(--on-primary-150), --on-primary-150: var(--on-primary-150),
--on-primary-100: var(--on-primary-100), --on-primary-100: var(--on-primary-100),
--on-primary-50: var(--on-primary-50), --on-primary-50: var(--on-primary-50),
--primary: var(--primary), --primary: var(--primary),
--primary-700: var(--primary-700), --primary-700: var(--primary-700),
--primary-650: var(--primary-650), --primary-650: var(--primary-650),
@ -208,16 +195,13 @@ $variables: (
--primary-150: var(--primary-150), --primary-150: var(--primary-150),
--primary-100: var(--primary-100), --primary-100: var(--primary-100),
--primary-50: var(--primary-50), --primary-50: var(--primary-50),
--border-primary: var(--border-primary), --border-primary: var(--border-primary),
--border-secondary: var(--border-secondary), --border-secondary: var(--border-secondary),
--alert-primary-fg: var(--alert-primary-fg), --alert-primary-fg: var(--alert-primary-fg),
--alert-primary-bg: var(--alert-primary-bg), --alert-primary-bg: var(--alert-primary-bg),
--alert-primary-border-color: var(--alert-primary-border-color), --alert-primary-border-color: var(--alert-primary-border-color),
--embed-fg: var(--embed-fg), --embed-fg: var(--embed-fg),
--embed-big-play-bg: var(--embed-big-play-bg), --embed-big-play-bg: var(--embed-big-play-bg)
); );
// SASS type check our CSS variables // SASS type check our CSS variables
@ -225,7 +209,7 @@ $variables: (
@if map.has-key($variables, $variable) { @if map.has-key($variables, $variable) {
@return map.get($variables, $variable); @return map.get($variables, $variable);
} @else { } @else {
@error 'ERROR: Variable #{$variable} does not exist'; @error "ERROR: Variable #{$variable} does not exist";
} }
} }
@ -233,7 +217,7 @@ $variables: (
@if map.has-key($variables, $variable) and map.has-key($variables, $fallback) { @if map.has-key($variables, $variable) and map.has-key($variables, $fallback) {
@return var($variable, map.get($variables, $fallback)); @return var($variable, map.get($variables, $fallback));
} @else { } @else {
@error 'ERROR: Variable #{$variable} or #{$fallback} does not exist'; @error "ERROR: Variable #{$variable} or #{$fallback} does not exist";
} }
} }

View file

@ -125,6 +125,14 @@ p-toast {
} }
} }
// ---------------------------------------------------------------------------
// Colorpicker
// ---------------------------------------------------------------------------
p-colorpicker .p-colorpicker-preview {
border: 1px solid pvar(--fg-300);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data table // Data table
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -1040,6 +1040,25 @@ followings:
theme: theme:
default: 'default' default: 'default'
# Easily redefine the client UI when the user is using your default instance theme
# Use null to keep the default values
# If you need more advanced customizations, install or develop a dedicated theme: https://docs.joinpeertube.org/contribute/plugins
customization:
primary_color: null # Hex color. Example: '#FF8F37'
foreground_color: null # Hex color
background_color: null # Hex color
background_secondary_color: null # Hex color
menu_foreground_color: null # Hex color
menu_background_color: null # Hex color
menu_border_radius: null # Pixels. Example: '5px'
header_background_color: null # Hex color
header_foreground_color: null # Hex color
input_border_radius: null # Pixels
broadcast_message: broadcast_message:
enabled: false enabled: false
message: '' # Support markdown message: '' # Support markdown
@ -1074,6 +1093,7 @@ search:
# PeerTube client/interface configuration # PeerTube client/interface configuration
client: client:
videos: videos:
miniature: miniature:
# By default PeerTube client displays author username # By default PeerTube client displays author username

View file

@ -56,6 +56,19 @@ export interface CustomConfig {
theme: { theme: {
default: string default: string
customization: {
primaryColor: string
foregroundColor: string
backgroundColor: string
backgroundSecondaryColor: string
menuForegroundColor: string
menuBackgroundColor: string
menuBorderRadius: string
headerForegroundColor: string
headerBackgroundColor: string
inputBorderRadius: string
}
} }
services: { services: {

View file

@ -162,6 +162,19 @@ export interface ServerConfig {
builtIn: { name: 'peertube-core-light-beige' | 'peertube-core-dark-brown' }[] builtIn: { name: 'peertube-core-light-beige' | 'peertube-core-dark-brown' }[]
default: string default: string
customization: {
primaryColor: string
foregroundColor: string
backgroundColor: string
backgroundSecondaryColor: string
menuForegroundColor: string
menuBackgroundColor: string
menuBorderRadius: string
headerForegroundColor: string
headerBackgroundColor: string
inputBorderRadius: string
}
} }
email: { email: {

View file

@ -66,7 +66,7 @@ export const ServerErrorCode = {
/** /**
* oauthjs/oauth2-server error codes * oauthjs/oauth2-server error codes
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
**/ */
export const OAuth2ErrorCode = { export const OAuth2ErrorCode = {
/** /**
* The provided authorization grant (e.g., authorization code, resource owner * The provided authorization grant (e.g., authorization code, resource owner

View file

@ -272,7 +272,20 @@ function customConfig (): CustomConfig {
} }
}, },
theme: { theme: {
default: CONFIG.THEME.DEFAULT default: CONFIG.THEME.DEFAULT,
customization: {
primaryColor: CONFIG.THEME.CUSTOMIZATION.PRIMARY_COLOR,
foregroundColor: CONFIG.THEME.CUSTOMIZATION.FOREGROUND_COLOR,
backgroundColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_COLOR,
backgroundSecondaryColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_SECONDARY_COLOR,
menuForegroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_FOREGROUND_COLOR,
menuBackgroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_BACKGROUND_COLOR,
menuBorderRadius: CONFIG.THEME.CUSTOMIZATION.MENU_BORDER_RADIUS,
headerForegroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_FOREGROUND_COLOR,
headerBackgroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_BACKGROUND_COLOR,
inputBorderRadius: CONFIG.THEME.CUSTOMIZATION.INPUT_BORDER_RADIUS
}
}, },
services: { services: {
twitter: { twitter: {

View file

@ -992,6 +992,39 @@ const CONFIG = {
THEME: { THEME: {
get DEFAULT () { get DEFAULT () {
return config.get<string>('theme.default') return config.get<string>('theme.default')
},
CUSTOMIZATION: {
get PRIMARY_COLOR () {
return config.get<string>('theme.customization.primary_color')
},
get FOREGROUND_COLOR () {
return config.get<string>('theme.customization.foreground_color')
},
get BACKGROUND_COLOR () {
return config.get<string>('theme.customization.background_color')
},
get BACKGROUND_SECONDARY_COLOR () {
return config.get<string>('theme.customization.background_secondary_color')
},
get MENU_FOREGROUND_COLOR () {
return config.get<string>('theme.customization.menu_foreground_color')
},
get MENU_BACKGROUND_COLOR () {
return config.get<string>('theme.customization.menu_background_color')
},
get MENU_BORDER_RADIUS () {
return config.get<string>('theme.customization.menu_border_radius')
},
get HEADER_BACKGROUND_COLOR () {
return config.get<string>('theme.customization.header_background_color')
},
get HEADER_FOREGROUND_COLOR () {
return config.get<string>('theme.customization.header_foreground_color')
},
get INPUT_BORDER_RADIUS () {
return config.get<string>('theme.customization.input_border_radius')
}
} }
}, },
BROADCAST_MESSAGE: { BROADCAST_MESSAGE: {

View file

@ -156,7 +156,19 @@ class ServerConfigManager {
theme: { theme: {
registered: this.getRegisteredThemes(), registered: this.getRegisteredThemes(),
builtIn: this.getBuiltInThemes(), builtIn: this.getBuiltInThemes(),
default: defaultTheme default: defaultTheme,
customization: {
primaryColor: CONFIG.THEME.CUSTOMIZATION.PRIMARY_COLOR,
foregroundColor: CONFIG.THEME.CUSTOMIZATION.FOREGROUND_COLOR,
backgroundColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_COLOR,
backgroundSecondaryColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_SECONDARY_COLOR,
menuForegroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_FOREGROUND_COLOR,
menuBackgroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_BACKGROUND_COLOR,
menuBorderRadius: CONFIG.THEME.CUSTOMIZATION.MENU_BORDER_RADIUS,
headerForegroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_FOREGROUND_COLOR,
headerBackgroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_BACKGROUND_COLOR,
inputBorderRadius: CONFIG.THEME.CUSTOMIZATION.INPUT_BORDER_RADIUS
}
}, },
email: { email: {
enabled: isEmailEnabled() enabled: isEmailEnabled()