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:
parent
03425e10d3
commit
a6b89bde2b
98 changed files with 3928 additions and 2525 deletions
|
@ -214,7 +214,6 @@
|
|||
"escape-string-regexp",
|
||||
"is-plain-object",
|
||||
"parse-srcset",
|
||||
"deepmerge",
|
||||
"core-js/features/reflect",
|
||||
"hammerjs",
|
||||
"jschannel"
|
||||
|
|
|
@ -150,6 +150,7 @@ export default defineConfig([
|
|||
'no-return-assign': 'off',
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'import/no-named-default': 'off',
|
||||
'@typescript-eslint/prefer-reduce-type-parameter': 'off',
|
||||
|
||||
"@typescript-eslint/no-deprecated": [ 'error', {
|
||||
allow: [
|
||||
|
|
9
client/src/app/+admin/config/admin-config.component.html
Normal file
9
client/src/app/+admin/config/admin-config.component.html
Normal 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>
|
14
client/src/app/+admin/config/admin-config.component.scss
Normal file
14
client/src/app/+admin/config/admin-config.component.scss
Normal 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;
|
||||
}
|
||||
}
|
67
client/src/app/+admin/config/admin-config.component.ts
Normal file
67
client/src/app/+admin/config/admin-config.component.ts
Normal 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,37 @@
|
|||
import { Routes } from '@angular/router'
|
||||
import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config'
|
||||
import { UserRightGuard } from '@app/core'
|
||||
import { UserRight } from '@peertube/peertube-models'
|
||||
import { inject } from '@angular/core'
|
||||
import { ActivatedRouteSnapshot, ResolveFn, RouterStateSnapshot, Routes } from '@angular/router'
|
||||
import { CanDeactivateGuard, ServerService, UserRightGuard } from '@app/core'
|
||||
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 = [
|
||||
{
|
||||
|
@ -10,18 +40,96 @@ export const configRoutes: Routes = [
|
|||
data: {
|
||||
userRight: UserRight.MANAGE_CONFIGURATION
|
||||
},
|
||||
resolve: {
|
||||
customConfig: customConfigResolver
|
||||
},
|
||||
component: AdminConfigComponent,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'edit-custom',
|
||||
// Old path with PeerTube < 7.3
|
||||
path: 'edit-custom',
|
||||
redirectTo: 'information',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'edit-custom',
|
||||
component: EditCustomConfigComponent,
|
||||
path: '',
|
||||
redirectTo: 'information',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'homepage',
|
||||
component: AdminConfigHomepageComponent,
|
||||
canDeactivate: [ CanDeactivateGuard ],
|
||||
resolve: {
|
||||
homepageContent: homepageResolver
|
||||
},
|
||||
data: {
|
||||
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`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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']
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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'
|
|
@ -1,2 +1,2 @@
|
|||
export * from './edit-custom-config'
|
||||
export * from './pages'
|
||||
export * from './config.routes'
|
||||
|
|
|
@ -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>
|
|
@ -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.`
|
||||
})
|
||||
}
|
||||
}
|
80
client/src/app/+admin/config/pages/admin-config-common.scss
Normal file
80
client/src/app/+admin/config/pages/admin-config-common.scss
Normal 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;
|
||||
}
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -1,23 +1,13 @@
|
|||
<ng-container [formGroup]="form()">
|
||||
<div class="pt-two-cols mt-5"> <!-- appearance grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>APPEARANCE</h2>
|
||||
<my-admin-save-bar i18n-title title="General configuration" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
|
||||
|
||||
<div i18n class="inner-form-description">
|
||||
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>
|
||||
<ng-container [formGroup]="form">
|
||||
<div class="pt-two-cols">
|
||||
<div class="title-col">
|
||||
<h2 i18n>BEHAVIOR</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>
|
||||
|
||||
<div class="form-group" formGroupName="instance">
|
||||
<label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label>
|
||||
|
||||
|
@ -30,7 +20,7 @@
|
|||
[clearable]="false"
|
||||
></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 class="form-group" formGroupName="trending">
|
||||
|
@ -47,24 +37,13 @@
|
|||
</select>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<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="login">
|
||||
<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"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span *ngIf="countExternalAuth() === 0" i18n>⚠️ You don't have any external auth plugin enabled.</span>
|
||||
<span *ngIf="countExternalAuth() > 1" i18n>⚠️ You have multiple external auth plugins enabled.</span>
|
||||
@if (countExternalAuth() === 0) {
|
||||
<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>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
@ -85,11 +67,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- broadcast grid -->
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>BROADCAST MESSAGE</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Display a message on your instance
|
||||
Display a message on your platform
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -122,7 +104,7 @@
|
|||
</select>
|
||||
</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 class="form-group">
|
||||
|
@ -130,10 +112,10 @@
|
|||
|
||||
<my-markdown-textarea
|
||||
inputId="broadcastMessageMessage" formControlName="message"
|
||||
[formError]="formErrors()['broadcastMessage.message']" markdownType="to-unsafe-html"
|
||||
[formError]="formErrors.broadcastMessage.message" markdownType="to-unsafe-html"
|
||||
></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>
|
||||
|
||||
</ng-container>
|
||||
|
@ -144,9 +126,6 @@
|
|||
<div class="pt-two-cols mt-4"> <!-- new users grid -->
|
||||
<div class="title-col">
|
||||
<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 class="content-col">
|
||||
|
@ -160,7 +139,7 @@
|
|||
<ng-container ngProjectAs="description">
|
||||
<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 ngProjectAs="extra">
|
||||
|
@ -180,17 +159,17 @@
|
|||
|
||||
<div [ngClass]="getDisabledSignupClass()">
|
||||
<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">
|
||||
<input
|
||||
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 *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>
|
||||
</div>
|
||||
|
@ -201,12 +180,12 @@
|
|||
<div class="number-with-unit">
|
||||
<input
|
||||
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 *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>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
|
@ -215,7 +194,7 @@
|
|||
|
||||
<ng-container formGroupName="user">
|
||||
<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
|
||||
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>
|
||||
|
||||
<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 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
|
||||
labelId="userVideoQuotaDailyLabel"
|
||||
|
@ -243,14 +222,14 @@
|
|||
[clearable]="false"
|
||||
></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 class="form-group">
|
||||
<ng-container formGroupName="history">
|
||||
<ng-container formGroupName="videos">
|
||||
<my-peertube-checkbox
|
||||
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>
|
||||
</ng-container>
|
||||
|
@ -261,9 +240,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- videos grid -->
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>VIDEOS</h2>
|
||||
<h2 i18n>VIDEO IMPORTS</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
@ -281,7 +260,7 @@
|
|||
<span i18n>jobs in parallel</span>
|
||||
</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 class="form-group" formGroupName="http">
|
||||
|
@ -328,16 +307,25 @@
|
|||
<div class="number-with-unit">
|
||||
<input
|
||||
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 *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>
|
||||
</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="videos">
|
||||
|
@ -414,7 +402,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- video channels grid -->
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>VIDEO CHANNELS</h2>
|
||||
</div>
|
||||
|
@ -426,17 +414,17 @@
|
|||
<div class="number-with-unit">
|
||||
<input
|
||||
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 *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 class="pt-two-cols mt-4"> <!-- search grid -->
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>SEARCH</h2>
|
||||
</div>
|
||||
|
@ -452,7 +440,7 @@
|
|||
i18n-labelText labelText="Allow users to do remote URI/handle search"
|
||||
>
|
||||
<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>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
@ -463,7 +451,7 @@
|
|||
i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
|
||||
>
|
||||
<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>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
@ -477,23 +465,23 @@
|
|||
i18n-labelText labelText="Enable global search"
|
||||
>
|
||||
<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 ngProjectAs="extra">
|
||||
<div [ngClass]="getDisabledSearchIndexClass()">
|
||||
<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.
|
||||
</div>
|
||||
|
||||
<input
|
||||
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 class="mt-3">
|
||||
|
@ -509,7 +497,7 @@
|
|||
i18n-labelText labelText="Search bar uses the global search index by default"
|
||||
>
|
||||
<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>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
@ -525,7 +513,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- import/export grid -->
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>USER IMPORT/EXPORT</h2>
|
||||
</div>
|
||||
|
@ -577,7 +565,7 @@
|
|||
[clearable]="false"
|
||||
></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 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 *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>
|
||||
|
||||
</ng-container>
|
||||
|
@ -599,11 +587,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- federation grid -->
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>FEDERATION</h2>
|
||||
<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>
|
||||
|
||||
|
@ -615,14 +603,14 @@
|
|||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="followersInstanceEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Other instances can follow yours"
|
||||
i18n-labelText labelText="Remote actors can follow your platform"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
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>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
@ -635,7 +623,7 @@
|
|||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
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">
|
||||
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
|
||||
|
@ -648,7 +636,7 @@
|
|||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
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">
|
||||
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
|
||||
|
@ -663,9 +651,9 @@
|
|||
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
|
||||
<input
|
||||
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>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
|
@ -678,68 +666,4 @@
|
|||
</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>
|
|
@ -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.`
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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">
|
||||
|
||||
<div class="pt-two-cols mt-5"> <!-- instance grid -->
|
||||
<div class="pt-two-cols">
|
||||
<div class="title-col">
|
||||
<h2 i18n>INSTANCE</h2>
|
||||
<h2 i18n>PLATFORM</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
@ -25,7 +55,7 @@
|
|||
<div class="form-group">
|
||||
<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>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>
|
||||
|
@ -41,10 +71,10 @@
|
|||
|
||||
<input
|
||||
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 class="form-group">
|
||||
|
@ -52,22 +82,22 @@
|
|||
|
||||
<textarea
|
||||
id="instanceShortDescription" formControlName="shortDescription" class="form-control small"
|
||||
[ngClass]="{ 'input-error': formErrors()['instance.shortDescription'] }"
|
||||
[ngClass]="{ 'input-error': formErrors.instance.shortDescription }"
|
||||
></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 class="form-group">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
inputId="instanceDescription" formControlName="description"
|
||||
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
|
||||
[formError]="formErrors()['instance.description']"
|
||||
[formError]="formErrors.instance.description"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
|
@ -77,7 +107,7 @@
|
|||
<div>
|
||||
<my-select-checkbox
|
||||
inputId="instanceCategories"
|
||||
formControlName="categories" [availableItems]="categoryItems()"
|
||||
formControlName="categories" [availableItems]="categoryItems"
|
||||
[selectableGroup]="false"
|
||||
i18n-placeholder placeholder="Add a new category"
|
||||
>
|
||||
|
@ -91,7 +121,7 @@
|
|||
<div>
|
||||
<my-select-checkbox
|
||||
inputId="instanceLanguages"
|
||||
formControlName="languages" [availableItems]="languageItems()"
|
||||
formControlName="languages" [availableItems]="languageItems"
|
||||
[selectableGroup]="false"
|
||||
i18n-placeholder placeholder="Add a new language"
|
||||
>
|
||||
|
@ -101,20 +131,20 @@
|
|||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceServerCountry">Server country</label>
|
||||
<div i18n class="label-small-info">PeerTube uses this setting to explain to your users which law they must follow in the "About" pages</div>
|
||||
<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
|
||||
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 class="pt-two-cols mt-4"> <!-- social grid -->
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>SOCIAL</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
|
@ -126,25 +156,25 @@
|
|||
|
||||
<div class="form-group" formGroupName="support">
|
||||
<label i18n for="instanceSupportText">Support text</label><my-help helpType="markdownText"></my-help>
|
||||
<div i18n class="label-small-info">Explain to your users how to support your platform. If set, PeerTube will display a "Support" button in "About" instance pages</div>
|
||||
<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
|
||||
inputId="instanceSupportText" formControlName="text" markdownType="enhanced"
|
||||
[formError]="formErrors()['instance.support.text']"
|
||||
[formError]="formErrors.instance.support.text"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<ng-container formGroupName="social">
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceSocialExternalLink">External link</label>
|
||||
<div i18n class="label-small-info">Link to your main website</div>
|
||||
<div i18n class="form-group-description">Link to your main website</div>
|
||||
|
||||
<input
|
||||
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 class="form-group">
|
||||
|
@ -152,10 +182,10 @@
|
|||
|
||||
<input
|
||||
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 class="form-group">
|
||||
|
@ -163,10 +193,10 @@
|
|||
|
||||
<input
|
||||
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>
|
||||
</ng-container>
|
||||
|
||||
|
@ -174,7 +204,7 @@
|
|||
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- moderation grid -->
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>MODERATION & SENSITIVE CONTENT</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
|
@ -205,7 +235,7 @@
|
|||
formControlName="defaultNSFWPolicy"
|
||||
></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 class="form-group">
|
||||
|
@ -213,7 +243,7 @@
|
|||
|
||||
<my-markdown-textarea
|
||||
inputId="instanceTerms" formControlName="terms" markdownType="enhanced"
|
||||
[formError]="formErrors()['instance.terms']"
|
||||
[formError]="formErrors.instance.terms"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
|
@ -222,74 +252,74 @@
|
|||
|
||||
<my-markdown-textarea
|
||||
inputId="instanceCodeOfConduct" formControlName="codeOfConduct" markdownType="enhanced"
|
||||
[formError]="formErrors()['instance.codeOfConduct']"
|
||||
[formError]="formErrors.instance.codeOfConduct"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<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
|
||||
inputId="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced"
|
||||
[formError]="formErrors()['instance.moderationInformation']"
|
||||
[formError]="formErrors.instance.moderationInformation"
|
||||
></my-markdown-textarea>
|
||||
</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">
|
||||
<h2 i18n>YOU AND YOUR INSTANCE</h2>
|
||||
<h2 i18n>YOU AND YOUR PLATFORM</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<div class="form-group">
|
||||
<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
|
||||
inputId="instanceAdministrator" formControlName="administrator" markdownType="enhanced"
|
||||
[formError]="formErrors()['instance.administrator']"
|
||||
[formError]="formErrors.instance.administrator"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<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
|
||||
inputId="instanceCreationReason" formControlName="creationReason" markdownType="enhanced"
|
||||
[formError]="formErrors()['instance.creationReason']"
|
||||
[formError]="formErrors.instance.creationReason"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<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
|
||||
inputId="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" markdownType="enhanced"
|
||||
[formError]="formErrors()['instance.maintenanceLifetime']"
|
||||
[formError]="formErrors.instance.maintenanceLifetime"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<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
|
||||
inputId="instanceBusinessModel" formControlName="businessModel" markdownType="enhanced"
|
||||
[formError]="formErrors()['instance.businessModel']"
|
||||
[formError]="formErrors.instance.businessModel"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- other information grid -->
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>OTHER INFORMATION</h2>
|
||||
</div>
|
||||
|
@ -298,11 +328,11 @@
|
|||
|
||||
<div class="form-group">
|
||||
<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
|
||||
inputId="instanceHardwareInformation" formControlName="hardwareInformation" markdownType="enhanced"
|
||||
[formError]="formErrors()['instance.hardwareInformation']"
|
||||
[formError]="formErrors.instance.hardwareInformation"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
|
@ -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.`
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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">
|
||||
<h2 i18n>LIVE</h2>
|
||||
|
||||
<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>
|
||||
|
||||
|
@ -46,16 +48,16 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<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 *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 class="form-group" [ngClass]="getDisabledLiveClass()">
|
||||
|
@ -64,10 +66,10 @@
|
|||
|
||||
<div class="number-with-unit">
|
||||
<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 *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 class="form-group" [ngClass]="getDisabledLiveClass()">
|
||||
|
@ -75,7 +77,7 @@
|
|||
|
||||
<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>
|
||||
|
||||
</ng-container>
|
||||
|
@ -123,7 +125,7 @@
|
|||
<span>FPS</span>
|
||||
</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 class="ms-2 mt-3">
|
||||
|
@ -193,7 +195,7 @@
|
|||
formControlName="threads"
|
||||
[clearable]="false"
|
||||
></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 class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()">
|
||||
|
@ -202,7 +204,7 @@
|
|||
|
||||
<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>
|
||||
|
||||
</ng-container>
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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="callout callout-primary">
|
||||
<div class="callout callout-primary mb-4">
|
||||
<span i18n>
|
||||
Estimating a server's capacity to transcode and stream videos isn't easy and we can't tune PeerTube automatically.
|
||||
</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.
|
||||
</span>
|
||||
</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">
|
||||
|
||||
|
@ -151,7 +145,7 @@
|
|||
<span>FPS</span>
|
||||
</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 class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
||||
|
@ -220,7 +214,7 @@
|
|||
[clearable]="false"
|
||||
></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 class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
|
||||
|
@ -232,7 +226,7 @@
|
|||
<span i18n>jobs in parallel</span>
|
||||
</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 class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
|
||||
|
@ -241,7 +235,7 @@
|
|||
|
||||
<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>
|
||||
|
||||
</ng-container>
|
295
client/src/app/+admin/config/pages/admin-config-vod.component.ts
Normal file
295
client/src/app/+admin/config/pages/admin-config-vod.component.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
6
client/src/app/+admin/config/pages/index.ts
Normal file
6
client/src/app/+admin/config/pages/index.ts
Normal 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'
|
210
client/src/app/+admin/config/shared/admin-config.service.ts
Normal file
210
client/src/app/+admin/config/shared/admin-config.service.ts
Normal 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
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)))
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
|
|||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
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 {
|
||||
USER_CHANNEL_NAME_VALIDATOR,
|
||||
|
@ -54,7 +54,7 @@ import { UserPasswordComponent } from './user-password.component'
|
|||
export class UserCreateComponent extends UserEdit implements OnInit {
|
||||
protected serverService = inject(ServerService)
|
||||
protected formReactiveService = inject(FormReactiveService)
|
||||
protected configService = inject(ConfigService)
|
||||
protected configService = inject(AdminConfigService)
|
||||
protected screenService = inject(ScreenService)
|
||||
protected auth = inject(AuthService)
|
||||
private router = inject(Router)
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
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 { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
||||
import { peertubeTranslate, USER_ROLE_LABELS } from '@peertube/peertube-core-utils'
|
||||
import { HTMLServerConfig, UserAdminFlag, UserRole } from '@peertube/peertube-models'
|
||||
import { SelectOptionsItem } from '../../../../../types/select-options-item.model'
|
||||
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
||||
|
||||
@Directive()
|
||||
export abstract class UserEdit extends FormReactive implements OnInit {
|
||||
|
@ -18,7 +19,7 @@ export abstract class UserEdit extends FormReactive implements OnInit {
|
|||
protected serverConfig: HTMLServerConfig
|
||||
|
||||
protected abstract serverService: ServerService
|
||||
protected abstract configService: ConfigService
|
||||
protected abstract configService: AdminConfigService
|
||||
protected abstract screenService: ScreenService
|
||||
protected abstract auth: AuthService
|
||||
abstract isCreation (): boolean
|
||||
|
@ -88,7 +89,7 @@ export abstract class UserEdit extends FormReactive implements OnInit {
|
|||
}
|
||||
|
||||
protected buildQuotaOptions () {
|
||||
this.videoQuotaOptions = this.configService.videoQuotaOptions
|
||||
this.videoQuotaDailyOptions = this.configService.videoQuotaDailyOptions
|
||||
this.videoQuotaOptions = getVideoQuotaOptions()
|
||||
this.videoQuotaDailyOptions = getVideoQuotaDailyOptions()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'
|
|||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
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 {
|
||||
USER_EMAIL_VALIDATOR,
|
||||
|
@ -52,7 +52,7 @@ import { UserPasswordComponent } from './user-password.component'
|
|||
export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
|
||||
protected formReactiveService = inject(FormReactiveService)
|
||||
protected serverService = inject(ServerService)
|
||||
protected configService = inject(ConfigService)
|
||||
protected configService = inject(AdminConfigService)
|
||||
protected screenService = inject(ScreenService)
|
||||
protected auth = inject(AuthService)
|
||||
private route = inject(ActivatedRoute)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 { pluginsRoutes } from '@app/+admin/plugins/plugins.routes'
|
||||
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 { AdminOverviewComponent } from './admin-overview.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 { AdminRegistrationService } from './moderation/registration-list'
|
||||
import { overviewRoutes, VideoAdminService } from './overview'
|
||||
|
@ -37,7 +37,6 @@ const commonConfig = {
|
|||
CustomMarkupService,
|
||||
CustomPageService,
|
||||
DebugService,
|
||||
EditConfigurationService,
|
||||
InstanceFollowService,
|
||||
JobService,
|
||||
LogsService,
|
||||
|
@ -48,7 +47,7 @@ const commonConfig = {
|
|||
VideoAdminService,
|
||||
VideoBlockService,
|
||||
VideoCommentService,
|
||||
ConfigService,
|
||||
AdminConfigService,
|
||||
AbuseService,
|
||||
DynamicElementService,
|
||||
FindInBulkService,
|
||||
|
|
33
client/src/app/+admin/shared/user-quota-options.ts
Normal file
33
client/src/app/+admin/shared/user-quota-options.ts
Normal 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` }
|
||||
]
|
||||
}
|
|
@ -2,7 +2,7 @@ import { NgClass, NgFor, NgIf } from '@angular/common'
|
|||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||
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 { 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 { AlertComponent } from '@app/shared/shared-main/common/alert.component'
|
||||
import debug from 'debug'
|
||||
|
@ -52,7 +52,7 @@ export class VideoChaptersComponent implements OnInit, OnDestroy {
|
|||
|
||||
form: FormGroup<Form>
|
||||
formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {}
|
||||
validationMessages: FormReactiveValidationMessages = {}
|
||||
validationMessages: FormReactiveMessages = {}
|
||||
|
||||
videoEdit: VideoEdit
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angul
|
|||
import { ServerService } from '@app/core'
|
||||
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
|
||||
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 debug from 'debug'
|
||||
import { DatePickerModule } from 'primeng/datepicker'
|
||||
|
@ -44,7 +44,7 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy {
|
|||
|
||||
form: FormGroup<Form>
|
||||
formErrors: FormReactiveErrors = {}
|
||||
validationMessages: FormReactiveValidationMessages = {}
|
||||
validationMessages: FormReactiveMessages = {}
|
||||
|
||||
videoEdit: VideoEdit
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
|||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { ServerService } from '@app/core'
|
||||
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 { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
import {
|
||||
|
@ -68,7 +68,7 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy {
|
|||
|
||||
form: FormGroup<Form>
|
||||
formErrors: FormReactiveErrors = {}
|
||||
validationMessages: FormReactiveValidationMessages = {}
|
||||
validationMessages: FormReactiveMessages = {}
|
||||
|
||||
videoEdit: VideoEdit
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
VIDEO_TAGS_ARRAY_VALIDATOR
|
||||
} from '@app/shared/form-validators/video-validators'
|
||||
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 { InputTextComponent } from '@app/shared/shared-forms/input-text.component'
|
||||
import { MarkdownTextareaComponent } from '@app/shared/shared-forms/markdown-textarea.component'
|
||||
|
@ -120,7 +120,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy {
|
|||
|
||||
form: FormGroup<Form>
|
||||
formErrors: FormReactiveErrors = {}
|
||||
validationMessages: FormReactiveValidationMessages = {}
|
||||
validationMessages: FormReactiveMessages = {}
|
||||
|
||||
forbidScheduledPublication: boolean
|
||||
hideWaitTranscoding: boolean
|
||||
|
@ -337,7 +337,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy {
|
|||
const { pluginData } = this.videoEdit.toCommonFormPatch()
|
||||
|
||||
const pluginObj: { [id: string]: BuildFormValidator } = {}
|
||||
const pluginValidationMessages: FormReactiveValidationMessages = {}
|
||||
const pluginValidationMessages: FormReactiveMessages = {}
|
||||
const pluginFormErrors: FormReactiveErrors = {}
|
||||
const pluginDefaults: Record<string, string | boolean> = {}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import { RouterLink } from '@angular/router'
|
|||
import { ServerService } from '@app/core'
|
||||
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
|
||||
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 debug from 'debug'
|
||||
import { Subscription } from 'rxjs'
|
||||
|
@ -51,7 +51,7 @@ export class VideoModerationComponent implements OnInit, OnDestroy {
|
|||
|
||||
form: FormGroup<Form>
|
||||
formErrors: FormReactiveErrors = {}
|
||||
validationMessages: FormReactiveValidationMessages = {}
|
||||
validationMessages: FormReactiveMessages = {}
|
||||
|
||||
commentPolicies: VideoConstant<VideoCommentPolicyType>[] = []
|
||||
serverConfig: HTMLServerConfig
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Component, inject, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
|||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { ServerService } from '@app/core'
|
||||
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 { Subscription } from 'rxjs'
|
||||
import { ReactiveFileComponent } from '../../../shared/shared-forms/reactive-file.component'
|
||||
|
@ -46,7 +46,7 @@ export class VideoReplaceFileComponent implements OnInit, OnDestroy {
|
|||
|
||||
form: FormGroup<Form>
|
||||
formErrors: FormReactiveErrors = {}
|
||||
validationMessages: FormReactiveValidationMessages = {}
|
||||
validationMessages: FormReactiveMessages = {}
|
||||
|
||||
videoEdit: VideoEdit
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
ChartJSDefaults.backgroundColor = getComputedStyle(document.body).getPropertyValue('--bg')
|
||||
ChartJSDefaults.borderColor = getComputedStyle(document.body).getPropertyValue('--bg-secondary-500')
|
||||
ChartJSDefaults.color = getComputedStyle(document.body).getPropertyValue('--fg')
|
||||
ChartJSDefaults.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--bg')
|
||||
ChartJSDefaults.borderColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-secondary-500')
|
||||
ChartJSDefaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg')
|
||||
|
||||
@Component({
|
||||
templateUrl: './video-stats.component.html',
|
||||
|
@ -654,7 +654,7 @@ export class VideoStatsComponent implements OnInit {
|
|||
}
|
||||
|
||||
private buildChartColor () {
|
||||
return getComputedStyle(document.body).getPropertyValue('--border-primary')
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--border-primary')
|
||||
}
|
||||
|
||||
private formatXTick (options: {
|
||||
|
|
|
@ -2,7 +2,7 @@ import { NgFor, NgIf } from '@angular/common'
|
|||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
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 { TimestampInputComponent } from '@app/shared/shared-forms/timestamp-input.component'
|
||||
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
|
||||
|
@ -49,7 +49,7 @@ export class VideoStudioEditComponent implements OnInit, OnDestroy {
|
|||
|
||||
form: FormGroup<Form>
|
||||
formErrors: FormReactiveErrors = {}
|
||||
validationMessages: FormReactiveValidationMessages = {}
|
||||
validationMessages: FormReactiveMessages = {}
|
||||
|
||||
isRunningEdit = false
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -1,27 +1,17 @@
|
|||
import { CommonModule } from '@angular/common'
|
||||
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 { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
|
||||
import { LateralMenuComponent, LateralMenuConfig } from '@app/shared/shared-main/menu/lateral-menu.component'
|
||||
import { getReplaceFileUnavailability, getStudioUnavailability } from './common/unavailable-features'
|
||||
import { VideoEdit } from './common/video-edit.model'
|
||||
import { UnavailableMenuEntryComponent } from './unavailable-menu-entry.component'
|
||||
import { VideoManageController } from './video-manage-controller.service'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-manage-menu',
|
||||
styleUrls: [ './video-manage-menu.component.scss' ],
|
||||
templateUrl: './video-manage-menu.component.html',
|
||||
template: '<my-lateral-menu [config]="menuConfig" />',
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbTooltipModule,
|
||||
GlobalIconComponent,
|
||||
UnavailableMenuEntryComponent
|
||||
LateralMenuComponent
|
||||
]
|
||||
})
|
||||
export class VideoManageMenuComponent implements OnInit {
|
||||
|
@ -30,6 +20,8 @@ export class VideoManageMenuComponent implements OnInit {
|
|||
|
||||
readonly canWatch = input.required<boolean, string | boolean>({ transform: booleanAttribute })
|
||||
|
||||
menuConfig: LateralMenuConfig
|
||||
|
||||
private videoEdit: VideoEdit
|
||||
private replaceFileEnabled: boolean
|
||||
private studioEnabled: boolean
|
||||
|
@ -43,6 +35,89 @@ export class VideoManageMenuComponent implements OnInit {
|
|||
|
||||
const { videoEdit } = this.manageController.getStore()
|
||||
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 () {
|
||||
|
|
|
@ -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 { Injectable, LOCALE_ID, inject } from '@angular/core'
|
||||
import { inject, Injectable, LOCALE_ID } from '@angular/core'
|
||||
import { getDevLocale, isOnDevLocale } from '@app/helpers'
|
||||
import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@peertube/peertube-core-utils'
|
||||
import {
|
||||
|
@ -14,6 +12,8 @@ import {
|
|||
VideoPrivacyType
|
||||
} from '@peertube/peertube-models'
|
||||
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'
|
||||
|
||||
@Injectable()
|
||||
|
@ -37,8 +37,6 @@ export class ServerService {
|
|||
private videoLanguagesObservable: Observable<VideoConstant<string>[]>
|
||||
private configObservable: Observable<ServerConfig>
|
||||
|
||||
private configReset = false
|
||||
|
||||
private configLoaded = false
|
||||
private config: ServerConfig
|
||||
private htmlConfig: HTMLServerConfig
|
||||
|
@ -68,13 +66,14 @@ export class ServerService {
|
|||
|
||||
resetConfig () {
|
||||
this.configLoaded = false
|
||||
this.configReset = true
|
||||
|
||||
// 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.configObservable) {
|
||||
|
@ -86,9 +85,8 @@ export class ServerService {
|
|||
this.configLoaded = true
|
||||
}),
|
||||
tap(config => {
|
||||
if (this.configReset) {
|
||||
if (options.isReset) {
|
||||
this.configReloaded.next(config)
|
||||
this.configReset = false
|
||||
}
|
||||
}),
|
||||
share()
|
||||
|
|
|
@ -179,7 +179,7 @@ export default {
|
|||
overlay: {
|
||||
select: {
|
||||
background: 'var(--bg)',
|
||||
borderColor: 'var---input-border-color)',
|
||||
borderColor: 'var(--input-border-color)',
|
||||
color: 'var(--fg)'
|
||||
},
|
||||
popover: {
|
||||
|
|
34
client/src/app/core/theme/primeng/components/colorpicker.ts
Normal file
34
client/src/app/core/theme/primeng/components/colorpicker.ts
Normal 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
|
|
@ -2,6 +2,7 @@ import base from './base'
|
|||
import autocomplete from './components/autocomplete'
|
||||
import checkbox from './components/checkbox'
|
||||
import chip from './components/chip'
|
||||
import colorpicker from './components/colorpicker'
|
||||
import datatable from './components/datatable'
|
||||
import datepicker from './components/datepicker'
|
||||
import inputchips from './components/inputchips'
|
||||
|
@ -18,6 +19,7 @@ export const PTPrimeTheme = {
|
|||
select,
|
||||
inputchips,
|
||||
chip,
|
||||
colorpicker,
|
||||
datepicker,
|
||||
inputtext,
|
||||
toast,
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core'
|
|||
import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models'
|
||||
import { logger } from '@root-helpers/logger'
|
||||
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 { environment } from '../../../environments/environment'
|
||||
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) {
|
||||
this.themes = themes
|
||||
|
||||
|
@ -89,7 +97,7 @@ export class ThemeService {
|
|||
}
|
||||
}
|
||||
|
||||
private getCurrentThemeName () {
|
||||
getCurrentThemeName () {
|
||||
if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name
|
||||
|
||||
const theme = this.auth.isLoggedIn()
|
||||
|
@ -137,7 +145,7 @@ export class ThemeService {
|
|||
this.localStorageService.removeItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, false)
|
||||
}
|
||||
|
||||
this.themeManager.injectCoreColorPalette()
|
||||
this.themeManager.injectColorPalette({ currentTheme: currentThemeName, config: this.serverConfig.theme })
|
||||
|
||||
this.oldThemeName = currentThemeName
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
@use 'sass:math';
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_button-mixins' as *;
|
||||
@use '_bootstrap-variables' as *;
|
||||
@use "sass:math";
|
||||
@use "_variables" as *;
|
||||
@use "_mixins" as *;
|
||||
@use "_button-mixins" as *;
|
||||
@use "_bootstrap-variables" as *;
|
||||
|
||||
.mobile-msg {
|
||||
display: flex;
|
||||
|
@ -29,7 +29,8 @@
|
|||
--co-logo-size: 34px;
|
||||
--co-root-padding: 1.5rem;
|
||||
|
||||
background-color: pvar(--bg);
|
||||
color: pvar(--header-fg);
|
||||
background-color: pvar(--header-bg);
|
||||
|
||||
padding: var(--co-root-padding);
|
||||
width: 100%;
|
||||
|
@ -96,7 +97,7 @@ my-search-typeahead {
|
|||
}
|
||||
|
||||
.dropdown {
|
||||
z-index: #{z('header') + 1} !important;
|
||||
z-index: #{z("header") + 1} !important;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
|
@ -119,7 +120,7 @@ my-search-typeahead {
|
|||
|
||||
.logged-in-container {
|
||||
border-radius: 25px;
|
||||
transition: all .1s ease-in-out;
|
||||
transition: all 0.1s ease-in-out;
|
||||
cursor: pointer;
|
||||
max-width: 250px;
|
||||
height: 100%;
|
||||
|
@ -173,7 +174,7 @@ my-actor-avatar {
|
|||
@include margin-right(0.5rem);
|
||||
}
|
||||
|
||||
.margin-button[theme=tertiary] {
|
||||
.margin-button[theme="tertiary"] {
|
||||
@include margin-right(5px);
|
||||
}
|
||||
|
||||
|
@ -238,7 +239,7 @@ my-actor-avatar {
|
|||
}
|
||||
|
||||
.peertube-title {
|
||||
@include margin-right(5px)
|
||||
@include margin-right(5px);
|
||||
}
|
||||
|
||||
.instance-name {
|
||||
|
|
|
@ -132,8 +132,8 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
|||
|
||||
this.getSearchHiddenSub = this.headerService.getSearchHiddenObs()
|
||||
.subscribe(hidden => {
|
||||
if (hidden) document.body.classList.add('global-search-hidden')
|
||||
else document.body.classList.remove('global-search-hidden')
|
||||
if (hidden) document.documentElement.classList.add('global-search-hidden')
|
||||
else document.documentElement.classList.remove('global-search-hidden')
|
||||
|
||||
this.searchHidden = hidden
|
||||
})
|
||||
|
@ -167,7 +167,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
|||
if (!isAndroid() && !isIphone()) return
|
||||
|
||||
this.mobileMsg = true
|
||||
document.body.classList.add('mobile-app-msg')
|
||||
document.documentElement.classList.add('mobile-app-msg')
|
||||
|
||||
const host = window.location.host
|
||||
const intentConfig = this.htmlConfig.client.openInApp.android.intent
|
||||
|
@ -228,7 +228,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
|
|||
|
||||
hideMobileMsg () {
|
||||
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')
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@use 'sass:math';
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_button-mixins' as *;
|
||||
@use "sass:math";
|
||||
@use "_variables" as *;
|
||||
@use "_mixins" as *;
|
||||
@use "_button-mixins" as *;
|
||||
|
||||
.menu-container {
|
||||
--co-menu-x-padding: 1.5rem;
|
||||
|
@ -81,7 +81,7 @@
|
|||
position: relative;
|
||||
padding-top: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-radius: 14px;
|
||||
border-radius: pvar(--menu-border-radius);
|
||||
background-color: pvar(--menu-bg);
|
||||
}
|
||||
|
||||
|
@ -99,7 +99,7 @@
|
|||
.collapsed .toggle-menu-container,
|
||||
.about-top {
|
||||
&::after {
|
||||
content: '';
|
||||
content: "";
|
||||
display: block;
|
||||
height: 2px;
|
||||
margin: 1rem var(--co-menu-x-padding);
|
||||
|
@ -123,7 +123,7 @@
|
|||
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
transition: background-color .1s ease-in-out;
|
||||
transition: background-color 0.1s ease-in-out;
|
||||
width: 100%;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
|
@ -245,7 +245,7 @@
|
|||
width: 100vw;
|
||||
height: 100vh;
|
||||
opacity: 0.75;
|
||||
content: '';
|
||||
content: "";
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: z(overlay);
|
||||
|
|
|
@ -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 = {
|
||||
VALIDATORS: ValidatorFn[]
|
||||
|
@ -11,6 +12,48 @@ export type BuildFormArgument = {
|
|||
[id: string]: BuildFormValidator | BuildFormArgument
|
||||
}
|
||||
|
||||
export type BuildFormDefaultValues = {
|
||||
[name: string]: Blob | Date | boolean | number | string | string[] | BuildFormDefaultValues
|
||||
export type BuildFormArgumentTyped<Form> = ReplaceForm<Form, BuildFormValidator>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_form-mixins' as *;
|
||||
@use "_variables" as *;
|
||||
@use "_mixins" as *;
|
||||
@use "_form-mixins" as *;
|
||||
|
||||
input:not([type=submit]) {
|
||||
input:not([type="submit"]) {
|
||||
max-width: 340px;
|
||||
width: 100%;
|
||||
|
||||
|
@ -21,10 +21,6 @@ textarea {
|
|||
@include peertube-select-container(340px);
|
||||
}
|
||||
|
||||
my-peertube-checkbox + .label-small-info {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
my-markdown-textarea {
|
||||
display: block;
|
||||
max-width: 500px;
|
||||
|
|
|
@ -1,19 +1,16 @@
|
|||
import { Injectable, inject } from '@angular/core'
|
||||
import { AbstractControl, FormGroup, StatusChangeEvent } from '@angular/forms'
|
||||
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'
|
||||
|
||||
export type FormReactiveErrors = { [id: string]: string | FormReactiveErrors | FormReactiveErrors[] }
|
||||
export type FormReactiveValidationMessages = {
|
||||
[id: string]: { [name: string]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[]
|
||||
}
|
||||
export * from '../form-validators/form-validator.model'
|
||||
|
||||
@Injectable()
|
||||
export class FormReactiveService {
|
||||
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)
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
|
@ -76,7 +73,7 @@ export class FormReactiveService {
|
|||
private onStatusChanged (options: {
|
||||
form: FormGroup
|
||||
formErrors: FormReactiveErrors
|
||||
validationMessages: FormReactiveValidationMessages
|
||||
validationMessages: FormReactiveMessages
|
||||
onlyDirty?: boolean // default true
|
||||
}) {
|
||||
const { form, formErrors, validationMessages, onlyDirty = true } = options
|
||||
|
@ -86,7 +83,7 @@ export class FormReactiveService {
|
|||
this.onStatusChanged({
|
||||
form: form.controls[field] as FormGroup,
|
||||
formErrors: formErrors[field] as FormReactiveErrors,
|
||||
validationMessages: validationMessages[field] as FormReactiveValidationMessages,
|
||||
validationMessages: validationMessages[field] as FormReactiveMessages,
|
||||
onlyDirty
|
||||
})
|
||||
|
||||
|
@ -99,7 +96,7 @@ export class FormReactiveService {
|
|||
|
||||
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)) {
|
||||
const formErrorValue = control.errors[key]
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { FormGroup } from '@angular/forms'
|
||||
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
|
||||
import { FormReactiveService, FormReactiveValidationMessages } from './form-reactive.service'
|
||||
import { BuildFormArgument, FormDefault } from '../form-validators/form-validator.model'
|
||||
import { FormReactiveService, FormReactiveMessages } from './form-reactive.service'
|
||||
|
||||
export abstract class FormReactive {
|
||||
protected abstract formReactiveService: FormReactiveService
|
||||
|
@ -8,9 +8,9 @@ export abstract class FormReactive {
|
|||
|
||||
form: FormGroup
|
||||
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)
|
||||
|
||||
this.form = form
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { Injectable, inject } from '@angular/core'
|
||||
import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
|
||||
import { objectKeysTyped } from '@peertube/peertube-core-utils'
|
||||
import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
|
||||
import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service'
|
||||
import { BuildFormArgument, FormDefault } from '../form-validators/form-validator.model'
|
||||
import { FormReactiveErrors, FormReactiveMessages } from './form-reactive.service'
|
||||
|
||||
@Injectable()
|
||||
export class FormValidatorService {
|
||||
private formBuilder = inject(FormBuilder)
|
||||
|
||||
internalBuildForm<T = any> (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
|
||||
internalBuildForm<T = any> (obj: BuildFormArgument, defaultValues: FormDefault = {}) {
|
||||
const formErrors: FormReactiveErrors = {}
|
||||
const validationMessages: FormReactiveValidationMessages = {}
|
||||
const validationMessages: FormReactiveMessages = {}
|
||||
const group: { [key: string]: any } = {}
|
||||
|
||||
for (const name of Object.keys(obj)) {
|
||||
|
@ -18,7 +18,7 @@ export class FormValidatorService {
|
|||
|
||||
const field = obj[name]
|
||||
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
|
||||
formErrors[name] = result.formErrors
|
||||
validationMessages[name] = result.validationMessages
|
||||
|
@ -41,9 +41,9 @@ export class FormValidatorService {
|
|||
updateFormGroup (
|
||||
form: FormGroup,
|
||||
formErrors: FormReactiveErrors,
|
||||
validationMessages: FormReactiveValidationMessages,
|
||||
validationMessages: FormReactiveMessages,
|
||||
formToBuild: BuildFormArgument,
|
||||
defaultValues: BuildFormDefaultValues = {}
|
||||
defaultValues: FormDefault = {}
|
||||
) {
|
||||
for (const name of objectKeysTyped(formToBuild)) {
|
||||
const field = formToBuild[name]
|
||||
|
@ -55,9 +55,9 @@ export class FormValidatorService {
|
|||
// FIXME: typings
|
||||
(form as any)[name],
|
||||
formErrors[name],
|
||||
validationMessages[name] as FormReactiveValidationMessages,
|
||||
validationMessages[name] as FormReactiveMessages,
|
||||
formToBuild[name] as BuildFormArgument,
|
||||
defaultValues[name] as BuildFormDefaultValues
|
||||
defaultValues[name] as FormDefault
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
@ -77,11 +77,11 @@ export class FormValidatorService {
|
|||
|
||||
addControlInFormArray (options: {
|
||||
formErrors: FormReactiveErrors
|
||||
validationMessages: FormReactiveValidationMessages
|
||||
validationMessages: FormReactiveMessages
|
||||
formArray: FormArray
|
||||
controlName: string
|
||||
formToBuild: BuildFormArgument
|
||||
defaultValues?: BuildFormDefaultValues
|
||||
defaultValues?: FormDefault
|
||||
}) {
|
||||
const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options
|
||||
|
||||
|
@ -90,7 +90,7 @@ export class FormValidatorService {
|
|||
if (!validationMessages[controlName]) validationMessages[controlName] = []
|
||||
|
||||
const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
|
||||
const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[]
|
||||
const formArrayValidationMessages = validationMessages[controlName] as FormReactiveMessages[]
|
||||
|
||||
const totalControls = formArray.controls.length
|
||||
formArrayErrors.push({})
|
||||
|
@ -109,7 +109,7 @@ export class FormValidatorService {
|
|||
|
||||
removeControlFromFormArray (options: {
|
||||
formErrors: FormReactiveErrors
|
||||
validationMessages: FormReactiveValidationMessages
|
||||
validationMessages: FormReactiveMessages
|
||||
index: number
|
||||
formArray: FormArray
|
||||
controlName: string
|
||||
|
@ -117,7 +117,7 @@ export class FormValidatorService {
|
|||
const { formArray, formErrors, validationMessages, index, controlName } = options
|
||||
|
||||
const formArrayErrors = formErrors[controlName] as FormReactiveErrors[]
|
||||
const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[]
|
||||
const formArrayValidationMessages = validationMessages[controlName] as FormReactiveMessages[]
|
||||
|
||||
formArrayErrors.splice(index, 1)
|
||||
formArrayValidationMessages.splice(index, 1)
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
<div *ngIf="recommended()" class="ms-2 pt-badge badge-secondary" i18n>Recommended</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'">
|
||||
<ng-content select="description"></ng-content>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use 'form-mixins' as *;
|
||||
@use "_variables" as *;
|
||||
@use "_mixins" as *;
|
||||
@use "form-mixins" as *;
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
|
@ -35,3 +35,7 @@
|
|||
.pt-badge {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.extra-container {
|
||||
@include margin-left(28px);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { of } from 'rxjs'
|
||||
import { catchError } from 'rxjs/operators'
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { RestExtractor } from '@app/core'
|
||||
import { CustomPage } from '@peertube/peertube-models'
|
||||
import { Observable, of } from 'rxjs'
|
||||
import { catchError } from 'rxjs/operators'
|
||||
import { environment } from '../../../../environments/environment'
|
||||
|
||||
@Injectable()
|
||||
|
@ -13,7 +13,7 @@ export class CustomPageService {
|
|||
|
||||
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)
|
||||
.pipe(
|
||||
catchError(err => {
|
||||
|
|
|
@ -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>
|
|
@ -1,7 +1,7 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_form-mixins' as *;
|
||||
@import 'bootstrap/scss/mixins';
|
||||
@use "_variables" as *;
|
||||
@use "_mixins" as *;
|
||||
@use "_form-mixins" as *;
|
||||
@import "bootstrap/scss/mixins";
|
||||
|
||||
h1 {
|
||||
color: pvar(--fg-200);
|
||||
|
@ -105,6 +105,10 @@ a {
|
|||
|
||||
@include padding-right(1.5rem);
|
||||
|
||||
&:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
|
@ -132,7 +136,7 @@ a {
|
|||
position: fixed;
|
||||
bottom: 0;
|
||||
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;
|
||||
border: 1px solid pvar(--bg-secondary-450);
|
||||
border-bottom: 0;
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ 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 { HelpComponent } from '../../shared/shared-main/buttons/help.component'
|
||||
import { HelpComponent } from '../buttons/help.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-unavailable-menu-entry',
|
|
@ -2,7 +2,7 @@ import { NgIf } from '@angular/common'
|
|||
import { Component, OnDestroy, OnInit, inject, input } from '@angular/core'
|
||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
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 { pick } from 'lodash-es'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
|
@ -55,7 +55,7 @@ export class UserVideoSettingsComponent implements OnInit, OnDestroy {
|
|||
|
||||
form: FormGroup<Form>
|
||||
formErrors: FormReactiveErrors = {}
|
||||
validationMessages: FormReactiveValidationMessages = {}
|
||||
validationMessages: FormReactiveMessages = {}
|
||||
|
||||
nsfwItems: SelectOptionsItem[] = [
|
||||
{
|
||||
|
|
|
@ -1,13 +1,87 @@
|
|||
import { sortBy } from '@peertube/peertube-core-utils'
|
||||
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 debug from 'debug'
|
||||
|
||||
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 {
|
||||
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) {
|
||||
const head = this.getHeadElement()
|
||||
|
@ -42,7 +116,7 @@ export class ThemeManager {
|
|||
link.disabled = link.getAttribute('title') !== name
|
||||
|
||||
if (!link.disabled) {
|
||||
link.onload = () => this.injectColorPalette()
|
||||
link.onload = () => this._injectColorPalette()
|
||||
} else {
|
||||
link.onload = undefined
|
||||
}
|
||||
|
@ -52,7 +126,10 @@ export class ThemeManager {
|
|||
document.documentElement.dataset.ptTheme = name
|
||||
}
|
||||
|
||||
injectCoreColorPalette (iteration = 0) {
|
||||
injectColorPalette (options: {
|
||||
config: ColorPaletteThemeConfig
|
||||
currentTheme: string
|
||||
}, iteration = 0) {
|
||||
if (iteration > 100) {
|
||||
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()) {
|
||||
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) {
|
||||
|
@ -78,18 +159,19 @@ export class ThemeManager {
|
|||
return isDark === '0' || isDark === '1'
|
||||
}
|
||||
|
||||
private injectColorPalette () {
|
||||
console.log(`Injecting color palette`)
|
||||
|
||||
const rootStyle = document.documentElement.style
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
|
||||
// FIXME: Remove previously injected properties
|
||||
for (const property of this.oldInjectedProperties) {
|
||||
rootStyle.removeProperty(property)
|
||||
private _injectColorPalette () {
|
||||
try {
|
||||
if (!this.colorPaletteStyle) {
|
||||
this.colorPaletteStyle = document.createElement('style')
|
||||
this.colorPaletteStyle.setAttribute('type', 'text/css')
|
||||
this.colorPaletteStyle.dataset.ptStyleId = 'color-palette'
|
||||
document.head.appendChild(this.colorPaletteStyle)
|
||||
}
|
||||
|
||||
this.oldInjectedProperties = []
|
||||
let paletteStyleContent = ''
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
this.colorPaletteStyle.textContent = ''
|
||||
|
||||
const isGlobalDarkTheme = () => {
|
||||
return this.isDarkTheme({
|
||||
|
@ -133,7 +215,7 @@ export class ThemeManager {
|
|||
|
||||
// Use trim for some web browsers: https://github.com/Chocobozzz/PeerTube/issues/6952
|
||||
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
|
||||
const toInject: { id: number, key: string, value: string }[] = [
|
||||
|
@ -147,7 +229,11 @@ export class ThemeManager {
|
|||
const suffix = 500 + (50 * i * j)
|
||||
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') {
|
||||
const newLuminance = this.buildNewLuminance(lastColorHSL, j * step, darkInverter)
|
||||
const newColorHSL = { ...lastColorHSL, l: newLuminance }
|
||||
|
@ -170,14 +256,23 @@ export class ThemeManager {
|
|||
}
|
||||
|
||||
for (const { key, value } of sortBy(toInject, 'id')) {
|
||||
rootStyle.setProperty(key, value)
|
||||
this.oldInjectedProperties.push(key)
|
||||
paletteStyleContent += ` ${key}: ${value};\n`
|
||||
}
|
||||
|
||||
if (paletteStyleContent) {
|
||||
// To override default variables
|
||||
document.documentElement.className = 'color-palette'
|
||||
|
||||
this.colorPaletteStyle.textContent = `:root.color-palette {\n${paletteStyleContent} }`
|
||||
}
|
||||
}
|
||||
|
||||
document.documentElement.dataset.bsTheme = isGlobalDarkTheme()
|
||||
? 'dark'
|
||||
: ''
|
||||
} catch (err) {
|
||||
logger.error('Cannot inject color palette', err)
|
||||
}
|
||||
}
|
||||
|
||||
private buildNewLuminance (base: { l: number }, factor: number, darkInverter: number) {
|
||||
|
|
|
@ -69,7 +69,7 @@ strong {
|
|||
|
||||
input[readonly] {
|
||||
// Force blank on readonly inputs
|
||||
background-color: pvar(--input-bg) !important;
|
||||
background-color: pvar(--input-bg);
|
||||
}
|
||||
|
||||
input,
|
||||
|
|
92
client/src/sass/bootstrap.scss
vendored
92
client/src/sass/bootstrap.scss
vendored
|
@ -1,47 +1,47 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_button-mixins' as *;
|
||||
@import './_bootstrap-variables';
|
||||
@import 'bootstrap/scss/functions';
|
||||
@import 'bootstrap/scss/variables';
|
||||
@import 'bootstrap/scss/maps';
|
||||
@import 'bootstrap/scss/mixins';
|
||||
@import 'bootstrap/scss/utilities';
|
||||
@import 'bootstrap/scss/root';
|
||||
@import 'bootstrap/scss/reboot';
|
||||
@import 'bootstrap/scss/type';
|
||||
@import 'bootstrap/scss/grid';
|
||||
@import 'bootstrap/scss/forms';
|
||||
@import 'bootstrap/scss/buttons';
|
||||
@import 'bootstrap/scss/transitions';
|
||||
@import 'bootstrap/scss/dropdown';
|
||||
@import 'bootstrap/scss/button-group';
|
||||
@import 'bootstrap/scss/nav';
|
||||
@import 'bootstrap/scss/card';
|
||||
@import 'bootstrap/scss/accordion';
|
||||
@import 'bootstrap/scss/alert';
|
||||
@import 'bootstrap/scss/close';
|
||||
@import 'bootstrap/scss/modal';
|
||||
@import 'bootstrap/scss/tooltip';
|
||||
@import 'bootstrap/scss/popover';
|
||||
@import 'bootstrap/scss/spinners';
|
||||
@use "_variables" as *;
|
||||
@use "_mixins" as *;
|
||||
@use "_button-mixins" as *;
|
||||
@import "./_bootstrap-variables";
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/utilities";
|
||||
@import "bootstrap/scss/root";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap/scss/type";
|
||||
@import "bootstrap/scss/grid";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/nav";
|
||||
@import "bootstrap/scss/card";
|
||||
@import "bootstrap/scss/accordion";
|
||||
@import "bootstrap/scss/alert";
|
||||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/tooltip";
|
||||
@import "bootstrap/scss/popover";
|
||||
@import "bootstrap/scss/spinners";
|
||||
|
||||
/* stylelint-disable-next-line at-rule-empty-line-before */
|
||||
@import 'bootstrap/scss/helpers/clearfix';
|
||||
@import 'bootstrap/scss/helpers/color-bg';
|
||||
@import "bootstrap/scss/helpers/clearfix";
|
||||
@import "bootstrap/scss/helpers/color-bg";
|
||||
// @import 'bootstrap/scss/helpers/colored-links';
|
||||
@import 'bootstrap/scss/helpers/focus-ring';
|
||||
@import 'bootstrap/scss/helpers/icon-link';
|
||||
@import 'bootstrap/scss/helpers/ratio';
|
||||
@import 'bootstrap/scss/helpers/position';
|
||||
@import 'bootstrap/scss/helpers/stacks';
|
||||
@import 'bootstrap/scss/helpers/visually-hidden';
|
||||
@import 'bootstrap/scss/helpers/stretched-link';
|
||||
@import 'bootstrap/scss/helpers/text-truncation';
|
||||
@import 'bootstrap/scss/helpers/vr';
|
||||
@import "bootstrap/scss/helpers/focus-ring";
|
||||
@import "bootstrap/scss/helpers/icon-link";
|
||||
@import "bootstrap/scss/helpers/ratio";
|
||||
@import "bootstrap/scss/helpers/position";
|
||||
@import "bootstrap/scss/helpers/stacks";
|
||||
@import "bootstrap/scss/helpers/visually-hidden";
|
||||
@import "bootstrap/scss/helpers/stretched-link";
|
||||
@import "bootstrap/scss/helpers/text-truncation";
|
||||
@import "bootstrap/scss/helpers/vr";
|
||||
|
||||
/* stylelint-disable-next-line at-rule-empty-line-before */
|
||||
@import 'bootstrap/scss/utilities/api';
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
|
||||
body {
|
||||
--bs-border-color-translucent: #{pvar(--input-border-color)};
|
||||
|
@ -166,7 +166,7 @@ body {
|
|||
@media screen and (min-width: #{breakpoint(md)}) {
|
||||
.modal::before {
|
||||
vertical-align: middle;
|
||||
content: ' ';
|
||||
content: " ";
|
||||
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
|
||||
.modal-open {
|
||||
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;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
color: pvar(--fg);
|
||||
background-color: pvar(--input-bg);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
> .btn,
|
||||
> .input-group-text {
|
||||
|
@ -342,7 +335,7 @@ body {
|
|||
|
||||
.form-control-clear {
|
||||
position: absolute;
|
||||
right: .5rem;
|
||||
right: 0.5rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
opacity: 0.4;
|
||||
|
@ -363,12 +356,11 @@ body {
|
|||
vertical-align: top;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RTL compatibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
:root[dir=rtl] .modal .modal-header .modal-title {
|
||||
:root[dir="rtl"] .modal .modal-header .modal-title {
|
||||
margin-inline-end: auto;
|
||||
margin-right: unset;
|
||||
}
|
||||
|
|
|
@ -82,8 +82,29 @@ label,
|
|||
}
|
||||
|
||||
label + .form-group-description,
|
||||
label + my-help + .form-group-description,
|
||||
.label + .form-group-description,
|
||||
.label-container + .form-group-description {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,10 +48,6 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.max-width-300px {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.d-none-mw {
|
||||
@include on-mobile-main-col {
|
||||
display: none !important;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use "_variables" as *;
|
||||
@use "_mixins" as *;
|
||||
|
||||
.sub-menu-entry {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
@ -83,7 +75,7 @@
|
|||
|
||||
&::before {
|
||||
display: inline-block;
|
||||
content: '/';
|
||||
content: "/";
|
||||
|
||||
@include padding-right(0.5rem);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
@use 'sass:map';
|
||||
@use '_variables' as *;
|
||||
@use "sass:map";
|
||||
@use "_variables" as *;
|
||||
|
||||
$modal-footer-border-width: 0;
|
||||
$modal-md: 600px;
|
||||
|
@ -17,7 +17,6 @@ $grid-breakpoints: (
|
|||
// Extra large screens / wide desktops
|
||||
xl: 1200px,
|
||||
xxl: 1600px,
|
||||
|
||||
// SCREEN GROUP
|
||||
fhd: 1800px,
|
||||
qhd: 2560px,
|
||||
|
@ -43,6 +42,11 @@ $input-btn-focus-width: 0;
|
|||
$input-btn-focus-color: inherit;
|
||||
$input-focus-border-color: pvar(--input-border-color);
|
||||
$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-bg: pvar(--bg-secondary-500);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use "_variables" as *;
|
||||
@use "_mixins" as *;
|
||||
|
||||
@mixin define-css-variables() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -10,6 +10,7 @@
|
|||
--menu-fg: var(--menuForegroundColor);
|
||||
--menu-margin-left: #{$menu-margin-left};
|
||||
--menu-width: #{$menu-width};
|
||||
--menu-border-radius: #{$menu-border-radius};
|
||||
|
||||
--fg: var(--mainForegroundColor, #000);
|
||||
|
||||
|
@ -33,6 +34,7 @@
|
|||
|
||||
--input-placeholder: var(--inputPlaceholderColor, #{pvar(--fg-50)});
|
||||
--input-border-color: var(--inputBorderColor, #{pvar(--input-bg)});
|
||||
--input-border-width: 1px;
|
||||
|
||||
--input-check-active-fg: #{pvar(--on-primary)};
|
||||
--input-check-active-bg: #{pvar(--primary)};
|
||||
|
@ -70,6 +72,9 @@
|
|||
--menu-fg: #{pvar(--fg-400)};
|
||||
--menu-bg: #{pvar(--bg-secondary-400)};
|
||||
|
||||
--header-fg: #{pvar(--fg)};
|
||||
--header-bg: #{pvar(--bg)};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
--tmp-header-height: #{$header-height};
|
||||
|
@ -95,8 +100,8 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Light theme
|
||||
&[data-pt-theme=peertube-core-light-beige],
|
||||
&[data-pt-theme=default] {
|
||||
&[data-pt-theme="peertube-core-light-beige"],
|
||||
&[data-pt-theme="default"] {
|
||||
--is-dark: 0;
|
||||
|
||||
--primary: #FF8F37;
|
||||
|
@ -128,7 +133,7 @@
|
|||
}
|
||||
|
||||
// Brown
|
||||
&[data-pt-theme=peertube-core-dark-brown] {
|
||||
&[data-pt-theme="peertube-core-dark-brown"] {
|
||||
--is-dark: 1;
|
||||
|
||||
--primary: #FD9C50;
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
max-width: $width;
|
||||
color: pvar(--input-fg);
|
||||
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);
|
||||
|
||||
@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);
|
||||
position: relative;
|
||||
border: 1px solid var(--input-border-color) !important;
|
||||
border: pvar(--input-border-width) solid var(--input-border-color) !important;
|
||||
appearance: none;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
@use 'sass:math';
|
||||
@use 'sass:color';
|
||||
@use '_variables' as *;
|
||||
@import '_bootstrap-mixins';
|
||||
@use "sass:math";
|
||||
@use "sass:color";
|
||||
@use "_variables" as *;
|
||||
@import "_bootstrap-mixins";
|
||||
|
||||
@mixin underline-primary {
|
||||
text-decoration: underline !important;
|
||||
|
@ -67,7 +67,7 @@
|
|||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
@ -134,7 +134,7 @@
|
|||
@mixin responsive-width($width) {
|
||||
width: $width;
|
||||
|
||||
@media screen and (max-width: #{$width - 30px}) {
|
||||
@media screen and (max-width: #{$width + 30px}) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
@ -145,7 +145,7 @@
|
|||
align-items: center;
|
||||
|
||||
> *:not(:last-child)::after {
|
||||
content: '•';
|
||||
content: "•";
|
||||
margin: 0 $separator-margin;
|
||||
color: pvar(--primary);
|
||||
}
|
||||
|
@ -179,7 +179,7 @@
|
|||
|
||||
my-global-icon {
|
||||
width: 22px;
|
||||
opacity: .7;
|
||||
opacity: 0.7;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
|
||||
|
@ -189,23 +189,23 @@
|
|||
|
||||
@mixin divider($color: pvar(--bg-secondary-400), $background: pvar(--bg)) {
|
||||
width: 95%;
|
||||
border-top: .05rem solid $color;
|
||||
height: .05rem;
|
||||
border-top: 0.05rem solid $color;
|
||||
height: 0.05rem;
|
||||
text-align: center;
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
&[data-content] {
|
||||
margin: .8rem 0;
|
||||
margin: 0.8rem 0;
|
||||
|
||||
&::after {
|
||||
background: $background;
|
||||
color: $color;
|
||||
content: attr(data-content);
|
||||
display: inline-block;
|
||||
font-size: .7rem;
|
||||
padding: 0 .4rem;
|
||||
transform: translateY(-.65rem);
|
||||
font-size: 0.7rem;
|
||||
padding: 0 0.4rem;
|
||||
transform: translateY(-0.65rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -213,7 +213,7 @@
|
|||
// 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
|
||||
// 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);
|
||||
|
||||
position: relative;
|
||||
|
@ -318,7 +318,6 @@
|
|||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* inset-inline properties are not supported by iOS < 14.5
|
||||
|
@ -335,7 +334,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
@mixin left($value) {
|
||||
@supports (inset-inline-start: $value) {
|
||||
inset-inline-start: $value;
|
||||
|
@ -345,4 +343,3 @@
|
|||
left: $value;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
@use 'sass:math';
|
||||
@use 'sass:color';
|
||||
@use 'sass:map';
|
||||
@use "sass:math";
|
||||
@use "sass:color";
|
||||
@use "sass:map";
|
||||
|
||||
$medium-view: 1000px;
|
||||
$small-view: 800px;
|
||||
$mobile-view: 500px;
|
||||
|
||||
$main-fonts: 'Source Sans Pro', sans-serif;
|
||||
$main-fonts: "Source Sans Pro", sans-serif;
|
||||
$font-regular: 400;
|
||||
$font-semibold: 600;
|
||||
$font-bold: 700;
|
||||
|
@ -28,6 +28,7 @@ $header-height-mobile-view-without-search: 80px;
|
|||
$header-mobile-msg-height: 48px;
|
||||
|
||||
$menu-width: 248px;
|
||||
$menu-border-radius: 14px;
|
||||
$menu-collapsed-width: 50px;
|
||||
$menu-margin-left: 2rem;
|
||||
$menu-overlay-view: 1200px;
|
||||
|
@ -74,7 +75,7 @@ $player-portrait-bottom-space: 50px;
|
|||
$sub-menu-margin-bottom: 30px;
|
||||
$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;
|
||||
|
||||
|
@ -88,46 +89,37 @@ $variables: (
|
|||
--x-margin-content: var(--x-margin-content),
|
||||
--tmp-header-height: var(--tmp-header-height),
|
||||
--header-height: var(--header-height),
|
||||
|
||||
--header-fg: var(--header-fg),
|
||||
--header-bg: var(--header-bg),
|
||||
--fg: var(--fg),
|
||||
--bg: var(--bg),
|
||||
|
||||
--red: var(--red),
|
||||
--green: var(--green),
|
||||
|
||||
--input-fg: var(--input-fg),
|
||||
|
||||
--input-bg: var(--input-bg),
|
||||
--input-bg-550: var(--input-bg-550),
|
||||
--input-bg-600: var(--input-bg-600),
|
||||
--input-bg-in-secondary: var(--input-bg-in-secondary),
|
||||
|
||||
--input-danger-fg: var(--input-danger-fg),
|
||||
--input-danger-bg: var(--input-danger-bg),
|
||||
|
||||
--input-placeholder: var(--input-placeholder),
|
||||
--input-border-color: var(--input-border-color),
|
||||
--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-bg: var(--input-check-active-bg),
|
||||
|
||||
--input-x-padding: var(--input-x-padding),
|
||||
--input-y-padding: var(--input-y-padding),
|
||||
|
||||
--textarea-x-padding: var(--textarea-x-padding),
|
||||
--textarea-y-padding: var(--textarea-y-padding),
|
||||
--textarea-fg: var(--textarea-fg),
|
||||
--textarea-bg: var(--textarea-bg),
|
||||
|
||||
--support-btn-bg: var(--support-btn-bg),
|
||||
--support-btn-fg: var(--support-btn-fg),
|
||||
--support-btn-heart-bg: var(--support-btn-heart-bg),
|
||||
|
||||
--secondary-icon-color: var(--secondary-icon-color),
|
||||
--active-icon-color: var(--active-icon-color),
|
||||
--active-icon-bg: var(--active-icon-bg),
|
||||
|
||||
--fg-500: var(--fg-500),
|
||||
--fg-450: var(--fg-450),
|
||||
--fg-400: var(--fg-400),
|
||||
|
@ -138,7 +130,6 @@ $variables: (
|
|||
--fg-150: var(--fg-150),
|
||||
--fg-100: var(--fg-100),
|
||||
--fg-50: var(--fg-50),
|
||||
|
||||
--bg-secondary-600: var(--bg-secondary-600),
|
||||
--bg-secondary-550: var(--bg-secondary-550),
|
||||
--bg-secondary-500: var(--bg-secondary-500),
|
||||
|
@ -148,7 +139,6 @@ $variables: (
|
|||
--bg-secondary-300: var(--bg-secondary-300),
|
||||
--bg-secondary-250: var(--bg-secondary-250),
|
||||
--bg-secondary-200: var(--bg-secondary-200),
|
||||
|
||||
--menu-fg: var(--menu-fg),
|
||||
--menu-fg-600: var(--menu-fg-600),
|
||||
--menu-fg-550: var(--menu-fg-550),
|
||||
|
@ -162,7 +152,6 @@ $variables: (
|
|||
--menu-fg-150: var(--menu-fg-150),
|
||||
--menu-fg-100: var(--menu-fg-100),
|
||||
--menu-fg-50: var(--menu-fg-50),
|
||||
|
||||
--menu-bg: var(--menu-bg),
|
||||
--menu-bg-600: var(--menu-bg-600),
|
||||
--menu-bg-550: var(--menu-bg-550),
|
||||
|
@ -173,10 +162,9 @@ $variables: (
|
|||
--menu-bg-300: var(--menu-bg-300),
|
||||
--menu-bg-250: var(--menu-bg-250),
|
||||
--menu-bg-200: var(--menu-bg-200),
|
||||
|
||||
--menu-margin-left: var(--menu-margin-left),
|
||||
--menu-width: var(--menu-width),
|
||||
|
||||
--menu-border-radius: var(--menu-border-radius),
|
||||
--on-primary: var(--on-primary),
|
||||
--on-primary-700: var(--on-primary-700),
|
||||
--on-primary-650: var(--on-primary-650),
|
||||
|
@ -192,7 +180,6 @@ $variables: (
|
|||
--on-primary-150: var(--on-primary-150),
|
||||
--on-primary-100: var(--on-primary-100),
|
||||
--on-primary-50: var(--on-primary-50),
|
||||
|
||||
--primary: var(--primary),
|
||||
--primary-700: var(--primary-700),
|
||||
--primary-650: var(--primary-650),
|
||||
|
@ -208,16 +195,13 @@ $variables: (
|
|||
--primary-150: var(--primary-150),
|
||||
--primary-100: var(--primary-100),
|
||||
--primary-50: var(--primary-50),
|
||||
|
||||
--border-primary: var(--border-primary),
|
||||
--border-secondary: var(--border-secondary),
|
||||
|
||||
--alert-primary-fg: var(--alert-primary-fg),
|
||||
--alert-primary-bg: var(--alert-primary-bg),
|
||||
--alert-primary-border-color: var(--alert-primary-border-color),
|
||||
|
||||
--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
|
||||
|
@ -225,7 +209,7 @@ $variables: (
|
|||
@if map.has-key($variables, $variable) {
|
||||
@return map.get($variables, $variable);
|
||||
} @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) {
|
||||
@return var($variable, map.get($variables, $fallback));
|
||||
} @else {
|
||||
@error 'ERROR: Variable #{$variable} or #{$fallback} does not exist';
|
||||
@error "ERROR: Variable #{$variable} or #{$fallback} does not exist";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -125,6 +125,14 @@ p-toast {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colorpicker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
p-colorpicker .p-colorpicker-preview {
|
||||
border: 1px solid pvar(--fg-300);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -1040,6 +1040,25 @@ followings:
|
|||
theme:
|
||||
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:
|
||||
enabled: false
|
||||
message: '' # Support markdown
|
||||
|
@ -1074,6 +1093,7 @@ search:
|
|||
|
||||
# PeerTube client/interface configuration
|
||||
client:
|
||||
|
||||
videos:
|
||||
miniature:
|
||||
# By default PeerTube client displays author username
|
||||
|
|
|
@ -56,6 +56,19 @@ export interface CustomConfig {
|
|||
|
||||
theme: {
|
||||
default: string
|
||||
|
||||
customization: {
|
||||
primaryColor: string
|
||||
foregroundColor: string
|
||||
backgroundColor: string
|
||||
backgroundSecondaryColor: string
|
||||
menuForegroundColor: string
|
||||
menuBackgroundColor: string
|
||||
menuBorderRadius: string
|
||||
headerForegroundColor: string
|
||||
headerBackgroundColor: string
|
||||
inputBorderRadius: string
|
||||
}
|
||||
}
|
||||
|
||||
services: {
|
||||
|
|
|
@ -162,6 +162,19 @@ export interface ServerConfig {
|
|||
builtIn: { name: 'peertube-core-light-beige' | 'peertube-core-dark-brown' }[]
|
||||
|
||||
default: string
|
||||
|
||||
customization: {
|
||||
primaryColor: string
|
||||
foregroundColor: string
|
||||
backgroundColor: string
|
||||
backgroundSecondaryColor: string
|
||||
menuForegroundColor: string
|
||||
menuBackgroundColor: string
|
||||
menuBorderRadius: string
|
||||
headerForegroundColor: string
|
||||
headerBackgroundColor: string
|
||||
inputBorderRadius: string
|
||||
}
|
||||
}
|
||||
|
||||
email: {
|
||||
|
|
|
@ -66,7 +66,7 @@ export const ServerErrorCode = {
|
|||
/**
|
||||
* oauthjs/oauth2-server error codes
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
||||
**/
|
||||
*/
|
||||
export const OAuth2ErrorCode = {
|
||||
/**
|
||||
* The provided authorization grant (e.g., authorization code, resource owner
|
||||
|
|
|
@ -272,7 +272,20 @@ function customConfig (): CustomConfig {
|
|||
}
|
||||
},
|
||||
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: {
|
||||
twitter: {
|
||||
|
|
|
@ -992,6 +992,39 @@ const CONFIG = {
|
|||
THEME: {
|
||||
get 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: {
|
||||
|
|
|
@ -156,7 +156,19 @@ class ServerConfigManager {
|
|||
theme: {
|
||||
registered: this.getRegisteredThemes(),
|
||||
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: {
|
||||
enabled: isEmailEnabled()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue