diff --git a/client/angular.json b/client/angular.json index 8c0d6f144..80ceaaccf 100644 --- a/client/angular.json +++ b/client/angular.json @@ -214,7 +214,6 @@ "escape-string-regexp", "is-plain-object", "parse-srcset", - "deepmerge", "core-js/features/reflect", "hammerjs", "jschannel" diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index 32623833b..b2c05a1f7 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -150,6 +150,7 @@ export default defineConfig([ 'no-return-assign': 'off', '@typescript-eslint/unbound-method': 'off', 'import/no-named-default': 'off', + '@typescript-eslint/prefer-reduce-type-parameter': 'off', "@typescript-eslint/no-deprecated": [ 'error', { allow: [ diff --git a/client/src/app/+admin/config/admin-config.component.html b/client/src/app/+admin/config/admin-config.component.html new file mode 100644 index 000000000..0381a6775 --- /dev/null +++ b/client/src/app/+admin/config/admin-config.component.html @@ -0,0 +1,9 @@ +
+
+ +
+ +
+ +
+
diff --git a/client/src/app/+admin/config/admin-config.component.scss b/client/src/app/+admin/config/admin-config.component.scss new file mode 100644 index 000000000..5712d28eb --- /dev/null +++ b/client/src/app/+admin/config/admin-config.component.scss @@ -0,0 +1,14 @@ +@use "_variables" as *; +@use "_mixins" as *; +@use "_form-mixins" as *; +@import "bootstrap/scss/mixins"; + +.root { + display: flex; +} + +@media screen and (max-width: $medium-view) { + .root { + margin-bottom: 150px; + } +} diff --git a/client/src/app/+admin/config/admin-config.component.ts b/client/src/app/+admin/config/admin-config.component.ts new file mode 100644 index 000000000..a1919a71c --- /dev/null +++ b/client/src/app/+admin/config/admin-config.component.ts @@ -0,0 +1,67 @@ +import { CommonModule } from '@angular/common' +import { Component, OnInit } from '@angular/core' +import { RouterModule } from '@angular/router' +import { LateralMenuComponent, LateralMenuConfig } from '../../shared/shared-main/menu/lateral-menu.component' + +@Component({ + selector: 'my-admin-config', + styleUrls: [ './admin-config.component.scss' ], + templateUrl: './admin-config.component.html', + imports: [ + CommonModule, + RouterModule, + LateralMenuComponent + ] +}) +export class AdminConfigComponent implements OnInit { + menuConfig: LateralMenuConfig + + ngOnInit (): void { + this.menuConfig = { + title: $localize`Configuration`, + entries: [ + { + type: 'link', + label: $localize`Information`, + routerLink: 'information' + }, + { + type: 'link', + label: $localize`General`, + routerLink: 'general' + }, + { + type: 'link', + label: $localize`Homepage`, + routerLink: 'homepage' + }, + { + type: 'link', + label: $localize`Customization`, + routerLink: 'customization' + }, + + { type: 'separator' }, + + { + type: 'link', + label: $localize`VOD`, + routerLink: 'vod' + }, + { + type: 'link', + label: $localize`Live`, + routerLink: 'live' + }, + + { type: 'separator' }, + + { + type: 'link', + label: $localize`Advanced`, + routerLink: 'advanced' + } + ] + } + } +} diff --git a/client/src/app/+admin/config/config.routes.ts b/client/src/app/+admin/config/config.routes.ts index 072c2dd21..755148280 100644 --- a/client/src/app/+admin/config/config.routes.ts +++ b/client/src/app/+admin/config/config.routes.ts @@ -1,7 +1,37 @@ -import { Routes } from '@angular/router' -import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config' -import { UserRightGuard } from '@app/core' -import { UserRight } from '@peertube/peertube-models' +import { inject } from '@angular/core' +import { ActivatedRouteSnapshot, ResolveFn, RouterStateSnapshot, Routes } from '@angular/router' +import { CanDeactivateGuard, ServerService, UserRightGuard } from '@app/core' +import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service' +import { CustomConfig, UserRight, VideoConstant } from '@peertube/peertube-models' +import { map } from 'rxjs' +import { AdminConfigComponent } from './admin-config.component' +import { + AdminConfigAdvancedComponent, + AdminConfigGeneralComponent, + AdminConfigHomepageComponent, + AdminConfigInformationComponent, + AdminConfigLiveComponent, + AdminConfigVODComponent +} from './pages' +import { AdminConfigCustomizationComponent } from './pages/admin-config-customization.component' +import { AdminConfigService } from './shared/admin-config.service' + +export const customConfigResolver: ResolveFn = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return inject(AdminConfigService).getCustomConfig() +} + +export const homepageResolver: ResolveFn = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return inject(CustomPageService).getInstanceHomepage() + .pipe(map(({ content }) => content)) +} + +export const categoriesResolver: ResolveFn[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return inject(ServerService).getVideoCategories() +} + +export const languagesResolver: ResolveFn[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => { + return inject(ServerService).getVideoLanguages() +} export const configRoutes: Routes = [ { @@ -10,18 +40,96 @@ export const configRoutes: Routes = [ data: { userRight: UserRight.MANAGE_CONFIGURATION }, + resolve: { + customConfig: customConfigResolver + }, + component: AdminConfigComponent, children: [ { - path: '', - redirectTo: 'edit-custom', + // Old path with PeerTube < 7.3 + path: 'edit-custom', + redirectTo: 'information', pathMatch: 'full' }, { - path: 'edit-custom', - component: EditCustomConfigComponent, + path: '', + redirectTo: 'information', + pathMatch: 'full' + }, + { + path: 'homepage', + component: AdminConfigHomepageComponent, + canDeactivate: [ CanDeactivateGuard ], + resolve: { + homepageContent: homepageResolver + }, data: { meta: { - title: $localize`Edit custom configuration` + title: $localize`Edit your platform homepage` + } + } + }, + { + path: 'customization', + component: AdminConfigCustomizationComponent, + canDeactivate: [ CanDeactivateGuard ], + data: { + meta: { + title: $localize`Platform customization` + } + } + }, + { + path: 'information', + component: AdminConfigInformationComponent, + canDeactivate: [ CanDeactivateGuard ], + resolve: { + categories: categoriesResolver, + languages: languagesResolver + }, + data: { + meta: { + title: $localize`Platform information` + } + } + }, + { + path: 'general', + component: AdminConfigGeneralComponent, + canDeactivate: [ CanDeactivateGuard ], + data: { + meta: { + title: $localize`General configuration` + } + } + }, + { + path: 'vod', + component: AdminConfigVODComponent, + canDeactivate: [ CanDeactivateGuard ], + data: { + meta: { + title: $localize`VOD configuration` + } + } + }, + { + path: 'live', + component: AdminConfigLiveComponent, + canDeactivate: [ CanDeactivateGuard ], + data: { + meta: { + title: $localize`Live configuration` + } + } + }, + { + path: 'advanced', + component: AdminConfigAdvancedComponent, + canDeactivate: [ CanDeactivateGuard ], + data: { + meta: { + title: $localize`Advanced configuration` } } } diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html deleted file mode 100644 index 990460e3d..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.html +++ /dev/null @@ -1,135 +0,0 @@ - - -
- -
-

CACHE

-
- Some files are not federated, and fetched when necessary. Define their caching policies. -
-
- -
- -
- - -
- - {getCacheSize('previews'), plural, =1 {cached image} other {cached images}} -
- - -
- -
- - -
- - {getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}} -
- - -
- -
- - -
- - {getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}} -
- - -
- -
- - -
- - {getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}} -
- - -
-
- -
-
- -
-
-
-

CUSTOMIZATIONS

-
- Slight modifications to your PeerTube instance for when creating a plugin or theme is overkill. -
-
- -
- - -
- - - -

Write JavaScript code directly. Example:

-
console.log('my instance is amazing');
-
-
- - - - -
- -
- - - - -

Write CSS code directly. Example:

-
-#custom-css {{ '{' }}
-  color: red;
-{{ '}' }}
-
-

Prepend with #custom-css to override styles. Example:

-
-#custom-css .logged-in-email {{ '{' }}
-  color: red;
-{{ '}' }}
-
-
-
- - - -
-
-
- -
-
- -
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts deleted file mode 100644 index 065737cac..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-advanced-configuration.component.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NgClass, NgIf } from '@angular/common' -import { Component, input } from '@angular/core' -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' -import { HelpComponent } from '../../../shared/shared-main/buttons/help.component' - -@Component({ - selector: 'my-edit-advanced-configuration', - templateUrl: './edit-advanced-configuration.component.html', - styleUrls: [ './edit-custom-config.component.scss' ], - imports: [ FormsModule, ReactiveFormsModule, NgClass, NgIf, HelpComponent ] -}) -export class EditAdvancedConfigurationComponent { - readonly form = input(undefined) - readonly formErrors = input(undefined) - - getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') { - return this.form().value['cache'][type]['size'] - } -} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts deleted file mode 100644 index 156c58f8a..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { NgClass, NgIf } from '@angular/common' -import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core' -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' -import { RouterLink } from '@angular/router' -import { ThemeService } from '@app/core' -import { AlertComponent } from '@app/shared/shared-main/common/alert.component' -import { HTMLServerConfig } from '@peertube/peertube-models' -import { pairwise } from 'rxjs/operators' -import { SelectOptionsItem } from 'src/types/select-options-item.model' -import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component' -import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' -import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component' -import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component' -import { HelpComponent } from '../../../shared/shared-main/buttons/help.component' -import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component' -import { ConfigService } from '../shared/config.service' - -@Component({ - selector: 'my-edit-basic-configuration', - templateUrl: './edit-basic-configuration.component.html', - styleUrls: [ './edit-custom-config.component.scss' ], - imports: [ - FormsModule, - ReactiveFormsModule, - RouterLink, - SelectCustomValueComponent, - NgIf, - PeertubeCheckboxComponent, - HelpComponent, - MarkdownTextareaComponent, - NgClass, - UserRealQuotaInfoComponent, - SelectOptionsComponent, - AlertComponent - ] -}) -export class EditBasicConfigurationComponent implements OnInit, OnChanges { - private configService = inject(ConfigService) - private themeService = inject(ThemeService) - - readonly form = input(undefined) - readonly formErrors = input(undefined) - - readonly serverConfig = input(undefined) - - signupAlertMessage: string - defaultLandingPageOptions: SelectOptionsItem[] = [] - availableThemes: SelectOptionsItem[] - - exportExpirationOptions: SelectOptionsItem[] = [] - exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = [] - - ngOnInit () { - this.buildLandingPageOptions() - this.checkSignupField() - this.checkImportSyncField() - - this.availableThemes = [ - this.themeService.getDefaultThemeItem(), - - ...this.themeService.buildAvailableThemes() - ] - - this.exportExpirationOptions = [ - { id: 1000 * 3600 * 24, label: $localize`1 day` }, - { id: 1000 * 3600 * 24 * 2, label: $localize`2 days` }, - { id: 1000 * 3600 * 24 * 7, label: $localize`7 days` }, - { id: 1000 * 3600 * 24 * 30, label: $localize`30 days` } - ] - - this.exportMaxUserVideoQuotaOptions = this.configService.videoQuotaOptions.filter(o => (o.id as number) >= 1) - } - - ngOnChanges (changes: SimpleChanges) { - if (changes['serverConfig']) { - this.buildLandingPageOptions() - } - } - - countExternalAuth () { - return this.serverConfig().plugin.registeredExternalAuths.length - } - - getVideoQuotaOptions () { - return this.configService.videoQuotaOptions - } - - getVideoQuotaDailyOptions () { - return this.configService.videoQuotaDailyOptions - } - - doesTrendingVideosAlgorithmsEnabledInclude (algorithm: string) { - const enabled = this.form().value['trending']['videos']['algorithms']['enabled'] - if (!Array.isArray(enabled)) return false - - return !!enabled.find((e: string) => e === algorithm) - } - - getUserVideoQuota () { - return this.form().value['user']['videoQuota'] - } - - isExportUsersEnabled () { - return this.form().value['export']['users']['enabled'] === true - } - - getDisabledExportUsersClass () { - return { 'disabled-checkbox-extra': !this.isExportUsersEnabled() } - } - - isSignupEnabled () { - return this.form().value['signup']['enabled'] === true - } - - getDisabledSignupClass () { - return { 'disabled-checkbox-extra': !this.isSignupEnabled() } - } - - isImportVideosHttpEnabled (): boolean { - return this.form().value['import']['videos']['http']['enabled'] === true - } - - importSynchronizationChecked () { - return this.isImportVideosHttpEnabled() && this.form().value['import']['videoChannelSynchronization']['enabled'] - } - - hasUnlimitedSignup () { - return this.form().value['signup']['limit'] === -1 - } - - isSearchIndexEnabled () { - return this.form().value['search']['searchIndex']['enabled'] === true - } - - getDisabledSearchIndexClass () { - return { 'disabled-checkbox-extra': !this.isSearchIndexEnabled() } - } - - // --------------------------------------------------------------------------- - - isTranscriptionEnabled () { - return this.form().value['videoTranscription']['enabled'] === true - } - - getTranscriptionRunnerDisabledClass () { - return { 'disabled-checkbox-extra': !this.isTranscriptionEnabled() } - } - - // --------------------------------------------------------------------------- - - isAutoFollowIndexEnabled () { - return this.form().value['followings']['instance']['autoFollowIndex']['enabled'] === true - } - - buildLandingPageOptions () { - let links: { label: string, path: string }[] = [] - - if (this.serverConfig().homepage.enabled) { - links.push({ label: $localize`Home`, path: '/home' }) - } - - links = links.concat([ - { label: $localize`Discover`, path: '/videos/overview' }, - { label: $localize`Browse all videos`, path: '/videos/browse' }, - { label: $localize`Browse local videos`, path: '/videos/browse?scope=local' } - ]) - - this.defaultLandingPageOptions = links.map(o => ({ - id: o.path, - label: o.label, - description: o.path - })) - } - - private checkImportSyncField () { - const importSyncControl = this.form().get('import.videoChannelSynchronization.enabled') - const importVideosHttpControl = this.form().get('import.videos.http.enabled') - - importVideosHttpControl.valueChanges - .subscribe(httpImportEnabled => { - importSyncControl.setValue(httpImportEnabled && importSyncControl.value) - if (httpImportEnabled) { - importSyncControl.enable() - } else { - importSyncControl.disable() - } - }) - } - - private checkSignupField () { - const signupControl = this.form().get('signup.enabled') - - signupControl.valueChanges - .pipe(pairwise()) - .subscribe(([ oldValue, newValue ]) => { - if (oldValue === false && newValue === true) { - this.signupAlertMessage = - // eslint-disable-next-line max-len - $localize`You enabled signup: we automatically enabled the "Block new videos automatically" checkbox of the "Videos" section just below.` - - this.form().patchValue({ - autoBlacklist: { - videos: { - ofUsers: { - enabled: true - } - } - } - }) - } - }) - - signupControl.updateValueAndValidity() - } -} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts b/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts deleted file mode 100644 index 9bcff2fc8..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-configuration.service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Injectable } from '@angular/core' -import { FormGroup } from '@angular/forms' -import { formatICU } from '@app/helpers' - -export type ResolutionOption = { - id: string - label: string - description?: string -} - -@Injectable() -export class EditConfigurationService { - - getTranscodingResolutions () { - return [ - { - id: '0p', - label: $localize`Audio-only`, - // eslint-disable-next-line max-len - description: $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users` - }, - { - id: '144p', - label: $localize`144p` - }, - { - id: '240p', - label: $localize`240p` - }, - { - id: '360p', - label: $localize`360p` - }, - { - id: '480p', - label: $localize`480p` - }, - { - id: '720p', - label: $localize`720p` - }, - { - id: '1080p', - label: $localize`1080p` - }, - { - id: '1440p', - label: $localize`1440p` - }, - { - id: '2160p', - label: $localize`2160p` - } - ] - } - - isTranscodingEnabled (form: FormGroup) { - return form.value['transcoding']['enabled'] === true - } - - isHLSEnabled (form: FormGroup) { - return form.value['transcoding']['hls']['enabled'] === true - } - - isRemoteRunnerVODEnabled (form: FormGroup) { - return form.value['transcoding']['remoteRunners']['enabled'] === true - } - - isRemoteRunnerLiveEnabled (form: FormGroup) { - return form.value['live']['transcoding']['remoteRunners']['enabled'] === true - } - - isStudioEnabled (form: FormGroup) { - return form.value['videoStudio']['enabled'] === true - } - - isLiveEnabled (form: FormGroup) { - return form.value['live']['enabled'] === true - } - - isLiveTranscodingEnabled (form: FormGroup) { - return form.value['live']['transcoding']['enabled'] === true - } - - getTotalTranscodingThreads (form: FormGroup) { - const transcodingEnabled = form.value['transcoding']['enabled'] - const transcodingThreads = form.value['transcoding']['threads'] - const liveTranscodingEnabled = form.value['live']['transcoding']['enabled'] - const liveTranscodingThreads = form.value['live']['transcoding']['threads'] - - // checks whether all enabled method are on fixed values and not on auto (= 0) - let noneOnAuto = !transcodingEnabled || +transcodingThreads > 0 - noneOnAuto &&= !liveTranscodingEnabled || +liveTranscodingThreads > 0 - - // count total of fixed value, repalcing auto by a single thread (knowing it will display "at least") - let value = 0 - if (transcodingEnabled) value += +transcodingThreads || 1 - if (liveTranscodingEnabled) value += +liveTranscodingThreads || 1 - - return { - value, - atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible - unit: formatICU($localize`{value, plural, =1 {thread} other {threads}}`, { value }) - } - } -} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html deleted file mode 100644 index 294299e20..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ /dev/null @@ -1,96 +0,0 @@ -

Configuration

- - - Updating instance configuration from the web interface is disabled by the system administrator. - - -
- - - -
- -
-
-
- - - - - You cannot allow live replay if you don't enable transcoding. - - - - You cannot change the server configuration because it's managed externally. - - - -
-
-
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss deleted file mode 100644 index a14aa30cb..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.scss +++ /dev/null @@ -1,144 +0,0 @@ -@use '_variables' as *; -@use '_mixins' as *; -@use '_form-mixins' as *; - -$form-base-input-width: 340px; -$form-max-width: 500px; - -form { - padding-bottom: 1.5rem; -} - -my-markdown-textarea { - display: block; - max-width: $form-max-width; -} - -.homepage my-markdown-textarea { - display: block; - max-width: 90%; - - ::ng-deep textarea { - height: 300px !important; - } -} - -input[type=text], -input[type=number] { - @include peertube-input-text($form-base-input-width); -} - -.number-with-unit { - position: relative; - width: fit-content; - - input[type=number] + span { - position: absolute; - top: 0.4em; - right: 3em; - - @media screen and (max-width: $mobile-view) { - display: none; - } - } - - input[disabled] { - opacity: 0.8; - pointer-events: none; - } -} - -input[type=checkbox] { - @include peertube-checkbox; -} - -.peertube-select-container { - @include peertube-select-container($form-base-input-width); -} - -my-select-checkbox, -my-select-options, -my-select-custom-value { - display: block; - - @include responsive-width($form-base-input-width); -} - -input[type=submit] { - display: flex; - - @include margin-left(auto); - - + .form-error { - display: inline; - - @include margin-left(5px); - } -} - -.inner-form-description { - font-size: 15px; - margin-bottom: 15px; -} - -textarea { - max-width: 100%; - display: block; - - @include peertube-textarea(500px, 150px); - - &.small { - height: 75px; - } -} - -.label-small-info { - font-style: italic; - margin-bottom: 10px; - font-size: 14px; -} - -.disabled-checkbox-extra { - &, - ::ng-deep label { - opacity: .5; - pointer-events: none; - } -} - -input[disabled] { - opacity: 0.5; -} - -ngb-tabset:not(.previews) ::ng-deep { - .nav-link { - font-size: 105%; - } -} - -.submit-error { - margin-bottom: 20px; -} - -.alert-signup { - width: fit-content; - margin-top: 10px; -} - -.callout-container { - position: absolute; - display: flex; - height: 0; - width: 100%; - justify-content: right; -} - -my-actor-banner-edit { - max-width: $form-max-width; -} - -h4 { - font-weight: $font-bold; - margin-bottom: 0.5rem; - font-size: 1rem; -} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts deleted file mode 100644 index 0d8a59000..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ /dev/null @@ -1,479 +0,0 @@ -import { NgFor, NgIf } from '@angular/common' -import { Component, OnInit, inject } from '@angular/core' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { ActivatedRoute, Router } from '@angular/router' -import { ConfigService } from '@app/+admin/config/shared/config.service' -import { Notifier } from '@app/core' -import { ServerService } from '@app/core/server/server.service' -import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators' -import { - ADMIN_EMAIL_VALIDATOR, - CACHE_SIZE_VALIDATOR, - CONCURRENCY_VALIDATOR, - EXPORT_EXPIRATION_VALIDATOR, - EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR, - INSTANCE_NAME_VALIDATOR, - INSTANCE_SHORT_DESCRIPTION_VALIDATOR, - MAX_INSTANCE_LIVES_VALIDATOR, - MAX_LIVE_DURATION_VALIDATOR, - MAX_SYNC_PER_USER, - MAX_USER_LIVES_VALIDATOR, - MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR, - SERVICES_TWITTER_USERNAME_VALIDATOR, - SIGNUP_LIMIT_VALIDATOR, - SIGNUP_MINIMUM_AGE_VALIDATOR, - TRANSCODING_MAX_FPS_VALIDATOR, - TRANSCODING_THREADS_VALIDATOR -} from '@app/shared/form-validators/custom-config-validators' -import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators' -import { FormReactive } from '@app/shared/shared-forms/form-reactive' -import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' -import { AlertComponent } from '@app/shared/shared-main/common/alert.component' -import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service' -import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap' -import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models' -import merge from 'lodash-es/merge' -import omit from 'lodash-es/omit' -import { forkJoin } from 'rxjs' -import { SelectOptionsItem } from 'src/types/select-options-item.model' -import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component' -import { EditBasicConfigurationComponent } from './edit-basic-configuration.component' -import { EditConfigurationService } from './edit-configuration.service' -import { EditHomepageComponent } from './edit-homepage.component' -import { EditInstanceInformationComponent } from './edit-instance-information.component' -import { EditLiveConfigurationComponent } from './edit-live-configuration.component' -import { EditVODTranscodingComponent } from './edit-vod-transcoding.component' - -type ComponentCustomConfig = CustomConfig & { - instanceCustomHomepage: CustomPage -} - -@Component({ - selector: 'my-edit-custom-config', - templateUrl: './edit-custom-config.component.html', - styleUrls: [ './edit-custom-config.component.scss' ], - imports: [ - NgIf, - FormsModule, - ReactiveFormsModule, - NgbNav, - NgbNavItem, - NgbNavLink, - NgbNavLinkBase, - NgbNavContent, - EditHomepageComponent, - EditInstanceInformationComponent, - EditBasicConfigurationComponent, - EditVODTranscodingComponent, - EditLiveConfigurationComponent, - EditAdvancedConfigurationComponent, - NgbNavOutlet, - NgFor, - AlertComponent - ] -}) -export class EditCustomConfigComponent extends FormReactive implements OnInit { - protected formReactiveService = inject(FormReactiveService) - private router = inject(Router) - private route = inject(ActivatedRoute) - private notifier = inject(Notifier) - private configService = inject(ConfigService) - private customPage = inject(CustomPageService) - private serverService = inject(ServerService) - private editConfigurationService = inject(EditConfigurationService) - - activeNav: string - - customConfig: ComponentCustomConfig - serverConfig: HTMLServerConfig - - homepage: CustomPage - - languageItems: SelectOptionsItem[] = [] - categoryItems: SelectOptionsItem[] = [] - - ngOnInit () { - this.serverConfig = this.serverService.getHTMLConfig() - - const formGroupData: { [key in keyof ComponentCustomConfig]: any } = { - instance: { - name: INSTANCE_NAME_VALIDATOR, - shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR, - description: null, - - isNSFW: false, - defaultNSFWPolicy: null, - - terms: null, - codeOfConduct: null, - - creationReason: null, - moderationInformation: null, - administrator: null, - maintenanceLifetime: null, - businessModel: null, - - hardwareInformation: null, - - categories: null, - languages: null, - - serverCountry: null, - support: { - text: null - }, - social: { - externalLink: URL_VALIDATOR, - mastodonLink: URL_VALIDATOR, - blueskyLink: URL_VALIDATOR - }, - - defaultClientRoute: null, - - customizations: { - javascript: null, - css: null - } - }, - theme: { - default: null - }, - services: { - twitter: { - username: SERVICES_TWITTER_USERNAME_VALIDATOR - } - }, - client: { - videos: { - miniature: { - preferAuthorDisplayName: null - } - }, - menu: { - login: { - redirectOnSingleExternalAuth: null - } - } - }, - cache: { - previews: { - size: CACHE_SIZE_VALIDATOR - }, - captions: { - size: CACHE_SIZE_VALIDATOR - }, - torrents: { - size: CACHE_SIZE_VALIDATOR - }, - storyboards: { - size: CACHE_SIZE_VALIDATOR - } - }, - signup: { - enabled: null, - limit: SIGNUP_LIMIT_VALIDATOR, - requiresApproval: null, - requiresEmailVerification: null, - minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR - }, - import: { - videos: { - concurrency: CONCURRENCY_VALIDATOR, - http: { - enabled: null - }, - torrent: { - enabled: null - } - }, - videoChannelSynchronization: { - enabled: null, - maxPerUser: MAX_SYNC_PER_USER - }, - users: { - enabled: null - } - }, - export: { - users: { - enabled: null, - maxUserVideoQuota: EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR, - exportExpiration: EXPORT_EXPIRATION_VALIDATOR - } - }, - trending: { - videos: { - algorithms: { - enabled: null, - default: null - } - } - }, - admin: { - email: ADMIN_EMAIL_VALIDATOR - }, - contactForm: { - enabled: null - }, - user: { - history: { - videos: { - enabled: null - } - }, - videoQuota: USER_VIDEO_QUOTA_VALIDATOR, - videoQuotaDaily: USER_VIDEO_QUOTA_DAILY_VALIDATOR - }, - videoChannels: { - maxPerUser: MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR - }, - transcoding: { - enabled: null, - threads: TRANSCODING_THREADS_VALIDATOR, - allowAdditionalExtensions: null, - allowAudioFiles: null, - profile: null, - concurrency: CONCURRENCY_VALIDATOR, - resolutions: {}, - alwaysTranscodeOriginalResolution: null, - originalFile: { - keep: null - }, - hls: { - enabled: null, - splitAudioAndVideo: null - }, - webVideos: { - enabled: null - }, - remoteRunners: { - enabled: null - }, - fps: { - max: TRANSCODING_MAX_FPS_VALIDATOR - } - }, - live: { - enabled: null, - - maxDuration: MAX_LIVE_DURATION_VALIDATOR, - maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR, - maxUserLives: MAX_USER_LIVES_VALIDATOR, - allowReplay: null, - latencySetting: { - enabled: null - }, - - transcoding: { - enabled: null, - threads: TRANSCODING_THREADS_VALIDATOR, - profile: null, - resolutions: {}, - alwaysTranscodeOriginalResolution: null, - remoteRunners: { - enabled: null - }, - fps: { - max: TRANSCODING_MAX_FPS_VALIDATOR - } - } - }, - videoStudio: { - enabled: null, - remoteRunners: { - enabled: null - } - }, - videoTranscription: { - enabled: null, - remoteRunners: { - enabled: null - } - }, - videoFile: { - update: { - enabled: null - } - }, - autoBlacklist: { - videos: { - ofUsers: { - enabled: null - } - } - }, - followers: { - instance: { - enabled: null, - manualApproval: null - } - }, - followings: { - instance: { - autoFollowBack: { - enabled: null - }, - autoFollowIndex: { - enabled: null, - indexUrl: URL_VALIDATOR - } - } - }, - broadcastMessage: { - enabled: null, - level: null, - dismissable: null, - message: null - }, - search: { - remoteUri: { - users: null, - anonymous: null - }, - searchIndex: { - enabled: null, - url: URL_VALIDATOR, - disableLocalSearch: null, - isDefaultSearch: null - } - }, - - instanceCustomHomepage: { - content: null - }, - - storyboards: { - enabled: null - } - } - - const defaultValues = { - transcoding: { - resolutions: {} as { [id: string]: string } - }, - live: { - transcoding: { - resolutions: {} as { [id: string]: string } - } - } - } - - for (const resolution of this.editConfigurationService.getTranscodingResolutions()) { - defaultValues.transcoding.resolutions[resolution.id] = 'false' - formGroupData.transcoding.resolutions[resolution.id] = null - - defaultValues.live.transcoding.resolutions[resolution.id] = 'false' - formGroupData.live.transcoding.resolutions[resolution.id] = null - } - - this.buildForm(formGroupData) - - if (this.route.snapshot.fragment) { - this.onNavChange(this.route.snapshot.fragment) - } - - this.loadConfigAndUpdateForm() - this.loadCategoriesAndLanguages() - - if (!this.isUpdateAllowed()) { - this.form.disable() - } - } - - formValidated () { - this.forceCheck() - if (!this.form.valid) return - - const value: ComponentCustomConfig = merge(this.customConfig, this.form.getRawValue()) - - forkJoin([ - this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')), - this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content) - ]) - .subscribe({ - next: ([ resConfig ]) => { - const instanceCustomHomepage = { content: value.instanceCustomHomepage.content } - - this.customConfig = { ...resConfig, instanceCustomHomepage } - - // Reload general configuration - this.serverService.resetConfig() - .subscribe(config => { - this.serverConfig = config - }) - - this.updateForm() - - this.notifier.success($localize`Configuration updated.`) - }, - - error: err => this.notifier.error(err.message) - }) - } - - isUpdateAllowed () { - return this.serverConfig.webadmin.configuration.edition.allowed === true - } - - hasConsistentOptions () { - if (this.hasLiveAllowReplayConsistentOptions()) return true - - return false - } - - hasLiveAllowReplayConsistentOptions () { - if ( - this.editConfigurationService.isTranscodingEnabled(this.form) === false && - this.editConfigurationService.isLiveEnabled(this.form) && - this.form.value['live']['allowReplay'] === true - ) { - return false - } - - return true - } - - onNavChange (newActiveNav: string) { - this.activeNav = newActiveNav - - this.router.navigate([], { fragment: this.activeNav }) - } - - grabAllErrors () { - return this.formReactiveService.grabAllErrors(this.formErrors) - } - - private updateForm () { - this.form.patchValue(this.customConfig) - } - - private loadConfigAndUpdateForm () { - forkJoin([ - this.configService.getCustomConfig(), - this.customPage.getInstanceHomepage() - ]).subscribe({ - next: ([ config, homepage ]) => { - this.customConfig = { ...config, instanceCustomHomepage: homepage } - - this.updateForm() - this.markAllAsDirty() - }, - - error: err => this.notifier.error(err.message) - }) - } - - private loadCategoriesAndLanguages () { - forkJoin([ - this.serverService.getVideoLanguages(), - this.serverService.getVideoCategories() - ]).subscribe({ - next: ([ languages, categories ]) => { - this.languageItems = languages.map(l => ({ label: l.label, id: l.id })) - this.categoryItems = categories.map(l => ({ label: l.label, id: l.id })) - }, - - error: err => this.notifier.error(err.message) - }) - } -} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html deleted file mode 100644 index 0633dc44a..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - -
-
-

INSTANCE HOMEPAGE

-
- -
- -
- -
- -
- - - - -
-
-
- -
- -
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts deleted file mode 100644 index 82ec7ff6f..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Component, inject, input } from '@angular/core' -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' -import { NgIf } from '@angular/common' -import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component' -import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component' -import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service' - -@Component({ - selector: 'my-edit-homepage', - templateUrl: './edit-homepage.component.html', - styleUrls: [ './edit-custom-config.component.scss' ], - imports: [ FormsModule, ReactiveFormsModule, CustomMarkupHelpComponent, MarkdownTextareaComponent, NgIf ] -}) -export class EditHomepageComponent { - private customMarkup = inject(CustomMarkupService) - - readonly form = input(undefined) - readonly formErrors = input(undefined) - - customMarkdownRenderer: (text: string) => Promise - - getCustomMarkdownRenderer () { - return this.customMarkup.getCustomMarkdownRenderer() - } -} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.ts deleted file mode 100644 index 8fc760ba0..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { CommonModule } from '@angular/common' -import { HttpErrorResponse } from '@angular/common/http' -import { Component, OnInit, inject, input } from '@angular/core' -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' -import { RouterLink } from '@angular/router' -import { Notifier, ServerService } from '@app/core' -import { genericUploadErrorHandler } from '@app/helpers' -import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service' -import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component' -import { InstanceService } from '@app/shared/shared-main/instance/instance.service' -import { maxBy } from '@peertube/peertube-core-utils' -import { ActorImage, HTMLServerConfig } from '@peertube/peertube-models' -import { SelectOptionsItem } from 'src/types/select-options-item.model' -import { ActorAvatarEditComponent } from '../../../shared/shared-actor-image-edit/actor-avatar-edit.component' -import { ActorBannerEditComponent } from '../../../shared/shared-actor-image-edit/actor-banner-edit.component' -import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component' -import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component' -import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' -import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/select-checkbox.component' -import { HelpComponent } from '../../../shared/shared-main/buttons/help.component' -import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive' - -@Component({ - selector: 'my-edit-instance-information', - templateUrl: './edit-instance-information.component.html', - styleUrls: [ './edit-custom-config.component.scss' ], - imports: [ - FormsModule, - ReactiveFormsModule, - ActorAvatarEditComponent, - ActorBannerEditComponent, - SelectRadioComponent, - CommonModule, - CustomMarkupHelpComponent, - MarkdownTextareaComponent, - SelectCheckboxComponent, - RouterLink, - PeertubeCheckboxComponent, - PeerTubeTemplateDirective, - HelpComponent - ] -}) -export class EditInstanceInformationComponent implements OnInit { - private customMarkup = inject(CustomMarkupService) - private notifier = inject(Notifier) - private instanceService = inject(InstanceService) - private server = inject(ServerService) - - readonly form = input(undefined) - readonly formErrors = input(undefined) - - readonly languageItems = input([]) - readonly categoryItems = input([]) - - instanceBannerUrl: string - instanceAvatars: ActorImage[] = [] - - nsfwItems: SelectOptionsItem[] = [ - { - id: 'do_not_list', - label: $localize`Hide` - }, - { - id: 'warn', - label: $localize`Warn` - }, - { - id: 'blur', - label: $localize`Blur` - }, - { - id: 'display', - label: $localize`Display` - } - ] - - private serverConfig: HTMLServerConfig - - get instanceName () { - return this.server.getHTMLConfig().instance.name - } - - ngOnInit () { - this.serverConfig = this.server.getHTMLConfig() - - this.updateActorImages() - } - - getCustomMarkdownRenderer () { - return this.customMarkup.getCustomMarkdownRenderer() - } - - onBannerChange (formData: FormData) { - this.instanceService.updateInstanceBanner(formData) - .subscribe({ - next: () => { - this.notifier.success($localize`Banner changed.`) - - this.resetActorImages() - }, - - error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier }) - }) - } - - onBannerDelete () { - this.instanceService.deleteInstanceBanner() - .subscribe({ - next: () => { - this.notifier.success($localize`Banner deleted.`) - - this.resetActorImages() - }, - - error: err => this.notifier.error(err.message) - }) - } - - onAvatarChange (formData: FormData) { - this.instanceService.updateInstanceAvatar(formData) - .subscribe({ - next: () => { - this.notifier.success($localize`Avatar changed.`) - - this.resetActorImages() - }, - - error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`avatar`, notifier: this.notifier }) - }) - } - - onAvatarDelete () { - this.instanceService.deleteInstanceAvatar() - .subscribe({ - next: () => { - this.notifier.success($localize`Avatar deleted.`) - - this.resetActorImages() - }, - - error: err => this.notifier.error(err.message) - }) - } - - private updateActorImages () { - this.instanceBannerUrl = maxBy(this.serverConfig.instance.banners, 'width')?.path - this.instanceAvatars = this.serverConfig.instance.avatars - } - - private resetActorImages () { - this.server.resetConfig() - .subscribe(config => { - this.serverConfig = config - - this.updateActorImages() - }) - } -} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.ts deleted file mode 100644 index fb5820f96..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-live-configuration.component.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { SelectOptionsItem } from 'src/types/select-options-item.model' -import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core' -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' -import { HTMLServerConfig } from '@peertube/peertube-models' -import { ConfigService } from '../shared/config.service' -import { EditConfigurationService, ResolutionOption } from './edit-configuration.service' -import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component' -import { RouterLink } from '@angular/router' -import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component' -import { NgClass, NgIf, NgFor } from '@angular/common' -import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive' -import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' - -@Component({ - selector: 'my-edit-live-configuration', - templateUrl: './edit-live-configuration.component.html', - styleUrls: [ './edit-custom-config.component.scss' ], - imports: [ - FormsModule, - ReactiveFormsModule, - PeertubeCheckboxComponent, - PeerTubeTemplateDirective, - NgClass, - NgIf, - SelectOptionsComponent, - NgFor, - RouterLink, - SelectCustomValueComponent - ] -}) -export class EditLiveConfigurationComponent implements OnInit, OnChanges { - private configService = inject(ConfigService) - private editConfigurationService = inject(EditConfigurationService) - - readonly form = input(undefined) - readonly formErrors = input(undefined) - readonly serverConfig = input(undefined) - - transcodingThreadOptions: SelectOptionsItem[] = [] - transcodingProfiles: SelectOptionsItem[] = [] - - liveMaxDurationOptions: SelectOptionsItem[] = [] - liveResolutions: ResolutionOption[] = [] - - ngOnInit () { - this.transcodingThreadOptions = this.configService.transcodingThreadOptions - - this.liveMaxDurationOptions = [ - { id: -1, label: $localize`No limit` }, - { id: 1000 * 3600, label: $localize`1 hour` }, - { id: 1000 * 3600 * 3, label: $localize`3 hours` }, - { id: 1000 * 3600 * 5, label: $localize`5 hours` }, - { id: 1000 * 3600 * 10, label: $localize`10 hours` } - ] - - this.liveResolutions = this.editConfigurationService.getTranscodingResolutions() - } - - ngOnChanges (changes: SimpleChanges) { - if (changes['serverConfig']) { - this.transcodingProfiles = this.buildAvailableTranscodingProfile() - } - } - - buildAvailableTranscodingProfile () { - const profiles = this.serverConfig().live.transcoding.availableProfiles - - return profiles.map(p => { - if (p === 'default') { - return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` } - } - - return { id: p, label: p } - }) - } - - getResolutionKey (resolution: string) { - return 'live.transcoding.resolutions.' + resolution - } - - getLiveRTMPPort () { - return this.serverConfig().live.rtmp.port - } - - isLiveEnabled () { - return this.editConfigurationService.isLiveEnabled(this.form()) - } - - isRemoteRunnerLiveEnabled () { - return this.editConfigurationService.isRemoteRunnerLiveEnabled(this.form()) - } - - getDisabledLiveClass () { - return { 'disabled-checkbox-extra': !this.isLiveEnabled() } - } - - getDisabledLiveTranscodingClass () { - return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() } - } - - getDisabledLiveLocalTranscodingClass () { - return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() || this.isRemoteRunnerLiveEnabled() } - } - - isLiveTranscodingEnabled () { - return this.editConfigurationService.isLiveTranscodingEnabled(this.form()) - } - - getTotalTranscodingThreads () { - return this.editConfigurationService.getTotalTranscodingThreads(this.form()) - } -} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts deleted file mode 100644 index 40e07daef..000000000 --- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { NgClass, NgFor, NgIf } from '@angular/common' -import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core' -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' -import { RouterLink } from '@angular/router' -import { Notifier } from '@app/core' -import { HTMLServerConfig } from '@peertube/peertube-models' -import { SelectOptionsItem } from 'src/types/select-options-item.model' -import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' -import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component' -import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component' -import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive' -import { ConfigService } from '../shared/config.service' -import { EditConfigurationService, ResolutionOption } from './edit-configuration.service' - -@Component({ - selector: 'my-edit-vod-transcoding', - templateUrl: './edit-vod-transcoding.component.html', - styleUrls: [ './edit-custom-config.component.scss' ], - imports: [ - FormsModule, - ReactiveFormsModule, - PeertubeCheckboxComponent, - PeerTubeTemplateDirective, - NgClass, - NgFor, - NgIf, - RouterLink, - SelectCustomValueComponent, - SelectOptionsComponent - ] -}) -export class EditVODTranscodingComponent implements OnInit, OnChanges { - private configService = inject(ConfigService) - private editConfigurationService = inject(EditConfigurationService) - private notifier = inject(Notifier) - - readonly form = input(undefined) - readonly formErrors = input(undefined) - readonly serverConfig = input(undefined) - - transcodingThreadOptions: SelectOptionsItem[] = [] - transcodingProfiles: SelectOptionsItem[] = [] - resolutions: ResolutionOption[] = [] - - additionalVideoExtensions = '' - - ngOnInit () { - this.transcodingThreadOptions = this.configService.transcodingThreadOptions - this.resolutions = this.editConfigurationService.getTranscodingResolutions() - - this.checkTranscodingFields() - } - - ngOnChanges (changes: SimpleChanges) { - if (changes['serverConfig']) { - this.transcodingProfiles = this.buildAvailableTranscodingProfile() - - this.additionalVideoExtensions = this.serverConfig().video.file.extensions.join(' ') - } - } - - buildAvailableTranscodingProfile () { - const profiles = this.serverConfig().transcoding.availableProfiles - - return profiles.map(p => { - if (p === 'default') { - return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` } - } - - return { id: p, label: p } - }) - } - - getResolutionKey (resolution: string) { - return 'transcoding.resolutions.' + resolution - } - - isRemoteRunnerVODEnabled () { - return this.editConfigurationService.isRemoteRunnerVODEnabled(this.form()) - } - - isTranscodingEnabled () { - return this.editConfigurationService.isTranscodingEnabled(this.form()) - } - - isHLSEnabled () { - return this.editConfigurationService.isHLSEnabled(this.form()) - } - - isStudioEnabled () { - return this.editConfigurationService.isStudioEnabled(this.form()) - } - - getTranscodingDisabledClass () { - return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() } - } - - getHLSDisabledClass () { - return { 'disabled-checkbox-extra': !this.isHLSEnabled() } - } - - getLocalTranscodingDisabledClass () { - return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() } - } - - getStudioDisabledClass () { - return { 'disabled-checkbox-extra': !this.isStudioEnabled() } - } - - getTotalTranscodingThreads () { - return this.editConfigurationService.getTotalTranscodingThreads(this.form()) - } - - private checkTranscodingFields () { - const transcodingControl = this.form().get('transcoding.enabled') - const videoStudioControl = this.form().get('videoStudio.enabled') - const hlsControl = this.form().get('transcoding.hls.enabled') - const webVideosControl = this.form().get('transcoding.webVideos.enabled') - - webVideosControl.valueChanges - .subscribe(newValue => { - if (newValue === false && hlsControl.value === false) { - hlsControl.setValue(true) - - this.notifier.info( - $localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`, - '', - 10000 - ) - } - }) - - hlsControl.valueChanges - .subscribe(newValue => { - if (newValue === false && webVideosControl.value === false) { - webVideosControl.setValue(true) - - this.notifier.info( - // eslint-disable-next-line max-len - $localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`, - '', - 10000 - ) - } - }) - - transcodingControl.valueChanges - .subscribe(newValue => { - if (newValue === false) { - videoStudioControl.setValue(false) - } - }) - - transcodingControl.updateValueAndValidity() - webVideosControl.updateValueAndValidity() - videoStudioControl.updateValueAndValidity() - hlsControl.updateValueAndValidity() - } -} diff --git a/client/src/app/+admin/config/edit-custom-config/index.ts b/client/src/app/+admin/config/edit-custom-config/index.ts deleted file mode 100644 index 4281ad09b..000000000 --- a/client/src/app/+admin/config/edit-custom-config/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from './edit-advanced-configuration.component' -export * from './edit-basic-configuration.component' -export * from './edit-configuration.service' -export * from './edit-custom-config.component' -export * from './edit-homepage.component' -export * from './edit-instance-information.component' -export * from './edit-live-configuration.component' -export * from './edit-vod-transcoding.component' diff --git a/client/src/app/+admin/config/index.ts b/client/src/app/+admin/config/index.ts index 898e92546..7e17aa06a 100644 --- a/client/src/app/+admin/config/index.ts +++ b/client/src/app/+admin/config/index.ts @@ -1,2 +1,2 @@ -export * from './edit-custom-config' +export * from './pages' export * from './config.routes' diff --git a/client/src/app/+admin/config/pages/admin-config-advanced.component.html b/client/src/app/+admin/config/pages/admin-config-advanced.component.html new file mode 100644 index 000000000..6cc09fd7a --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-advanced.component.html @@ -0,0 +1,112 @@ + + + + +
+ +
+

CACHE

+
+ Some files are not federated, and fetched when necessary. Define their caching policies. +
+
+ +
+ +
+ + +
+ + {getCacheSize('previews'), plural, =1 {cached image} other {cached images}} +
+ + +
+ +
+ + +
+ + {getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}} +
+ + +
+ +
+ + +
+ + {getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}} +
+ + +
+ +
+ + +
+ + {getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}} +
+ + +
+
+ +
+
+ +
+
+

TWITTER/X

+ +
+ Extra configuration required by Twitter/X. All other social media (Facebook, Mastodon, etc.) are supported out of the box. +
+
+ +
+ + + + +
+ + +
+

Indicates the Twitter/X account for the website or platform where the content was published.

+ +

This is just an extra information injected in PeerTube HTML that is required by Twitter/X. If you don't have a Twitter/X account, just leave the default value.

+
+ + + + +
+ +
+
+ +
+
+
diff --git a/client/src/app/+admin/config/pages/admin-config-advanced.component.ts b/client/src/app/+admin/config/pages/admin-config-advanced.component.ts new file mode 100644 index 000000000..d58996a93 --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-advanced.component.ts @@ -0,0 +1,116 @@ +import { CommonModule } from '@angular/common' +import { Component, inject, OnInit } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { ActivatedRoute } from '@angular/router' +import { CanComponentDeactivate } from '@app/core' +import { CACHE_SIZE_VALIDATOR, SERVICES_TWITTER_USERNAME_VALIDATOR } from '@app/shared/form-validators/custom-config-validators' +import { + BuildFormArgumentTyped, + FormDefaultTyped, + FormReactiveErrorsTyped, + FormReactiveMessagesTyped +} from '@app/shared/form-validators/form-validator.model' +import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { CustomConfig } from '@peertube/peertube-models' +import { AdminConfigService } from '../shared/admin-config.service' +import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' + +type Form = { + services: FormGroup<{ + twitter: FormGroup<{ + username: FormControl + }> + }> + + cache: FormGroup<{ + previews: FormGroup<{ + size: FormControl + }> + captions: FormGroup<{ + size: FormControl + }> + torrents: FormGroup<{ + size: FormControl + }> + storyboards: FormGroup<{ + size: FormControl + }> + }> +} + +@Component({ + selector: 'my-admin-config-advanced', + templateUrl: './admin-config-advanced.component.html', + styleUrls: [ './admin-config-common.scss' ], + imports: [ CommonModule, FormsModule, ReactiveFormsModule, AdminSaveBarComponent ] +}) +export class AdminConfigAdvancedComponent implements OnInit, CanComponentDeactivate { + private route = inject(ActivatedRoute) + private formReactiveService = inject(FormReactiveService) + private adminConfigService = inject(AdminConfigService) + + form: FormGroup
+ formErrors: FormReactiveErrorsTyped = {} + validationMessages: FormReactiveMessagesTyped = {} + + private customConfig: CustomConfig + + ngOnInit () { + this.customConfig = this.route.parent.snapshot.data['customConfig'] + + this.buildForm() + } + + canDeactivate () { + return { canDeactivate: !this.form.dirty } + } + + private buildForm () { + const obj: BuildFormArgumentTyped = { + services: { + twitter: { + username: SERVICES_TWITTER_USERNAME_VALIDATOR + } + }, + cache: { + previews: { + size: CACHE_SIZE_VALIDATOR + }, + captions: { + size: CACHE_SIZE_VALIDATOR + }, + torrents: { + size: CACHE_SIZE_VALIDATOR + }, + storyboards: { + size: CACHE_SIZE_VALIDATOR + } + } + } + + const defaultValues: FormDefaultTyped = this.customConfig + + const { + form, + formErrors, + validationMessages + } = this.formReactiveService.buildForm(obj, defaultValues) + + this.form = form + this.formErrors = formErrors + this.validationMessages = validationMessages + } + + getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') { + return this.form.value.cache[type].size + } + + save () { + this.adminConfigService.saveAndUpdateCurrent({ + currentConfig: this.customConfig, + form: this.form, + formConfig: this.form.value, + success: $localize`Advanced configuration updated.` + }) + } +} diff --git a/client/src/app/+admin/config/pages/admin-config-common.scss b/client/src/app/+admin/config/pages/admin-config-common.scss new file mode 100644 index 000000000..5d9469b69 --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-common.scss @@ -0,0 +1,80 @@ +@use "_variables" as *; +@use "_mixins" as *; +@use "_form-mixins" as *; + +$form-base-input-width: 340px; +$form-max-width: 500px; + +form { + padding-bottom: 1.5rem; +} + +my-markdown-textarea { + display: block; + max-width: $form-max-width; +} + +.homepage my-markdown-textarea { + display: block; + max-width: 100%; + + ::ng-deep textarea { + height: 300px !important; + } +} + +input[type="text"], +input[type="number"] { + @include peertube-input-text($form-base-input-width); +} + +input[type="checkbox"] { + @include peertube-checkbox; +} + +.peertube-select-container { + @include peertube-select-container($form-base-input-width); +} + +my-select-checkbox, +my-select-options, +my-select-custom-value { + display: block; + + @include responsive-width($form-base-input-width); +} + +.inner-form-description { + font-size: 14px; + margin-bottom: 1rem; + color: pvar(--fg-300); +} + +textarea { + max-width: 100%; + display: block; + + @include peertube-textarea(500px, 150px); + + &.small { + height: 75px; + } +} + +.disabled-checkbox-extra { + &, + ::ng-deep label { + opacity: 0.5; + pointer-events: none; + } +} + +my-actor-banner-edit { + max-width: $form-max-width; +} + +h4 { + font-weight: $font-bold; + margin-bottom: 0.5rem; + font-size: 1rem; +} diff --git a/client/src/app/+admin/config/pages/admin-config-customization.component.html b/client/src/app/+admin/config/pages/admin-config-customization.component.html new file mode 100644 index 000000000..b12ab0cde --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-customization.component.html @@ -0,0 +1,178 @@ + + +
+
+

APPEARANCE

+
+ +
+ +
+ + + +
+
+ + + + +
+ +
+
+
+
+
+
+ +
+
+

CUSTOMIZATION

+ +
+ Use plugins & themes for more involved changes +
+
+ +
+ +
UI customization only applies if the user is using the default platform theme.
+
+ + @if (getCurrentThemeName() !== getDefaultThemeName()) { + + + +
You can't preview the changes because you aren't using your platform's default theme.
+
Current theme: {{ getCurrentThemeLabel() }}
+
Platform theme: {{ getDefaultThemeLabel() }}.
+
+ } @else { + + + +
You can preview your UI customization but don't forget to save your changes once you are happy with the results.
+
+ } + +
+ + @for (field of customizationFormFields; track field.name) { +
+ + + + +
{{ field.description }}
+ + @if (field.type === 'color') { + + } @else if (field.type === 'pixels' && isCustomizationFieldNumber(field.name)) { +
+ + pixels +
+ } @else { + + } +
+ } +
+
+
+
+ +
+
+
+ +

Advanced

+
+ Advanced modifications to your PeerTube platform if creating a plugin or a theme is overkill. +
+
+ +
+ + +
+ + + +

Write JavaScript code directly. Example:

+
console.log('my instance is amazing');
+
+
+ + + + +
+ +
+ + + + +

Write CSS code directly. Example:

+
+#custom-css {{ '{' }}
+color: red;
+{{ '}' }}
+
+

Prepend with #custom-css to override styles. Example:

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

APPEARANCE

+ -
- Use plugins & themes for more involved changes, or add slight customizations. -
+ +
+
+

BEHAVIOR

- -
- - - -
-
-
@@ -30,7 +20,7 @@ [clearable]="false" > - +
@@ -47,24 +37,13 @@
- +
- - -
- -
-
-
-
@@ -73,8 +52,11 @@ i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu" > - ⚠️ You don't have any external auth plugin enabled. - ⚠️ You have multiple external auth plugins enabled. + @if (countExternalAuth() === 0) { + ⚠️ You don't have any external auth plugin enabled. + } @else if (countExternalAuth() > 1) { + ⚠️ You have multiple external auth plugins enabled. + }
@@ -85,11 +67,11 @@
-
+

BROADCAST MESSAGE

- Display a message on your instance + Display a message on your platform
@@ -122,7 +104,7 @@
- +
@@ -130,10 +112,10 @@ - +
@@ -144,9 +126,6 @@

NEW USERS

-
- Manage users to set their quota individually. -
@@ -160,7 +139,7 @@ ⚠️ This functionality requires a lot of attention and extra moderation. - + {{ signupAlertMessage }} @@ -180,17 +159,17 @@
- When the total number of users in your instance reaches this limit, registrations are disabled. -1 == unlimited + When the total number of users in your platform reaches this limit, registrations are disabled. -1 == unlimited
- {form().value['signup']['limit'], plural, =1 {user} other {users}} + {form.value.signup.limit, plural, =1 {user} other {users}}
- + Signup won't be limited to a fixed number of users.
@@ -201,12 +180,12 @@
- {form().value['signup']['minimumAge'], plural, =1 {year old} other {years old}} + {form.value.signup.minimumAge, plural, =1 {year old} other {years old}}
- +
@@ -215,7 +194,7 @@
- + - +
- + - +
@@ -261,9 +240,9 @@
-
+
-

VIDEOS

+

VIDEO IMPORTS

@@ -281,7 +260,7 @@ jobs in parallel
- +
@@ -328,16 +307,25 @@
- {form().value['import']['videoChannelSynchronization']['maxPerUser'], plural, =1 {sync} other {syncs}} + {form.value.import.videoChannelSynchronization.maxPerUser, plural, =1 {sync} other {syncs}}
- +
+
+
+ +
+
+

VIDEOS

+
+ +
@@ -414,7 +402,7 @@
-
+

VIDEO CHANNELS

@@ -426,17 +414,17 @@
- {form().value['videoChannels']['maxPerUser'], plural, =1 {channel} other {channels}} + {form.value.videoChannels.maxPerUser, plural, =1 {channel} other {channels}}
- +
-
+

SEARCH

@@ -452,7 +440,7 @@ i18n-labelText labelText="Allow users to do remote URI/handle search" > - Allow your users to look up remote videos/actors that may not be federated with your instance + Allow your users to look up remote videos/actors that may not be federated with your platform
@@ -463,7 +451,7 @@ i18n-labelText labelText="Allow anonymous to do remote URI/handle search" > - Allow anonymous users to look up remote videos/actors that may not be federated with your instance + Allow anonymous users to look up remote videos/actors that may not be federated with your platform
@@ -477,23 +465,23 @@ i18n-labelText labelText="Enable global search" > -
⚠️ This functionality depends heavily on the moderation of instances followed by the search index you select.
+
⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select.
-
+
Use your your own search index or choose the official one, https://sepiasearch.org, that is not moderated.
- +
@@ -509,7 +497,7 @@ i18n-labelText labelText="Search bar uses the global search index by default" > - Otherwise the local search stays used by default + Otherwise, the local search will be used by default
@@ -525,7 +513,7 @@
-
+

USER IMPORT/EXPORT

@@ -577,7 +565,7 @@ [clearable]="false" > - +
@@ -587,7 +575,7 @@
The archive file is deleted after this period.
- +
@@ -599,11 +587,11 @@
-
+

FEDERATION

- Manage relations with other instances. + Manage relations with other platforms.
@@ -615,14 +603,14 @@
@@ -635,7 +623,7 @@
⚠️ This functionality requires a lot of attention and extra moderation. @@ -648,7 +636,7 @@
⚠️ This functionality requires a lot of attention and extra moderation.
@@ -663,9 +651,9 @@ - +
@@ -678,68 +666,4 @@
-
-
-

ADMINISTRATORS

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

TWITTER/X

- -
- Extra configuration required by Twitter/X. All other social media (Facebook, Mastodon, etc.) are supported out of the box. -
-
- -
- - - - -
- - -
-

Indicates the Twitter/X account for the website or platform where the content was published.

- -

This is just an extra information injected in PeerTube HTML that is required by Twitter/X. If you don't have a Twitter/X account, just leave the default value.

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

HOMEPAGE

+
+ +
+
+ +
+ +
+ + + + +
+
+
diff --git a/client/src/app/+admin/config/pages/admin-config-homepage.component.ts b/client/src/app/+admin/config/pages/admin-config-homepage.component.ts new file mode 100644 index 000000000..9357af7cf --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-homepage.component.ts @@ -0,0 +1,83 @@ +import { CommonModule } from '@angular/common' +import { Component, inject, OnInit } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { ActivatedRoute } from '@angular/router' +import { CanComponentDeactivate, Notifier } from '@app/core' +import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' +import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service' +import { FormReactiveErrors, FormReactiveMessages, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service' +import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component' +import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component' +import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' + +type Form = { + homepageContent: FormControl +} + +@Component({ + selector: 'my-admin-config-homepage', + templateUrl: './admin-config-homepage.component.html', + styleUrls: [ './admin-config-common.scss' ], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + CustomMarkupHelpComponent, + MarkdownTextareaComponent, + AdminSaveBarComponent + ] +}) +export class AdminConfigHomepageComponent implements OnInit, CanComponentDeactivate { + private formReactiveService = inject(FormReactiveService) + private notifier = inject(Notifier) + + private route = inject(ActivatedRoute) + private customMarkup = inject(CustomMarkupService) + private customPage = inject(CustomPageService) + + form: FormGroup + formErrors: FormReactiveErrors = {} + validationMessages: FormReactiveMessages = {} + + ngOnInit () { + this.buildForm() + } + + canDeactivate () { + return { canDeactivate: !this.form.dirty } + } + + getCustomMarkdownRenderer () { + return this.customMarkup.getCustomMarkdownRenderer() + } + + save () { + this.customPage.updateInstanceHomepage(this.form.value.homepageContent) + .subscribe({ + next: () => { + this.form.markAsPristine() + + this.notifier.success($localize`Homepage updated.`) + }, + + error: err => this.notifier.error(err.message) + }) + } + + private buildForm () { + const obj: BuildFormArgument = { + homepageContent: null + } + + const { + form, + formErrors, + validationMessages + } = this.formReactiveService.buildForm(obj, { homepageContent: this.route.snapshot.data['homepageContent'] }) + + this.form = form + this.formErrors = formErrors + this.validationMessages = validationMessages + } +} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html b/client/src/app/+admin/config/pages/admin-config-information.component.html similarity index 68% rename from client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html rename to client/src/app/+admin/config/pages/admin-config-information.component.html index efc36c750..ff39f56ad 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html +++ b/client/src/app/+admin/config/pages/admin-config-information.component.html @@ -1,17 +1,47 @@ - + + + + +
+
+

ADMINISTRATORS

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

INSTANCE

+

PLATFORM

-
+

Square icon can be used on your custom homepage.

@@ -25,7 +55,7 @@
-
+

Banner is displayed in the about, login and registration pages and be used on your custom homepage.

It can also be displayed on external websites to promote your instance, such as JoinPeerTube.org.

@@ -41,10 +71,10 @@ - +
@@ -52,22 +82,22 @@ - +
-
+
@@ -77,7 +107,7 @@
@@ -91,7 +121,7 @@
@@ -101,20 +131,20 @@
-
PeerTube uses this setting to explain to your users which law they must follow in the "About" pages
+
PeerTube uses this setting to explain to your users which law they must follow in the "About" pages
- +
-
+

SOCIAL

@@ -126,25 +156,25 @@
-
Explain to your users how to support your platform. If set, PeerTube will display a "Support" button in "About" instance pages
+
Explain to your users how to support your platform. If set, PeerTube will display a "Support" button in "About" instance pages
-
Link to your main website
+
Link to your main website
- +
@@ -152,10 +182,10 @@ - +
@@ -163,10 +193,10 @@ - +
@@ -174,7 +204,7 @@
-
+

MODERATION & SENSITIVE CONTENT

@@ -205,7 +235,7 @@ formControlName="defaultNSFWPolicy" > - +
@@ -213,7 +243,7 @@
@@ -222,74 +252,74 @@
-
Who moderates the instance? What is the policy regarding sensitive content? Political videos? etc
+
Who moderates the instance? What is the policy regarding sensitive content? Political videos? etc
-
+
-

YOU AND YOUR INSTANCE

+

YOU AND YOUR PLATFORM

-
A single person? A non-profit? A company?
+
A single person? A non-profit? A company?
-
To share your personal videos? To open registrations and allow people to upload what they want?
+
To share your personal videos? To open registrations and allow people to upload what they want?
-
It's important to know for users who want to register on your instance
+
It's important to know for users who want to register on your instance
-
With your own funds? With user donations? Advertising?
+
With your own funds? With user donations? Advertising?
-
+

OTHER INFORMATION

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

LIVE

- Enable users of your instance to stream live. + Enable users of your platform to stream a live.
@@ -46,16 +48,16 @@
- + (-1 for "unlimited")
- {form().value['live']['maxInstanceLives'], plural, =1 {live} other {lives}} + {form.value.live.maxInstanceLives, plural, =1 {live} other {lives}}
- +
@@ -64,10 +66,10 @@
- {form().value['live']['maxUserLives'], plural, =1 {live} other {lives}} + {form.value.live.maxUserLives, plural, =1 {live} other {lives}}
- +
@@ -75,7 +77,7 @@ - +
@@ -123,7 +125,7 @@ FPS
- +
@@ -193,7 +195,7 @@ formControlName="threads" [clearable]="false" > - +
@@ -202,7 +204,7 @@ - +
diff --git a/client/src/app/+admin/config/pages/admin-config-live.component.ts b/client/src/app/+admin/config/pages/admin-config-live.component.ts new file mode 100644 index 000000000..bdf3271fa --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-live.component.ts @@ -0,0 +1,224 @@ +import { CommonModule } from '@angular/common' +import { Component, OnInit, inject } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { ActivatedRoute, RouterLink } from '@angular/router' +import { CanComponentDeactivate, ServerService } from '@app/core' +import { + MAX_INSTANCE_LIVES_VALIDATOR, + MAX_LIVE_DURATION_VALIDATOR, + MAX_USER_LIVES_VALIDATOR, + TRANSCODING_MAX_FPS_VALIDATOR, + TRANSCODING_THREADS_VALIDATOR +} from '@app/shared/form-validators/custom-config-validators' +import { + BuildFormArgumentTyped, + FormDefaultTyped, + FormReactiveErrorsTyped, + FormReactiveMessagesTyped +} from '@app/shared/form-validators/form-validator.model' +import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { CustomConfig } from '@peertube/peertube-models' +import { SelectOptionsItem } from 'src/types/select-options-item.model' +import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' +import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component' +import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component' +import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive' +import { AdminConfigService, FormResolutions, ResolutionOption } from '../shared/admin-config.service' +import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' + +type Form = { + live: FormGroup<{ + enabled: FormControl + allowReplay: FormControl + latencySetting: FormGroup<{ + enabled: FormControl + }> + maxInstanceLives: FormControl + maxUserLives: FormControl + maxDuration: FormControl + + transcoding: FormGroup<{ + enabled: FormControl + + fps: FormGroup<{ + max: FormControl + }> + + resolutions: FormGroup + alwaysTranscodeOriginalResolution: FormControl + + remoteRunners: FormGroup<{ + enabled: FormControl + }> + + threads: FormControl + profile: FormControl + }> + }> +} + +@Component({ + selector: 'my-admin-config-live', + templateUrl: './admin-config-live.component.html', + styleUrls: [ './admin-config-common.scss' ], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + PeertubeCheckboxComponent, + PeerTubeTemplateDirective, + SelectOptionsComponent, + RouterLink, + SelectCustomValueComponent, + AdminSaveBarComponent + ] +}) +export class AdminConfigLiveComponent implements OnInit, CanComponentDeactivate { + private configService = inject(AdminConfigService) + private server = inject(ServerService) + private route = inject(ActivatedRoute) + private formReactiveService = inject(FormReactiveService) + private adminConfigService = inject(AdminConfigService) + + form: FormGroup + formErrors: FormReactiveErrorsTyped = {} + validationMessages: FormReactiveMessagesTyped = {} + + transcodingThreadOptions: SelectOptionsItem[] = [] + transcodingProfiles: SelectOptionsItem[] = [] + + liveMaxDurationOptions: SelectOptionsItem[] = [] + liveResolutions: ResolutionOption[] = [] + + private customConfig: CustomConfig + + ngOnInit () { + this.customConfig = this.route.parent.snapshot.data['customConfig'] + + this.transcodingThreadOptions = this.configService.transcodingThreadOptions + + this.liveMaxDurationOptions = [ + { id: -1, label: $localize`No limit` }, + { id: 1000 * 3600, label: $localize`1 hour` }, + { id: 1000 * 3600 * 3, label: $localize`3 hours` }, + { id: 1000 * 3600 * 5, label: $localize`5 hours` }, + { id: 1000 * 3600 * 10, label: $localize`10 hours` } + ] + + this.liveResolutions = this.adminConfigService.transcodingResolutionOptions + this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles( + this.server.getHTMLConfig().live.transcoding.availableProfiles + ) + + this.buildForm() + } + + private buildForm () { + const obj: BuildFormArgumentTyped = { + live: { + enabled: null, + allowReplay: null, + + maxDuration: MAX_LIVE_DURATION_VALIDATOR, + maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR, + maxUserLives: MAX_USER_LIVES_VALIDATOR, + latencySetting: { + enabled: null + }, + + transcoding: { + enabled: null, + threads: TRANSCODING_THREADS_VALIDATOR, + profile: null, + resolutions: this.adminConfigService.buildFormResolutions(), + alwaysTranscodeOriginalResolution: null, + remoteRunners: { + enabled: null + }, + fps: { + max: TRANSCODING_MAX_FPS_VALIDATOR + } + } + } + } + + const defaultValues: FormDefaultTyped = this.customConfig + + const { + form, + formErrors, + validationMessages + } = this.formReactiveService.buildForm(obj, defaultValues) + + this.form = form + this.formErrors = formErrors + this.validationMessages = validationMessages + } + + canDeactivate () { + return { canDeactivate: !this.form.dirty } + } + + getResolutionKey (resolution: string) { + return 'live.transcoding.resolutions.' + resolution + } + + getLiveRTMPPort () { + return this.server.getHTMLConfig().live.rtmp.port + } + + isLiveEnabled () { + return this.form.value.live.enabled === true + } + + isRemoteRunnerLiveEnabled () { + return this.form.value.live.transcoding.remoteRunners.enabled === true + } + + getDisabledLiveClass () { + return { 'disabled-checkbox-extra': !this.isLiveEnabled() } + } + + getDisabledLiveTranscodingClass () { + return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() } + } + + getDisabledLiveLocalTranscodingClass () { + return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() || this.isRemoteRunnerLiveEnabled() } + } + + isLiveTranscodingEnabled () { + return this.form.value.live.transcoding.enabled === true + } + + getTotalTranscodingThreads () { + return this.adminConfigService.getTotalTranscodingThreads({ + transcoding: this.customConfig.transcoding, + live: { + transcoding: { + enabled: this.form.value.live.transcoding.enabled, + threads: this.form.value.live.transcoding.threads + } + } + }) + } + + save () { + this.adminConfigService.saveAndUpdateCurrent({ + currentConfig: this.customConfig, + form: this.form, + formConfig: this.form.value, + success: $localize`Live configuration updated.` + }) + } + + checkTranscodingConsistentOptions () { + return this.adminConfigService.checkTranscodingConsistentOptions({ + transcoding: this.customConfig.transcoding, + live: { + enabled: this.form.value.live.enabled, + allowReplay: this.form.value.live.allowReplay + } + }) + } +} diff --git a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html b/client/src/app/+admin/config/pages/admin-config-vod.component.html similarity index 92% rename from client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html rename to client/src/app/+admin/config/pages/admin-config-vod.component.html index c91925c77..b6a54bbea 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-vod-transcoding.component.html +++ b/client/src/app/+admin/config/pages/admin-config-vod.component.html @@ -1,10 +1,18 @@ - + + + + +
+
+

TRANSCODING

+ +
+ Process uploaded videos so that they are streamable on any device. Although this is costly in terms of resources, it is a critical part of PeerTube, so proceed with caution. +
+
-
-
- -
+
Estimating a server's capacity to transcode and stream videos isn't easy and we can't tune PeerTube automatically. @@ -15,20 +23,6 @@ However, you may want to read our guidelines before tweaking the following values.
-
-
- -
-
-

TRANSCODING

- -
- Process uploaded videos so that they are in a streamable form that any device can play. Though costly in - resources, this is a critical part of PeerTube, so tread carefully. -
-
- -
@@ -151,7 +145,7 @@ FPS
- +
@@ -220,7 +214,7 @@ [clearable]="false" > - +
@@ -232,7 +226,7 @@ jobs in parallel
- +
@@ -241,7 +235,7 @@ - +
diff --git a/client/src/app/+admin/config/pages/admin-config-vod.component.ts b/client/src/app/+admin/config/pages/admin-config-vod.component.ts new file mode 100644 index 000000000..82d1a04bd --- /dev/null +++ b/client/src/app/+admin/config/pages/admin-config-vod.component.ts @@ -0,0 +1,295 @@ +import { CommonModule } from '@angular/common' +import { Component, OnInit, inject } from '@angular/core' +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' +import { ActivatedRoute, RouterLink } from '@angular/router' +import { CanComponentDeactivate, Notifier, ServerService } from '@app/core' +import { + CONCURRENCY_VALIDATOR, + TRANSCODING_MAX_FPS_VALIDATOR, + TRANSCODING_THREADS_VALIDATOR +} from '@app/shared/form-validators/custom-config-validators' +import { + BuildFormArgumentTyped, + FormDefaultTyped, + FormReactiveErrorsTyped, + FormReactiveMessagesTyped +} from '@app/shared/form-validators/form-validator.model' +import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { CustomConfig } from '@peertube/peertube-models' +import { SelectOptionsItem } from 'src/types/select-options-item.model' +import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component' +import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component' +import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component' +import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive' +import { AdminConfigService, FormResolutions, ResolutionOption } from '../shared/admin-config.service' +import { AdminSaveBarComponent } from '../shared/admin-save-bar.component' + +type Form = { + transcoding: FormGroup<{ + enabled: FormControl + allowAdditionalExtensions: FormControl + allowAudioFiles: FormControl + + originalFile: FormGroup<{ + keep: FormControl + }> + + webVideos: FormGroup<{ + enabled: FormControl + }> + + hls: FormGroup<{ + enabled: FormControl + splitAudioAndVideo: FormControl + }> + + fps: FormGroup<{ + max: FormControl + }> + + resolutions: FormGroup + alwaysTranscodeOriginalResolution: FormControl + + remoteRunners: FormGroup<{ + enabled: FormControl + }> + + threads: FormControl + + profile: FormControl + concurrency: FormControl + }> + + videoStudio: FormGroup<{ + enabled: FormControl + remoteRunners: FormGroup<{ + enabled: FormControl + }> + }> +} + +@Component({ + selector: 'my-admin-config-vod', + templateUrl: './admin-config-vod.component.html', + styleUrls: [ './admin-config-common.scss' ], + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + PeertubeCheckboxComponent, + PeerTubeTemplateDirective, + RouterLink, + SelectCustomValueComponent, + SelectOptionsComponent, + AdminSaveBarComponent + ] +}) +export class AdminConfigVODComponent implements OnInit, CanComponentDeactivate { + private configService = inject(AdminConfigService) + private notifier = inject(Notifier) + private server = inject(ServerService) + private route = inject(ActivatedRoute) + private formReactiveService = inject(FormReactiveService) + private adminConfigService = inject(AdminConfigService) + + form: FormGroup + formErrors: FormReactiveErrorsTyped = {} + validationMessages: FormReactiveMessagesTyped = {} + + transcodingThreadOptions: SelectOptionsItem[] = [] + transcodingProfiles: SelectOptionsItem[] = [] + resolutions: ResolutionOption[] = [] + + additionalVideoExtensions = '' + + private customConfig: CustomConfig + + ngOnInit () { + const serverConfig = this.server.getHTMLConfig() + + this.customConfig = this.route.parent.snapshot.data['customConfig'] + + this.transcodingThreadOptions = this.configService.transcodingThreadOptions + this.resolutions = this.adminConfigService.transcodingResolutionOptions + this.additionalVideoExtensions = serverConfig.video.file.extensions.join(' ') + this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(serverConfig.transcoding.availableProfiles) + + this.buildForm() + + this.subscribeToTranscodingChanges() + } + + private buildForm () { + const obj: BuildFormArgumentTyped = { + transcoding: { + enabled: null, + allowAdditionalExtensions: null, + allowAudioFiles: null, + + originalFile: { + keep: null + }, + + webVideos: { + enabled: null + }, + + hls: { + enabled: null, + splitAudioAndVideo: null + }, + + fps: { + max: TRANSCODING_MAX_FPS_VALIDATOR + }, + + resolutions: this.adminConfigService.buildFormResolutions(), + alwaysTranscodeOriginalResolution: null, + + remoteRunners: { + enabled: null + }, + + threads: TRANSCODING_THREADS_VALIDATOR, + + profile: null, + concurrency: CONCURRENCY_VALIDATOR + }, + + videoStudio: { + enabled: null, + remoteRunners: { + enabled: null + } + } + } + + const defaultValues: FormDefaultTyped = this.customConfig + + const { + form, + formErrors, + validationMessages + } = this.formReactiveService.buildForm(obj, defaultValues) + + this.form = form + this.formErrors = formErrors + this.validationMessages = validationMessages + } + + canDeactivate () { + return { canDeactivate: !this.form.dirty } + } + + getResolutionKey (resolution: string) { + return 'transcoding.resolutions.' + resolution + } + + isRemoteRunnerVODEnabled () { + return this.form.value.transcoding.remoteRunners.enabled === true + } + + isTranscodingEnabled () { + return this.form.value.transcoding.enabled === true + } + + isHLSEnabled () { + return this.form.value.transcoding.hls.enabled === true + } + + isStudioEnabled () { + return this.form.value.videoStudio.enabled === true + } + + getTranscodingDisabledClass () { + return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() } + } + + getHLSDisabledClass () { + return { 'disabled-checkbox-extra': !this.isHLSEnabled() } + } + + getLocalTranscodingDisabledClass () { + return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() } + } + + getStudioDisabledClass () { + return { 'disabled-checkbox-extra': !this.isStudioEnabled() } + } + + getTotalTranscodingThreads () { + return this.adminConfigService.getTotalTranscodingThreads({ + live: this.customConfig.live, + transcoding: { + enabled: this.form.value.transcoding.enabled, + threads: this.form.value.transcoding.threads + } + }) + } + + private subscribeToTranscodingChanges () { + const controls = this.form.controls + + const transcodingControl = controls.transcoding.controls.enabled + const videoStudioControl = controls.videoStudio.controls.enabled + const hlsControl = controls.transcoding.controls.hls.controls.enabled + const webVideosControl = controls.transcoding.controls.webVideos.controls.enabled + + webVideosControl.valueChanges + .subscribe(newValue => { + if (newValue === false && hlsControl.value === false) { + hlsControl.setValue(true) + + this.notifier.info( + $localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`, + '', + 10000 + ) + } + }) + + hlsControl.valueChanges + .subscribe(newValue => { + if (newValue === false && webVideosControl.value === false) { + webVideosControl.setValue(true) + + this.notifier.info( + // eslint-disable-next-line max-len + $localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`, + '', + 10000 + ) + } + }) + + transcodingControl.valueChanges + .subscribe(newValue => { + if (newValue === false) { + videoStudioControl.setValue(false) + } + }) + + transcodingControl.updateValueAndValidity() + webVideosControl.updateValueAndValidity() + videoStudioControl.updateValueAndValidity() + hlsControl.updateValueAndValidity() + } + + save () { + this.adminConfigService.saveAndUpdateCurrent({ + currentConfig: this.customConfig, + form: this.form, + formConfig: this.form.value, + success: $localize`VOD configuration updated.` + }) + } + + checkTranscodingConsistentOptions () { + return this.adminConfigService.checkTranscodingConsistentOptions({ + transcoding: { + enabled: this.form.value.transcoding.enabled + }, + live: this.customConfig.live + }) + } +} diff --git a/client/src/app/+admin/config/pages/index.ts b/client/src/app/+admin/config/pages/index.ts new file mode 100644 index 000000000..4fc85839d --- /dev/null +++ b/client/src/app/+admin/config/pages/index.ts @@ -0,0 +1,6 @@ +export * from './admin-config-advanced.component' +export * from './admin-config-general.component' +export * from './admin-config-homepage.component' +export * from './admin-config-information.component' +export * from './admin-config-live.component' +export * from './admin-config-vod.component' diff --git a/client/src/app/+admin/config/shared/admin-config.service.ts b/client/src/app/+admin/config/shared/admin-config.service.ts new file mode 100644 index 000000000..7f44fba04 --- /dev/null +++ b/client/src/app/+admin/config/shared/admin-config.service.ts @@ -0,0 +1,210 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable, inject } from '@angular/core' +import { FormControl, FormGroup } from '@angular/forms' +import { Notifier, RestExtractor, ServerService } from '@app/core' +import { formatICU } from '@app/helpers' +import { BuildFormValidator } from '@app/shared/form-validators/form-validator.model' +import { CustomConfig } from '@peertube/peertube-models' +import { DeepPartial } from '@peertube/peertube-typescript-utils' +import merge from 'lodash-es/merge' +import { catchError, map, switchMap } from 'rxjs/operators' +import { environment } from '../../../../environments/environment' +import { SelectOptionsItem } from '../../../../types/select-options-item.model' + +export type FormResolutions = { + '0p': FormControl + '144p': FormControl + '240p': FormControl + '360p': FormControl + '480p': FormControl + '720p': FormControl + '1080p': FormControl + '1440p': FormControl + '2160p': FormControl +} + +export type ResolutionOption = { id: keyof FormResolutions, label: string, description?: string } + +@Injectable() +export class AdminConfigService { + private authHttp = inject(HttpClient) + private restExtractor = inject(RestExtractor) + private notifier = inject(Notifier) + private serverService = inject(ServerService) + + private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config' + + transcodingThreadOptions: SelectOptionsItem[] = [] + transcodingResolutionOptions: ResolutionOption[] = [] + + constructor () { + this.transcodingThreadOptions = [ + { id: 0, label: $localize`Auto (via ffmpeg)` }, + { id: 1, label: '1' }, + { id: 2, label: '2' }, + { id: 4, label: '4' }, + { id: 8, label: '8' }, + { id: 12, label: '12' }, + { id: 16, label: '16' }, + { id: 32, label: '32' } + ] + + this.transcodingResolutionOptions = [ + { + id: '0p', + label: $localize`Audio-only`, + description: + $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users` + }, + { + id: '144p', + label: $localize`144p` + }, + { + id: '240p', + label: $localize`240p` + }, + { + id: '360p', + label: $localize`360p` + }, + { + id: '480p', + label: $localize`480p` + }, + { + id: '720p', + label: $localize`720p` + }, + { + id: '1080p', + label: $localize`1080p` + }, + { + id: '1440p', + label: $localize`1440p` + }, + { + id: '2160p', + label: $localize`2160p` + } + ] + } + + // --------------------------------------------------------------------------- + + getCustomConfig () { + return this.authHttp.get(AdminConfigService.BASE_APPLICATION_URL + '/custom') + .pipe(catchError(res => this.restExtractor.handleError(res))) + } + + updateCustomConfig (partialConfig: DeepPartial) { + return this.getCustomConfig() + .pipe( + switchMap(customConfig => { + const newConfig = merge(customConfig, partialConfig) + + return this.authHttp.put(AdminConfigService.BASE_APPLICATION_URL + '/custom', newConfig) + .pipe(map(() => newConfig)) + }), + catchError(res => this.restExtractor.handleError(res)) + ) + } + + saveAndUpdateCurrent (options: { + currentConfig: CustomConfig + form: FormGroup + formConfig: DeepPartial + success: string + }) { + const { currentConfig, form, formConfig, success } = options + + this.updateCustomConfig(formConfig) + .pipe(switchMap(() => this.serverService.resetConfig())) + .subscribe({ + next: newConfig => { + Object.assign(currentConfig, newConfig) + + form.markAsPristine() + + this.notifier.success(success) + }, + + error: err => this.notifier.error(err.message) + }) + } + + // --------------------------------------------------------------------------- + + buildFormResolutions () { + const formResolutions = {} as Record + + for (const resolution of this.transcodingResolutionOptions) { + formResolutions[resolution.id] = null + } + + return formResolutions + } + + buildTranscodingProfiles (profiles: string[]) { + return profiles.map(p => { + if (p === 'default') { + return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` } + } + + return { id: p, label: p } + }) + } + + getTotalTranscodingThreads (options: { + transcoding: { + enabled: boolean + threads: number + } + live: { + transcoding: { + enabled: boolean + threads: number + } + } + }) { + const transcodingEnabled = options.transcoding.enabled + const transcodingThreads = options.transcoding.threads + const liveTranscodingEnabled = options.live.transcoding.enabled + const liveTranscodingThreads = options.live.transcoding.threads + + // checks whether all enabled method are on fixed values and not on auto (= 0) + let noneOnAuto = !transcodingEnabled || +transcodingThreads > 0 + noneOnAuto &&= !liveTranscodingEnabled || +liveTranscodingThreads > 0 + + // count total of fixed value, repalcing auto by a single thread (knowing it will display "at least") + let value = 0 + if (transcodingEnabled) value += +transcodingThreads || 1 + if (liveTranscodingEnabled) value += +liveTranscodingThreads || 1 + + return { + value, + atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible + unit: formatICU($localize`{value, plural, =1 {thread} other {threads}}`, { value }) + } + } + + checkTranscodingConsistentOptions (options: { + transcoding: { + enabled: boolean + } + live: { + enabled: boolean + allowReplay: boolean + } + }) { + if ( + options.transcoding.enabled === false && + options.live.enabled === true && options.live.allowReplay === true + ) { + return $localize`You cannot allow live replay if you don't enable transcoding.` + } + + return undefined + } +} diff --git a/client/src/app/+admin/config/shared/admin-save-bar.component.html b/client/src/app/+admin/config/shared/admin-save-bar.component.html new file mode 100644 index 000000000..f24cd8642 --- /dev/null +++ b/client/src/app/+admin/config/shared/admin-save-bar.component.html @@ -0,0 +1,28 @@ +
+
+

{{ title() }}

+ + Save +
+ + @if (!isUpdateAllowed()) { + + Updating platform configuration from the web interface is disabled by the system administrator. + + } @else if (displayFormErrors && !form().valid) { + + There are errors in the configuration: + +
    +
  • {{ error }}
  • +
+
+ } + + @if (inconsistentOptions()) { + {{ inconsistentOptions() }} + } +
diff --git a/client/src/app/+admin/config/shared/admin-save-bar.component.scss b/client/src/app/+admin/config/shared/admin-save-bar.component.scss new file mode 100644 index 000000000..dc171e4d8 --- /dev/null +++ b/client/src/app/+admin/config/shared/admin-save-bar.component.scss @@ -0,0 +1,55 @@ +@use "_variables" as *; +@use "_mixins" as *; +@use "_form-mixins" as *; +@import "bootstrap/scss/mixins"; + +.root { + position: sticky; + top: pvar(--header-height); + z-index: 11; + background-color: pvar(--bg); + + @include rfs(3rem, margin-bottom); +} + +.root-bar { + display: flex; + gap: 0.5rem; + width: 100%; + min-width: 0; + justify-content: center; + + border-radius: 14px; + background-color: pvar(--bg-secondary-350); + + @include rfs(1.5rem, padding); +} + +.save-button { + @include margin-left(auto); +} + +h2 { + flex-shrink: 1; + color: pvar(--fg-350); + font-weight: $font-bold; + margin-bottom: 0; + line-height: normal; + + @include margin-left(auto); + @include font-size(1.5rem); + @include ellipsis; +} + +@media screen and (max-width: $small-view) { + .root-bar { + flex-direction: column; + align-items: center; + padding: 0.5rem; + } + + .save-button, + h2 { + @include margin-left(0); + } +} diff --git a/client/src/app/+admin/config/shared/admin-save-bar.component.ts b/client/src/app/+admin/config/shared/admin-save-bar.component.ts new file mode 100644 index 000000000..4c6d6341b --- /dev/null +++ b/client/src/app/+admin/config/shared/admin-save-bar.component.ts @@ -0,0 +1,74 @@ +import { CommonModule } from '@angular/common' +import { Component, inject, input, OnDestroy, OnInit, output } from '@angular/core' +import { FormGroup } from '@angular/forms' +import { RouterModule } from '@angular/router' +import { ScreenService, ServerService } from '@app/core' +import { HeaderService } from '@app/header/header.service' +import { FormReactiveErrors, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' +import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component' +import { AlertComponent } from '../../../shared/shared-main/common/alert.component' + +@Component({ + selector: 'my-admin-save-bar', + styleUrls: [ './admin-save-bar.component.scss' ], + templateUrl: './admin-save-bar.component.html', + imports: [ + CommonModule, + RouterModule, + ButtonComponent, + AlertComponent + ] +}) +export class AdminSaveBarComponent implements OnInit, OnDestroy { + private formReactiveService = inject(FormReactiveService) + private server = inject(ServerService) + private headerService = inject(HeaderService) + private screenService = inject(ScreenService) + + readonly title = input.required() + readonly form = input.required() + readonly formErrors = input.required() + readonly inconsistentOptions = input() + + readonly save = output() + + displayFormErrors = false + + ngOnInit () { + if (this.screenService.isInMobileView()) { + this.headerService.setSearchHidden(true) + } + } + + ngOnDestroy () { + this.headerService.setSearchHidden(false) + } + + isUpdateAllowed () { + return this.server.getHTMLConfig().webadmin.configuration.edition.allowed === true + } + + canUpdate () { + if (!this.isUpdateAllowed()) return false + if (this.inconsistentOptions()) return false + + return this.form().dirty + } + + grabAllErrors () { + return this.formReactiveService.grabAllErrors(this.formErrors()) + } + + onSave (event: Event) { + this.displayFormErrors = false + + if (this.form().valid) { + this.save.emit() + return + } + + event.preventDefault() + + this.displayFormErrors = true + } +} diff --git a/client/src/app/+admin/config/shared/config.service.ts b/client/src/app/+admin/config/shared/config.service.ts deleted file mode 100644 index 2f500664e..000000000 --- a/client/src/app/+admin/config/shared/config.service.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { catchError } from 'rxjs/operators' -import { HttpClient } from '@angular/common/http' -import { Injectable, inject } from '@angular/core' -import { RestExtractor } from '@app/core' -import { CustomConfig } from '@peertube/peertube-models' -import { SelectOptionsItem } from '../../../../types/select-options-item.model' -import { environment } from '../../../../environments/environment' - -@Injectable() -export class ConfigService { - private authHttp = inject(HttpClient) - private restExtractor = inject(RestExtractor) - - private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config' - - videoQuotaOptions: SelectOptionsItem[] = [] - videoQuotaDailyOptions: SelectOptionsItem[] = [] - transcodingThreadOptions: SelectOptionsItem[] = [] - - constructor () { - this.videoQuotaOptions = [ - { id: -1, label: $localize`Unlimited` }, - { id: 0, label: $localize`None - no upload possible` }, - { id: 100 * 1024 * 1024, label: $localize`100MB` }, - { id: 500 * 1024 * 1024, label: $localize`500MB` }, - { id: 1024 * 1024 * 1024, label: $localize`1GB` }, - { id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` }, - { id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` }, - { id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` }, - { id: 100 * 1024 * 1024 * 1024, label: $localize`100GB` }, - { id: 200 * 1024 * 1024 * 1024, label: $localize`200GB` }, - { id: 500 * 1024 * 1024 * 1024, label: $localize`500GB` } - ] - - this.videoQuotaDailyOptions = [ - { id: -1, label: $localize`Unlimited` }, - { id: 0, label: $localize`None - no upload possible` }, - { id: 10 * 1024 * 1024, label: $localize`10MB` }, - { id: 50 * 1024 * 1024, label: $localize`50MB` }, - { id: 100 * 1024 * 1024, label: $localize`100MB` }, - { id: 500 * 1024 * 1024, label: $localize`500MB` }, - { id: 2 * 1024 * 1024 * 1024, label: $localize`2GB` }, - { id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` }, - { id: 10 * 1024 * 1024 * 1024, label: $localize`10GB` }, - { id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` }, - { id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` } - ] - - this.transcodingThreadOptions = [ - { id: 0, label: $localize`Auto (via ffmpeg)` }, - { id: 1, label: '1' }, - { id: 2, label: '2' }, - { id: 4, label: '4' }, - { id: 8, label: '8' }, - { id: 12, label: '12' }, - { id: 16, label: '16' }, - { id: 32, label: '32' } - ] - } - - getCustomConfig () { - return this.authHttp.get(ConfigService.BASE_APPLICATION_URL + '/custom') - .pipe(catchError(res => this.restExtractor.handleError(res))) - } - - updateCustomConfig (data: CustomConfig) { - return this.authHttp.put(ConfigService.BASE_APPLICATION_URL + '/custom', data) - .pipe(catchError(res => this.restExtractor.handleError(res))) - } -} diff --git a/client/src/app/+admin/overview/users/user-edit/user-create.component.ts b/client/src/app/+admin/overview/users/user-edit/user-create.component.ts index e7c9ae6ec..8967f5657 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-create.component.ts +++ b/client/src/app/+admin/overview/users/user-edit/user-create.component.ts @@ -2,7 +2,7 @@ import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common' import { Component, OnInit, inject } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { Router, RouterLink } from '@angular/router' -import { ConfigService } from '@app/+admin/config/shared/config.service' +import { AdminConfigService } from '@app/+admin/config/shared/admin-config.service' import { AuthService, Notifier, ScreenService, ServerService } from '@app/core' import { USER_CHANNEL_NAME_VALIDATOR, @@ -54,7 +54,7 @@ import { UserPasswordComponent } from './user-password.component' export class UserCreateComponent extends UserEdit implements OnInit { protected serverService = inject(ServerService) protected formReactiveService = inject(FormReactiveService) - protected configService = inject(ConfigService) + protected configService = inject(AdminConfigService) protected screenService = inject(ScreenService) protected auth = inject(AuthService) private router = inject(Router) diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.ts b/client/src/app/+admin/overview/users/user-edit/user-edit.ts index 5c1c9a94e..5f506f396 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-edit.ts +++ b/client/src/app/+admin/overview/users/user-edit/user-edit.ts @@ -1,10 +1,11 @@ import { Directive, OnInit } from '@angular/core' -import { ConfigService } from '@app/+admin/config/shared/config.service' +import { AdminConfigService } from '@app/+admin/config/shared/admin-config.service' +import { getVideoQuotaDailyOptions, getVideoQuotaOptions } from '@app/+admin/shared/user-quota-options' import { AuthService, ScreenService, ServerService, User } from '@app/core' +import { FormReactive } from '@app/shared/shared-forms/form-reactive' import { peertubeTranslate, USER_ROLE_LABELS } from '@peertube/peertube-core-utils' import { HTMLServerConfig, UserAdminFlag, UserRole } from '@peertube/peertube-models' import { SelectOptionsItem } from '../../../../../types/select-options-item.model' -import { FormReactive } from '@app/shared/shared-forms/form-reactive' @Directive() export abstract class UserEdit extends FormReactive implements OnInit { @@ -18,7 +19,7 @@ export abstract class UserEdit extends FormReactive implements OnInit { protected serverConfig: HTMLServerConfig protected abstract serverService: ServerService - protected abstract configService: ConfigService + protected abstract configService: AdminConfigService protected abstract screenService: ScreenService protected abstract auth: AuthService abstract isCreation (): boolean @@ -88,7 +89,7 @@ export abstract class UserEdit extends FormReactive implements OnInit { } protected buildQuotaOptions () { - this.videoQuotaOptions = this.configService.videoQuotaOptions - this.videoQuotaDailyOptions = this.configService.videoQuotaDailyOptions + this.videoQuotaOptions = getVideoQuotaOptions() + this.videoQuotaDailyOptions = getVideoQuotaDailyOptions() } } diff --git a/client/src/app/+admin/overview/users/user-edit/user-update.component.ts b/client/src/app/+admin/overview/users/user-edit/user-update.component.ts index 3bf31ca9b..bec1fa8ff 100644 --- a/client/src/app/+admin/overview/users/user-edit/user-update.component.ts +++ b/client/src/app/+admin/overview/users/user-edit/user-update.component.ts @@ -2,7 +2,7 @@ import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common' import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { ActivatedRoute, Router, RouterLink } from '@angular/router' -import { ConfigService } from '@app/+admin/config/shared/config.service' +import { AdminConfigService } from '@app/+admin/config/shared/admin-config.service' import { AuthService, Notifier, ScreenService, ServerService, User, UserService } from '@app/core' import { USER_EMAIL_VALIDATOR, @@ -52,7 +52,7 @@ import { UserPasswordComponent } from './user-password.component' export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy { protected formReactiveService = inject(FormReactiveService) protected serverService = inject(ServerService) - protected configService = inject(ConfigService) + protected configService = inject(AdminConfigService) protected screenService = inject(ScreenService) protected auth = inject(AuthService) private route = inject(ActivatedRoute) diff --git a/client/src/app/+admin/routes.ts b/client/src/app/+admin/routes.ts index 95837b4fd..a09f4eb45 100644 --- a/client/src/app/+admin/routes.ts +++ b/client/src/app/+admin/routes.ts @@ -1,5 +1,5 @@ import { Route, Routes, UrlSegment } from '@angular/router' -import { configRoutes, EditConfigurationService } from '@app/+admin/config' +import { configRoutes } from '@app/+admin/config' import { moderationRoutes } from '@app/+admin/moderation/moderation.routes' import { pluginsRoutes } from '@app/+admin/plugins/plugins.routes' import { DebugService, JobService, LogsService, RunnerService, systemRoutes } from '@app/+admin/system' @@ -21,7 +21,7 @@ import { WatchedWordsListService } from '@app/shared/standalone-watched-words/wa import { AdminModerationComponent } from './admin-moderation.component' import { AdminOverviewComponent } from './admin-overview.component' import { AdminSettingsComponent } from './admin-settings.component' -import { ConfigService } from './config/shared/config.service' +import { AdminConfigService } from './config/shared/admin-config.service' import { followsRoutes } from './follows' import { AdminRegistrationService } from './moderation/registration-list' import { overviewRoutes, VideoAdminService } from './overview' @@ -37,7 +37,6 @@ const commonConfig = { CustomMarkupService, CustomPageService, DebugService, - EditConfigurationService, InstanceFollowService, JobService, LogsService, @@ -48,7 +47,7 @@ const commonConfig = { VideoAdminService, VideoBlockService, VideoCommentService, - ConfigService, + AdminConfigService, AbuseService, DynamicElementService, FindInBulkService, diff --git a/client/src/app/+admin/shared/user-quota-options.ts b/client/src/app/+admin/shared/user-quota-options.ts new file mode 100644 index 000000000..4f79c35f1 --- /dev/null +++ b/client/src/app/+admin/shared/user-quota-options.ts @@ -0,0 +1,33 @@ +import { SelectOptionsItem } from '../../../types/select-options-item.model' + +export function getVideoQuotaOptions (): SelectOptionsItem[] { + return [ + { id: -1, label: $localize`Unlimited` }, + { id: 0, label: $localize`None - no upload possible` }, + { id: 100 * 1024 * 1024, label: $localize`100MB` }, + { id: 500 * 1024 * 1024, label: $localize`500MB` }, + { id: 1024 * 1024 * 1024, label: $localize`1GB` }, + { id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` }, + { id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` }, + { id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` }, + { id: 100 * 1024 * 1024 * 1024, label: $localize`100GB` }, + { id: 200 * 1024 * 1024 * 1024, label: $localize`200GB` }, + { id: 500 * 1024 * 1024 * 1024, label: $localize`500GB` } + ] +} + +export function getVideoQuotaDailyOptions (): SelectOptionsItem[] { + return [ + { id: -1, label: $localize`Unlimited` }, + { id: 0, label: $localize`None - no upload possible` }, + { id: 10 * 1024 * 1024, label: $localize`10MB` }, + { id: 50 * 1024 * 1024, label: $localize`50MB` }, + { id: 100 * 1024 * 1024, label: $localize`100MB` }, + { id: 500 * 1024 * 1024, label: $localize`500MB` }, + { id: 2 * 1024 * 1024 * 1024, label: $localize`2GB` }, + { id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` }, + { id: 10 * 1024 * 1024 * 1024, label: $localize`10GB` }, + { id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` }, + { id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` } + ] +} diff --git a/client/src/app/+videos-publish-manage/shared-manage/chapters/video-chapters.component.ts b/client/src/app/+videos-publish-manage/shared-manage/chapters/video-chapters.component.ts index 6f3c2e0de..0d4627e6b 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/chapters/video-chapters.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/chapters/video-chapters.component.ts @@ -2,7 +2,7 @@ import { NgClass, NgFor, NgIf } from '@angular/common' import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { FormArray, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms' import { VIDEO_CHAPTERS_ARRAY_VALIDATOR, VIDEO_CHAPTER_TITLE_VALIDATOR } from '@app/shared/form-validators/video-chapter-validators' -import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' +import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service' import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import debug from 'debug' @@ -52,7 +52,7 @@ export class VideoChaptersComponent implements OnInit, OnDestroy { form: FormGroup formErrors: FormReactiveErrors & { chapters?: { title: string }[] } = {} - validationMessages: FormReactiveValidationMessages = {} + validationMessages: FormReactiveMessages = {} videoEdit: VideoEdit diff --git a/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.ts b/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.ts index 71718c054..22a2e542c 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/customization/video-customization.component.ts @@ -4,7 +4,7 @@ import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angul import { ServerService } from '@app/core' import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' import { VIDEO_ORIGINALLY_PUBLISHED_AT_VALIDATOR } from '@app/shared/form-validators/video-validators' -import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' +import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { HTMLServerConfig } from '@peertube/peertube-models' import debug from 'debug' import { DatePickerModule } from 'primeng/datepicker' @@ -44,7 +44,7 @@ export class VideoCustomizationComponent implements OnInit, OnDestroy { form: FormGroup formErrors: FormReactiveErrors = {} - validationMessages: FormReactiveValidationMessages = {} + validationMessages: FormReactiveMessages = {} videoEdit: VideoEdit diff --git a/client/src/app/+videos-publish-manage/shared-manage/live-settings/video-live-settings.component.ts b/client/src/app/+videos-publish-manage/shared-manage/live-settings/video-live-settings.component.ts index 94a77b93a..d451866ff 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/live-settings/video-live-settings.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/live-settings/video-live-settings.component.ts @@ -3,7 +3,7 @@ import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { ServerService } from '@app/core' import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' -import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' +import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { VideoService } from '@app/shared/shared-main/video/video.service' import { @@ -68,7 +68,7 @@ export class VideoLiveSettingsComponent implements OnInit, OnDestroy { form: FormGroup formErrors: FormReactiveErrors = {} - validationMessages: FormReactiveValidationMessages = {} + validationMessages: FormReactiveMessages = {} videoEdit: VideoEdit diff --git a/client/src/app/+videos-publish-manage/shared-manage/main-info/video-main-info.component.ts b/client/src/app/+videos-publish-manage/shared-manage/main-info/video-main-info.component.ts index 14a114f36..40498cf11 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/main-info/video-main-info.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/main-info/video-main-info.component.ts @@ -18,7 +18,7 @@ import { VIDEO_TAGS_ARRAY_VALIDATOR } from '@app/shared/form-validators/video-validators' import { DynamicFormFieldComponent } from '@app/shared/shared-forms/dynamic-form-field.component' -import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' +import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { FormValidatorService } from '@app/shared/shared-forms/form-validator.service' import { InputTextComponent } from '@app/shared/shared-forms/input-text.component' import { MarkdownTextareaComponent } from '@app/shared/shared-forms/markdown-textarea.component' @@ -120,7 +120,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy { form: FormGroup formErrors: FormReactiveErrors = {} - validationMessages: FormReactiveValidationMessages = {} + validationMessages: FormReactiveMessages = {} forbidScheduledPublication: boolean hideWaitTranscoding: boolean @@ -337,7 +337,7 @@ export class VideoMainInfoComponent implements OnInit, OnDestroy { const { pluginData } = this.videoEdit.toCommonFormPatch() const pluginObj: { [id: string]: BuildFormValidator } = {} - const pluginValidationMessages: FormReactiveValidationMessages = {} + const pluginValidationMessages: FormReactiveMessages = {} const pluginFormErrors: FormReactiveErrors = {} const pluginDefaults: Record = {} diff --git a/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.ts b/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.ts index 5cf8a2b52..d21f075e4 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/moderation/video-moderation.component.ts @@ -5,7 +5,7 @@ import { RouterLink } from '@angular/router' import { ServerService } from '@app/core' import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' import { VIDEO_NSFW_SUMMARY_VALIDATOR } from '@app/shared/form-validators/video-validators' -import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' +import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { HTMLServerConfig, VideoCommentPolicyType, VideoConstant } from '@peertube/peertube-models' import debug from 'debug' import { Subscription } from 'rxjs' @@ -51,7 +51,7 @@ export class VideoModerationComponent implements OnInit, OnDestroy { form: FormGroup formErrors: FormReactiveErrors = {} - validationMessages: FormReactiveValidationMessages = {} + validationMessages: FormReactiveMessages = {} commentPolicies: VideoConstant[] = [] serverConfig: HTMLServerConfig diff --git a/client/src/app/+videos-publish-manage/shared-manage/replace-file/video-replace-file.component.ts b/client/src/app/+videos-publish-manage/shared-manage/replace-file/video-replace-file.component.ts index 80f8c18d2..ab2fc16e5 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/replace-file/video-replace-file.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/replace-file/video-replace-file.component.ts @@ -3,7 +3,7 @@ import { Component, inject, OnDestroy, OnInit, ViewChild } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { ServerService } from '@app/core' import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model' -import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' +import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import debug from 'debug' import { Subscription } from 'rxjs' import { ReactiveFileComponent } from '../../../shared/shared-forms/reactive-file.component' @@ -46,7 +46,7 @@ export class VideoReplaceFileComponent implements OnInit, OnDestroy { form: FormGroup formErrors: FormReactiveErrors = {} - validationMessages: FormReactiveValidationMessages = {} + validationMessages: FormReactiveMessages = {} videoEdit: VideoEdit diff --git a/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.component.ts b/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.component.ts index a30cc7a4f..8818638a8 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/stats/video-stats.component.ts @@ -51,9 +51,9 @@ type Card = { label: string, value: string | number, moreInfo?: string, help?: s const isBarGraph = (graphId: ActiveGraphId): graphId is BarGraphs => BAR_GRAPHS.some(graph => graph === graphId) -ChartJSDefaults.backgroundColor = getComputedStyle(document.body).getPropertyValue('--bg') -ChartJSDefaults.borderColor = getComputedStyle(document.body).getPropertyValue('--bg-secondary-500') -ChartJSDefaults.color = getComputedStyle(document.body).getPropertyValue('--fg') +ChartJSDefaults.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--bg') +ChartJSDefaults.borderColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-secondary-500') +ChartJSDefaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg') @Component({ templateUrl: './video-stats.component.html', @@ -654,7 +654,7 @@ export class VideoStatsComponent implements OnInit { } private buildChartColor () { - return getComputedStyle(document.body).getPropertyValue('--border-primary') + return getComputedStyle(document.documentElement).getPropertyValue('--border-primary') } private formatXTick (options: { diff --git a/client/src/app/+videos-publish-manage/shared-manage/studio/video-studio.component.ts b/client/src/app/+videos-publish-manage/shared-manage/studio/video-studio.component.ts index 56725a13b..966a9669d 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/studio/video-studio.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/studio/video-studio.component.ts @@ -2,7 +2,7 @@ import { NgFor, NgIf } from '@angular/common' import { Component, OnDestroy, OnInit, inject } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { ServerService } from '@app/core' -import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' +import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { ReactiveFileComponent } from '@app/shared/shared-forms/reactive-file.component' import { TimestampInputComponent } from '@app/shared/shared-forms/timestamp-input.component' import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component' @@ -49,7 +49,7 @@ export class VideoStudioEditComponent implements OnInit, OnDestroy { form: FormGroup formErrors: FormReactiveErrors = {} - validationMessages: FormReactiveValidationMessages = {} + validationMessages: FormReactiveMessages = {} isRunningEdit = false diff --git a/client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.html b/client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.html deleted file mode 100644 index 7fbb8f373..000000000 --- a/client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.html +++ /dev/null @@ -1,141 +0,0 @@ - - - diff --git a/client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.ts b/client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.ts index 022889f86..7cf5d63c9 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.ts +++ b/client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.ts @@ -1,27 +1,17 @@ import { CommonModule } from '@angular/common' import { booleanAttribute, Component, inject, input, OnInit } from '@angular/core' -import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { RouterModule } from '@angular/router' import { ServerService } from '@app/core' -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' -import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component' +import { LateralMenuComponent, LateralMenuConfig } from '@app/shared/shared-main/menu/lateral-menu.component' import { getReplaceFileUnavailability, getStudioUnavailability } from './common/unavailable-features' import { VideoEdit } from './common/video-edit.model' -import { UnavailableMenuEntryComponent } from './unavailable-menu-entry.component' import { VideoManageController } from './video-manage-controller.service' @Component({ selector: 'my-video-manage-menu', - styleUrls: [ './video-manage-menu.component.scss' ], - templateUrl: './video-manage-menu.component.html', + template: '', imports: [ CommonModule, - RouterModule, - FormsModule, - ReactiveFormsModule, - NgbTooltipModule, - GlobalIconComponent, - UnavailableMenuEntryComponent + LateralMenuComponent ] }) export class VideoManageMenuComponent implements OnInit { @@ -30,6 +20,8 @@ export class VideoManageMenuComponent implements OnInit { readonly canWatch = input.required({ transform: booleanAttribute }) + menuConfig: LateralMenuConfig + private videoEdit: VideoEdit private replaceFileEnabled: boolean private studioEnabled: boolean @@ -43,6 +35,89 @@ export class VideoManageMenuComponent implements OnInit { const { videoEdit } = this.manageController.getStore() this.videoEdit = videoEdit + + this.menuConfig = { + title: $localize``, + + entries: [ + { + type: 'link', + label: $localize`Main information`, + routerLinkActiveOptions: { exact: true }, + icon: 'film', + routerLink: '.' + }, + { + type: 'link', + isDisplayed: () => this.getVideo().isLive, + label: $localize`Live settings`, + icon: 'live', + routerLink: 'live-settings' + }, + + { + type: 'separator' + }, + + { + type: 'link', + label: $localize`Customization`, + icon: 'cog', + routerLink: 'customization' + }, + { + type: 'link', + label: $localize`Moderation`, + icon: 'moderation', + routerLink: 'moderation' + }, + { + type: 'link', + isDisplayed: () => !this.getVideo().isLive, + label: $localize`Captions`, + icon: 'captions', + routerLink: 'captions' + }, + { + type: 'link', + isDisplayed: () => !this.getVideo().isLive, + label: $localize`Chapters`, + icon: 'chapters', + routerLink: 'chapters' + }, + + { + type: 'separator' + }, + + { + type: 'link', + label: $localize`Studio`, + icon: 'studio', + routerLink: 'studio', + unavailableText: () => this.studioUnavailable() + }, + { + type: 'link', + label: $localize`Replace file`, + icon: 'upload', + routerLink: 'replace-file', + unavailableText: () => this.replaceFileUnavailable() + }, + + { + type: 'separator' + }, + + { + type: 'link', + isDisplayed: () => this.canWatch(), + label: $localize`Statistics`, + icon: 'stats', + routerLink: 'stats' + } + ] + } } getVideo () { diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index e26a729f7..aec1568b9 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts @@ -1,7 +1,5 @@ -import { Observable, of, Subject } from 'rxjs' -import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators' import { HttpClient } from '@angular/common/http' -import { Injectable, LOCALE_ID, inject } from '@angular/core' +import { inject, Injectable, LOCALE_ID } from '@angular/core' import { getDevLocale, isOnDevLocale } from '@app/helpers' import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@peertube/peertube-core-utils' import { @@ -14,6 +12,8 @@ import { VideoPrivacyType } from '@peertube/peertube-models' import { logger } from '@root-helpers/logger' +import { Observable, of, Subject } from 'rxjs' +import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators' import { environment } from '../../../environments/environment' @Injectable() @@ -37,8 +37,6 @@ export class ServerService { private videoLanguagesObservable: Observable[]> private configObservable: Observable - private configReset = false - private configLoaded = false private config: ServerConfig private htmlConfig: HTMLServerConfig @@ -68,13 +66,14 @@ export class ServerService { resetConfig () { this.configLoaded = false - this.configReset = true // Notify config update - return this.getConfig() + return this.getConfig({ isReset: true }) } - getConfig () { + getConfig (options: { + isReset?: boolean + } = {}) { if (this.configLoaded) return of(this.config) if (!this.configObservable) { @@ -86,9 +85,8 @@ export class ServerService { this.configLoaded = true }), tap(config => { - if (this.configReset) { + if (options.isReset) { this.configReloaded.next(config) - this.configReset = false } }), share() diff --git a/client/src/app/core/theme/primeng/base.ts b/client/src/app/core/theme/primeng/base.ts index f8eb1c6c4..97ec00455 100644 --- a/client/src/app/core/theme/primeng/base.ts +++ b/client/src/app/core/theme/primeng/base.ts @@ -179,7 +179,7 @@ export default { overlay: { select: { background: 'var(--bg)', - borderColor: 'var---input-border-color)', + borderColor: 'var(--input-border-color)', color: 'var(--fg)' }, popover: { diff --git a/client/src/app/core/theme/primeng/components/colorpicker.ts b/client/src/app/core/theme/primeng/components/colorpicker.ts new file mode 100644 index 000000000..9eaac8cd6 --- /dev/null +++ b/client/src/app/core/theme/primeng/components/colorpicker.ts @@ -0,0 +1,34 @@ +import { ColorPickerDesignTokens } from '@primeng/themes/types/colorpicker' + +export default { + root: { + transitionDuration: '{transition.duration}' + }, + preview: { + width: '100%', + height: '1.5rem', + borderRadius: '{form.field.border.radius}', + focusRing: { + width: '{focus.ring.width}', + style: '{focus.ring.style}', + color: '{focus.ring.color}', + offset: '{focus.ring.offset}', + shadow: '{focus.ring.shadow}' + } + }, + panel: { + shadow: '{overlay.popover.shadow}', + borderRadius: '{overlay.popover.borderRadius}' + }, + colorScheme: { + light: { + panel: { + background: 'var(--bg-secondary-400)', + borderColor: 'var(--bg-secondary-450)' + }, + handle: { + color: 'var(--fg)' + } + } + } +} as ColorPickerDesignTokens diff --git a/client/src/app/core/theme/primeng/primeng-theme.ts b/client/src/app/core/theme/primeng/primeng-theme.ts index 2f4a1cae4..a7cc835ad 100644 --- a/client/src/app/core/theme/primeng/primeng-theme.ts +++ b/client/src/app/core/theme/primeng/primeng-theme.ts @@ -2,6 +2,7 @@ import base from './base' import autocomplete from './components/autocomplete' import checkbox from './components/checkbox' import chip from './components/chip' +import colorpicker from './components/colorpicker' import datatable from './components/datatable' import datepicker from './components/datepicker' import inputchips from './components/inputchips' @@ -18,6 +19,7 @@ export const PTPrimeTheme = { select, inputchips, chip, + colorpicker, datepicker, inputtext, toast, diff --git a/client/src/app/core/theme/theme.service.ts b/client/src/app/core/theme/theme.service.ts index 964eac917..444de815e 100644 --- a/client/src/app/core/theme/theme.service.ts +++ b/client/src/app/core/theme/theme.service.ts @@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core' import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models' import { logger } from '@root-helpers/logger' import { capitalizeFirstLetter } from '@root-helpers/string' -import { ThemeManager } from '@root-helpers/theme-manager' +import { ColorPaletteThemeConfig, ThemeCustomizationKey, ThemeManager } from '@root-helpers/theme-manager' import { UserLocalStorageKeys } from '@root-helpers/users' import { environment } from '../../../environments/environment' import { AuthService } from '../auth' @@ -72,6 +72,14 @@ export class ThemeService { ] } + updateColorPalette (config: ColorPaletteThemeConfig = this.serverConfig.theme) { + this.themeManager.injectColorPalette({ currentTheme: this.getCurrentThemeName(), config }) + } + + getCSSConfigValue (configKey: ThemeCustomizationKey) { + return this.themeManager.getCSSConfigValue(configKey) + } + private injectThemes (themes: ServerConfigTheme[], fromLocalStorage = false) { this.themes = themes @@ -89,7 +97,7 @@ export class ThemeService { } } - private getCurrentThemeName () { + getCurrentThemeName () { if (this.themeFromLocalStorage) return this.themeFromLocalStorage.name const theme = this.auth.isLoggedIn() @@ -137,7 +145,7 @@ export class ThemeService { this.localStorageService.removeItem(UserLocalStorageKeys.LAST_ACTIVE_THEME, false) } - this.themeManager.injectCoreColorPalette() + this.themeManager.injectColorPalette({ currentTheme: currentThemeName, config: this.serverConfig.theme }) this.oldThemeName = currentThemeName } diff --git a/client/src/app/header/header.component.scss b/client/src/app/header/header.component.scss index 99fb7fdf7..aec81ec31 100644 --- a/client/src/app/header/header.component.scss +++ b/client/src/app/header/header.component.scss @@ -1,8 +1,8 @@ -@use 'sass:math'; -@use '_variables' as *; -@use '_mixins' as *; -@use '_button-mixins' as *; -@use '_bootstrap-variables' as *; +@use "sass:math"; +@use "_variables" as *; +@use "_mixins" as *; +@use "_button-mixins" as *; +@use "_bootstrap-variables" as *; .mobile-msg { display: flex; @@ -29,7 +29,8 @@ --co-logo-size: 34px; --co-root-padding: 1.5rem; - background-color: pvar(--bg); + color: pvar(--header-fg); + background-color: pvar(--header-bg); padding: var(--co-root-padding); width: 100%; @@ -96,7 +97,7 @@ my-search-typeahead { } .dropdown { - z-index: #{z('header') + 1} !important; + z-index: #{z("header") + 1} !important; } .dropdown-item { @@ -119,7 +120,7 @@ my-search-typeahead { .logged-in-container { border-radius: 25px; - transition: all .1s ease-in-out; + transition: all 0.1s ease-in-out; cursor: pointer; max-width: 250px; height: 100%; @@ -173,7 +174,7 @@ my-actor-avatar { @include margin-right(0.5rem); } - .margin-button[theme=tertiary] { + .margin-button[theme="tertiary"] { @include margin-right(5px); } @@ -238,7 +239,7 @@ my-actor-avatar { } .peertube-title { - @include margin-right(5px) + @include margin-right(5px); } .instance-name { diff --git a/client/src/app/header/header.component.ts b/client/src/app/header/header.component.ts index 112cd6872..57d4b156b 100644 --- a/client/src/app/header/header.component.ts +++ b/client/src/app/header/header.component.ts @@ -132,8 +132,8 @@ export class HeaderComponent implements OnInit, OnDestroy { this.getSearchHiddenSub = this.headerService.getSearchHiddenObs() .subscribe(hidden => { - if (hidden) document.body.classList.add('global-search-hidden') - else document.body.classList.remove('global-search-hidden') + if (hidden) document.documentElement.classList.add('global-search-hidden') + else document.documentElement.classList.remove('global-search-hidden') this.searchHidden = hidden }) @@ -167,7 +167,7 @@ export class HeaderComponent implements OnInit, OnDestroy { if (!isAndroid() && !isIphone()) return this.mobileMsg = true - document.body.classList.add('mobile-app-msg') + document.documentElement.classList.add('mobile-app-msg') const host = window.location.host const intentConfig = this.htmlConfig.client.openInApp.android.intent @@ -228,7 +228,7 @@ export class HeaderComponent implements OnInit, OnDestroy { hideMobileMsg () { this.mobileMsg = false - document.body.classList.remove('mobile-app-msg') + document.documentElement.classList.remove('mobile-app-msg') peertubeLocalStorage.setItem(HeaderComponent.LS_HIDE_MOBILE_MSG, 'true') } diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss index b95285603..ce4ce3d6e 100644 --- a/client/src/app/menu/menu.component.scss +++ b/client/src/app/menu/menu.component.scss @@ -1,7 +1,7 @@ -@use 'sass:math'; -@use '_variables' as *; -@use '_mixins' as *; -@use '_button-mixins' as *; +@use "sass:math"; +@use "_variables" as *; +@use "_mixins" as *; +@use "_button-mixins" as *; .menu-container { --co-menu-x-padding: 1.5rem; @@ -81,7 +81,7 @@ position: relative; padding-top: 1.5rem; padding-bottom: 1.5rem; - border-radius: 14px; + border-radius: pvar(--menu-border-radius); background-color: pvar(--menu-bg); } @@ -99,7 +99,7 @@ .collapsed .toggle-menu-container, .about-top { &::after { - content: ''; + content: ""; display: block; height: 2px; margin: 1rem var(--co-menu-x-padding); @@ -123,7 +123,7 @@ white-space: normal; word-break: break-word; - transition: background-color .1s ease-in-out; + transition: background-color 0.1s ease-in-out; width: 100%; padding-top: 0.5rem; padding-bottom: 0.5rem; @@ -245,7 +245,7 @@ width: 100vw; height: 100vh; opacity: 0.75; - content: ''; + content: ""; display: none; position: fixed; z-index: z(overlay); diff --git a/client/src/app/shared/form-validators/form-validator.model.ts b/client/src/app/shared/form-validators/form-validator.model.ts index 87d3651d1..d4fdcd460 100644 --- a/client/src/app/shared/form-validators/form-validator.model.ts +++ b/client/src/app/shared/form-validators/form-validator.model.ts @@ -1,4 +1,5 @@ -import { AsyncValidatorFn, ValidatorFn } from '@angular/forms' +import { AsyncValidatorFn, FormArray, FormControl, FormGroup, ValidatorFn } from '@angular/forms' +import { PartialDeep } from 'type-fest' export type BuildFormValidator = { VALIDATORS: ValidatorFn[] @@ -11,6 +12,48 @@ export type BuildFormArgument = { [id: string]: BuildFormValidator | BuildFormArgument } -export type BuildFormDefaultValues = { - [name: string]: Blob | Date | boolean | number | string | string[] | BuildFormDefaultValues +export type BuildFormArgumentTyped = ReplaceForm + +// --------------------------------------------------------------------------- + +export type FormDefault = { + [name: string]: Blob | Date | boolean | number | number[] | string | string[] | FormDefault } +export type FormDefaultTyped = PartialDeep> + +// --------------------------------------------------------------------------- + +export type FormReactiveMessages = { + [id: string]: { [name: string]: string } | FormReactiveMessages | FormReactiveMessages[] +} + +export type FormReactiveMessagesTyped = Partial> + +// --------------------------------------------------------------------------- + +export type FormReactiveErrors = { [id: string]: string | FormReactiveErrors | FormReactiveErrors[] } +export type FormReactiveErrorsTyped = Partial> + +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- + +export type UnwrapForm = { + [K in keyof Form]: _UnwrapForm +} + +type _UnwrapForm = T extends FormGroup ? UnwrapForm : + T extends FormArray ? _UnwrapForm[] : + T extends FormControl ? U + : never + +// --------------------------------------------------------------------------- + +export type ReplaceForm = { + [K in keyof Form]: _ReplaceForm +} + +type _ReplaceForm = T extends FormGroup ? ReplaceForm : + T extends FormArray ? _ReplaceForm : + T extends FormControl ? By + : never diff --git a/client/src/app/shared/shared-forms/dynamic-form-field.component.scss b/client/src/app/shared/shared-forms/dynamic-form-field.component.scss index 9b6c8f6fd..63fadf1f8 100644 --- a/client/src/app/shared/shared-forms/dynamic-form-field.component.scss +++ b/client/src/app/shared/shared-forms/dynamic-form-field.component.scss @@ -1,8 +1,8 @@ -@use '_variables' as *; -@use '_mixins' as *; -@use '_form-mixins' as *; +@use "_variables" as *; +@use "_mixins" as *; +@use "_form-mixins" as *; -input:not([type=submit]) { +input:not([type="submit"]) { max-width: 340px; width: 100%; @@ -21,10 +21,6 @@ textarea { @include peertube-select-container(340px); } -my-peertube-checkbox + .label-small-info { - margin-top: 5px; -} - my-markdown-textarea { display: block; max-width: 500px; diff --git a/client/src/app/shared/shared-forms/form-reactive.service.ts b/client/src/app/shared/shared-forms/form-reactive.service.ts index 9b608db85..5246eff98 100644 --- a/client/src/app/shared/shared-forms/form-reactive.service.ts +++ b/client/src/app/shared/shared-forms/form-reactive.service.ts @@ -1,19 +1,16 @@ import { Injectable, inject } from '@angular/core' import { AbstractControl, FormGroup, StatusChangeEvent } from '@angular/forms' import { filter, firstValueFrom } from 'rxjs' -import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' +import { BuildFormArgument, FormDefault, FormReactiveErrors, FormReactiveMessages } from '../form-validators/form-validator.model' import { FormValidatorService } from './form-validator.service' -export type FormReactiveErrors = { [id: string]: string | FormReactiveErrors | FormReactiveErrors[] } -export type FormReactiveValidationMessages = { - [id: string]: { [name: string]: string } | FormReactiveValidationMessages | FormReactiveValidationMessages[] -} +export * from '../form-validators/form-validator.model' @Injectable() export class FormReactiveService { private formValidatorService = inject(FormValidatorService) - buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { + buildForm (obj: BuildFormArgument, defaultValues: FormDefault = {}) { const { formErrors, validationMessages, form } = this.formValidatorService.internalBuildForm(obj, defaultValues) form.events @@ -44,7 +41,7 @@ export class FormReactiveService { } } - forceCheck (form: FormGroup, formErrors: any, validationMessages: FormReactiveValidationMessages) { + forceCheck (form: FormGroup, formErrors: any, validationMessages: FormReactiveMessages) { this.onStatusChanged({ form, formErrors, validationMessages, onlyDirty: false }) } @@ -76,7 +73,7 @@ export class FormReactiveService { private onStatusChanged (options: { form: FormGroup formErrors: FormReactiveErrors - validationMessages: FormReactiveValidationMessages + validationMessages: FormReactiveMessages onlyDirty?: boolean // default true }) { const { form, formErrors, validationMessages, onlyDirty = true } = options @@ -86,7 +83,7 @@ export class FormReactiveService { this.onStatusChanged({ form: form.controls[field] as FormGroup, formErrors: formErrors[field] as FormReactiveErrors, - validationMessages: validationMessages[field] as FormReactiveValidationMessages, + validationMessages: validationMessages[field] as FormReactiveMessages, onlyDirty }) @@ -99,7 +96,7 @@ export class FormReactiveService { if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue - const staticMessages = validationMessages[field] as FormReactiveValidationMessages + const staticMessages = validationMessages[field] as FormReactiveMessages for (const key of Object.keys(control.errors)) { const formErrorValue = control.errors[key] diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts index 0257dcd5d..e02ba3934 100644 --- a/client/src/app/shared/shared-forms/form-reactive.ts +++ b/client/src/app/shared/shared-forms/form-reactive.ts @@ -1,6 +1,6 @@ import { FormGroup } from '@angular/forms' -import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' -import { FormReactiveService, FormReactiveValidationMessages } from './form-reactive.service' +import { BuildFormArgument, FormDefault } from '../form-validators/form-validator.model' +import { FormReactiveService, FormReactiveMessages } from './form-reactive.service' export abstract class FormReactive { protected abstract formReactiveService: FormReactiveService @@ -8,9 +8,9 @@ export abstract class FormReactive { form: FormGroup formErrors: any // To avoid casting in template because of string | FormReactiveErrors - validationMessages: FormReactiveValidationMessages + validationMessages: FormReactiveMessages - buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { + buildForm (obj: BuildFormArgument, defaultValues: FormDefault = {}) { const { formErrors, validationMessages, form } = this.formReactiveService.buildForm(obj, defaultValues) this.form = form diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts index cbfb51c20..7450d0347 100644 --- a/client/src/app/shared/shared-forms/form-validator.service.ts +++ b/client/src/app/shared/shared-forms/form-validator.service.ts @@ -1,16 +1,16 @@ import { Injectable, inject } from '@angular/core' import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' import { objectKeysTyped } from '@peertube/peertube-core-utils' -import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' -import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service' +import { BuildFormArgument, FormDefault } from '../form-validators/form-validator.model' +import { FormReactiveErrors, FormReactiveMessages } from './form-reactive.service' @Injectable() export class FormValidatorService { private formBuilder = inject(FormBuilder) - internalBuildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { + internalBuildForm (obj: BuildFormArgument, defaultValues: FormDefault = {}) { const formErrors: FormReactiveErrors = {} - const validationMessages: FormReactiveValidationMessages = {} + const validationMessages: FormReactiveMessages = {} const group: { [key: string]: any } = {} for (const name of Object.keys(obj)) { @@ -18,7 +18,7 @@ export class FormValidatorService { const field = obj[name] if (this.isRecursiveField(field)) { - const result = this.internalBuildForm(field as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues) + const result = this.internalBuildForm(field as BuildFormArgument, defaultValues[name] as FormDefault) group[name] = result.form formErrors[name] = result.formErrors validationMessages[name] = result.validationMessages @@ -41,9 +41,9 @@ export class FormValidatorService { updateFormGroup ( form: FormGroup, formErrors: FormReactiveErrors, - validationMessages: FormReactiveValidationMessages, + validationMessages: FormReactiveMessages, formToBuild: BuildFormArgument, - defaultValues: BuildFormDefaultValues = {} + defaultValues: FormDefault = {} ) { for (const name of objectKeysTyped(formToBuild)) { const field = formToBuild[name] @@ -55,9 +55,9 @@ export class FormValidatorService { // FIXME: typings (form as any)[name], formErrors[name], - validationMessages[name] as FormReactiveValidationMessages, + validationMessages[name] as FormReactiveMessages, formToBuild[name] as BuildFormArgument, - defaultValues[name] as BuildFormDefaultValues + defaultValues[name] as FormDefault ) continue } @@ -77,11 +77,11 @@ export class FormValidatorService { addControlInFormArray (options: { formErrors: FormReactiveErrors - validationMessages: FormReactiveValidationMessages + validationMessages: FormReactiveMessages formArray: FormArray controlName: string formToBuild: BuildFormArgument - defaultValues?: BuildFormDefaultValues + defaultValues?: FormDefault }) { const { formArray, formErrors, validationMessages, controlName, formToBuild, defaultValues = {} } = options @@ -90,7 +90,7 @@ export class FormValidatorService { if (!validationMessages[controlName]) validationMessages[controlName] = [] const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] - const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] + const formArrayValidationMessages = validationMessages[controlName] as FormReactiveMessages[] const totalControls = formArray.controls.length formArrayErrors.push({}) @@ -109,7 +109,7 @@ export class FormValidatorService { removeControlFromFormArray (options: { formErrors: FormReactiveErrors - validationMessages: FormReactiveValidationMessages + validationMessages: FormReactiveMessages index: number formArray: FormArray controlName: string @@ -117,7 +117,7 @@ export class FormValidatorService { const { formArray, formErrors, validationMessages, index, controlName } = options const formArrayErrors = formErrors[controlName] as FormReactiveErrors[] - const formArrayValidationMessages = validationMessages[controlName] as FormReactiveValidationMessages[] + const formArrayValidationMessages = validationMessages[controlName] as FormReactiveMessages[] formArrayErrors.splice(index, 1) formArrayValidationMessages.splice(index, 1) diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.html b/client/src/app/shared/shared-forms/peertube-checkbox.component.html index cf1f851c4..a7f61b143 100644 --- a/client/src/app/shared/shared-forms/peertube-checkbox.component.html +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.html @@ -32,7 +32,7 @@
Recommended
-
+
diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.scss b/client/src/app/shared/shared-forms/peertube-checkbox.component.scss index 4ea83b0bd..f53cdf250 100644 --- a/client/src/app/shared/shared-forms/peertube-checkbox.component.scss +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.scss @@ -1,6 +1,6 @@ -@use '_variables' as *; -@use '_mixins' as *; -@use 'form-mixins' as *; +@use "_variables" as *; +@use "_mixins" as *; +@use "form-mixins" as *; .root { display: flex; @@ -35,3 +35,7 @@ .pt-badge { height: fit-content; } + +.extra-container { + @include margin-left(28px); +} diff --git a/client/src/app/shared/shared-main/custom-page/custom-page.service.ts b/client/src/app/shared/shared-main/custom-page/custom-page.service.ts index 6340550fb..208a39a1d 100644 --- a/client/src/app/shared/shared-main/custom-page/custom-page.service.ts +++ b/client/src/app/shared/shared-main/custom-page/custom-page.service.ts @@ -1,9 +1,9 @@ -import { of } from 'rxjs' -import { catchError } from 'rxjs/operators' import { HttpClient } from '@angular/common/http' import { Injectable, inject } from '@angular/core' import { RestExtractor } from '@app/core' import { CustomPage } from '@peertube/peertube-models' +import { Observable, of } from 'rxjs' +import { catchError } from 'rxjs/operators' import { environment } from '../../../../environments/environment' @Injectable() @@ -13,7 +13,7 @@ export class CustomPageService { static BASE_INSTANCE_HOMEPAGE_URL = environment.apiUrl + '/api/v1/custom-pages/homepage/instance' - getInstanceHomepage () { + getInstanceHomepage (): Observable { return this.authHttp.get(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL) .pipe( catchError(err => { diff --git a/client/src/app/shared/shared-main/menu/lateral-menu.component.html b/client/src/app/shared/shared-main/menu/lateral-menu.component.html new file mode 100644 index 000000000..ae95c122a --- /dev/null +++ b/client/src/app/shared/shared-main/menu/lateral-menu.component.html @@ -0,0 +1,37 @@ + + + diff --git a/client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.scss b/client/src/app/shared/shared-main/menu/lateral-menu.component.scss similarity index 92% rename from client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.scss rename to client/src/app/shared/shared-main/menu/lateral-menu.component.scss index 64fdd0d6d..90bc2841b 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/video-manage-menu.component.scss +++ b/client/src/app/shared/shared-main/menu/lateral-menu.component.scss @@ -1,7 +1,7 @@ -@use '_variables' as *; -@use '_mixins' as *; -@use '_form-mixins' as *; -@import 'bootstrap/scss/mixins'; +@use "_variables" as *; +@use "_mixins" as *; +@use "_form-mixins" as *; +@import "bootstrap/scss/mixins"; h1 { color: pvar(--fg-200); @@ -105,6 +105,10 @@ a { @include padding-right(1.5rem); + &:last-child { + display: none; + } + > div { height: 2px; width: 100%; @@ -132,7 +136,7 @@ a { position: fixed; bottom: 0; top: unset; - width: calc(100vw - #{pvar(--menu-width)} - (#{pvar(--x-margin-content)} * 2)); + width: calc(100vw - #{pvar(--menu-width)} - #{pvar(--x-margin-content)} * 2); padding: 0.75rem 0.5rem; border: 1px solid pvar(--bg-secondary-450); border-bottom: 0; diff --git a/client/src/app/shared/shared-main/menu/lateral-menu.component.ts b/client/src/app/shared/shared-main/menu/lateral-menu.component.ts new file mode 100644 index 000000000..9640f0f74 --- /dev/null +++ b/client/src/app/shared/shared-main/menu/lateral-menu.component.ts @@ -0,0 +1,56 @@ +import { CommonModule } from '@angular/common' +import { Component, input } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { RouterModule } from '@angular/router' +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' +import { GlobalIconComponent, GlobalIconName } from '../../shared-icons/global-icon.component' +import { UnavailableMenuEntryComponent } from './unavailable-menu-entry.component' + +type LateralMenuLinkEntry = { + type: 'link' + label: string + routerLink: string + routerLinkActiveOptions?: { exact: boolean } + + icon?: GlobalIconName + + isDisplayed?: () => boolean + unavailableText?: () => string +} + +export type LateralMenuConfig = { + title: string + + entries: ({ type: 'separator' } | LateralMenuLinkEntry)[] +} + +@Component({ + selector: 'my-lateral-menu', + styleUrls: [ './lateral-menu.component.scss' ], + templateUrl: './lateral-menu.component.html', + imports: [ + CommonModule, + RouterModule, + FormsModule, + ReactiveFormsModule, + NgbTooltipModule, + GlobalIconComponent, + UnavailableMenuEntryComponent, + GlobalIconComponent + ] +}) +export class LateralMenuComponent { + config = input.required() + + isDisplayed (entry: LateralMenuLinkEntry) { + if (!entry.isDisplayed) return true + + return entry.isDisplayed() + } + + isUnavailable (entry: LateralMenuLinkEntry) { + if (!entry.unavailableText) return false + + return !!entry.unavailableText() + } +} diff --git a/client/src/app/+videos-publish-manage/shared-manage/unavailable-menu-entry.component.html b/client/src/app/shared/shared-main/menu/unavailable-menu-entry.component.html similarity index 100% rename from client/src/app/+videos-publish-manage/shared-manage/unavailable-menu-entry.component.html rename to client/src/app/shared/shared-main/menu/unavailable-menu-entry.component.html diff --git a/client/src/app/+videos-publish-manage/shared-manage/unavailable-menu-entry.component.scss b/client/src/app/shared/shared-main/menu/unavailable-menu-entry.component.scss similarity index 100% rename from client/src/app/+videos-publish-manage/shared-manage/unavailable-menu-entry.component.scss rename to client/src/app/shared/shared-main/menu/unavailable-menu-entry.component.scss diff --git a/client/src/app/+videos-publish-manage/shared-manage/unavailable-menu-entry.component.ts b/client/src/app/shared/shared-main/menu/unavailable-menu-entry.component.ts similarity index 89% rename from client/src/app/+videos-publish-manage/shared-manage/unavailable-menu-entry.component.ts rename to client/src/app/shared/shared-main/menu/unavailable-menu-entry.component.ts index 1d7952169..3187cb1d0 100644 --- a/client/src/app/+videos-publish-manage/shared-manage/unavailable-menu-entry.component.ts +++ b/client/src/app/shared/shared-main/menu/unavailable-menu-entry.component.ts @@ -3,7 +3,7 @@ import { Component, input } from '@angular/core' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { RouterModule } from '@angular/router' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' -import { HelpComponent } from '../../shared/shared-main/buttons/help.component' +import { HelpComponent } from '../buttons/help.component' @Component({ selector: 'my-unavailable-menu-entry', diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts index ccca7de79..6550dda22 100644 --- a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts +++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts @@ -2,7 +2,7 @@ import { NgIf } from '@angular/common' import { Component, OnDestroy, OnInit, inject, input } from '@angular/core' import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms' import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' -import { FormReactiveErrors, FormReactiveService, FormReactiveValidationMessages } from '@app/shared/shared-forms/form-reactive.service' +import { FormReactiveErrors, FormReactiveService, FormReactiveMessages } from '@app/shared/shared-forms/form-reactive.service' import { NSFWFlag, NSFWFlagType, NSFWPolicyType, UserUpdateMe } from '@peertube/peertube-models' import { pick } from 'lodash-es' import { Subject, Subscription } from 'rxjs' @@ -55,7 +55,7 @@ export class UserVideoSettingsComponent implements OnInit, OnDestroy { form: FormGroup formErrors: FormReactiveErrors = {} - validationMessages: FormReactiveValidationMessages = {} + validationMessages: FormReactiveMessages = {} nsfwItems: SelectOptionsItem[] = [ { diff --git a/client/src/root-helpers/theme-manager.ts b/client/src/root-helpers/theme-manager.ts index a920e7442..284b6011f 100644 --- a/client/src/root-helpers/theme-manager.ts +++ b/client/src/root-helpers/theme-manager.ts @@ -1,13 +1,87 @@ import { sortBy } from '@peertube/peertube-core-utils' import { getLuminance, parse, toHSLA } from 'color-bits' -import { ServerConfigTheme } from '@peertube/peertube-models' +import { HTMLServerConfig, ServerConfig, ServerConfigTheme } from '@peertube/peertube-models' import { logger } from './logger' import debug from 'debug' const debugLogger = debug('peertube:theme') +type ConfigCSSVariableMap = Record + +export type ThemeCustomizationKey = keyof ConfigCSSVariableMap +export type ColorPaletteThemeConfig = Pick + export class ThemeManager { - private oldInjectedProperties: string[] = [] + private configVariablesStyle: HTMLStyleElement + private colorPaletteStyle: HTMLStyleElement + private configuredCSSVariables = new Set() + + private readonly configCSSVariableMap: ConfigCSSVariableMap = { + primaryColor: '--primary', + foregroundColor: '--fg', + backgroundColor: '--bg', + backgroundSecondaryColor: '--bg-secondary', + menuForegroundColor: '--menu-fg', + menuBackgroundColor: '--menu-bg', + menuBorderRadius: '--menu-border-radius', + headerForegroundColor: '--header-fg', + headerBackgroundColor: '--header-bg', + inputBorderRadius: '--input-border-radius' + } + + private defaultConfigValue: Record + + getCSSConfigValue (configKey: ThemeCustomizationKey) { + const cssVariable = this.configCSSVariableMap[configKey] + + return getComputedStyle(document.documentElement).getPropertyValue(cssVariable) + } + + injectConfigVariables (options: { + currentTheme: string + config: ColorPaletteThemeConfig + }) { + const { currentTheme, config } = options + + if (!this.configVariablesStyle) { + this.configVariablesStyle = document.createElement('style') + this.configVariablesStyle.setAttribute('type', 'text/css') + this.configVariablesStyle.dataset.ptStyleId = 'config-variables' + document.head.appendChild(this.configVariablesStyle) + } + + this.configuredCSSVariables.clear() + this.configVariablesStyle.textContent = '' + + // Only inject config variables for the default theme + if (currentTheme !== config.default) return + + const computedStyle = getComputedStyle(document.documentElement) + + let configStyleContent = '' + + this.defaultConfigValue = {} as any + + for (const [ configKey, configValue ] of Object.entries(config.customization) as ([keyof ConfigCSSVariableMap, string][])) { + const cssVariable = this.configCSSVariableMap[configKey] + + this.defaultConfigValue[configKey] = computedStyle.getPropertyValue(cssVariable) + + if (!configValue) continue + + if (!cssVariable) { + logger.error(`Unknown UI config variable "${configKey}" with value "${configValue}"`) + continue + } + + configStyleContent += ` ${cssVariable}: ${configValue};\n` + this.configuredCSSVariables.add(cssVariable) + } + + if (configStyleContent) { + this.configVariablesStyle.textContent = `:root[data-pt-theme=${currentTheme}] {\n${configStyleContent} }` + } + } injectTheme (theme: ServerConfigTheme, apiUrl: string) { const head = this.getHeadElement() @@ -42,7 +116,7 @@ export class ThemeManager { link.disabled = link.getAttribute('title') !== name if (!link.disabled) { - link.onload = () => this.injectColorPalette() + link.onload = () => this._injectColorPalette() } else { link.onload = undefined } @@ -52,7 +126,10 @@ export class ThemeManager { document.documentElement.dataset.ptTheme = name } - injectCoreColorPalette (iteration = 0) { + injectColorPalette (options: { + config: ColorPaletteThemeConfig + currentTheme: string + }, iteration = 0) { if (iteration > 100) { logger.error('Too many iteration when checking color palette injection. The theme may be missing the --is-dark CSS variable') @@ -61,10 +138,14 @@ export class ThemeManager { } if (!this.canInjectCoreColorPalette()) { - return setTimeout(() => this.injectCoreColorPalette(iteration + 1), Math.floor(iteration / 10)) + return setTimeout(() => this.injectColorPalette(options, iteration + 1), Math.floor(iteration / 10)) } - return this.injectColorPalette() + debugLogger(`Update color palette`, options.config) + + this.injectConfigVariables(options) + + return this._injectColorPalette() } removeThemeLink (linkEl: HTMLLinkElement) { @@ -78,106 +159,120 @@ export class ThemeManager { return isDark === '0' || isDark === '1' } - private injectColorPalette () { - console.log(`Injecting color palette`) - - const rootStyle = document.documentElement.style - const computedStyle = getComputedStyle(document.documentElement) - - // FIXME: Remove previously injected properties - for (const property of this.oldInjectedProperties) { - rootStyle.removeProperty(property) - } - - this.oldInjectedProperties = [] - - const isGlobalDarkTheme = () => { - return this.isDarkTheme({ - fg: computedStyle.getPropertyValue('--fg') || computedStyle.getPropertyValue('--mainForegroundColor'), - bg: computedStyle.getPropertyValue('--bg') || computedStyle.getPropertyValue('--mainBackgroundColor'), - isDarkVar: computedStyle.getPropertyValue('--is-dark') - }) - } - - const isMenuDarkTheme = () => { - return this.isDarkTheme({ - fg: computedStyle.getPropertyValue('--menu-fg'), - bg: computedStyle.getPropertyValue('--menu-bg'), - isDarkVar: computedStyle.getPropertyValue('--is-menu-dark') - }) - } - - const toProcess = [ - { prefix: 'primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme }, - { prefix: 'on-primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme }, - { prefix: 'bg-secondary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme }, - { prefix: 'fg', invertIfDark: true, fallbacks: { '--fg-300': '--greyForegroundColor' }, step: 5, darkTheme: isGlobalDarkTheme }, - - { prefix: 'input-bg', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme }, - - { prefix: 'menu-fg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme }, - { prefix: 'menu-bg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme } - ] as { prefix: string, invertIfDark: boolean, step: number, darkTheme: () => boolean, fallbacks?: Record }[] - - for (const { prefix, invertIfDark, step, darkTheme, fallbacks = {} } of toProcess) { - const mainColor = computedStyle.getPropertyValue('--' + prefix) - - const darkInverter = invertIfDark && darkTheme() - ? -1 - : 1 - - if (!mainColor) { - console.error(`Cannot create palette of nonexistent "--${prefix}" CSS documentElement variable`) - continue + private _injectColorPalette () { + try { + if (!this.colorPaletteStyle) { + this.colorPaletteStyle = document.createElement('style') + this.colorPaletteStyle.setAttribute('type', 'text/css') + this.colorPaletteStyle.dataset.ptStyleId = 'color-palette' + document.head.appendChild(this.colorPaletteStyle) } - // Use trim for some web browsers: https://github.com/Chocobozzz/PeerTube/issues/6952 - const mainColorHSL = toHSLA(parse(mainColor.trim())) - debugLogger(`Theme main variable ${mainColor} -> ${this.toHSLStr(mainColorHSL)}`) + let paletteStyleContent = '' - // Inject in alphabetical order for easy debug - const toInject: { id: number, key: string, value: string }[] = [ - { id: 500, key: `--${prefix}-500`, value: this.toHSLStr(mainColorHSL) } - ] + const computedStyle = getComputedStyle(document.documentElement) + this.colorPaletteStyle.textContent = '' - for (const j of [ -1, 1 ]) { - let lastColorHSL = { ...mainColorHSL } + const isGlobalDarkTheme = () => { + return this.isDarkTheme({ + fg: computedStyle.getPropertyValue('--fg') || computedStyle.getPropertyValue('--mainForegroundColor'), + bg: computedStyle.getPropertyValue('--bg') || computedStyle.getPropertyValue('--mainBackgroundColor'), + isDarkVar: computedStyle.getPropertyValue('--is-dark') + }) + } - for (let i = 1; i <= 9; i++) { - const suffix = 500 + (50 * i * j) - const key = `--${prefix}-${suffix}` + const isMenuDarkTheme = () => { + return this.isDarkTheme({ + fg: computedStyle.getPropertyValue('--menu-fg'), + bg: computedStyle.getPropertyValue('--menu-bg'), + isDarkVar: computedStyle.getPropertyValue('--is-menu-dark') + }) + } - const existingValue = computedStyle.getPropertyValue(key) - if (!existingValue || existingValue === '0') { - const newLuminance = this.buildNewLuminance(lastColorHSL, j * step, darkInverter) - const newColorHSL = { ...lastColorHSL, l: newLuminance } + const toProcess = [ + { prefix: 'primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme }, + { prefix: 'on-primary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme }, + { prefix: 'bg-secondary', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme }, + { prefix: 'fg', invertIfDark: true, fallbacks: { '--fg-300': '--greyForegroundColor' }, step: 5, darkTheme: isGlobalDarkTheme }, - const newColorStr = this.toHSLStr(newColorHSL) + { prefix: 'input-bg', invertIfDark: true, step: 5, darkTheme: isGlobalDarkTheme }, - const value = fallbacks[key] - ? `var(${fallbacks[key]}, ${newColorStr})` - : newColorStr + { prefix: 'menu-fg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme }, + { prefix: 'menu-bg', invertIfDark: true, step: 5, darkTheme: isMenuDarkTheme } + ] as { prefix: string, invertIfDark: boolean, step: number, darkTheme: () => boolean, fallbacks?: Record }[] - toInject.push({ id: suffix, key, value }) + for (const { prefix, invertIfDark, step, darkTheme, fallbacks = {} } of toProcess) { + const mainColor = computedStyle.getPropertyValue('--' + prefix) - lastColorHSL = newColorHSL + const darkInverter = invertIfDark && darkTheme() + ? -1 + : 1 - debugLogger(`Injected theme palette ${key} -> ${value}`) - } else { - lastColorHSL = toHSLA(parse(existingValue)) + if (!mainColor) { + console.error(`Cannot create palette of nonexistent "--${prefix}" CSS documentElement variable`) + continue + } + + // Use trim for some web browsers: https://github.com/Chocobozzz/PeerTube/issues/6952 + const mainColorHSL = toHSLA(parse(mainColor.trim())) + debugLogger(`Theme main variable --${prefix}: ${mainColor} -> ${this.toHSLStr(mainColorHSL)}`) + + // Inject in alphabetical order for easy debug + const toInject: { id: number, key: string, value: string }[] = [ + { id: 500, key: `--${prefix}-500`, value: this.toHSLStr(mainColorHSL) } + ] + + for (const j of [ -1, 1 ]) { + let lastColorHSL = { ...mainColorHSL } + + for (let i = 1; i <= 9; i++) { + const suffix = 500 + (50 * i * j) + const key = `--${prefix}-${suffix}` + + // Override all our variables if the CSS variable has been configured by the admin + const existingValue = this.configuredCSSVariables.has(`--${prefix}`) + ? '0' + : computedStyle.getPropertyValue(key) + + if (!existingValue || existingValue === '0') { + const newLuminance = this.buildNewLuminance(lastColorHSL, j * step, darkInverter) + const newColorHSL = { ...lastColorHSL, l: newLuminance } + + const newColorStr = this.toHSLStr(newColorHSL) + + const value = fallbacks[key] + ? `var(${fallbacks[key]}, ${newColorStr})` + : newColorStr + + toInject.push({ id: suffix, key, value }) + + lastColorHSL = newColorHSL + + debugLogger(`Injected theme palette ${key} -> ${value}`) + } else { + lastColorHSL = toHSLA(parse(existingValue)) + } } } + + for (const { key, value } of sortBy(toInject, 'id')) { + paletteStyleContent += ` ${key}: ${value};\n` + } + + if (paletteStyleContent) { + // To override default variables + document.documentElement.className = 'color-palette' + + this.colorPaletteStyle.textContent = `:root.color-palette {\n${paletteStyleContent} }` + } } - for (const { key, value } of sortBy(toInject, 'id')) { - rootStyle.setProperty(key, value) - this.oldInjectedProperties.push(key) - } + document.documentElement.dataset.bsTheme = isGlobalDarkTheme() + ? 'dark' + : '' + } catch (err) { + logger.error('Cannot inject color palette', err) } - - document.documentElement.dataset.bsTheme = isGlobalDarkTheme() - ? 'dark' - : '' } private buildNewLuminance (base: { l: number }, factor: number, darkInverter: number) { diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss index 0ef7162a6..80d139e06 100644 --- a/client/src/sass/application.scss +++ b/client/src/sass/application.scss @@ -69,7 +69,7 @@ strong { input[readonly] { // Force blank on readonly inputs - background-color: pvar(--input-bg) !important; + background-color: pvar(--input-bg); } input, diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss index bce628265..f53fa4105 100644 --- a/client/src/sass/bootstrap.scss +++ b/client/src/sass/bootstrap.scss @@ -1,47 +1,47 @@ -@use '_variables' as *; -@use '_mixins' as *; -@use '_button-mixins' as *; -@import './_bootstrap-variables'; -@import 'bootstrap/scss/functions'; -@import 'bootstrap/scss/variables'; -@import 'bootstrap/scss/maps'; -@import 'bootstrap/scss/mixins'; -@import 'bootstrap/scss/utilities'; -@import 'bootstrap/scss/root'; -@import 'bootstrap/scss/reboot'; -@import 'bootstrap/scss/type'; -@import 'bootstrap/scss/grid'; -@import 'bootstrap/scss/forms'; -@import 'bootstrap/scss/buttons'; -@import 'bootstrap/scss/transitions'; -@import 'bootstrap/scss/dropdown'; -@import 'bootstrap/scss/button-group'; -@import 'bootstrap/scss/nav'; -@import 'bootstrap/scss/card'; -@import 'bootstrap/scss/accordion'; -@import 'bootstrap/scss/alert'; -@import 'bootstrap/scss/close'; -@import 'bootstrap/scss/modal'; -@import 'bootstrap/scss/tooltip'; -@import 'bootstrap/scss/popover'; -@import 'bootstrap/scss/spinners'; +@use "_variables" as *; +@use "_mixins" as *; +@use "_button-mixins" as *; +@import "./_bootstrap-variables"; +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/maps"; +@import "bootstrap/scss/mixins"; +@import "bootstrap/scss/utilities"; +@import "bootstrap/scss/root"; +@import "bootstrap/scss/reboot"; +@import "bootstrap/scss/type"; +@import "bootstrap/scss/grid"; +@import "bootstrap/scss/forms"; +@import "bootstrap/scss/buttons"; +@import "bootstrap/scss/transitions"; +@import "bootstrap/scss/dropdown"; +@import "bootstrap/scss/button-group"; +@import "bootstrap/scss/nav"; +@import "bootstrap/scss/card"; +@import "bootstrap/scss/accordion"; +@import "bootstrap/scss/alert"; +@import "bootstrap/scss/close"; +@import "bootstrap/scss/modal"; +@import "bootstrap/scss/tooltip"; +@import "bootstrap/scss/popover"; +@import "bootstrap/scss/spinners"; /* stylelint-disable-next-line at-rule-empty-line-before */ -@import 'bootstrap/scss/helpers/clearfix'; -@import 'bootstrap/scss/helpers/color-bg'; +@import "bootstrap/scss/helpers/clearfix"; +@import "bootstrap/scss/helpers/color-bg"; // @import 'bootstrap/scss/helpers/colored-links'; -@import 'bootstrap/scss/helpers/focus-ring'; -@import 'bootstrap/scss/helpers/icon-link'; -@import 'bootstrap/scss/helpers/ratio'; -@import 'bootstrap/scss/helpers/position'; -@import 'bootstrap/scss/helpers/stacks'; -@import 'bootstrap/scss/helpers/visually-hidden'; -@import 'bootstrap/scss/helpers/stretched-link'; -@import 'bootstrap/scss/helpers/text-truncation'; -@import 'bootstrap/scss/helpers/vr'; +@import "bootstrap/scss/helpers/focus-ring"; +@import "bootstrap/scss/helpers/icon-link"; +@import "bootstrap/scss/helpers/ratio"; +@import "bootstrap/scss/helpers/position"; +@import "bootstrap/scss/helpers/stacks"; +@import "bootstrap/scss/helpers/visually-hidden"; +@import "bootstrap/scss/helpers/stretched-link"; +@import "bootstrap/scss/helpers/text-truncation"; +@import "bootstrap/scss/helpers/vr"; /* stylelint-disable-next-line at-rule-empty-line-before */ -@import 'bootstrap/scss/utilities/api'; +@import "bootstrap/scss/utilities/api"; body { --bs-border-color-translucent: #{pvar(--input-border-color)}; @@ -166,7 +166,7 @@ body { @media screen and (min-width: #{breakpoint(md)}) { .modal::before { vertical-align: middle; - content: ' '; + content: " "; height: 100%; } @@ -217,7 +217,6 @@ body { } } - // On desktop browsers, make the content and header horizontally sticked to right not move when modal open and close .modal-open { overflow-y: scroll !important; // Make sure vertical scroll bar is always visible on desktop browsers to get disabled scrollbar effect @@ -299,12 +298,6 @@ body { font-size: $button-font-size; } -.form-control { - color: pvar(--fg); - background-color: pvar(--input-bg); - outline: none; -} - .input-group { > .btn, > .input-group-text { @@ -342,7 +335,7 @@ body { .form-control-clear { position: absolute; - right: .5rem; + right: 0.5rem; top: 0; bottom: 0; opacity: 0.4; @@ -363,12 +356,11 @@ body { vertical-align: top; } - // --------------------------------------------------------------------------- // RTL compatibility // --------------------------------------------------------------------------- -:root[dir=rtl] .modal .modal-header .modal-title { +:root[dir="rtl"] .modal .modal-header .modal-title { margin-inline-end: auto; margin-right: unset; } diff --git a/client/src/sass/class-helpers/_forms.scss b/client/src/sass/class-helpers/_forms.scss index e72f00510..577c5e55c 100644 --- a/client/src/sass/class-helpers/_forms.scss +++ b/client/src/sass/class-helpers/_forms.scss @@ -82,8 +82,29 @@ label, } label + .form-group-description, +label + my-help + .form-group-description, .label + .form-group-description, .label-container + .form-group-description { margin-bottom: 10px; - margin-top: -0.5rem; + margin-top: -0.4rem; +} + +.number-with-unit { + position: relative; + width: fit-content; + + input[type="number"] + span { + position: absolute; + top: 0.4em; + right: 3em; + + @media screen and (max-width: $mobile-view) { + display: none; + } + } + + input[disabled] { + opacity: 0.8; + pointer-events: none; + } } diff --git a/client/src/sass/class-helpers/_layout.scss b/client/src/sass/class-helpers/_layout.scss index 6e8357680..d2c2e0e76 100644 --- a/client/src/sass/class-helpers/_layout.scss +++ b/client/src/sass/class-helpers/_layout.scss @@ -48,10 +48,6 @@ min-width: 0; } -.max-width-300px { - max-width: 300px; -} - .d-none-mw { @include on-mobile-main-col { display: none !important; diff --git a/client/src/sass/class-helpers/_menu.scss b/client/src/sass/class-helpers/_menu.scss index cfd36aa33..d67a221ce 100644 --- a/client/src/sass/class-helpers/_menu.scss +++ b/client/src/sass/class-helpers/_menu.scss @@ -1,5 +1,5 @@ -@use '_variables' as *; -@use '_mixins' as *; +@use "_variables" as *; +@use "_mixins" as *; .sub-menu-entry { border: 0; @@ -50,14 +50,6 @@ } } -.admin-sub-header { - display: flex; - align-items: center; - margin-bottom: 2rem; - flex-wrap: wrap; - gap: 0.5rem; -} - .pt-breadcrumb { display: flex; flex-wrap: wrap; @@ -83,7 +75,7 @@ &::before { display: inline-block; - content: '/'; + content: "/"; @include padding-right(0.5rem); } diff --git a/client/src/sass/include/_bootstrap-variables.scss b/client/src/sass/include/_bootstrap-variables.scss index f1e8f0e33..0d6e3b543 100644 --- a/client/src/sass/include/_bootstrap-variables.scss +++ b/client/src/sass/include/_bootstrap-variables.scss @@ -1,5 +1,5 @@ -@use 'sass:map'; -@use '_variables' as *; +@use "sass:map"; +@use "_variables" as *; $modal-footer-border-width: 0; $modal-md: 600px; @@ -17,7 +17,6 @@ $grid-breakpoints: ( // Extra large screens / wide desktops xl: 1200px, xxl: 1600px, - // SCREEN GROUP fhd: 1800px, qhd: 2560px, @@ -43,6 +42,11 @@ $input-btn-focus-width: 0; $input-btn-focus-color: inherit; $input-focus-border-color: pvar(--input-border-color); $input-focus-box-shadow: #{$focus-box-shadow-form}; +$input-padding-y: pvar(--input-y-padding); +$input-padding-x: pvar(--input-x-padding); +$input-border-radius: pvar(--input-border-radius); +$input-border-width: pvar(--input-border-width); +$input-border-color: pvar(--input-border-color); $input-group-addon-color: pvar(--fg); $input-group-addon-bg: pvar(--bg-secondary-500); diff --git a/client/src/sass/include/_css-variables.scss b/client/src/sass/include/_css-variables.scss index 7deffb108..4db763764 100644 --- a/client/src/sass/include/_css-variables.scss +++ b/client/src/sass/include/_css-variables.scss @@ -1,8 +1,8 @@ -@use '_variables' as *; -@use '_mixins' as *; +@use "_variables" as *; +@use "_mixins" as *; -@mixin define-css-variables () { - // --------------------------------------------------------------------------- +@mixin define-css-variables() { + // --------------------------------------------------------------------------- // New theme with fallback // --------------------------------------------------------------------------- @@ -10,6 +10,7 @@ --menu-fg: var(--menuForegroundColor); --menu-margin-left: #{$menu-margin-left}; --menu-width: #{$menu-width}; + --menu-border-radius: #{$menu-border-radius}; --fg: var(--mainForegroundColor, #000); @@ -33,6 +34,7 @@ --input-placeholder: var(--inputPlaceholderColor, #{pvar(--fg-50)}); --input-border-color: var(--inputBorderColor, #{pvar(--input-bg)}); + --input-border-width: 1px; --input-check-active-fg: #{pvar(--on-primary)}; --input-check-active-bg: #{pvar(--primary)}; @@ -70,6 +72,9 @@ --menu-fg: #{pvar(--fg-400)}; --menu-bg: #{pvar(--bg-secondary-400)}; + --header-fg: #{pvar(--fg)}; + --header-bg: #{pvar(--bg)}; + // --------------------------------------------------------------------------- --tmp-header-height: #{$header-height}; @@ -95,8 +100,8 @@ // --------------------------------------------------------------------------- // Light theme - &[data-pt-theme=peertube-core-light-beige], - &[data-pt-theme=default] { + &[data-pt-theme="peertube-core-light-beige"], + &[data-pt-theme="default"] { --is-dark: 0; --primary: #FF8F37; @@ -128,7 +133,7 @@ } // Brown - &[data-pt-theme=peertube-core-dark-brown] { + &[data-pt-theme="peertube-core-dark-brown"] { --is-dark: 1; --primary: #FD9C50; diff --git a/client/src/sass/include/_form-mixins.scss b/client/src/sass/include/_form-mixins.scss index f193c55f8..1cb1c005e 100644 --- a/client/src/sass/include/_form-mixins.scss +++ b/client/src/sass/include/_form-mixins.scss @@ -12,7 +12,7 @@ max-width: $width; color: pvar(--input-fg); background-color: pvar(--input-bg); - border: 1px solid pvar(--input-border-color); + border: pvar(--input-border-width) solid pvar(--input-border-color); border-radius: pvar(--input-border-radius); @include rounded-line-height-1-5($font-size); @@ -84,7 +84,7 @@ padding: pvar(--input-y-padding) calc(#{pvar(--input-x-padding)} + 23px) pvar(--input-y-padding) pvar(--input-x-padding); position: relative; - border: 1px solid var(--input-border-color) !important; + border: pvar(--input-border-width) solid var(--input-border-color) !important; appearance: none; text-overflow: ellipsis; diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index c9f0551b9..6e64d61f8 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -1,7 +1,7 @@ -@use 'sass:math'; -@use 'sass:color'; -@use '_variables' as *; -@import '_bootstrap-mixins'; +@use "sass:math"; +@use "sass:color"; +@use "_variables" as *; +@import "_bootstrap-mixins"; @mixin underline-primary { text-decoration: underline !important; @@ -62,12 +62,12 @@ } } -@mixin fade-text ($fade-after, $background-color) { +@mixin fade-text($fade-after, $background-color) { position: relative; overflow: hidden; &::after { - content: ''; + content: ""; pointer-events: none; width: 100%; height: 100%; @@ -78,7 +78,7 @@ } } -@mixin peertube-word-wrap ($with-hyphen: true) { +@mixin peertube-word-wrap($with-hyphen: true) { word-break: break-word; word-wrap: break-word; overflow-wrap: break-word; @@ -88,7 +88,7 @@ } } -@mixin apply-svg-color ($color) { +@mixin apply-svg-color($color) { ::ng-deep .feather, ::ng-deep .material, ::ng-deep .misc { @@ -96,7 +96,7 @@ } } -@mixin fill-path-svg-color ($color) { +@mixin fill-path-svg-color($color) { ::ng-deep svg { path { fill: $color; @@ -104,17 +104,17 @@ } } -@mixin fill-svg-color ($color) { +@mixin fill-svg-color($color) { ::ng-deep svg { fill: $color; } } -@mixin rounded-line-height-1-5 ($font-size) { +@mixin rounded-line-height-1-5($font-size) { line-height: calc(#{$font-size} + #{math.round(math.div($font-size, 2))}); } -@mixin icon ($size) { +@mixin icon($size) { display: inline-block; background-repeat: no-repeat; background-size: contain; @@ -124,34 +124,34 @@ cursor: pointer; } -@mixin global-icon-size ($size) { +@mixin global-icon-size($size) { display: inline-block; width: $size; height: $size; line-height: $size; } -@mixin responsive-width ($width) { +@mixin responsive-width($width) { width: $width; - @media screen and (max-width: #{$width - 30px}) { + @media screen and (max-width: #{$width + 30px}) { width: 100%; } } -@mixin actor-counters ($separator-margin: 10px) { +@mixin actor-counters($separator-margin: 10px) { color: pvar(--fg-300); display: flex; align-items: center; > *:not(:last-child)::after { - content: '•'; + content: "•"; margin: 0 $separator-margin; color: pvar(--primary); } } -@mixin row-blocks ($column-responsive: true, $min-height: 130px, $separator: true) { +@mixin row-blocks($column-responsive: true, $min-height: 130px, $separator: true) { display: flex; min-height: $min-height; padding-bottom: 20px; @@ -179,7 +179,7 @@ my-global-icon { width: 22px; - opacity: .7; + opacity: 0.7; position: relative; top: -2px; @@ -189,23 +189,23 @@ @mixin divider($color: pvar(--bg-secondary-400), $background: pvar(--bg)) { width: 95%; - border-top: .05rem solid $color; - height: .05rem; + border-top: 0.05rem solid $color; + height: 0.05rem; text-align: center; display: block; position: relative; &[data-content] { - margin: .8rem 0; + margin: 0.8rem 0; &::after { background: $background; color: $color; content: attr(data-content); display: inline-block; - font-size: .7rem; - padding: 0 .4rem; - transform: translateY(-.65rem); + font-size: 0.7rem; + padding: 0 0.4rem; + transform: translateY(-0.65rem); } } } @@ -213,7 +213,7 @@ // applies ratio (default to 16:9) to a child element (using $selector) only using // an immediate's parent size. This allows to set a ratio without explicit // dimensions, as width/height cannot be computed from each other. -@mixin block-ratio ($selector: 'div', $inverted-ratio: math.div(9, 16)) { +@mixin block-ratio($selector: "div", $inverted-ratio: math.div(9, 16)) { $padding-percent: math.percentage($inverted-ratio); position: relative; @@ -231,7 +231,7 @@ } } -@mixin play-icon ($width, $height) { +@mixin play-icon($width, $height) { width: 0; height: 0; @@ -246,13 +246,13 @@ border-left: $width solid #F4F4F5; } -@mixin on-small-main-col () { +@mixin on-small-main-col() { @media screen and (max-width: $small-view) { @content; } } -@mixin on-mobile-main-col () { +@mixin on-mobile-main-col() { @media screen and (max-width: $mobile-view) { @content; } @@ -260,7 +260,7 @@ // --------------------------------------------------------------------------- -@mixin margin ($arg1: null, $arg2: null, $arg3: null, $arg4: null) { +@mixin margin($arg1: null, $arg2: null, $arg3: null, $arg4: null) { @if $arg2 == null and $arg3 == null and $arg4 == null { @include margin-original($arg1, $arg1, $arg1, $arg1); } @else if $arg3 == null and $arg4 == null { @@ -272,31 +272,31 @@ } } -@mixin margin-original ($block-start, $inline-end, $block-end, $inline-start) { +@mixin margin-original($block-start, $inline-end, $block-end, $inline-start) { @include margin-left($inline-start); @include margin-right($inline-end); @include margin-top($block-start); @include margin-bottom($block-end); } -@mixin margin-left ($value) { +@mixin margin-left($value) { @include rfs($value, margin-inline-start); } -@mixin margin-right ($value) { +@mixin margin-right($value) { @include rfs($value, margin-inline-end); } // --------------------------------------------------------------------------- -@mixin padding-original ($block-start, $inline-end, $block-end, $inline-start) { +@mixin padding-original($block-start, $inline-end, $block-end, $inline-start) { @include padding-left($inline-start); @include padding-right($inline-end); @include padding-top($block-start); @include padding-bottom($block-end); } -@mixin padding ($arg1: null, $arg2: null, $arg3: null, $arg4: null) { +@mixin padding($arg1: null, $arg2: null, $arg3: null, $arg4: null) { @if $arg2 == null and $arg3 == null and $arg4 == null { @include padding-original($arg1, $arg1, $arg1, $arg1); } @else if $arg3 == null and $arg4 == null { @@ -308,24 +308,23 @@ } } -@mixin padding-left ($value) { +@mixin padding-left($value) { @include rfs($value, padding-inline-start); } -@mixin padding-right ($value) { +@mixin padding-right($value) { @include rfs($value, padding-inline-end); } // --------------------------------------------------------------------------- - /** * * inset-inline properties are not supported by iOS < 14.5 * */ -@mixin right ($value) { +@mixin right($value) { @supports (inset-inline-end: $value) { inset-inline-end: $value; } @@ -335,8 +334,7 @@ } } - -@mixin left ($value) { +@mixin left($value) { @supports (inset-inline-start: $value) { inset-inline-start: $value; } @@ -345,4 +343,3 @@ left: $value; } } - diff --git a/client/src/sass/include/_variables.scss b/client/src/sass/include/_variables.scss index 9643c22e1..5cadd93de 100644 --- a/client/src/sass/include/_variables.scss +++ b/client/src/sass/include/_variables.scss @@ -1,12 +1,12 @@ -@use 'sass:math'; -@use 'sass:color'; -@use 'sass:map'; +@use "sass:math"; +@use "sass:color"; +@use "sass:map"; $medium-view: 1000px; $small-view: 800px; $mobile-view: 500px; -$main-fonts: 'Source Sans Pro', sans-serif; +$main-fonts: "Source Sans Pro", sans-serif; $font-regular: 400; $font-semibold: 600; $font-bold: 700; @@ -28,6 +28,7 @@ $header-height-mobile-view-without-search: 80px; $header-mobile-msg-height: 48px; $menu-width: 248px; +$menu-border-radius: 14px; $menu-collapsed-width: 50px; $menu-margin-left: 2rem; $menu-overlay-view: 1200px; @@ -74,7 +75,7 @@ $player-portrait-bottom-space: 50px; $sub-menu-margin-bottom: 30px; $sub-menu-margin-bottom-small-view: 10px; -$focus-box-shadow-dimensions: 0 0 0 .2rem; +$focus-box-shadow-dimensions: 0 0 0 0.2rem; $form-input-font-size: 16px; @@ -88,46 +89,37 @@ $variables: ( --x-margin-content: var(--x-margin-content), --tmp-header-height: var(--tmp-header-height), --header-height: var(--header-height), - + --header-fg: var(--header-fg), + --header-bg: var(--header-bg), --fg: var(--fg), --bg: var(--bg), - --red: var(--red), --green: var(--green), - --input-fg: var(--input-fg), - --input-bg: var(--input-bg), --input-bg-550: var(--input-bg-550), --input-bg-600: var(--input-bg-600), --input-bg-in-secondary: var(--input-bg-in-secondary), - --input-danger-fg: var(--input-danger-fg), --input-danger-bg: var(--input-danger-bg), - --input-placeholder: var(--input-placeholder), --input-border-color: var(--input-border-color), --input-border-radius: var(--input-border-radius), - + --input-border-width: var(--input-border-width), --input-check-active-fg: var(--input-check-active-fg), --input-check-active-bg: var(--input-check-active-bg), - --input-x-padding: var(--input-x-padding), --input-y-padding: var(--input-y-padding), - --textarea-x-padding: var(--textarea-x-padding), --textarea-y-padding: var(--textarea-y-padding), --textarea-fg: var(--textarea-fg), --textarea-bg: var(--textarea-bg), - --support-btn-bg: var(--support-btn-bg), --support-btn-fg: var(--support-btn-fg), --support-btn-heart-bg: var(--support-btn-heart-bg), - --secondary-icon-color: var(--secondary-icon-color), --active-icon-color: var(--active-icon-color), --active-icon-bg: var(--active-icon-bg), - --fg-500: var(--fg-500), --fg-450: var(--fg-450), --fg-400: var(--fg-400), @@ -138,7 +130,6 @@ $variables: ( --fg-150: var(--fg-150), --fg-100: var(--fg-100), --fg-50: var(--fg-50), - --bg-secondary-600: var(--bg-secondary-600), --bg-secondary-550: var(--bg-secondary-550), --bg-secondary-500: var(--bg-secondary-500), @@ -148,7 +139,6 @@ $variables: ( --bg-secondary-300: var(--bg-secondary-300), --bg-secondary-250: var(--bg-secondary-250), --bg-secondary-200: var(--bg-secondary-200), - --menu-fg: var(--menu-fg), --menu-fg-600: var(--menu-fg-600), --menu-fg-550: var(--menu-fg-550), @@ -162,7 +152,6 @@ $variables: ( --menu-fg-150: var(--menu-fg-150), --menu-fg-100: var(--menu-fg-100), --menu-fg-50: var(--menu-fg-50), - --menu-bg: var(--menu-bg), --menu-bg-600: var(--menu-bg-600), --menu-bg-550: var(--menu-bg-550), @@ -173,10 +162,9 @@ $variables: ( --menu-bg-300: var(--menu-bg-300), --menu-bg-250: var(--menu-bg-250), --menu-bg-200: var(--menu-bg-200), - --menu-margin-left: var(--menu-margin-left), --menu-width: var(--menu-width), - + --menu-border-radius: var(--menu-border-radius), --on-primary: var(--on-primary), --on-primary-700: var(--on-primary-700), --on-primary-650: var(--on-primary-650), @@ -192,7 +180,6 @@ $variables: ( --on-primary-150: var(--on-primary-150), --on-primary-100: var(--on-primary-100), --on-primary-50: var(--on-primary-50), - --primary: var(--primary), --primary-700: var(--primary-700), --primary-650: var(--primary-650), @@ -208,16 +195,13 @@ $variables: ( --primary-150: var(--primary-150), --primary-100: var(--primary-100), --primary-50: var(--primary-50), - --border-primary: var(--border-primary), --border-secondary: var(--border-secondary), - --alert-primary-fg: var(--alert-primary-fg), --alert-primary-bg: var(--alert-primary-bg), --alert-primary-border-color: var(--alert-primary-border-color), - --embed-fg: var(--embed-fg), - --embed-big-play-bg: var(--embed-big-play-bg), + --embed-big-play-bg: var(--embed-big-play-bg) ); // SASS type check our CSS variables @@ -225,7 +209,7 @@ $variables: ( @if map.has-key($variables, $variable) { @return map.get($variables, $variable); } @else { - @error 'ERROR: Variable #{$variable} does not exist'; + @error "ERROR: Variable #{$variable} does not exist"; } } @@ -233,7 +217,7 @@ $variables: ( @if map.has-key($variables, $variable) and map.has-key($variables, $fallback) { @return var($variable, map.get($variables, $fallback)); } @else { - @error 'ERROR: Variable #{$variable} or #{$fallback} does not exist'; + @error "ERROR: Variable #{$variable} or #{$fallback} does not exist"; } } @@ -242,20 +226,20 @@ $variables: ( // --------------------------------------------------------------------------- $zindex: ( - miniature : 10, - overlay : 12550, - menu : 12600, + miniature: 10, + overlay: 12550, + menu: 12600, search-typeahead: 12650, - popover : 13000, - tooltip : 14000, - loadbar : 15000, - privacymsg : 17500, - root-header : 17500, - help-popover : 17600, - dropdown : 17600, - modal : 19000, - hotkeys : 19000, - notification : 20000 + popover: 13000, + tooltip: 14000, + loadbar: 15000, + privacymsg: 17500, + root-header: 17500, + help-popover: 17600, + dropdown: 17600, + modal: 19000, + hotkeys: 19000, + notification: 20000 ); @function z($label) { diff --git a/client/src/sass/primeng.scss b/client/src/sass/primeng.scss index 7d9e79e16..5897eed35 100644 --- a/client/src/sass/primeng.scss +++ b/client/src/sass/primeng.scss @@ -125,6 +125,14 @@ p-toast { } } +// --------------------------------------------------------------------------- +// Colorpicker +// --------------------------------------------------------------------------- + +p-colorpicker .p-colorpicker-preview { + border: 1px solid pvar(--fg-300); +} + // --------------------------------------------------------------------------- // Data table // --------------------------------------------------------------------------- diff --git a/config/default.yaml b/config/default.yaml index c6512ca80..6396a8a8c 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -1040,6 +1040,25 @@ followings: theme: default: 'default' + # Easily redefine the client UI when the user is using your default instance theme + # Use null to keep the default values + # If you need more advanced customizations, install or develop a dedicated theme: https://docs.joinpeertube.org/contribute/plugins + customization: + primary_color: null # Hex color. Example: '#FF8F37' + + foreground_color: null # Hex color + background_color: null # Hex color + background_secondary_color: null # Hex color + + menu_foreground_color: null # Hex color + menu_background_color: null # Hex color + menu_border_radius: null # Pixels. Example: '5px' + + header_background_color: null # Hex color + header_foreground_color: null # Hex color + + input_border_radius: null # Pixels + broadcast_message: enabled: false message: '' # Support markdown @@ -1074,6 +1093,7 @@ search: # PeerTube client/interface configuration client: + videos: miniature: # By default PeerTube client displays author username diff --git a/packages/models/src/server/custom-config.model.ts b/packages/models/src/server/custom-config.model.ts index ce4d078c1..2d765e33f 100644 --- a/packages/models/src/server/custom-config.model.ts +++ b/packages/models/src/server/custom-config.model.ts @@ -56,6 +56,19 @@ export interface CustomConfig { theme: { default: string + + customization: { + primaryColor: string + foregroundColor: string + backgroundColor: string + backgroundSecondaryColor: string + menuForegroundColor: string + menuBackgroundColor: string + menuBorderRadius: string + headerForegroundColor: string + headerBackgroundColor: string + inputBorderRadius: string + } } services: { diff --git a/packages/models/src/server/server-config.model.ts b/packages/models/src/server/server-config.model.ts index 537bdc73f..1fa533e27 100644 --- a/packages/models/src/server/server-config.model.ts +++ b/packages/models/src/server/server-config.model.ts @@ -162,6 +162,19 @@ export interface ServerConfig { builtIn: { name: 'peertube-core-light-beige' | 'peertube-core-dark-brown' }[] default: string + + customization: { + primaryColor: string + foregroundColor: string + backgroundColor: string + backgroundSecondaryColor: string + menuForegroundColor: string + menuBackgroundColor: string + menuBorderRadius: string + headerForegroundColor: string + headerBackgroundColor: string + inputBorderRadius: string + } } email: { diff --git a/packages/models/src/server/server-error-code.enum.ts b/packages/models/src/server/server-error-code.enum.ts index f872e2263..2b863731d 100644 --- a/packages/models/src/server/server-error-code.enum.ts +++ b/packages/models/src/server/server-error-code.enum.ts @@ -66,7 +66,7 @@ export const ServerErrorCode = { /** * oauthjs/oauth2-server error codes * @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 - **/ + */ export const OAuth2ErrorCode = { /** * The provided authorization grant (e.g., authorization code, resource owner diff --git a/server/core/controllers/api/config.ts b/server/core/controllers/api/config.ts index f4a687748..bf9841864 100644 --- a/server/core/controllers/api/config.ts +++ b/server/core/controllers/api/config.ts @@ -272,7 +272,20 @@ function customConfig (): CustomConfig { } }, theme: { - default: CONFIG.THEME.DEFAULT + default: CONFIG.THEME.DEFAULT, + + customization: { + primaryColor: CONFIG.THEME.CUSTOMIZATION.PRIMARY_COLOR, + foregroundColor: CONFIG.THEME.CUSTOMIZATION.FOREGROUND_COLOR, + backgroundColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_COLOR, + backgroundSecondaryColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_SECONDARY_COLOR, + menuForegroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_FOREGROUND_COLOR, + menuBackgroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_BACKGROUND_COLOR, + menuBorderRadius: CONFIG.THEME.CUSTOMIZATION.MENU_BORDER_RADIUS, + headerForegroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_FOREGROUND_COLOR, + headerBackgroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_BACKGROUND_COLOR, + inputBorderRadius: CONFIG.THEME.CUSTOMIZATION.INPUT_BORDER_RADIUS + } }, services: { twitter: { diff --git a/server/core/initializers/config.ts b/server/core/initializers/config.ts index 878ffeb8d..afb20f194 100644 --- a/server/core/initializers/config.ts +++ b/server/core/initializers/config.ts @@ -992,6 +992,39 @@ const CONFIG = { THEME: { get DEFAULT () { return config.get('theme.default') + }, + + CUSTOMIZATION: { + get PRIMARY_COLOR () { + return config.get('theme.customization.primary_color') + }, + get FOREGROUND_COLOR () { + return config.get('theme.customization.foreground_color') + }, + get BACKGROUND_COLOR () { + return config.get('theme.customization.background_color') + }, + get BACKGROUND_SECONDARY_COLOR () { + return config.get('theme.customization.background_secondary_color') + }, + get MENU_FOREGROUND_COLOR () { + return config.get('theme.customization.menu_foreground_color') + }, + get MENU_BACKGROUND_COLOR () { + return config.get('theme.customization.menu_background_color') + }, + get MENU_BORDER_RADIUS () { + return config.get('theme.customization.menu_border_radius') + }, + get HEADER_BACKGROUND_COLOR () { + return config.get('theme.customization.header_background_color') + }, + get HEADER_FOREGROUND_COLOR () { + return config.get('theme.customization.header_foreground_color') + }, + get INPUT_BORDER_RADIUS () { + return config.get('theme.customization.input_border_radius') + } } }, BROADCAST_MESSAGE: { diff --git a/server/core/lib/server-config-manager.ts b/server/core/lib/server-config-manager.ts index f44777c65..fe7b6c300 100644 --- a/server/core/lib/server-config-manager.ts +++ b/server/core/lib/server-config-manager.ts @@ -156,7 +156,19 @@ class ServerConfigManager { theme: { registered: this.getRegisteredThemes(), builtIn: this.getBuiltInThemes(), - default: defaultTheme + default: defaultTheme, + customization: { + primaryColor: CONFIG.THEME.CUSTOMIZATION.PRIMARY_COLOR, + foregroundColor: CONFIG.THEME.CUSTOMIZATION.FOREGROUND_COLOR, + backgroundColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_COLOR, + backgroundSecondaryColor: CONFIG.THEME.CUSTOMIZATION.BACKGROUND_SECONDARY_COLOR, + menuForegroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_FOREGROUND_COLOR, + menuBackgroundColor: CONFIG.THEME.CUSTOMIZATION.MENU_BACKGROUND_COLOR, + menuBorderRadius: CONFIG.THEME.CUSTOMIZATION.MENU_BORDER_RADIUS, + headerForegroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_FOREGROUND_COLOR, + headerBackgroundColor: CONFIG.THEME.CUSTOMIZATION.HEADER_BACKGROUND_COLOR, + inputBorderRadius: CONFIG.THEME.CUSTOMIZATION.INPUT_BORDER_RADIUS + } }, email: { enabled: isEmailEnabled()