1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 17:59:37 +02:00

Add admin config wizard

This commit is contained in:
Chocobozzz 2025-06-05 15:42:15 +02:00
parent a6b89bde2b
commit eb11e5793f
No known key found for this signature in database
GPG key ID: 583A612D890159BE
96 changed files with 2609 additions and 616 deletions

View file

@ -14,7 +14,7 @@ import {
AdminConfigVODComponent
} from './pages'
import { AdminConfigCustomizationComponent } from './pages/admin-config-customization.component'
import { AdminConfigService } from './shared/admin-config.service'
import { AdminConfigService } from '../../shared/shared-admin/admin-config.service'
export const customConfigResolver: ResolveFn<CustomConfig> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(AdminConfigService).getCustomConfig()

View file

@ -1,6 +1,6 @@
<my-admin-save-bar i18n-title title="Advanced configuration" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<ng-container [formGroup]="form">
<form [formGroup]="form">
<div class="pt-two-cols">
@ -109,4 +109,4 @@
</div>
</div>
</ng-container>
</form>

View file

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { CanComponentDeactivate } from '@app/core'
@ -12,7 +12,8 @@ import {
} 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 { Subscription } from 'rxjs'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
@ -44,7 +45,7 @@ type Form = {
styleUrls: [ './admin-config-common.scss' ],
imports: [ CommonModule, FormsModule, ReactiveFormsModule, AdminSaveBarComponent ]
})
export class AdminConfigAdvancedComponent implements OnInit, CanComponentDeactivate {
export class AdminConfigAdvancedComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
@ -54,11 +55,23 @@ export class AdminConfigAdvancedComponent implements OnInit, CanComponentDeactiv
validationMessages: FormReactiveMessagesTyped<Form> = {}
private customConfig: CustomConfig
private customConfigSub: Subscription
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.buildForm()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
canDeactivate () {

View file

@ -1,6 +1,7 @@
<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">
<form [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col">
<h2 i18n>APPEARANCE</h2>
</div>
@ -29,9 +30,9 @@
</ng-container>
</ng-container>
</div>
</div>
</div>
<div class="pt-two-cols mt-4" [formGroup]="form">
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>CUSTOMIZATION</h2>
@ -108,9 +109,9 @@
</ng-container>
</div>
</div>
</div>
</div>
<div class="pt-two-cols mt-4" [formGroup]="form">
<div class="pt-two-cols mt-4">
<div class="title-col">
<div class="anchor" id="customizations"></div>
<!-- customizations anchor -->
@ -150,16 +151,16 @@
<ng-container i18n>
<p class="mb-2">Write CSS code directly. Example:</p>
<pre>
#custom-css {{ '{' }}
color: red;
{{ '}' }}
</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>
#custom-css .logged-in-email {{ '{' }}
color: red;
{{ '}' }}
</pre>
</ng-container>
</my-help>
@ -175,4 +176,5 @@ color: red;
</ng-container>
</ng-container>
</div>
</div>
</div>
</form>

View file

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { Component, inject, OnDestroy, 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'
@ -9,17 +9,15 @@ import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-che
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 { debounceTime, Subscription } from 'rxjs'
import { SelectOptionsItem } from 'src/types'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
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')
@ -75,7 +73,7 @@ type Form = {
PeertubeCheckboxComponent
]
})
export class AdminConfigCustomizationComponent implements OnInit, CanComponentDeactivate {
export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
private serverService = inject(ServerService)
@ -99,6 +97,8 @@ export class AdminConfigCustomizationComponent implements OnInit, CanComponentDe
private customizationResetFields = new Set<ThemeCustomizationKey>()
private customConfig: CustomConfig
private customConfigSub: Subscription
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' },
@ -127,6 +127,17 @@ export class AdminConfigCustomizationComponent implements OnInit, CanComponentDe
this.buildForm()
this.subscribeToCustomizationChanges()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.getDefaultFormValues(), { emitEvent: false })
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
canDeactivate () {
@ -209,20 +220,11 @@ export class AdminConfigCustomizationComponent implements OnInit, CanComponentDe
}
}
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.formReactiveService.buildForm<Form>(obj, this.getDefaultFormValues())
this.form = form
this.formErrors = formErrors
@ -281,6 +283,17 @@ export class AdminConfigCustomizationComponent implements OnInit, CanComponentDe
return this.form.get('theme.customization').get(field)
}
private getDefaultFormValues (): FormDefaultTyped<Form> {
return {
...this.customConfig,
theme: {
default: this.customConfig.theme.default,
customization: this.getDefaultCustomization()
}
}
}
private getDefaultCustomization () {
const config = this.customConfig.theme.customization
@ -305,39 +318,16 @@ export class AdminConfigCustomizationComponent implements OnInit, CanComponentDe
private formatCustomizationFieldForForm (field: ThemeCustomizationKey, value: string) {
if (this.formFieldsObject[field].type === 'pixels') {
return this.formatPixelsForForm(value)
return this.themeService.formatPixelsForForm(value)
}
if (this.formFieldsObject[field].type === 'color') {
return this.formatColorForForm(value)
return this.themeService.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) {

View file

@ -1,6 +1,6 @@
<my-admin-save-bar i18n-title title="General configuration" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<ng-container [formGroup]="form">
<form [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col">
<h2 i18n>BEHAVIOR</h2>
@ -666,4 +666,4 @@
</div>
</div>
</ng-container>
</form>

View file

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnDestroy, 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'
@ -24,15 +24,16 @@ import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@a
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 { Subscription } from 'rxjs'
import { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
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 = {
@ -192,7 +193,7 @@ type Form = {
AdminSaveBarComponent
]
})
export class AdminConfigGeneralComponent implements OnInit, CanComponentDeactivate {
export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
@ -209,6 +210,7 @@ export class AdminConfigGeneralComponent implements OnInit, CanComponentDeactiva
exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = []
private customConfig: CustomConfig
private customConfigSub: Subscription
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
@ -222,12 +224,23 @@ export class AdminConfigGeneralComponent implements OnInit, CanComponentDeactiva
{ id: 1000 * 3600 * 24 * 30, label: $localize`30 days` }
]
this.exportMaxUserVideoQuotaOptions = this.getVideoQuotaOptions().filter(o => (o.id as number) >= 1)
this.exportMaxUserVideoQuotaOptions = this.getVideoQuotaOptions().filter(o => o.id >= 1)
this.buildForm()
this.subscribeToSignupChanges()
this.subscribeToImportSyncChanges()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
private buildForm () {

View file

@ -1,6 +1,6 @@
<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">
<form class="homepage pt-two-cols" [formGroup]="form">
<div class="title-col">
<h2 i18n>HOMEPAGE</h2>
</div>
@ -25,4 +25,4 @@
<div *ngIf="formErrors.homepageContent" class="form-error" role="alert">{{ formErrors.homepageContent }}</div>
</div>
</div>
</div>
</form>

View file

@ -1,6 +1,6 @@
<my-admin-save-bar i18n-title title="Platform information" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<ng-container [formGroup]="form">
<form [formGroup]="form">
<div class="pt-two-cols mt-4">
<div class="title-col">
@ -341,4 +341,4 @@
</ng-container>
</ng-container>
</form>

View file

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http'
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, OnDestroy, 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'
@ -26,14 +26,15 @@ import { ActorImage, CustomConfig, HTMLServerConfig, NSFWPolicyType, VideoConsta
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 { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
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'
import { Subscription } from 'rxjs'
type Form = {
admin: FormGroup<{
@ -97,7 +98,7 @@ type Form = {
AdminSaveBarComponent
]
})
export class AdminConfigInformationComponent implements OnInit, CanComponentDeactivate {
export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private customMarkup = inject(CustomMarkupService)
private notifier = inject(Notifier)
private instanceService = inject(InstanceService)
@ -137,6 +138,7 @@ export class AdminConfigInformationComponent implements OnInit, CanComponentDeac
private serverConfig: HTMLServerConfig
private customConfig: CustomConfig
private customConfigSub: Subscription
get instanceName () {
return this.server.getHTMLConfig().instance.name
@ -157,6 +159,17 @@ export class AdminConfigInformationComponent implements OnInit, CanComponentDeac
this.updateActorImages()
this.buildForm()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
private buildForm () {

View file

@ -1,6 +1,6 @@
<my-admin-save-bar i18n-title title="Live configuration" (save)="save()" [form]="form" [formErrors]="formErrors" [inconsistentOptions]="checkTranscodingConsistentOptions()"></my-admin-save-bar>
<ng-container [formGroup]="form">
<form [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col">
@ -212,4 +212,4 @@
</div>
</div>
</ng-container>
</form>

View file

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, OnDestroy, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { CanComponentDeactivate, ServerService } from '@app/core'
@ -23,8 +23,9 @@ import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube
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 { AdminConfigService, FormResolutions, ResolutionOption } from '../../../shared/shared-admin/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
import { Subscription } from 'rxjs'
type Form = {
live: FormGroup<{
@ -73,7 +74,7 @@ type Form = {
AdminSaveBarComponent
]
})
export class AdminConfigLiveComponent implements OnInit, CanComponentDeactivate {
export class AdminConfigLiveComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private configService = inject(AdminConfigService)
private server = inject(ServerService)
private route = inject(ActivatedRoute)
@ -91,6 +92,7 @@ export class AdminConfigLiveComponent implements OnInit, CanComponentDeactivate
liveResolutions: ResolutionOption[] = []
private customConfig: CustomConfig
private customConfigSub: Subscription
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
@ -111,6 +113,17 @@ export class AdminConfigLiveComponent implements OnInit, CanComponentDeactivate
)
this.buildForm()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
private buildForm () {

View file

@ -1,6 +1,6 @@
<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">
<form [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col">
@ -281,4 +281,4 @@
</ng-container>
</div>
</div>
</ng-container>
</form>

View file

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { Component, OnInit, OnDestroy, 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'
@ -16,12 +16,13 @@ import {
} 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 { Subscription } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { AdminConfigService, FormResolutions, ResolutionOption } from '../../../shared/shared-admin/admin-config.service'
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 = {
@ -84,7 +85,7 @@ type Form = {
AdminSaveBarComponent
]
})
export class AdminConfigVODComponent implements OnInit, CanComponentDeactivate {
export class AdminConfigVODComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private configService = inject(AdminConfigService)
private notifier = inject(Notifier)
private server = inject(ServerService)
@ -103,6 +104,7 @@ export class AdminConfigVODComponent implements OnInit, CanComponentDeactivate {
additionalVideoExtensions = ''
private customConfig: CustomConfig
private customConfigSub: Subscription
ngOnInit () {
const serverConfig = this.server.getHTMLConfig()
@ -117,6 +119,17 @@ export class AdminConfigVODComponent implements OnInit, CanComponentDeactivate {
this.buildForm()
this.subscribeToTranscodingChanges()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
private buildForm () {

View file

@ -2,10 +2,11 @@
<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 class="buttons">
<my-button theme="secondary" class="pre-config" (click)="openConfigWizard()" i18n>Open config wizard</my-button>
<my-button theme="primary" class="save-button" icon="circle-tick" [disabled]="!canUpdate()" (click)="onSave($event)" i18n>Save</my-button>
</div>
</div>
@if (!isUpdateAllowed()) {

View file

@ -25,10 +25,16 @@
@include rfs(1.5rem, padding);
}
.save-button {
.buttons {
@include margin-left(auto);
}
.pre-config {
display: inline-block;
@include margin-right(0.5rem);
}
h2 {
flex-shrink: 1;
color: pvar(--fg-350);
@ -48,7 +54,7 @@ h2 {
padding: 0.5rem;
}
.save-button,
.buttons,
h2 {
@include margin-left(0);
}

View file

@ -5,6 +5,7 @@ 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 { PeertubeModalService } from '@app/shared/shared-main/peertube-modal/peertube-modal.service'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { AlertComponent } from '../../../shared/shared-main/common/alert.component'
@ -24,6 +25,7 @@ export class AdminSaveBarComponent implements OnInit, OnDestroy {
private server = inject(ServerService)
private headerService = inject(HeaderService)
private screenService = inject(ScreenService)
private peertubeModal = inject(PeertubeModalService)
readonly title = input.required<string>()
readonly form = input.required<FormGroup>()
@ -59,6 +61,10 @@ export class AdminSaveBarComponent implements OnInit, OnDestroy {
return this.formReactiveService.grabAllErrors(this.formErrors())
}
openConfigWizard () {
this.peertubeModal.openAdminConfigWizardSubject.next({ showWelcome: false })
}
onSave (event: Event) {
this.displayFormErrors = false

View file

@ -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 { AdminConfigService } from '@app/+admin/config/shared/admin-config.service'
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
import { AuthService, Notifier, ScreenService, ServerService } from '@app/core'
import {
USER_CHANNEL_NAME_VALIDATOR,

View file

@ -1,5 +1,5 @@
import { Directive, OnInit } from '@angular/core'
import { AdminConfigService } from '@app/+admin/config/shared/admin-config.service'
import { AdminConfigService } from '@app/shared/shared-admin/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'

View file

@ -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 { AdminConfigService } from '@app/+admin/config/shared/admin-config.service'
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
import { AuthService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core'
import {
USER_EMAIL_VALIDATOR,

View file

@ -1,7 +1,7 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
import { PluginApiService } from '@app/shared/shared-admin/plugin-api.service'
import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, resetCurrentPage, updatePaginationOnDelete } from '@app/core'
import { PluginService } from '@app/core/plugins/plugin.service'
import { compareSemVer } from '@peertube/peertube-core-utils'

View file

@ -1,7 +1,7 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
import { PluginApiService } from '@app/shared/shared-admin/plugin-api.service'
import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, PluginService, resetCurrentPage } from '@app/core'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { PeerTubePluginIndex, PluginType, PluginType_Type } from '@peertube/peertube-models'

View file

@ -6,7 +6,7 @@ import { HooksService, Notifier, PluginService } from '@app/core'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { PeerTubePlugin, RegisterServerSettingOptions } from '@peertube/peertube-models'
import { PluginApiService } from '../shared/plugin-api.service'
import { PluginApiService } from '../../../shared/shared-admin/plugin-api.service'
import { DynamicFormFieldComponent } from '../../../shared/shared-forms/dynamic-form-field.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIf, NgFor } from '@angular/common'

View file

@ -1,6 +1,6 @@
import { Component, inject, input } from '@angular/core'
import { PeerTubePlugin, PeerTubePluginIndex, PluginType_Type } from '@peertube/peertube-models'
import { PluginApiService } from './plugin-api.service'
import { PluginApiService } from '../../../shared/shared-admin/plugin-api.service'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
@Component({

View file

@ -21,11 +21,11 @@ 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 { AdminConfigService } from './config/shared/admin-config.service'
import { AdminConfigService } from '../shared/shared-admin/admin-config.service'
import { followsRoutes } from './follows'
import { AdminRegistrationService } from './moderation/registration-list'
import { overviewRoutes, VideoAdminService } from './overview'
import { PluginApiService } from './plugins/shared/plugin-api.service'
import { PluginApiService } from '../shared/shared-admin/plugin-api.service'
const commonConfig = {
path: '',

View file

@ -1,6 +1,6 @@
import { SelectOptionsItem } from '../../../types/select-options-item.model'
export function getVideoQuotaOptions (): SelectOptionsItem[] {
export function getVideoQuotaOptions (): SelectOptionsItem<number>[] {
return [
{ id: -1, label: $localize`Unlimited` },
{ id: 0, label: $localize`None - no upload possible` },
@ -16,7 +16,7 @@ export function getVideoQuotaOptions (): SelectOptionsItem[] {
]
}
export function getVideoQuotaDailyOptions (): SelectOptionsItem[] {
export function getVideoQuotaDailyOptions (): SelectOptionsItem<number>[] {
return [
{ id: -1, label: $localize`Unlimited` },
{ id: 0, label: $localize`None - no upload possible` },

View file

@ -4,14 +4,13 @@ import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.compo
import { NgIf, NgFor, NgClass, NgTemplateOutlet } from '@angular/common'
@Component({
selector: 'my-custom-stepper',
templateUrl: './custom-stepper.component.html',
styleUrls: [ './custom-stepper.component.scss' ],
providers: [ { provide: CdkStepper, useExisting: CustomStepperComponent } ],
selector: 'my-register-stepper',
templateUrl: './register-stepper.component.html',
styleUrls: [ './register-stepper.component.scss' ],
providers: [ { provide: CdkStepper, useExisting: RegisterStepperComponent } ],
imports: [ NgIf, NgFor, NgClass, GlobalIconComponent, NgTemplateOutlet ]
})
export class CustomStepperComponent extends CdkStepper {
export class RegisterStepperComponent extends CdkStepper {
onClick (index: number): void {
this.selectedIndex = index
}

View file

@ -6,7 +6,7 @@
<ng-container *ngIf="!signupDisabled">
<div class="register-content">
<my-custom-stepper linear>
<my-register-stepper linear>
<cdk-step i18n-label label="About" [editable]="!signupSuccess">
<my-signup-step-title mascotImageName="about">
@ -119,7 +119,7 @@
<button class="peertube-button-big secondary-button" cdkStepperPrevious>{{ defaultPreviousStepButtonLabel }}</button>
</div>
</cdk-step>
</my-custom-stepper>
</my-register-stepper>
</div>
</ng-container>

View file

@ -13,7 +13,7 @@ import { SignupLabelComponent } from '../../shared/shared-main/users/signup-labe
import { SignupStepTitleComponent } from '../shared/signup-step-title.component'
import { SignupSuccessBeforeEmailComponent } from '../shared/signup-success-before-email.component'
import { SignupService } from '../shared/signup.service'
import { CustomStepperComponent } from './custom-stepper.component'
import { RegisterStepperComponent } from './register-stepper.component'
import { RegisterStepAboutComponent } from './steps/register-step-about.component'
import { RegisterStepChannelComponent } from './steps/register-step-channel.component'
import { RegisterStepTermsComponent } from './steps/register-step-terms.component'
@ -26,7 +26,7 @@ import { RegisterStepUserComponent } from './steps/register-step-user.component'
imports: [
NgIf,
SignupLabelComponent,
CustomStepperComponent,
RegisterStepperComponent,
CdkStep,
SignupStepTitleComponent,
RegisterStepAboutComponent,

View file

@ -1,6 +1,7 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_form-mixins' as *;
@use "_variables" as *;
@use "_css-variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
$width-size: 275px;
@ -13,12 +14,12 @@ $width-size: 275px;
}
.first-step-block {
--input-bg: #{pvar(--bg-secondary-500)};
display: flex;
flex-direction: column;
align-items: center;
@include define-input-css-variables-in-modal;
.upload-icon {
width: 90px;
margin-bottom: 25px;
@ -45,7 +46,7 @@ $width-size: 275px;
white-space: nowrap;
}
input[type=text] {
input[type="text"] {
display: block;
@include peertube-input-text($width-size);

View file

@ -1,5 +1,5 @@
@use '_variables' as *;
@use '_mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
.caption-raw-textarea,
.segments {
@ -18,7 +18,7 @@
&.active,
&:hover {
background: pvar(--bg-secondary-300);
background: pvar(--bg-secondary-400);
}
}

View file

@ -55,8 +55,10 @@
@defer (when isUserLoggedIn()) {
<my-account-setup-warning-modal #accountSetupWarningModal (created)="onModalCreated()"></my-account-setup-warning-modal>
}
<my-admin-welcome-modal #adminWelcomeModal (created)="onModalCreated()"></my-admin-welcome-modal>
@defer (when isUserAdmin()) {
<my-admin-config-wizard-modal #adminConfigWizardModal (created)="onModalCreated()"></my-admin-config-wizard-modal>
<my-instance-config-warning-modal #instanceConfigWarningModal (created)="onModalCreated()"></my-instance-config-warning-modal>
}

View file

@ -1,9 +1,7 @@
import { forkJoin } from 'rxjs'
import { filter, first, map } from 'rxjs/operators'
import { DOCUMENT, getLocaleDirection, NgClass, NgIf, PlatformLocation } from '@angular/common'
import { AfterViewInit, Component, LOCALE_ID, OnDestroy, OnInit, inject, viewChild } from '@angular/core'
import { AfterViewInit, Component, inject, LOCALE_ID, OnDestroy, OnInit, viewChild } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouterOutlet } from '@angular/router'
import { ActivatedRoute, Event, GuardsCheckStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouterOutlet } from '@angular/router'
import {
AuthService,
Hotkey,
@ -19,7 +17,7 @@ import {
import { HooksService } from '@app/core/plugins/hooks.service'
import { PluginService } from '@app/core/plugins/plugin.service'
import { AccountSetupWarningModalComponent } from '@app/modal/account-setup-warning-modal.component'
import { AdminWelcomeModalComponent } from '@app/modal/admin-welcome-modal.component'
import { AdminConfigWizardModalComponent } from '@app/modal/admin-config-wizard/admin-config-wizard-modal.component'
import { CustomModalComponent } from '@app/modal/custom-modal.component'
import { InstanceConfigWarningModalComponent } from '@app/modal/instance-config-warning-modal.component'
import { NgbConfig, NgbModal } from '@ng-bootstrap/ng-bootstrap'
@ -30,6 +28,8 @@ import { logger } from '@root-helpers/logger'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { SharedModule } from 'primeng/api'
import { ToastModule } from 'primeng/toast'
import { forkJoin } from 'rxjs'
import { filter, first, map } from 'rxjs/operators'
import { MenuService } from './core/menu/menu.service'
import { HeaderComponent } from './header/header.component'
import { POP_STATE_MODAL_DISMISS } from './helpers'
@ -37,8 +37,8 @@ import { HotkeysCheatSheetComponent } from './hotkeys/hotkeys-cheat-sheet.compon
import { MenuComponent } from './menu/menu.component'
import { ConfirmComponent } from './modal/confirm.component'
import { GlobalIconComponent, GlobalIconName } from './shared/shared-icons/global-icon.component'
import { InstanceService } from './shared/shared-main/instance/instance.service'
import { PeertubeModalService } from './shared/shared-main/peertube-modal/peertube-modal.service'
@Component({
selector: 'my-app',
@ -57,9 +57,9 @@ import { InstanceService } from './shared/shared-main/instance/instance.service'
ToastModule,
SharedModule,
AccountSetupWarningModalComponent,
AdminWelcomeModalComponent,
InstanceConfigWarningModalComponent,
CustomModalComponent
CustomModalComponent,
AdminConfigWizardModalComponent
]
})
export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
@ -82,12 +82,15 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
private loadingBar = inject(LoadingBarService)
private scrollService = inject(ScrollService)
private userLocalStorage = inject(UserLocalStorageService)
private peertubeModal = inject(PeertubeModalService)
private route = inject(ActivatedRoute)
menu = inject(MenuService)
private static LS_BROADCAST_MESSAGE = 'app-broadcast-message-dismissed'
readonly accountSetupWarningModal = viewChild<AccountSetupWarningModalComponent>('accountSetupWarningModal')
readonly adminWelcomeModal = viewChild<AdminWelcomeModalComponent>('adminWelcomeModal')
readonly adminConfigWizardModal = viewChild<AdminConfigWizardModalComponent>('adminConfigWizardModal')
readonly instanceConfigWarningModal = viewChild<InstanceConfigWarningModalComponent>('instanceConfigWarningModal')
readonly customModal = viewChild<CustomModalComponent>('customModal')
@ -154,6 +157,13 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
return Promise.resolve()
})
this.peertubeModal.openAdminConfigWizardSubject.subscribe(({ showWelcome }) => {
const adminWelcomeModal = this.adminConfigWizardModal()
if (!adminWelcomeModal) return
adminWelcomeModal.show({ showWelcome })
})
}
ngAfterViewInit () {
@ -171,6 +181,10 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
return this.authService.isLoggedIn()
}
isUserAdmin () {
return this.isUserLoggedIn() && this.authService.getUser().role.id === UserRole.ADMINISTRATOR
}
hideBroadcastMessage () {
peertubeLocalStorage.setItem(AppComponent.LS_BROADCAST_MESSAGE, this.serverConfig.broadcastMessage.message)
@ -301,23 +315,23 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
}
private openAdminModalsIfNeeded (user: User) {
const adminWelcomeModal = this.adminWelcomeModal()
const adminWelcomeModal = this.adminConfigWizardModal()
if (!adminWelcomeModal) return
if (adminWelcomeModal.shouldOpen(user)) {
return adminWelcomeModal.show()
if (adminWelcomeModal.shouldAutoOpen(user)) {
return adminWelcomeModal.show({ showWelcome: true })
}
const instanceConfigWarningModal = this.instanceConfigWarningModal()
if (!instanceConfigWarningModal) return
if (!instanceConfigWarningModal.shouldOpenByUser(user)) return
if (!instanceConfigWarningModal.canBeOpenByUser(user)) return
forkJoin([
this.serverService.getConfig().pipe(first()),
this.instanceService.getAbout().pipe(first())
]).subscribe(([ config, about ]) => {
const instanceConfigWarningModalValue = this.instanceConfigWarningModal()
if (instanceConfigWarningModalValue.shouldOpen(config, about)) {
if (instanceConfigWarningModalValue.shouldAutoOpen(config, about)) {
instanceConfigWarningModalValue.show(about)
}
})
@ -327,7 +341,7 @@ export class AppComponent implements OnInit, AfterViewInit, OnDestroy {
const accountSetupWarningModal = this.accountSetupWarningModal()
if (!accountSetupWarningModal) return
if (accountSetupWarningModal.shouldOpen(user)) {
if (accountSetupWarningModal.shouldAutoOpen(user)) {
accountSetupWarningModal.show(user)
}
}

View file

@ -81,7 +81,11 @@ export class HtmlRendererService {
})
}
async toSimpleSafeHtml (text: string, options: {
toSimpleSafeHtml (text: string) {
return this.sanitize(this.simpleDomPurify, this.removeClassAttributes(text))
}
async toSimpleSafeHtmlWithLinks (text: string, options: {
allowImages?: boolean
} = {}) {
const { allowImages = false } = options
@ -89,6 +93,7 @@ export class HtmlRendererService {
const additionalTags = allowImages
? [ 'img' ]
: []
const additionalAttributes = allowImages
? [ 'src', 'alt' ]
: []

View file

@ -149,10 +149,10 @@ export class MarkdownService {
}
if (name === 'enhancedMarkdownIt' || name === 'enhancedWithHTMLMarkdownIt') {
return this.htmlRenderer.toSimpleSafeHtml(html, { allowImages: true })
return this.htmlRenderer.toSimpleSafeHtmlWithLinks(html, { allowImages: true })
}
return this.htmlRenderer.toSimpleSafeHtml(html)
return this.htmlRenderer.toSimpleSafeHtmlWithLinks(html)
}
return html

View file

@ -66,6 +66,7 @@ export class ServerService {
resetConfig () {
this.configLoaded = false
this.configObservable = undefined
// Notify config update
return this.getConfig({ isReset: true })

View file

@ -195,13 +195,13 @@ export default {
},
list: {
option: {
focusBackground: 'var(--bg-secondary-500)',
selectedBackground: '{highlight.background}',
focusBackground: 'var(--bg-secondary-450)',
selectedBackground: 'var(--bg-secondary-500)',
selectedFocusBackground: 'var(--bg-secondary-500)',
color: '{text.color}',
focusColor: '{text.hover.color}',
selectedColor: '{highlight.color}',
selectedFocusColor: '{highlight.focus.color}',
focusColor: '{text.color}',
selectedColor: '{text.color}',
selectedFocusColor: '{text.color}',
icon: {
color: '{surface.400}',
focusColor: '{surface.500}'

View file

@ -10,6 +10,7 @@ import { PluginService } from '../plugins/plugin.service'
import { ServerService } from '../server'
import { UserService } from '../users/user.service'
import { LocalStorageService } from '../wrappers/storage.service'
import { formatHEX, parse } from 'color-bits'
@Injectable()
export class ThemeService {
@ -216,4 +217,31 @@ export class ThemeService {
private getTheme (name: string) {
return this.themes.find(t => t.name === name)
}
// ---------------------------------------------------------------------------
// Utils
// ---------------------------------------------------------------------------
formatColorForForm (value: string) {
if (!value) return null
try {
return formatHEX(parse(value))
} catch (err) {
logger.warn(`Error parsing color value "${value}"`, err)
return null
}
}
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 + ''
}
}

View file

@ -50,7 +50,7 @@ export class AccountSetupWarningModalComponent implements OnInit {
return !!user.account.description
}
shouldOpen (user: User) {
shouldAutoOpen (user: User) {
if (this.modalService.hasOpenModals()) return false
if (user.noAccountSetupWarningModal === true) return false
if (peertubeLocalStorage.getItem(this.LS_KEYS.NO_ACCOUNT_SETUP_WARNING_MODAL) === 'true') return false

View file

@ -0,0 +1,40 @@
<ng-template #modal let-hide="close">
<div class="modal-body">
<my-admin-config-wizard-stepper #stepper>
@if (showWelcome) {
<cdk-step i18n-label label="Welcome introduction">
<my-admin-config-wizard-welcome (back)="stepper.previous()" (next)="stepper.next()" (hide)="hide()"></my-admin-config-wizard-welcome>
</cdk-step>
}
<cdk-step i18n-label label="Edit general information">
<my-admin-config-wizard-edit-info
[currentStep]="currentStep()" totalSteps="3"
(back)="stepper.previous()" (next)="instanceInfo = $event; stepper.next()" (hide)="hide()"
[showBack]="showWelcome"
></my-admin-config-wizard-edit-info>
</cdk-step>
<cdk-step i18n-label label="Usage type">
<my-admin-config-wizard-form
[currentStep]="currentStep()" totalSteps="3"
(back)="stepper.previous()" (next)="usageType = $event; stepper.next()" (hide)="hide()"
></my-admin-config-wizard-form>
</cdk-step>
<cdk-step i18n-label label="Configuration preview">
<my-admin-config-wizard-preview
[usageType]="usageType" [instanceInfo]="instanceInfo"
[currentStep]="currentStep()" totalSteps="3"
(back)="stepper.previous()" (next)="showWelcome ? stepper.next() : hide()" (hide)="hide()"
></my-admin-config-wizard-preview>
</cdk-step>
@if (showWelcome) {
<cdk-step i18n-label label="Post configuration documentation">
<my-admin-config-wizard-documentation (hide)="hide()"></my-admin-config-wizard-documentation>
</cdk-step>
}
</my-admin-config-wizard-stepper>
</div>
</ng-template>

View file

@ -0,0 +1,6 @@
@use "_variables" as *;
@use "_mixins" as *;
.modal-body {
padding: 2rem 3rem;
}

View file

@ -0,0 +1,90 @@
import { CdkStepperModule } from '@angular/cdk/stepper'
import { CommonModule } from '@angular/common'
import { Component, ElementRef, OnInit, inject, output, viewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { User } from '@app/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { AdminConfigWizardStepperComponent } from './admin-config-wizard-stepper.component'
import { getNoWelcomeModalLocalStorageKey } from './shared/admin-config-wizard-modal-utils'
import { AdminConfigWizardDocumentationComponent } from './steps/admin-config-wizard-documentation.component'
import { AdminConfigWizardEditInfoComponent, FormInfo } from './steps/admin-config-wizard-edit-info.component'
import { AdminConfigWizardFormComponent } from './steps/admin-config-wizard-form.component'
import { AdminConfigWizardPreviewComponent } from './steps/admin-config-wizard-preview.component'
import { AdminConfigWizardWelcomeComponent } from './steps/admin-config-wizard-welcome.component'
import { UsageType } from './steps/usage-type/usage-type.model'
@Component({
selector: 'my-admin-config-wizard-modal',
templateUrl: './admin-config-wizard-modal.component.html',
styleUrls: [ './admin-config-wizard-modal.component.scss' ],
imports: [
CommonModule,
CdkStepperModule,
AdminConfigWizardStepperComponent,
AdminConfigWizardWelcomeComponent,
AdminConfigWizardEditInfoComponent,
AdminConfigWizardFormComponent,
AdminConfigWizardPreviewComponent,
AdminConfigWizardDocumentationComponent
]
})
export class AdminConfigWizardModalComponent implements OnInit {
private modalService = inject(NgbModal)
private route = inject(ActivatedRoute)
readonly modal = viewChild<ElementRef>('modal')
readonly stepper = viewChild<AdminConfigWizardStepperComponent>('stepper')
readonly created = output()
usageType: UsageType
showWelcome: boolean
instanceInfo: FormInfo
ngOnInit () {
this.created.emit()
}
shouldAutoOpen (user: User) {
if (this.modalService.hasOpenModals()) return false
if (this.route.snapshot.fragment === 'admin-welcome-wizard') return true
if (user.noWelcomeModal === true) return false
if (peertubeLocalStorage.getItem(getNoWelcomeModalLocalStorageKey()) === 'true') return false
return true
}
show ({ showWelcome }: { showWelcome: boolean }) {
this.showWelcome = showWelcome
this.modalService.open(this.modal(), {
centered: true,
backdrop: 'static',
keyboard: false,
size: 'lg'
})
}
currentStep () {
if (!this.stepper()) return 0
const currentStep = this.stepper().selectedIndex
// The welcome step is not counted in the total steps
if (this.showWelcome) return currentStep
return currentStep + 1
}
totalSteps () {
if (!this.stepper()) return 0
const totalSteps = this.stepper().steps.length
// The welcome step is not counted in the total steps
if (this.showWelcome) return totalSteps - 1
return totalSteps
}
}

View file

@ -0,0 +1,3 @@
<div>
<div [ngTemplateOutlet]="selected ? selected.content : null"></div>
</div>

View file

@ -0,0 +1,12 @@
import { CdkStepper } from '@angular/cdk/stepper'
import { CommonModule, NgTemplateOutlet } from '@angular/common'
import { Component } from '@angular/core'
@Component({
selector: 'my-admin-config-wizard-stepper',
templateUrl: './admin-config-wizard-stepper.component.html',
providers: [ { provide: CdkStepper, useExisting: AdminConfigWizardStepperComponent } ],
imports: [ CommonModule, NgTemplateOutlet ]
})
export class AdminConfigWizardStepperComponent extends CdkStepper {
}

View file

@ -0,0 +1,83 @@
@use "_variables" as *;
@use "_mixins" as *;
.steps {
color: pvar(--fg-200);
font-size: 14px;
font-weight: $font-bold;
}
.title {
color: pvar(--fg-350);
font-weight: $font-bold;
@include font-size(38px);
}
.sub-title {
color: pvar(--fg-300);
font-weight: normal;
@include font-size(20px);
}
h4 {
margin-bottom: 0;
}
.text-content {
color: pvar(--fg-200);
}
.buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
@include rfs(2.5rem, margin-top);
@include rfs(1.5rem, gap);
}
form {
max-width: 512px;
width: 100%;
}
.two-columns {
display: flex;
gap: 1rem;
&.width-50 {
> div {
width: 50%;
}
}
}
.one-column {
text-align: center;
.mascot-container {
position: relative;
height: 110px;
margin: 0 auto;
}
.mascot-container,
.mascot {
width: 170px;
}
.mascot {
position: absolute;
top: -100px;
left: 0;
height: 190px;
}
}
h5 {
font-size: 1rem;
font-weight: $font-bold;
color: var(--fg-300);
}

View file

@ -0,0 +1,3 @@
export function getNoWelcomeModalLocalStorageKey () {
return 'no_welcome_modal'
}

View file

@ -0,0 +1,70 @@
<div class="root">
<div class="one-column">
<div class="mascot-container">
<img class="mascot" src="/client/assets/images/mascot/happy.svg" alt="mascot" />
</div>
<h4>
<div i18n class="title">Congratulations</div>
<div i18n class="sub-title">Your platform has been configured!</div>
</h4>
</div>
<div class="mt-4">
<span class="text-content" i18n>
It's time to add information about your platform!
<strong>Setting up a description</strong>, specifying <strong>who you are</strong>, why <strong>you created your platform</strong> and
<strong>how long</strong> you plan to <strong>maintain it</strong>
is very important for visitors to understand on what type of website they are.
</span>
</div>
<div class="mt-4">
<h5 i18n>Useful links</h5>
<ul class="text-content">
<li>
<a class="link-primary me-1" href="https://joinpeertube.org" target="_blank" i18n>Official PeerTube website</a>
<span i18n>Blog post, get help or discover PeerTube</span>
</li>
<li>
<a class="link-primary me-1" href="https://instances.joinpeertube.org/instances" target="_blank" i18n>Public PeerTube index</a>
<span i18n>Put your platform on the official PeerTube public index</span>
</li>
</ul>
</div>
<div class="mt-4">
<h5 i18n>Documentation</h5>
<ul class="text-content">
<li>
<a class="link-primary me-1" href="https://docs.joinpeertube.org/admin/following-instances" target="_blank" i18n>Admin</a>
<span i18n>Managing users, following other platforms, dealing with spammers, configure object storage or remote transcoding...</span>
</li>
<li>
<a class="link-primary me-1" href="https://docs.joinpeertube.org/use/setup-account" target="_blank">User</a>
<span i18n>Setup your account, managing video playlists, discover third-party applications...</span>
</li>
<li>
<a class="link-primary me-1" href="https://docs.joinpeertube.org/maintain/tools" target="_blank" i18n>CLI</a>
<span i18n>Upload or import videos, parse logs, prune storage directories, reset user password...</span>
</li>
</ul>
</div>
<div class="buttons">
<my-button i18n (click)="hide.emit()" theme="secondary">Close</my-button>
<my-button i18n theme="primary" ptRouterLink="/admin/settings/config" (click)="hide.emit()">Fill platform information</my-button>
</div>
</div>

View file

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common'
import { Component, output } from '@angular/core'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
@Component({
selector: 'my-admin-config-wizard-documentation',
templateUrl: './admin-config-wizard-documentation.component.html',
styleUrls: [ '../shared/admin-config-wizard-modal-common.scss' ],
imports: [ CommonModule, ButtonComponent ]
})
export class AdminConfigWizardDocumentationComponent {
readonly hide = output()
}

View file

@ -0,0 +1,53 @@
<div class="root">
<div class="two-columns">
<div>
<img class="mascot" src="/client/assets/images/mascot/pointing.svg" alt="mascot">
</div>
<div>
<div class="steps">
<div class="header-steps" i18n>STEP {{ currentStep() }}/{{ totalSteps() }}</div>
</div>
<h4 i18n class="title">General information</h4>
<div class="text-content">You can edit this information later</div>
<form [formGroup]="form">
<div class="form-group">
<label i18n for="platformName">Platform name</label>
<input type="text" id="platformName" class="form-control" formControlName="platformName" [ngClass]="{ 'input-error': formErrors.platformName }">
<div *ngIf="formErrors.platformName" class="form-error" role="alert">{{ formErrors.platformName }}</div>
</div>
<div class="form-group">
<label i18n for="shortDescription">Short description</label>
<textarea
id="shortDescription" formControlName="shortDescription" class="form-control small"
[ngClass]="{ 'input-error': formErrors.shortDescription }"
></textarea>
<div *ngIf="formErrors.shortDescription" class="form-error" role="alert">{{ formErrors.shortDescription }}</div>
</div>
<div class="form-group">
<label i18n for="primaryColor">Primary color</label>
<p-colorpicker class="d-block" inputId="primaryColor" formControlName="primaryColor" />
</div>
<div class="buttons">
@if (showBack()) {
<my-button i18n icon="arrow-left" (click)="back.emit()" theme="secondary">Back</my-button>
}
<my-button i18n theme="primary" (click)="next.emit(form.value)" [disabled]="!form.valid">Next step</my-button>
<button i18n (click)="hide.emit()" class="button-as-link">Ignore for now</button>
</div>
</form>
</div>
</div>
</div>

View file

@ -0,0 +1,26 @@
@use "_variables" as *;
@use "_mixins" as *;
form {
@include rfs(2rem, margin-top);
}
.mascot {
margin-top: -90px;
width: 170px;
height: 190px;
}
@media screen and (max-width: $small-view) {
.mascot {
display: none;
}
.two-columns {
justify-content: center;
}
}
textarea {
min-height: 150px;
}

View file

@ -0,0 +1,84 @@
import { CdkStepperModule } from '@angular/cdk/stepper'
import { CommonModule } from '@angular/common'
import { booleanAttribute, Component, inject, input, numberAttribute, OnInit, output } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ServerService, ThemeService } from '@app/core'
import { 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 { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { ColorPickerModule } from 'primeng/colorpicker'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
type Form = {
platformName: FormControl<string>
shortDescription: FormControl<string>
primaryColor: FormControl<string>
}
export type FormInfo = FormDefaultTyped<Form>
@Component({
selector: 'my-admin-config-wizard-edit-info',
templateUrl: './admin-config-wizard-edit-info.component.html',
styleUrls: [ './admin-config-wizard-edit-info.component.scss', '../shared/admin-config-wizard-modal-common.scss' ],
imports: [ CommonModule, FormsModule, ReactiveFormsModule, ColorPickerModule, CdkStepperModule, ButtonComponent ]
})
export class AdminConfigWizardEditInfoComponent implements OnInit {
private server = inject(ServerService)
private formReactiveService = inject(FormReactiveService)
private themeService = inject(ThemeService)
readonly currentStep = input.required({ transform: numberAttribute })
readonly totalSteps = input.required({ transform: numberAttribute })
readonly showBack = input.required({ transform: booleanAttribute })
readonly back = output()
readonly next = output<FormInfo>()
readonly hide = output()
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
ngOnInit () {
this.buildForm()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
platformName: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
primaryColor: null
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, this.getDefaultValues())
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
private getDefaultValues (): FormDefaultTyped<Form> {
const config = this.server.getHTMLConfig()
const primaryColorConfig = config.theme.customization.primaryColor
const primaryColor = primaryColorConfig
? this.themeService.formatColorForForm(primaryColorConfig)
: this.themeService.formatColorForForm(this.themeService.getCSSConfigValue('primaryColor'))
return {
platformName: config.instance.name,
shortDescription: config.instance.shortDescription,
primaryColor
}
}
}

View file

@ -0,0 +1,90 @@
<div class="root">
<div class="two-columns mb-4">
<div>
<img class="mascot" src="/client/assets/images/mascot/default.svg" alt="mascot">
</div>
<div>
<div class="steps">
<div class="header-steps" i18n>STEP {{ currentStep() }}/{{ totalSteps() }}</div>
</div>
<h4 i18n class="title">Usage type</h4>
<div class="text-content">You can also edit your platform configuration at a later time</div>
</div>
</div>
<div class="label" i18n>My platform is more like...</div>
<div class="two-columns width-50">
<div class="platform-types" [ngClass]="{ 'platform-type-selected': platformType }">
<ul class="ul-unstyle">
<li class="platform-type">
<input type="radio" name="platformType" id="platformTypeCommunity" value="community" [(ngModel)]="platformType">
<label for="platformTypeCommunity">
<div>
<my-global-icon iconName="users"></my-global-icon>
</div>
<div>
<div class="type-label" i18n>Community-based</div>
<div class="type-description" i18n>Enable a community to publish content and interact together.</div>
</div>
</label>
</li>
<li class="platform-type">
<input type="radio" name="platformType" id="platformTypeInstitution" value="institution" [(ngModel)]="platformType">
<label for="platformTypeInstitution">
<div>
<my-custom-icon [html]="iconInstitution"></my-custom-icon>
</div>
<div>
<div class="type-label" i18n>Institutional</div>
<div class="type-description" i18n>To broadcast your videos. Recommended for public institutions, association and companies.</div>
</div>
</label>
</li>
<li class="platform-type">
<input type="radio" name="platformType" id="platformTypePrivate" value="private" [(ngModel)]="platformType">
<label for="platformTypePrivate">
<div>
<my-custom-icon [html]="iconKey"></my-custom-icon>
</div>
<div>
<div class="type-label" i18n>Private</div>
<div class="type-description" i18n>Only certain people can access content. Recommended for families and closed communities.</div>
</div>
</label>
</li>
</ul>
</div>
<div class="platform-config">
@if (platformType === 'community') {
<my-community-based-config [usageType]="usageType[platformType]"></my-community-based-config>
} @else if (platformType === 'institution') {
<my-institutional-config [usageType]="usageType[platformType]"></my-institutional-config>
} @else if (platformType === 'private') {
<my-private-instance-config [usageType]="usageType[platformType]"></my-private-instance-config>
}
</div>
</div>
<div class="buttons">
<my-button i18n icon="arrow-left" (click)="back.emit()" theme="secondary">Back</my-button>
<my-button i18n theme="primary" (click)="next.emit(usageType[platformType])" [disabled]="!platformType">Preview configuration</my-button>
<button i18n (click)="hide.emit()" class="button-as-link">Ignore for now</button>
</div>
</div>

View file

@ -0,0 +1,116 @@
@use "_variables" as *;
@use "_mixins" as *;
.mascot {
margin-top: -90px;
width: 170px;
height: 190px;
}
.two-columns.width-50 {
gap: 0;
}
.platform-types {
display: flex;
@media screen and (min-width: $small-view) {
&.platform-type-selected + div {
border-inline-start: 1px solid pvar(--bg-secondary-450);
}
+ div {
@include padding-left(2rem);
}
@include margin-right(2rem);
}
@media screen and (max-width: $small-view) {
&.platform-type-selected + div {
border-block-start: 1px solid pvar(--bg-secondary-450);
}
+ div {
@include padding-top(2rem);
}
@include rfs(2rem, margin-bottom);
}
ul {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
.platform-type {
label {
cursor: pointer;
display: flex;
align-items: center;
padding: 1rem 1.5rem;
gap: 1rem;
color: pvar(--fg-300);
border-radius: 8px;
border: 1px solid pvar(--bg-secondary-450);
margin: 0;
my-global-icon,
my-custom-icon {
color: pvar(--secondary-icon-color);
@include global-icon-size(34px);
}
&:hover {
background-color: pvar(--bg-secondary-300);
}
}
input[type="radio"] {
display: none;
}
}
.type-label {
color: pvar(--fg-400);
font-weight: $font-bold;
font-size: 1rem;
}
.type-description {
font-size: 14px;
font-weight: normal;
}
input[type="radio"]:checked + label {
border: 2px solid pvar(--border-primary);
margin: -1px;
background-color: pvar(--bg);
my-global-icon,
my-custom-icon {
color: pvar(--border-primary);
}
}
@media screen and (max-width: $small-view) {
.mascot {
display: none;
}
.two-columns {
justify-content: center;
}
.two-columns.width-50 {
flex-direction: column;
align-items: center;
> div {
width: 100% !important;
}
}
}

View file

@ -0,0 +1,136 @@
import { CdkStepperModule } from '@angular/cdk/stepper'
import { CommonModule } from '@angular/common'
import { Component, inject, input, numberAttribute, OnInit, output } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ServerService, ThemeService } from '@app/core'
import { INSTANCE_NAME_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 { ColorPickerModule } from 'primeng/colorpicker'
import { debounceTime } from 'rxjs'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { CommunityBasedConfigComponent } from './usage-type/community-based-config.component'
import { InstitutionalConfigComponent } from './usage-type/institutional-config.component'
import { PrivateInstanceConfigComponent } from './usage-type/private-instance-config.component'
import { UsageType } from './usage-type/usage-type.model'
import { CustomIconComponent } from '../../../shared/shared-icons/custom-icon.component'
type Form = {
platformName: FormControl<string>
primaryColor: FormControl<string>
}
type PlatformType = 'community' | 'institution' | 'private'
@Component({
selector: 'my-admin-config-wizard-form',
templateUrl: './admin-config-wizard-form.component.html',
styleUrls: [ './admin-config-wizard-form.component.scss', '../shared/admin-config-wizard-modal-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
ColorPickerModule,
CdkStepperModule,
ButtonComponent,
GlobalIconComponent,
CommunityBasedConfigComponent,
PrivateInstanceConfigComponent,
InstitutionalConfigComponent,
CustomIconComponent
]
})
export class AdminConfigWizardFormComponent implements OnInit {
private server = inject(ServerService)
private formReactiveService = inject(FormReactiveService)
private themeService = inject(ThemeService)
readonly currentStep = input.required({ transform: numberAttribute })
readonly totalSteps = input.required({ transform: numberAttribute })
readonly back = output()
readonly next = output<UsageType>()
readonly hide = output()
iconKey = require('../../../../assets/images/feather/key.svg')
iconInstitution = require('../../../../assets/images/feather/institution.svg')
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
platformType: PlatformType
usageType: { [id in PlatformType]: UsageType } = {
community: UsageType.initForCommunity(),
institution: UsageType.initForInstitution(),
private: UsageType.initForPrivateInstance()
}
ngOnInit () {
this.buildForm()
this.subscribeToColorChanges()
}
private subscribeToColorChanges () {
let currentAnimationFrame: number
this.form.get('primaryColor').valueChanges.pipe(debounceTime(250)).subscribe(value => {
if (currentAnimationFrame) {
cancelAnimationFrame(currentAnimationFrame)
currentAnimationFrame = null
}
currentAnimationFrame = requestAnimationFrame(() => {
const config = this.server.getHTMLConfig()
this.themeService.updateColorPalette({
...config.theme,
customization: {
...config.theme.customization,
primaryColor: this.themeService.formatColorForForm(value)
}
})
})
})
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
platformName: INSTANCE_NAME_VALIDATOR,
primaryColor: null
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, this.getDefaultValues())
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
private getDefaultValues (): FormDefaultTyped<Form> {
const config = this.server.getHTMLConfig()
const primaryColorConfig = config.theme.customization.primaryColor
const primaryColor = primaryColorConfig
? this.themeService.formatColorForForm(primaryColorConfig)
: this.themeService.formatColorForForm(this.themeService.getCSSConfigValue('primaryColor'))
return {
platformName: config.instance.name,
primaryColor
}
}
}

View file

@ -0,0 +1,36 @@
<div class="root">
<div class="two-columns">
<div>
<img class="mascot" src="/client/assets/images/mascot/default.svg" alt="mascot">
</div>
<div>
<div class="steps">
<div class="header-steps" i18n>STEP {{ currentStep() }}/{{ totalSteps() }}</div>
</div>
<h4 i18n class="title">Configuration preview</h4>
<div class="text-content mt-3">
<p i18n>If you confirm, PeerTube will:</p>
<ul>
@for (explanation of safeExplanations; track explanation) {
<li [innerHTML]="explanation"></li>
}
</ul>
<p i18n>
If you want finer settings control, your platform configuration can be easily changed after the pre-configuration wizard!
</p>
</div>
</div>
</div>
<div class="buttons">
<my-button i18n icon="arrow-left" (click)="back.emit()" theme="secondary">Back</my-button>
<my-button i18n theme="primary" [disabled]="updating" [loading]="updating" (click)="confirm()">Confirm this configuration</my-button>
<button i18n (click)="hide.emit()" class="button-as-link">Ignore for now</button>
</div>
</div>

View file

@ -0,0 +1,104 @@
import { CdkStepperModule } from '@angular/cdk/stepper'
import { CommonModule } from '@angular/common'
import { Component, inject, input, numberAttribute, OnChanges, output } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { HtmlRendererService, Notifier, ServerService } from '@app/core'
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
import { PluginApiService } from '@app/shared/shared-admin/plugin-api.service'
import { CustomConfig } from '@peertube/peertube-models'
import merge from 'lodash-es/merge'
import { ColorPickerModule } from 'primeng/colorpicker'
import { concatMap, from, switchMap, toArray } from 'rxjs'
import { PartialDeep } from 'type-fest'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { FormInfo } from './admin-config-wizard-edit-info.component'
import { UsageType } from './usage-type/usage-type.model'
@Component({
selector: 'my-admin-config-wizard-preview',
templateUrl: './admin-config-wizard-preview.component.html',
styleUrls: [ '../shared/admin-config-wizard-modal-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
ColorPickerModule,
CdkStepperModule,
ButtonComponent
],
providers: [ AdminConfigService, PluginApiService ]
})
export class AdminConfigWizardPreviewComponent implements OnChanges {
private adminConfig = inject(AdminConfigService)
private pluginAPI = inject(PluginApiService)
private notifier = inject(Notifier)
private html = inject(HtmlRendererService)
private server = inject(ServerService)
readonly currentStep = input.required({ transform: numberAttribute })
readonly totalSteps = input.required({ transform: numberAttribute })
readonly usageType = input.required<UsageType>()
readonly instanceInfo = input.required<FormInfo>()
readonly back = output()
readonly next = output()
readonly hide = output()
safeExplanations: string[] = []
plugins: string[] = []
config: PartialDeep<CustomConfig> = {}
updating = false
ngOnChanges () {
if (this.usageType()) {
this.safeExplanations = this.usageType()
.getUnsafeExplanations()
.map(e => this.html.toSimpleSafeHtml(e))
this.config = merge(
{
instance: {
name: this.instanceInfo().platformName,
shortDescription: this.instanceInfo().shortDescription
},
theme: {
customization: {
primaryColor: this.instanceInfo().primaryColor
}
}
} satisfies PartialDeep<CustomConfig>,
this.usageType().getConfig()
)
this.plugins = this.usageType().getPlugins()
}
}
confirm () {
this.updating = true
this.adminConfig.updateCustomConfig(this.config)
.pipe(
switchMap(() => this.server.resetConfig()),
switchMap(() => {
return from(this.plugins)
.pipe(
concatMap(plugin => this.pluginAPI.install(plugin)),
toArray()
)
})
).subscribe({
next: () => {
this.updating = false
this.next.emit()
},
error: err => {
this.notifier.error(err.message)
this.updating = false
}
})
}
}

View file

@ -0,0 +1,19 @@
<div class="root one-column">
<div class="mascot-container">
<img class="mascot" src="/client/assets/images/mascot/happy.svg" alt="mascot">
</div>
<h4>
<div i18n class="title">Welcome to PeerTube</div>
<div i18n class="sub-title">dear administrator!</div>
</h4>
<div class="text-content mt-3" i18n>Sepia, the cuttlefish, has a few questions to help you <strong>quickly pre-configure your platform</strong>.</div>
<div class="buttons">
<my-button i18n theme="primary" (click)="next.emit()">Let's go!</my-button>
<my-button i18n (click)="hide.emit()" theme="secondary">Remind me later</my-button>
<button i18n (click)="doNotOpenAgain(); hide.emit()" class="button-as-link">Do not display again</button>
</div>
</div>

View file

@ -0,0 +1,35 @@
import { CdkStepperModule } from '@angular/cdk/stepper'
import { CommonModule } from '@angular/common'
import { Component, inject, output } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Notifier, UserService } from '@app/core'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { logger } from '@root-helpers/logger'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { getNoWelcomeModalLocalStorageKey } from '../shared/admin-config-wizard-modal-utils'
@Component({
selector: 'my-admin-config-wizard-welcome',
templateUrl: './admin-config-wizard-welcome.component.html',
styleUrls: [ '../shared/admin-config-wizard-modal-common.scss' ],
imports: [ CommonModule, FormsModule, ReactiveFormsModule, CdkStepperModule, ButtonComponent ]
})
export class AdminConfigWizardWelcomeComponent {
private userService = inject(UserService)
private notifier = inject(Notifier)
readonly back = output()
readonly next = output()
readonly hide = output()
doNotOpenAgain () {
peertubeLocalStorage.setItem(getNoWelcomeModalLocalStorageKey(), 'true')
this.userService.updateMyProfile({ noWelcomeModal: true })
.subscribe({
next: () => logger.info('We will not open the welcome modal again.'),
error: err => this.notifier.error(err.message)
})
}
}

View file

@ -0,0 +1,31 @@
<form [formGroup]="form">
<div class="form-group">
<label i18n for="">Registration policy</label>
<my-select-options inputId="registration" [items]="registrationOptions" formControlName="registration"></my-select-options>
</div>
<div class="form-group">
<label i18n for="">Video quota for new users</label>
<my-select-options inputId="videoQuota" [items]="videoQuotaOptions" formControlName="videoQuota"></my-select-options>
</div>
<div class="form-group">
<label i18n for="remoteImport">Video import and synchronization</label>
<my-select-options inputId="remoteImport" [items]="importOptions" formControlName="remoteImport"></my-select-options>
</div>
<div class="form-group">
<label i18n for="">My community can stream lives</label>
<my-select-options inputId="live" [items]="liveOptions" formControlName="live"></my-select-options>
</div>
<div class="form-group">
<label i18n for="">Search</label>
<my-select-options inputId="globalSearch" [items]="globalSearchOptions" formControlName="globalSearch"></my-select-options>
</div>
</form>

View file

@ -0,0 +1,135 @@
import { CdkStepperModule } from '@angular/cdk/stepper'
import { CommonModule } from '@angular/common'
import { Component, inject, model, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { getVideoQuotaOptions } from '@app/+admin/shared/user-quota-options'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { ColorPickerModule } from 'primeng/colorpicker'
import { SelectOptionsItem } from 'src/types'
import { EnabledDisabled, RegistrationType, UsageType } from './usage-type.model'
type Form = {
registration: FormControl<RegistrationType>
videoQuota: FormControl<number>
remoteImport: FormControl<EnabledDisabled>
live: FormControl<EnabledDisabled>
globalSearch: FormControl<EnabledDisabled>
}
@Component({
selector: 'my-community-based-config',
templateUrl: './community-based-config.component.html',
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
ColorPickerModule,
CdkStepperModule,
SelectOptionsComponent
]
})
export class CommunityBasedConfigComponent implements OnInit {
private formReactiveService = inject(FormReactiveService)
usageType = model.required<UsageType>()
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
registrationOptions: SelectOptionsItem<RegistrationType>[] = [
{
id: 'open',
label: 'Open',
description: 'Anyone can register and use the platform'
},
{
id: 'approval',
label: 'Requires approval',
description: 'Anyone can register, but a moderator must approve their account before they can use the platform'
},
{
id: 'closed',
label: 'Closed',
description: 'Only an administrator can create users on the platform'
}
]
importOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Enabled',
description:
'Your community can import videos from remote platforms (YouTube, Vimeo...) and automatically synchronize remote channels'
},
{
id: 'disabled',
label: 'Disabled',
description: 'Your community cannot import or synchronize content from remote platforms'
}
]
liveOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Yes',
description: 'Your community can live stream on the platform (this requires extra moderation work)'
},
{
id: 'disabled',
label: 'No',
description: 'Your community is not permitted to run live streams on the platform'
}
]
globalSearchOptions: SelectOptionsItem<string>[] = [
{
id: 'enabled',
label: 'Enable global search',
description: 'Use https://sepiasearch.org as default search engine to search for content across all known peertube platforms'
},
{
id: 'disabled',
label: 'Disable global search',
description: 'Use your platform search engine which only displays local content'
}
]
videoQuotaOptions: SelectOptionsItem<number>[] = getVideoQuotaOptions()
ngOnInit () {
this.buildForm()
this.form.valueChanges.subscribe(value => {
this.usageType().patch(value)
})
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
registration: null,
remoteImport: null,
videoQuota: null,
live: null,
globalSearch: null
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, this.usageType() as FormDefaultTyped<Form>)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
}

View file

@ -0,0 +1,25 @@
<form [formGroup]="form">
<div class="form-group">
<label i18n for="keepOriginalVideo">Save a copy of the uploaded video</label>
<my-select-options inputId="keepOriginalVideo" [items]="keepOriginalVideoOptions" formControlName="keepOriginalVideo"></my-select-options>
</div>
<div class="form-group">
<label i18n for="p2p">P2P</label>
<my-select-options inputId="p2p" [items]="p2pOptions" formControlName="p2p"></my-select-options>
</div>
<div class="form-group">
<label i18n for="transcription">Video transcription</label>
<my-select-options inputId="transcription" [items]="transcriptionOptions" formControlName="transcription"></my-select-options>
</div>
<div class="form-group">
<label i18n for="authType">Remote authentication</label>
<my-select-options inputId="authType" [items]="authenticationOptions" formControlName="authType"></my-select-options>
</div>
</form>

View file

@ -0,0 +1,133 @@
import { CdkStepperModule } from '@angular/cdk/stepper'
import { CommonModule } from '@angular/common'
import { Component, inject, model, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { ColorPickerModule } from 'primeng/colorpicker'
import { SelectOptionsItem } from 'src/types'
import { AuthType, EnabledDisabled, UsageType } from './usage-type.model'
type Form = {
keepOriginalVideo: FormControl<EnabledDisabled>
p2p: FormControl<EnabledDisabled>
transcription: FormControl<EnabledDisabled>
authType: FormControl<AuthType>
}
@Component({
selector: 'my-institutional-config',
templateUrl: './institutional-config.component.html',
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
ColorPickerModule,
CdkStepperModule,
SelectOptionsComponent
]
})
export class InstitutionalConfigComponent implements OnInit {
private formReactiveService = inject(FormReactiveService)
usageType = model.required<UsageType>()
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
p2pOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Enabled',
description: 'Enable P2P streaming by default for anonymous and new users'
},
{
id: 'disabled',
label: 'Disabled',
description: 'Disable P2P streaming'
}
]
transcriptionOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Enabled',
description: 'Enable automatic transcription of videos to automatically generate subtitles'
},
{
id: 'disabled',
label: 'Disabled',
description: 'Disable automatic transcription of videos'
}
]
keepOriginalVideoOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Yes',
description: 'Keep the original video file on the server'
},
{
id: 'disabled',
label: 'No',
description: 'Delete the original video file after processing'
}
]
authenticationOptions: SelectOptionsItem<AuthType>[] = [
{
id: 'local',
label: 'Disabled',
description: 'Your platform will manage user registration and login internally'
},
{
id: 'ldap',
label: 'LDAP',
description: 'Use LDAP for user authentication'
},
{
id: 'oidc',
label: 'OIDC',
description: 'Use OpenID Connect for user authentication'
},
{
id: 'saml',
label: 'SAML',
description: 'Use SAML 2.0 for user authentication'
}
]
ngOnInit () {
this.buildForm()
this.form.valueChanges.subscribe(value => {
this.usageType().patch(value)
})
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
keepOriginalVideo: null,
p2p: null,
transcription: null,
authType: null
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, this.usageType() as FormDefaultTyped<Form>)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
}

View file

@ -0,0 +1,19 @@
<form [formGroup]="form">
<div class="form-group">
<label i18n for="remoteImport">Video import and synchronization</label>
<my-select-options inputId="remoteImport" [items]="importOptions" formControlName="remoteImport"></my-select-options>
</div>
<div class="form-group">
<label i18n for="live">Users can stream lives</label>
<my-select-options inputId="live" [items]="liveOptions" formControlName="live"></my-select-options>
</div>
<div class="form-group">
<label i18n for="keepOriginalVideo">Save a copy of the uploaded video</label>
<my-select-options inputId="keepOriginalVideo" [items]="keepOriginalVideoOptions" formControlName="keepOriginalVideo"></my-select-options>
</div>
</form>

View file

@ -0,0 +1,106 @@
import { CdkStepperModule } from '@angular/cdk/stepper'
import { CommonModule } from '@angular/common'
import { Component, inject, model, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { ColorPickerModule } from 'primeng/colorpicker'
import { SelectOptionsItem } from 'src/types'
import { EnabledDisabled, UsageType } from './usage-type.model'
type Form = {
remoteImport: FormControl<EnabledDisabled>
live: FormControl<EnabledDisabled>
keepOriginalVideo: FormControl<EnabledDisabled>
}
@Component({
selector: 'my-private-instance-config',
templateUrl: './private-instance-config.component.html',
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
ColorPickerModule,
CdkStepperModule,
SelectOptionsComponent
]
})
export class PrivateInstanceConfigComponent implements OnInit {
private formReactiveService = inject(FormReactiveService)
usageType = model.required<UsageType>()
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
importOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Enabled',
description: 'Users can import videos from remote platforms (YouTube, Vimeo...) and automatically synchronize remote channels'
},
{
id: 'disabled',
label: 'Disabled',
description: 'Disable video import and channel synchronization'
}
]
liveOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Yes'
},
{
id: 'disabled',
label: 'No'
}
]
keepOriginalVideoOptions: SelectOptionsItem<EnabledDisabled>[] = [
{
id: 'enabled',
label: 'Yes',
description: 'Keep the original video file on the server'
},
{
id: 'disabled',
label: 'No',
description: 'Delete the original video file after processing'
}
]
ngOnInit () {
this.buildForm()
this.form.valueChanges.subscribe(value => {
this.usageType().patch(value)
})
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
remoteImport: null,
live: null,
keepOriginalVideo: null
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, this.usageType() as FormDefaultTyped<Form>)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
}

View file

@ -0,0 +1,429 @@
import { exists } from '@peertube/peertube-core-utils'
import { CustomConfig, VideoPrivacy } from '@peertube/peertube-models'
import { AttributesOnly } from '@peertube/peertube-typescript-utils'
import { getBytes } from '@root-helpers/bytes'
import merge from 'lodash-es/merge'
import { PartialDeep } from 'type-fest'
export type RegistrationType = 'open' | 'closed' | 'approval'
export type EnabledDisabled = 'disabled' | 'enabled'
export type AuthType = 'local' | 'ldap' | 'saml' | 'oidc'
export class UsageType {
registration: RegistrationType
videoQuota: number
remoteImport: EnabledDisabled
live: EnabledDisabled
globalSearch: EnabledDisabled
defaultPrivacy: typeof VideoPrivacy.INTERNAL | typeof VideoPrivacy.PUBLIC
p2p: EnabledDisabled
federation: EnabledDisabled
keepOriginalVideo: EnabledDisabled
allowReplaceFile: EnabledDisabled
preferDisplayName: EnabledDisabled
transcription: EnabledDisabled
authType: AuthType
private unsafeExplanations: string[] = []
private config: PartialDeep<CustomConfig> = {}
private plugins: string[] = []
private constructor () {
}
static initForCommunity () {
const usageType = new UsageType()
usageType.registration = 'approval'
usageType.remoteImport = 'disabled'
usageType.live = 'enabled'
usageType.videoQuota = 5 * 1024 * 1024 * 1024 // Default to 5GB
usageType.globalSearch = 'enabled'
usageType.defaultPrivacy = VideoPrivacy.PUBLIC
usageType.p2p = 'enabled'
usageType.federation = 'enabled'
usageType.keepOriginalVideo = 'disabled'
usageType.allowReplaceFile = 'disabled'
// Use current config for: authType, preferDisplayName and transcription
usageType.compute()
return usageType
}
static initForPrivateInstance () {
const usageType = new UsageType()
usageType.registration = 'closed'
usageType.remoteImport = 'enabled'
usageType.live = 'enabled'
usageType.videoQuota = -1
usageType.globalSearch = 'disabled'
usageType.defaultPrivacy = VideoPrivacy.INTERNAL
usageType.p2p = 'disabled'
usageType.federation = 'disabled'
usageType.keepOriginalVideo = 'enabled'
usageType.allowReplaceFile = 'enabled'
usageType.preferDisplayName = 'enabled'
// Use current config for: authType and transcription
usageType.compute()
return usageType
}
static initForInstitution () {
const usageType = new UsageType()
usageType.registration = 'closed'
usageType.remoteImport = 'enabled'
usageType.live = 'enabled'
usageType.videoQuota = -1
usageType.globalSearch = 'disabled'
usageType.defaultPrivacy = VideoPrivacy.PUBLIC
usageType.p2p = 'disabled'
usageType.keepOriginalVideo = 'enabled'
usageType.allowReplaceFile = 'enabled'
usageType.preferDisplayName = 'enabled'
usageType.authType = 'local'
usageType.transcription = 'enabled'
// Use current config for: federation
usageType.compute()
return usageType
}
private compute () {
this.unsafeExplanations = []
this.plugins = []
this.config = {}
this.computeRegistration()
this.computeVideoPrivacy()
this.computeVideoQuota()
this.computeKeepOriginalVideo()
this.computeReplaceVideoFile()
this.computeVideoImport()
this.computeStreamLives()
this.computeP2P()
this.computeGlobalSearch()
this.computeFederation()
this.computeMiniatureSettings()
this.computeTranscription()
this.computeAuth()
}
getUnsafeExplanations () {
return [ ...this.unsafeExplanations ]
}
getConfig () {
return { ...this.config }
}
getPlugins () {
return [ ...this.plugins ]
}
patch (obj: Partial<AttributesOnly<UsageType>>) {
for (const [ key, value ] of Object.entries(obj)) {
;(this as any)[key] = value
}
this.compute()
}
private computeRegistration () {
if (!exists(this.registration)) return
if (this.registration === 'open') {
this.addExplanation($localize`<strong>Allow</strong> any user <strong>to register</strong>`)
this.addConfig({
signup: {
enabled: true,
requiresApproval: false
}
})
} else if (this.registration === 'approval') {
this.addExplanation($localize`Allow users to <strong>apply for registration</strong> on your platform`)
this.addConfig({
signup: {
enabled: true,
requiresApproval: true
}
})
} else if (this.registration === 'closed') {
this.addExplanation($localize`<strong>Disable</strong> user <strong>registration</strong>`)
this.addConfig({
signup: {
enabled: false
}
})
}
if (this.registration === 'approval' || this.registration === 'open') {
this.addExplanation($localize`Require <strong>moderator approval</strong> for videos published by your community`)
this.addConfig({
autoBlacklist: {
videos: {
ofUsers: {
enabled: true
}
}
}
})
}
}
private computeVideoQuota () {
if (!exists(this.videoQuota)) return
this.addConfig({
user: {
videoQuota: this.videoQuota
}
})
if (this.videoQuota === 0) {
this.addExplanation(
$localize`<strong>Prevent</strong> new users <strong>from uploading videos</strong> (can be changed by moderators)`
)
} else if (this.videoQuota === -1) {
this.addExplanation($localize`Will <strong>not limit the amount of videos</strong> new users can upload`)
} else {
this.addExplanation(
$localize`Set <strong>video quota to ${getBytes(this.videoQuota, 0)}</strong> for new users (can be changed by moderators)`
)
}
}
private computeVideoImport () {
if (!exists(this.remoteImport)) return
this.addConfig({
import: {
videos: {
http: {
enabled: this.remoteImport === 'enabled'
}
},
videoChannelSynchronization: {
enabled: this.remoteImport === 'enabled'
}
}
})
if (this.remoteImport === 'enabled') {
this.addExplanation(
// eslint-disable-next-line max-len
$localize`<strong>Allow</strong> your users <strong>to import and synchronize</strong> videos from remote platforms (YouTube, Vimeo...)`
)
} else {
this.addExplanation($localize`<strong>Prevent</strong> your users <strong>from importing videos</strong> from remote platforms`)
}
}
private computeStreamLives () {
if (!exists(this.live)) return
this.addConfig({
live: {
enabled: this.live === 'enabled'
}
})
if (this.live === 'enabled') {
this.plugins.push('peertube-plugin-livechat')
this.addExplanation(
// eslint-disable-next-line max-len
$localize`<strong>Allow</strong> your users <strong>to stream lives</strong> and chat with their viewers using the <strong>Livechat</strong> plugin`
)
} else {
this.addExplanation($localize`<strong>Prevent</strong> your users from running <strong>live streams</strong>`)
}
}
private computeVideoPrivacy () {
if (!exists(this.defaultPrivacy)) return
this.addConfig({
defaults: {
publish: {
privacy: this.defaultPrivacy
}
}
})
if (this.defaultPrivacy === VideoPrivacy.INTERNAL) {
this.addExplanation($localize`Set the <strong>default video privacy</strong> to <strong>Internal</strong>`)
} else if (this.defaultPrivacy === VideoPrivacy.PUBLIC) {
this.addExplanation($localize`Set the <strong>default video privacy</strong> to <strong>Public</strong>`)
}
}
private computeP2P () {
if (!exists(this.p2p)) return
this.addConfig({
defaults: {
p2p: {
embed: {
enabled: this.p2p === 'enabled'
},
webapp: {
enabled: this.p2p === 'enabled'
}
}
}
})
if (this.p2p === 'enabled') {
this.addExplanation($localize`<strong>Enable P2P streaming</strong> by default for anonymous and new users`)
} else {
this.addExplanation($localize`<strong>Disable P2P streaming</strong> by default for anonymous and new users`)
}
}
private computeFederation () {
if (!exists(this.federation)) return
this.addConfig({
followers: {
instance: {
enabled: this.federation === 'enabled'
}
}
})
if (this.federation === 'enabled') {
this.addExplanation($localize`<strong>Allow</strong> external platforms/users to <strong>subscribe</strong> to your content`)
} else {
this.addExplanation($localize`<strong>Prevent</strong> external platforms/users to <strong>subscribe to your content</strong>`)
}
}
private computeKeepOriginalVideo () {
if (!exists(this.keepOriginalVideo)) return
this.addConfig({
transcoding: {
originalFile: {
keep: this.keepOriginalVideo === 'enabled'
}
}
})
if (this.keepOriginalVideo === 'enabled') {
this.addExplanation($localize`Will <strong>save a copy</strong> of the uploaded video file`)
}
}
private computeReplaceVideoFile () {
if (!exists(this.allowReplaceFile)) return
this.addConfig({
videoFile: {
update: {
enabled: this.allowReplaceFile === 'enabled'
}
}
})
if (this.allowReplaceFile === 'enabled') {
this.addExplanation(
$localize`Will <strong>allow</strong> your users <strong>to replace a video</strong> that has already been published`
)
}
}
private computeMiniatureSettings () {
if (!exists(this.preferDisplayName)) return
this.addConfig({
client: {
videos: {
miniature: {
preferAuthorDisplayName: this.preferDisplayName === 'enabled'
}
}
}
})
}
private computeGlobalSearch () {
if (!exists(this.globalSearch)) return
this.addConfig({
search: {
searchIndex: {
enabled: this.globalSearch === 'enabled',
isDefaultSearch: this.globalSearch === 'enabled',
url: 'https://sepiasearch.org'
}
}
})
if (this.globalSearch === 'enabled') {
this.addExplanation(
$localize`Set <a href="https://sepiasearch.org" target="_blank">SepiaSearch</a> as <strong>default search engine</strong>`
)
}
}
private computeTranscription () {
if (!exists(this.transcription)) return
this.addConfig({
videoTranscription: {
enabled: this.transcription === 'enabled'
}
})
if (this.transcription === 'enabled') {
this.addExplanation(
$localize`<strong>Enable automatic transcription</strong> of videos to create subtitles and improve accessibility`
)
}
}
private computeAuth () {
if (!exists(this.authType)) return
const configStr = $localize` The plugin <strong>must be configured</strong> after the pre-configuration wizard confirmation.`
if (this.authType === 'ldap') {
this.addExplanation($localize`Install the <strong>LDAP</strong> authentication plugin.` + configStr)
this.plugins.push('peertube-plugin-auth-ldap')
} else if (this.authType === 'saml') {
this.addExplanation($localize`Install the <strong>SAML 2.0</strong> authentication plugin.` + configStr)
this.plugins.push('peertube-plugin-auth-saml2')
} else if (this.authType === 'oidc') {
this.addExplanation($localize`Install the <strong>OpenID Connect</strong> authentication plugin.` + configStr)
this.plugins.push('peertube-plugin-auth-openid-connect')
}
}
private addConfig (newConfig: PartialDeep<CustomConfig>) {
return this.config = merge(this.config, newConfig)
}
private addExplanation (explanation: string) {
this.unsafeExplanations.push(explanation)
}
}

View file

@ -1,86 +0,0 @@
<ng-template #modal let-hide="close">
<div class="modal-header">
<h4 i18n class="modal-title">Welcome to PeerTube, dear administrator!</h4>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body">
<div class="block-documentation">
<div class="columns">
<a class="link-block" href="https://docs.joinpeertube.org/maintain/tools" target="_blank" i18n-title title="Go to the CLI documentation">
<h5 i18n class="link-primary">CLI documentation</h5>
<div i18n>Upload or import videos, parse logs, prune storage directories, reset user password...</div>
</a>
<a class="link-block" href="https://docs.joinpeertube.org/admin/following-instances" target="_blank" i18n-title title="Go to the admin documentation">
<h5 i18n class="link-primary">Admin documentation</h5>
<div i18n>Managing users, following other instances, dealing with spammers...</div>
</a>
<a class="link-block" href="https://docs.joinpeertube.org/use/setup-account" target="_blank" i18n-title title="Go to the user documentation">
<h5 i18n class="link-primary">User documentation</h5>
<div i18n>Setup your account, managing video playlists, discover third-party applications...</div>
</a>
</div>
</div>
<div class="two-columns">
<img class="mascot mascot-fw" src="/client/assets/images/mascot/pointing.svg" alt="mascot">
<div class="block-links">
<div i18n class="subtitle">Useful links</div>
<ul>
<li i18n>
Official PeerTube website (news, support, contribute...): <a href="https://joinpeertube.org" target="_blank" rel="noopener noreferrer">https://joinpeertube.org</a>
</li>
<li i18n>
Put your instance on the public PeerTube index: <a href="https://instances.joinpeertube.org/instances">https://instances.joinpeertube.org/instances</a>
</li>
</ul>
</div>
</div>
<div class="two-columns">
<img class="mascot" src="/client/assets/images/mascot/happy.svg" alt="mascot">
<div class="block-configuration">
<div i18n class="subtitle">It's time to configure your instance!</div>
<p i18n>
Choosing your <strong>instance name</strong>, <strong>setting up a description</strong>, specifying <strong>who you are</strong>,
why <strong>you created your instance</strong> and <strong>how long</strong> you plan to <strong>maintain it</strong>
is very important for visitors to understand on what type of instance they are.
</p>
<p i18n>
If you want to open registrations, please decide what <strong>your moderation rules</strong> and <strong>instance
terms of service</strong> are, as well as specify the categories and languages and your moderators speak.
This way, you will help users to register on <strong>the appropriate</strong> PeerTube instance.
</p>
</div>
</div>
</div>
<div class="modal-footer inputs">
<input
type="button" role="button" i18n-value value="Remind me later" class="peertube-button secondary-button"
(click)="hide()" (key.enter)="hide()"
>
<a i18n (click)="doNotOpenAgain(); hide()" (key.enter)="doNotOpenAgain(); hide()"
class="peertube-button-link primary-button" href="/admin/settings/config/edit-custom" target="_blank"
rel="noopener noreferrer" ngbAutofocus>
Configure my instance
</a>
</div>
</ng-template>

View file

@ -1,70 +0,0 @@
@use '_variables' as *;
@use '_mixins' as *;
.two-columns {
display: flex;
align-items: center;
justify-content: center;
margin-top: 50px;
@include on-small-main-col {
flex-wrap: wrap;
}
}
.mascot-fw {
width: 170px;
}
.mascot {
display: block;
min-width: 170px;
@include margin-right(2rem);
}
.subtitle {
font-weight: $font-semibold;
margin-bottom: 10px;
}
.block-documentation {
.subtitle {
margin-bottom: 20px;
}
}
li {
margin-bottom: 10px;
}
.configure-instance {
text-align: center;
font-weight: 600;
font-size: 18px;
margin: 20px 0 40px;
}
.columns {
display: flex;
}
.link-block {
color: pvar(--fg);
padding: 10px;
transition: background-color 0.2s ease-in;
flex-basis: 33%;
text-align: center;
@include disable-default-a-behaviour;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
> h5 {
font-weight: $font-semibold;
font-size: 1rem;
margin-bottom: 5px;
}
}

View file

@ -1,58 +0,0 @@
import { Component, ElementRef, OnInit, inject, output, viewChild } from '@angular/core'
import { Notifier, User, UserService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { logger } from '@root-helpers/logger'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
@Component({
selector: 'my-admin-welcome-modal',
templateUrl: './admin-welcome-modal.component.html',
styleUrls: [ './admin-welcome-modal.component.scss' ],
imports: [ GlobalIconComponent ]
})
export class AdminWelcomeModalComponent implements OnInit {
private userService = inject(UserService)
private modalService = inject(NgbModal)
private notifier = inject(Notifier)
readonly modal = viewChild<ElementRef>('modal')
readonly created = output()
private LS_KEYS = {
NO_WELCOME_MODAL: 'no_welcome_modal'
}
ngOnInit () {
this.created.emit()
}
shouldOpen (user: User) {
if (this.modalService.hasOpenModals()) return false
if (user.noWelcomeModal === true) return false
if (peertubeLocalStorage.getItem(this.LS_KEYS.NO_WELCOME_MODAL) === 'true') return false
return true
}
show () {
this.modalService.open(this.modal(), {
centered: true,
backdrop: 'static',
keyboard: false,
size: 'lg'
})
}
doNotOpenAgain () {
peertubeLocalStorage.setItem(this.LS_KEYS.NO_WELCOME_MODAL, 'true')
this.userService.updateMyProfile({ noWelcomeModal: true })
.subscribe({
next: () => logger.info('We will not open the welcome modal again.'),
error: err => this.notifier.error(err.message)
})
}
}

View file

@ -71,7 +71,7 @@ export class ConfirmComponent implements OnInit {
this.confirmButtonText = confirmButtonText || $localize`Confirm`
this.cancelButtonText = cancelButtonText || $localize`Cancel`
this.html.toSimpleSafeHtml(message)
this.html.toSimpleSafeHtmlWithLinks(message)
.then(html => {
this.message = html

View file

@ -36,7 +36,7 @@ export class InstanceConfigWarningModalComponent implements OnInit {
this.created.emit()
}
shouldOpenByUser (user: User) {
canBeOpenByUser (user: User) {
if (this.modalService.hasOpenModals()) return false
if (user.noInstanceConfigWarningModal === true) return false
if (peertubeLocalStorage.getItem(this.LS_KEYS.NO_INSTANCE_CONFIG_WARNING_MODAL) === 'true') return false
@ -44,7 +44,7 @@ export class InstanceConfigWarningModalComponent implements OnInit {
return true
}
shouldOpen (serverConfig: ServerConfig, about: About) {
shouldAutoOpen (serverConfig: ServerConfig, about: About) {
if (!serverConfig.signup.allowed) return false
return serverConfig.instance.name.toLowerCase() === 'peertube' ||

View file

@ -8,8 +8,8 @@ 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'
import { environment } from '../../../environments/environment'
import { SelectOptionsItem } from '../../../types/select-options-item.model'
export type FormResolutions = {
'0p': FormControl<boolean>
@ -111,6 +111,11 @@ export class AdminConfigService {
)
}
getCustomConfigReloadedObs () {
return this.serverService.configReloaded
.pipe(switchMap(() => this.getCustomConfig()))
}
saveAndUpdateCurrent (options: {
currentConfig: CustomConfig
form: FormGroup

View file

@ -13,7 +13,7 @@ import {
RegisteredServerSettings,
ResultList
} from '@peertube/peertube-models'
import { environment } from '../../../../environments/environment'
import { environment } from '../../../environments/environment'
@Injectable()
export class PluginApiService {

View file

@ -1,7 +1,7 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_button-mixins' as *;
@use '_form-mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
@use "_button-mixins" as *;
@use "_form-mixins" as *;
input {
@include peertube-input-text(auto);
@ -9,11 +9,11 @@ input {
.btn,
my-copy-button ::ng-deep .btn {
background-color: pvar(--bg-secondary-400);
background-color: pvar(--input-bg);
border-left: 1px solid pvar(--bg);
&:hover {
background-color: pvar(--bg-secondary-450);
opacity: 0.8;
}
}

View file

@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, ElementRef, OnInit, inject, input } from '@angular/core'
@Component({
selector: 'my-custom-icon',
template: '',
styleUrls: [ './common-icon.component.scss' ],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true
})
export class CustomIconComponent implements OnInit {
private el = inject(ElementRef)
readonly html = input.required<string>()
ngOnInit () {
const nativeElement = this.el.nativeElement as HTMLElement
nativeElement.innerHTML = this.html()
nativeElement.ariaHidden = 'true'
}
}

View file

@ -105,7 +105,7 @@ export type GlobalIconName = keyof typeof icons
@Component({
selector: 'my-global-icon',
template: '',
styleUrls: [ './global-icon.component.scss' ],
styleUrls: [ './common-icon.component.scss' ],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true
})

View file

@ -1,6 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_button-mixins' as *;
@use "_variables" as *;
@use "_mixins" as *;
@use "_button-mixins" as *;
@mixin reduced-padding {
padding: pvar(--input-y-padding) calc(#{pvar(--input-x-padding)} / 2) !important;
@ -25,7 +25,7 @@
vertical-align: middle;
margin-top: -1px;
@include margin-right(3px);
@include margin-right(0.5rem);
}
&:not(.rounded-icon-button) {

View file

@ -4,4 +4,5 @@ import { Subject } from 'rxjs'
@Injectable({ providedIn: 'root' })
export class PeertubeModalService {
openQuickSettingsSubject = new Subject<void>()
openAdminConfigWizardSubject = new Subject<{ showWelcome: boolean }>()
}

View file

@ -1,5 +1,6 @@
@use '_variables' as *;
@use '_mixins' as *;
@use "_variables" as *;
@use "_css-variables" as *;
@use "_mixins" as *;
$filters-background: pvar(--bg-secondary-400);
@ -51,17 +52,15 @@ $filters-background: pvar(--bg-secondary-400);
}
.filters {
--input-bg: #{pvar(--input-bg-in-secondary)};
--input-border-color: #{pvar(--input-bg-in-secondary)};
display: flex;
flex-wrap: wrap;
background-color: $filters-background;
border-radius: 14px;
@include define-input-css-variables-in-secondary;
@include rfs(1.5rem, padding);
input[type=radio] + label {
input[type="radio"] + label {
font-weight: $font-regular;
}
@ -78,7 +77,7 @@ $filters-background: pvar(--bg-secondary-400);
.active-filters {
.active-filter:not(:last-child)::after {
content: '';
content: "";
font-weight: normal;
display: inline-block;
margin: 0 5px;

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-briefcase-business-icon lucide-briefcase-business">
<path d="M12 12h.01" />
<path d="M16 6V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2" />
<path d="M22 13a18.15 18.15 0 0 1-20 0" />
<rect width="20" height="14" x="2" y="6" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 441 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-key-round-icon lucide-key-round">
<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" />
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 488 B

View file

@ -132,12 +132,7 @@ export class ThemeManager {
}, 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')
this.injectColorPalette()
return
}
if (!this.canInjectCoreColorPalette()) {
} else if (!this.canInjectCoreColorPalette()) {
return setTimeout(() => this.injectColorPalette(options, iteration + 1), Math.floor(iteration / 10))
}

View file

@ -1,4 +1,5 @@
@use "_variables" as *;
@use "_css-variables" as *;
@use "_mixins" as *;
@use "_button-mixins" as *;
@import "./_bootstrap-variables";
@ -117,8 +118,6 @@ body {
}
.dropdown-item {
padding: 3px 15px;
&.active {
color: pvar(--on-primary) !important;
background-color: pvar(--primary);
@ -180,8 +179,9 @@ body {
}
.modal {
@include define-input-css-variables-in-modal;
.modal-content {
background-color: pvar(--bg);
word-break: break-word;
}
@ -251,7 +251,7 @@ body {
@include disable-default-a-behaviour;
&.active {
background-color: pvar(--bg) !important;
background-color: transparent !important;
border-bottom-color: pvar(--border-primary);
}

View file

@ -3,6 +3,9 @@
$modal-footer-border-width: 0;
$modal-md: 600px;
$modal-lg: 900px;
$modal-content-border-radius: 14px;
$modal-content-bg: pvar(--bg-secondary-350);
$grid-breakpoints: (
// CLASSIC BREAKPOINTS GROUP

View file

@ -27,7 +27,8 @@
--input-fg: var(--inputForegroundColor, #{pvar(--fg)});
--input-bg: var(--inputBackgroundColor, #{pvar(--bg-secondary-400)});
--input-bg-in-secondary: #{pvar(--input-bg-550)};
--input-bg-in-modal: #{pvar(--input-bg-550)};
--input-bg-in-secondary: #{pvar(--input-bg-600)};
--input-danger-fg: #9C221C;
--input-danger-bg: #FEBBB2;
@ -48,6 +49,8 @@
--textarea-x-padding: 15px;
--textarea-fg: var(--textareaForegroundColor, #{pvar(--input-fg)});
--textarea-bg: var(--textareaBackgroundColor, #{pvar(--input-bg)});
--textarea-bg-in-modal: #{pvar(--input-bg-in-modal)};
--textarea-bg-in-secondary: #{pvar(--input-bg-in-secondary)};
--support-btn-fg: var(--supportButtonColor, #{pvar(--fg-300)});
--support-btn-bg: var(--supportButtonBackgroundColor, transparent);
@ -158,3 +161,22 @@
--active-icon-bg: #{pvar(--bg-secondary-600)};
}
}
@mixin define-input-css-variables-in-secondary() {
--textarea-bg: #{pvar(--textarea-bg-in-secondary)};
@include define-input-css-variables(pvar(--input-bg-in-secondary));
}
@mixin define-input-css-variables-in-modal() {
--textarea-bg: #{pvar(--textarea-bg-in-modal)};
@include define-input-css-variables(pvar(--input-bg-in-modal));
}
@mixin define-input-css-variables($value) {
--input-bg: #{$value};
--input-border-color: #{$value};
--p-multiselect-background: #{$value};
--p-select-background: #{$value};
}

View file

@ -100,6 +100,7 @@ $variables: (
--input-bg-550: var(--input-bg-550),
--input-bg-600: var(--input-bg-600),
--input-bg-in-secondary: var(--input-bg-in-secondary),
--input-bg-in-modal: var(--input-bg-in-modal),
--input-danger-fg: var(--input-danger-fg),
--input-danger-bg: var(--input-danger-bg),
--input-placeholder: var(--input-placeholder),
@ -114,6 +115,8 @@ $variables: (
--textarea-y-padding: var(--textarea-y-padding),
--textarea-fg: var(--textarea-fg),
--textarea-bg: var(--textarea-bg),
--textarea-bg-in-secondary: var(--textarea-bg-in-secondary),
--textarea-bg-in-modal: var(--textarea-bg-in-modal),
--support-btn-bg: var(--support-btn-bg),
--support-btn-fg: var(--support-btn-fg),
--support-btn-heart-bg: var(--support-btn-heart-bg),

View file

@ -1,20 +1,20 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_css-variables' as *;
@use '../player/build/peertube-player';
@use "_variables" as *;
@use "_mixins" as *;
@use "_css-variables" as *;
@use "../player/build/peertube-player";
[hidden] {
display: none !important;
}
body {
:root {
@include define-css-variables();
}
& {
body {
font-family: $main-fonts;
font-weight: $font-regular;
color: #000;
}
}
video {
@ -66,11 +66,11 @@ body {
}
#error-details {
margin-top: 30px
margin-top: 30px;
}
#error-details-content {
margin-top: 10px
margin-top: 10px;
}
#placeholder-preview {
@ -105,7 +105,7 @@ body {
margin: 1rem 0.5rem;
border: 0;
font-weight: 600;
border-radius: 3px!important;
border-radius: 3px !important;
font-size: 18px;
display: inline-block;
}
@ -141,4 +141,3 @@ body {
font-size: 14px;
}
}

View file

@ -19,7 +19,7 @@ export class PeerTubeTheme {
this.themeManager.loadThemeStyle(themeName)
this.themeManager.injectCoreColorPalette()
this.themeManager.injectColorPalette({ config: config.theme, currentTheme: themeName })
}
loadThemePlugins (config: HTMLServerConfig) {

View file

@ -121,39 +121,6 @@ email:
subject:
prefix: '[PeerTube]'
# Update default PeerTube values
# Set by API when the field is not provided and put as default value in client
defaults:
# Change default values when publishing a video (upload/import/go Live)
publish:
download_enabled: true
# enabled = 1, disabled = 2, requires_approval = 3
comments_policy: 1
# public = 1, unlisted = 2, private = 3, internal = 4
privacy: 1
# CC-BY = 1, CC-SA = 2, CC-ND = 3, CC-NC = 4, CC-NC-SA = 5, CC-NC-ND = 6, Public Domain = 7
# You can also choose a custom licence value added by a plugin
# No licence by default
licence: null
p2p:
# Enable P2P by default in PeerTube client
# Can be enabled/disabled by anonymous users and logged in users
webapp:
enabled: true
# Enable P2P by default in PeerTube embed
# Can be enabled/disabled by URL option
embed:
enabled: true
player:
# By default, playback starts automatically when opening a video
auto_play: true
# From the project root directory
storage:
tmp: 'storage/tmp/' # Use to download data (imports etc), store uploaded files before and during processing...
@ -654,7 +621,6 @@ transcoding:
# Generate videos in a web compatible format
# If you also enabled the hls format, it will multiply videos storage by 2
# If disabled, breaks federation with PeerTube instances < 2.1
web_videos:
enabled: false
@ -880,6 +846,7 @@ import:
# Max number of videos to import when the user asks for full sync
full_sync_videos_limit: 1000
# Add ability for your users to import a PeerTube archive file to automatically create videos, channels, captions, etc
users:
# Video quota is checked on import so the user doesn't upload a too big archive file
# Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import
@ -1136,3 +1103,36 @@ client:
storyboards:
# Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video
enabled: true
# Update default PeerTube values
# Set by API when the field is not provided and put as default value in client
defaults:
# Change default values when publishing a video (upload/import/go Live)
publish:
download_enabled: true
# enabled = 1, disabled = 2, requires_approval = 3
comments_policy: 1
# public = 1, unlisted = 2, private = 3, internal = 4
privacy: 1
# CC-BY = 1, CC-SA = 2, CC-ND = 3, CC-NC = 4, CC-NC-SA = 5, CC-NC-ND = 6, Public Domain = 7
# You can also choose a custom licence value added by a plugin
# No licence by default
licence: null
p2p:
# Enable P2P by default in PeerTube client
# Can be enabled/disabled by anonymous users and logged in users
webapp:
enabled: true
# Enable P2P by default in PeerTube embed
# Can be enabled/disabled by URL option
embed:
enabled: true
player:
# By default, playback starts automatically when opening a video
auto_play: true

View file

@ -119,39 +119,6 @@ email:
subject:
prefix: '[PeerTube]'
# Update default PeerTube values
# Set by API when the field is not provided and put as default value in client
defaults:
# Change default values when publishing a video (upload/import/go Live)
publish:
download_enabled: true
# enabled = 1, disabled = 2, requires_approval = 3
comments_policy: 1
# public = 1, unlisted = 2, private = 3, internal = 4
privacy: 1
# CC-BY = 1, CC-SA = 2, CC-ND = 3, CC-NC = 4, CC-NC-SA = 5, CC-NC-ND = 6, Public Domain = 7
# You can also choose a custom licence value added by a plugin
# No licence by default
licence: null
p2p:
# Enable P2P by default in PeerTube client
# Can be enabled/disabled by anonymous users and logged in users
webapp:
enabled: true
# Enable P2P by default in PeerTube embed
# Can be enabled/disabled by URL option
embed:
enabled: true
player:
# By default, playback starts automatically when opening a video
auto_play: true
# From the project root directory
storage:
tmp: '/var/www/peertube/storage/tmp/' # Use to download data (imports etc), store uploaded files before and during processing...
@ -664,7 +631,6 @@ transcoding:
# Generate videos in a web compatible format
# If you also enabled the hls format, it will multiply videos storage by 2
# If disabled, breaks federation with PeerTube instances < 2.1
web_videos:
enabled: false
@ -890,6 +856,7 @@ import:
# Max number of videos to import when the user asks for full sync
full_sync_videos_limit: 1000
# Add ability for your users to import a PeerTube archive file to automatically create videos, channels, captions, etc
users:
# Video quota is checked on import so the user doesn't upload a too big archive file
# Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import
@ -1050,6 +1017,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
@ -1084,6 +1070,7 @@ search:
# PeerTube client/interface configuration
client:
videos:
miniature:
# By default PeerTube client displays author username
@ -1126,3 +1113,36 @@ client:
storyboards:
# Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video
enabled: true
# Update default PeerTube values
# Set by API when the field is not provided and put as default value in client
defaults:
# Change default values when publishing a video (upload/import/go Live)
publish:
download_enabled: true
# enabled = 1, disabled = 2, requires_approval = 3
comments_policy: 1
# public = 1, unlisted = 2, private = 3, internal = 4
privacy: 1
# CC-BY = 1, CC-SA = 2, CC-ND = 3, CC-NC = 4, CC-NC-SA = 5, CC-NC-ND = 6, Public Domain = 7
# You can also choose a custom licence value added by a plugin
# No licence by default
licence: null
p2p:
# Enable P2P by default in PeerTube client
# Can be enabled/disabled by anonymous users and logged in users
webapp:
enabled: true
# Enable P2P by default in PeerTube embed
# Can be enabled/disabled by URL option
embed:
enabled: true
player:
# By default, playback starts automatically when opening a video
auto_play: true

View file

@ -1,3 +1,4 @@
import { VideoCommentPolicyType, VideoPrivacyType } from '../videos/index.js'
import { NSFWPolicyType } from '../videos/nsfw-policy.type.js'
import { BroadcastMessageLevel } from './broadcast-message-level.type.js'
@ -320,4 +321,27 @@ export interface CustomConfig {
storyboards: {
enabled: boolean
}
defaults: {
publish: {
downloadEnabled: boolean
commentsPolicy: VideoCommentPolicyType
privacy: VideoPrivacyType
licence: number
}
p2p: {
webapp: {
enabled: boolean
}
embed: {
enabled: boolean
}
}
player: {
autoPlay: boolean
}
}
}

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import { ActorImageType, CustomConfig, HttpStatusCode } from '@peertube/peertube-models'
import { ActorImageType, CustomConfig, HttpStatusCode, VideoCommentPolicy, VideoPrivacy } from '@peertube/peertube-models'
import {
PeerTubeServer,
cleanupTests,
@ -188,7 +188,19 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
}
},
theme: {
default: 'default'
default: 'default',
customization: {
primaryColor: '#001',
foregroundColor: '#002',
backgroundColor: '#003',
backgroundSecondaryColor: '#004',
menuForegroundColor: '#005',
menuBackgroundColor: '#006',
menuBorderRadius: '1px',
headerForegroundColor: '#008',
headerBackgroundColor: '#009',
inputBorderRadius: '2px'
}
},
services: {
twitter: {
@ -410,6 +422,25 @@ function buildNewCustomConfig (server: PeerTubeServer): CustomConfig {
exportExpiration: 43,
maxUserVideoQuota: 42
}
},
defaults: {
publish: {
commentsPolicy: VideoCommentPolicy.REQUIRES_APPROVAL,
downloadEnabled: false,
licence: 2,
privacy: VideoPrivacy.INTERNAL
},
p2p: {
embed: {
enabled: false
},
webapp: {
enabled: true
}
},
player: {
autoPlay: false
}
}
}
}

View file

@ -508,6 +508,26 @@ function customConfig (): CustomConfig {
},
storyboards: {
enabled: CONFIG.STORYBOARDS.ENABLED
},
defaults: {
publish: {
downloadEnabled: CONFIG.DEFAULTS.PUBLISH.DOWNLOAD_ENABLED,
commentsPolicy: CONFIG.DEFAULTS.PUBLISH.COMMENTS_POLICY,
privacy: CONFIG.DEFAULTS.PUBLISH.PRIVACY,
licence: CONFIG.DEFAULTS.PUBLISH.LICENCE
},
p2p: {
webapp: {
enabled: CONFIG.DEFAULTS.P2P.WEBAPP.ENABLED
},
embed: {
enabled: CONFIG.DEFAULTS.P2P.EMBED.ENABLED
}
},
player: {
autoPlay: CONFIG.DEFAULTS.PLAYER.AUTO_PLAY
}
}
}
}

View file

@ -10043,6 +10043,43 @@ components:
type: boolean
manualApproval:
type: boolean
storyboard:
type: object
properties:
enabled:
type: boolean
defaults:
type: object
properties:
publish:
type: object
properties:
downloadEnabled:
type: boolean
commentsPolicy:
$ref: '#/components/schemas/VideoCommentsPolicySet'
privacy:
$ref: '#/components/schemas/VideoPrivacySet'
licence:
$ref: '#/components/schemas/VideoLicenceSet'
p2p:
type: object
properties:
webapp:
type: object
properties:
enabled:
type: boolean
embed:
type: object
properties:
enabled:
type: boolean
player:
type: object
properties:
autoPlay:
type: boolean
CustomHomepage:
properties: