はじまりの大地
このコミットが含まれているのは:
@@ -0,0 +1,4 @@
|
||||
last 1 Chrome version
|
||||
last 2 Edge major versions
|
||||
Firefox ESR
|
||||
ios_saf >= 13.1
|
||||
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"root": true,
|
||||
"ignorePatterns": [
|
||||
"projects/**/*",
|
||||
"node_modules/",
|
||||
"src/standalone/embed-player-api/dist"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"tsconfig.eslint.json"
|
||||
],
|
||||
"EXPERIMENTAL_useSourceOfProjectReferenceRedirect": true,
|
||||
"createDefaultProgram": false
|
||||
},
|
||||
"extends": [
|
||||
"../.eslintrc.json",
|
||||
"plugin:@angular-eslint/recommended",
|
||||
"plugin:@angular-eslint/template/process-inline-templates"
|
||||
],
|
||||
"rules": {
|
||||
"jsdoc/newline-after-description": "off",
|
||||
"jsdoc/check-alignment": "off",
|
||||
"lines-between-class-members": "off",
|
||||
"@typescript-eslint/lines-between-class-members": [ "off" ],
|
||||
"arrow-body-style": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"n/no-callback-literal": "off",
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": [ "element", "attribute" ],
|
||||
"prefix": "my",
|
||||
"style": "kebab-case"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": [ "element", "attribute" ],
|
||||
"prefix": "my",
|
||||
"style": "camelCase"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-this-alias": [
|
||||
"error",
|
||||
{
|
||||
"allowDestructuring": true,
|
||||
"allowedNames": ["self", "player"]
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/prefer-readonly": "off",
|
||||
"@angular-eslint/use-component-view-encapsulation": "error",
|
||||
"prefer-arrow/prefer-arrow-functions": "off",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/dot-notation": "off",
|
||||
"@typescript-eslint/explicit-member-accessibility": [
|
||||
"off",
|
||||
{
|
||||
"accessibility": "explicit"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/member-ordering": [
|
||||
"off"
|
||||
],
|
||||
"@typescript-eslint/member-delimiter-style": [
|
||||
"error",
|
||||
{
|
||||
"multiline": {
|
||||
"delimiter": "none",
|
||||
"requireLast": true
|
||||
},
|
||||
"singleline": {
|
||||
"delimiter": "comma",
|
||||
"requireLast": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/prefer-for-of": "off",
|
||||
"@typescript-eslint/no-empty-function": "error",
|
||||
"@typescript-eslint/no-floating-promises": "off",
|
||||
"@typescript-eslint/no-inferrable-types": "error",
|
||||
"@typescript-eslint/no-shadow": [
|
||||
"off",
|
||||
{
|
||||
"hoist": "all"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unnecessary-qualifier": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
"@typescript-eslint/no-unused-expressions": [
|
||||
"error",
|
||||
{
|
||||
"allowTaggedTemplates": true,
|
||||
"allowShortCircuit": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/quotes": [
|
||||
"error",
|
||||
"single",
|
||||
{
|
||||
"avoidEscape": true,
|
||||
"allowTemplateLiterals": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/semi": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"brace-style": [
|
||||
"error",
|
||||
"1tbs"
|
||||
],
|
||||
"comma-dangle": "error",
|
||||
"curly": [
|
||||
"error",
|
||||
"multi-line"
|
||||
],
|
||||
"dot-notation": "off",
|
||||
"no-useless-return": "off",
|
||||
"indent": "off",
|
||||
"no-bitwise": "off",
|
||||
"no-console": "off",
|
||||
"no-return-assign": "off",
|
||||
"no-constant-condition": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-empty": "error",
|
||||
"no-empty-function": [
|
||||
"error",
|
||||
{ "allow": [ "constructors" ] }
|
||||
],
|
||||
"no-invalid-regexp": "error",
|
||||
"no-multiple-empty-lines": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-return-await": "error",
|
||||
"no-shadow": "off",
|
||||
"no-unused-expressions": "error",
|
||||
"semi": "error",
|
||||
"space-before-function-paren": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"space-in-parens": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"object-shorthand": [
|
||||
"error",
|
||||
"properties"
|
||||
],
|
||||
"quote-props": [
|
||||
"error",
|
||||
"consistent-as-needed"
|
||||
],
|
||||
"no-constant-binary-expression": "error",
|
||||
"@typescript-eslint/unbound-method": [
|
||||
"error",
|
||||
{ "ignoreStatic": true }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@angular-eslint/template/recommended",
|
||||
"plugin:@angular-eslint/template/accessibility"
|
||||
],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/.angular/cache
|
||||
/dist/
|
||||
/node_modules
|
||||
/compiled
|
||||
/stats.json
|
||||
/dll
|
||||
/.awcache
|
||||
/src/locale/pending_target/
|
||||
/src/locale/target/iso639_*.xml
|
||||
/src/locale/target/player_*.xml
|
||||
/src/locale/target/server_*.xml
|
||||
/e2e/local.log
|
||||
/e2e/browserstack.err
|
||||
/e2e/screenshots
|
||||
/src/standalone/embed-player-api/build
|
||||
/src/standalone/embed-player-api/dist
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"extends": "stylelint-config-sass-guidelines",
|
||||
"rules": {
|
||||
"scss/at-import-no-partial-leading-underscore": null,
|
||||
"color-hex-length": null,
|
||||
"selector-pseudo-element-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoElements": [ "ng-deep" ]
|
||||
}
|
||||
],
|
||||
"max-nesting-depth": [
|
||||
8,
|
||||
{
|
||||
"ignore": [ "blockless-at-rules", "pseudo-classes" ]
|
||||
}
|
||||
],
|
||||
"selector-max-compound-selectors": 9,
|
||||
"selector-no-qualifying-type": null,
|
||||
"scss/at-extend-no-missing-placeholder": null,
|
||||
"rule-empty-line-before": null,
|
||||
"selector-max-id": null,
|
||||
"scss/at-function-pattern": null,
|
||||
"scss/load-no-partial-leading-underscore": null,
|
||||
"property-no-vendor-prefix": [
|
||||
true,
|
||||
{
|
||||
"ignoreProperties": [ "mask-image", "mask-size" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"xliffmergeOptions": {
|
||||
"i18nFormat": "xlf",
|
||||
"srcDir": "src/locale",
|
||||
"genDir": "src/locale",
|
||||
"i18nBaseFile": "angular",
|
||||
"defaultLanguage": "en-US"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"PeerTube": {
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"projectType": "application",
|
||||
"i18n": {
|
||||
"sourceLocale": {
|
||||
"code": "en",
|
||||
"baseHref": "/client/en-US/"
|
||||
},
|
||||
"locales": {
|
||||
"ar": {
|
||||
"translation": "src/locale/angular.ar.xlf",
|
||||
"baseHref": "/client/ar/"
|
||||
},
|
||||
"fa": {
|
||||
"translation": "src/locale/angular.fa-IR.xlf",
|
||||
"baseHref": "/client/fa-IR/"
|
||||
},
|
||||
"hu": {
|
||||
"translation": "src/locale/angular.hu-HU.xlf",
|
||||
"baseHref": "/client/hu-HU/"
|
||||
},
|
||||
"th": {
|
||||
"translation": "src/locale/angular.th-TH.xlf",
|
||||
"baseHref": "/client/th-TH/"
|
||||
},
|
||||
"tr": {
|
||||
"translation": "src/locale/angular.tr-TR.xlf",
|
||||
"baseHref": "/client/tr-TR/"
|
||||
},
|
||||
"fi": {
|
||||
"translation": "src/locale/angular.fi-FI.xlf",
|
||||
"baseHref": "/client/fi-FI/"
|
||||
},
|
||||
"nl": {
|
||||
"translation": "src/locale/angular.nl-NL.xlf",
|
||||
"baseHref": "/client/nl-NL/"
|
||||
},
|
||||
"gd": {
|
||||
"translation": "src/locale/angular.gd.xlf",
|
||||
"baseHref": "/client/gd/"
|
||||
},
|
||||
"el": {
|
||||
"translation": "src/locale/angular.el-GR.xlf",
|
||||
"baseHref": "/client/el-GR/"
|
||||
},
|
||||
"es": {
|
||||
"translation": "src/locale/angular.es-ES.xlf",
|
||||
"baseHref": "/client/es-ES/"
|
||||
},
|
||||
"oc": {
|
||||
"translation": "src/locale/angular.oc.xlf",
|
||||
"baseHref": "/client/oc/"
|
||||
},
|
||||
"pt": {
|
||||
"translation": "src/locale/angular.pt-BR.xlf",
|
||||
"baseHref": "/client/pt-BR/"
|
||||
},
|
||||
"pt-PT": {
|
||||
"translation": "src/locale/angular.pt-PT.xlf",
|
||||
"baseHref": "/client/pt-PT/"
|
||||
},
|
||||
"sv": {
|
||||
"translation": "src/locale/angular.sv-SE.xlf",
|
||||
"baseHref": "/client/sv-SE/"
|
||||
},
|
||||
"pl": {
|
||||
"translation": "src/locale/angular.pl-PL.xlf",
|
||||
"baseHref": "/client/pl-PL/"
|
||||
},
|
||||
"ru": {
|
||||
"translation": "src/locale/angular.ru-RU.xlf",
|
||||
"baseHref": "/client/ru-RU/"
|
||||
},
|
||||
"sq": {
|
||||
"translation": "src/locale/angular.sq.xlf",
|
||||
"baseHref": "/client/sq/"
|
||||
},
|
||||
"hr": {
|
||||
"translation": "src/locale/angular.hr.xlf",
|
||||
"baseHref": "/client/hr/"
|
||||
},
|
||||
"zh-Hans": {
|
||||
"translation": "src/locale/angular.zh-Hans-CN.xlf",
|
||||
"baseHref": "/client/zh-Hans-CN/"
|
||||
},
|
||||
"zh-Hant": {
|
||||
"translation": "src/locale/angular.zh-Hant-TW.xlf",
|
||||
"baseHref": "/client/zh-Hant-TW/"
|
||||
},
|
||||
"fr": {
|
||||
"translation": "src/locale/angular.fr-FR.xlf",
|
||||
"baseHref": "/client/fr-FR/"
|
||||
},
|
||||
"ja": {
|
||||
"translation": "src/locale/angular.ja-JP.xlf",
|
||||
"baseHref": "/client/ja-JP/"
|
||||
},
|
||||
"eu": {
|
||||
"translation": "src/locale/angular.eu-ES.xlf",
|
||||
"baseHref": "/client/eu-ES/"
|
||||
},
|
||||
"ca": {
|
||||
"translation": "src/locale/angular.ca-ES.xlf",
|
||||
"baseHref": "/client/ca-ES/"
|
||||
},
|
||||
"gl": {
|
||||
"translation": "src/locale/angular.gl-ES.xlf",
|
||||
"baseHref": "/client/gl-ES/"
|
||||
},
|
||||
"cs": {
|
||||
"translation": "src/locale/angular.cs-CZ.xlf",
|
||||
"baseHref": "/client/cs-CZ/"
|
||||
},
|
||||
"eo": {
|
||||
"translation": "src/locale/angular.eo.xlf",
|
||||
"baseHref": "/client/eo/"
|
||||
},
|
||||
"de": {
|
||||
"translation": "src/locale/angular.de-DE.xlf",
|
||||
"baseHref": "/client/de-DE/"
|
||||
},
|
||||
"it": {
|
||||
"translation": "src/locale/angular.it-IT.xlf",
|
||||
"baseHref": "/client/it-IT/"
|
||||
},
|
||||
"vi": {
|
||||
"translation": "src/locale/angular.vi-VN.xlf",
|
||||
"baseHref": "/client/vi-VN/"
|
||||
},
|
||||
"kab": {
|
||||
"translation": "src/locale/angular.kab.xlf",
|
||||
"baseHref": "/client/kab/"
|
||||
},
|
||||
"nb": {
|
||||
"translation": "src/locale/angular.nb-NO.xlf",
|
||||
"baseHref": "/client/nb-NO/"
|
||||
},
|
||||
"tok": {
|
||||
"translation": "src/locale/angular.tok.xlf",
|
||||
"baseHref": "/client/tok/"
|
||||
},
|
||||
"nn": {
|
||||
"translation": "src/locale/angular.nn.xlf",
|
||||
"baseHref": "/client/nn/"
|
||||
},
|
||||
"is": {
|
||||
"translation": "src/locale/angular.is.xlf",
|
||||
"baseHref": "/client/is/"
|
||||
},
|
||||
"uk": {
|
||||
"translation": "src/locale/angular.uk-UA.xlf",
|
||||
"baseHref": "/client/uk-UA/"
|
||||
}
|
||||
}
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"i18nMissingTranslation": "ignore",
|
||||
"localize": true,
|
||||
"outputPath": {
|
||||
"base": "dist"
|
||||
},
|
||||
"index": "src/index.html",
|
||||
"tsConfig": "tsconfig.json",
|
||||
"polyfills": [
|
||||
"src/polyfills.ts",
|
||||
"@angular/localize/init"
|
||||
],
|
||||
"baseHref": "/",
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": [
|
||||
"src/sass/include",
|
||||
"."
|
||||
]
|
||||
},
|
||||
"assets": [
|
||||
"src/assets/images",
|
||||
"src/manifest.webmanifest"
|
||||
],
|
||||
"styles": [
|
||||
"src/sass/application.scss"
|
||||
],
|
||||
"allowedCommonJsDependencies": [
|
||||
"qrcode",
|
||||
"chart.js",
|
||||
"htmlparser2",
|
||||
"markdown-it-emoji/light",
|
||||
"linkifyjs/lib/linkify-html",
|
||||
"linkifyjs/lib/plugins/mention",
|
||||
"sanitize-html",
|
||||
"debug",
|
||||
"@peertube/p2p-media-loader-hlsjs",
|
||||
"video.js",
|
||||
"sha.js",
|
||||
"postcss",
|
||||
"focus-visible",
|
||||
"path-browserify",
|
||||
"deep-merge",
|
||||
"escape-string-regexp",
|
||||
"is-plain-object",
|
||||
"parse-srcset",
|
||||
"deepmerge",
|
||||
"core-js/features/reflect",
|
||||
"@formatjs/intl-locale/polyfill",
|
||||
"@formatjs/intl-locale/should-polyfill",
|
||||
"@formatjs/intl-pluralrules/polyfill-force",
|
||||
"@formatjs/intl-pluralrules/should-polyfill"
|
||||
],
|
||||
"scripts": [],
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true,
|
||||
"browser": "src/main.ts",
|
||||
"loader": {
|
||||
".svg": "text"
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"serviceWorker": "src/ngsw-config.json",
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "100kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ar-locale": {
|
||||
"localize": [
|
||||
"ar"
|
||||
],
|
||||
"budgets": [
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.hmr.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hmr": {
|
||||
"localize": false,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.hmr.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.config.json",
|
||||
"buildTarget": "PeerTube:build"
|
||||
},
|
||||
"configurations": {
|
||||
"hmr": {
|
||||
"buildTarget": "PeerTube:build:hmr"
|
||||
},
|
||||
"ar-locale": {
|
||||
"buildTarget": "PeerTube:build:ar-locale"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"e2e/**/*.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"prefix": "my",
|
||||
"style": "scss",
|
||||
"skipTests": true,
|
||||
"flat": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"prefix": "my"
|
||||
},
|
||||
"@angular-eslint/schematics:application": {
|
||||
"setParserOptionsProject": true
|
||||
},
|
||||
"@angular-eslint/schematics:library": {
|
||||
"setParserOptionsProject": true
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
バイナリファイルは表示されません.
バイナリファイルは表示されません.
バイナリファイルは表示されません.
@@ -0,0 +1,12 @@
|
||||
browser.addCommand('chooseFile', async function (this: WebdriverIO.Element, localFilePath: string) {
|
||||
try {
|
||||
const remoteFile = await browser.uploadFile(localFilePath)
|
||||
|
||||
return this.addValue(remoteFile)
|
||||
} catch {
|
||||
console.log('Cannot upload file, fallback to add value.')
|
||||
|
||||
// Firefox does not support upload file, but if we're running the test in local we don't really need it
|
||||
return this.addValue(localFilePath)
|
||||
}
|
||||
}, true)
|
||||
@@ -0,0 +1,65 @@
|
||||
import { browserSleep, getCheckbox, go, isCheckboxSelected } from '../utils'
|
||||
|
||||
export class AdminConfigPage {
|
||||
|
||||
async navigateTo (tab: 'instance-homepage' | 'basic-configuration' | 'instance-information') {
|
||||
const waitTitles = {
|
||||
'instance-homepage': 'INSTANCE HOMEPAGE',
|
||||
'basic-configuration': 'APPEARANCE',
|
||||
'instance-information': 'INSTANCE'
|
||||
}
|
||||
await go('/admin/config/edit-custom#' + tab)
|
||||
|
||||
await $('h2=' + waitTitles[tab]).waitForDisplayed()
|
||||
}
|
||||
|
||||
async updateNSFWSetting (newValue: 'do_not_list' | 'blur' | 'display') {
|
||||
const elem = $('#instanceDefaultNSFWPolicy')
|
||||
|
||||
await elem.waitForDisplayed()
|
||||
await elem.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await elem.waitForClickable()
|
||||
|
||||
return elem.selectByAttribute('value', newValue)
|
||||
}
|
||||
|
||||
updateHomepage (newValue: string) {
|
||||
return $('#instanceCustomHomepageContent').setValue(newValue)
|
||||
}
|
||||
|
||||
async toggleSignup (enabled: boolean) {
|
||||
if (await isCheckboxSelected('signupEnabled') === enabled) return
|
||||
|
||||
const checkbox = await getCheckbox('signupEnabled')
|
||||
|
||||
await checkbox.waitForClickable()
|
||||
await checkbox.click()
|
||||
}
|
||||
|
||||
async toggleSignupApproval (required: boolean) {
|
||||
if (await isCheckboxSelected('signupRequiresApproval') === required) return
|
||||
|
||||
const checkbox = await getCheckbox('signupRequiresApproval')
|
||||
|
||||
await checkbox.waitForClickable()
|
||||
await checkbox.click()
|
||||
}
|
||||
|
||||
async toggleSignupEmailVerification (required: boolean) {
|
||||
if (await isCheckboxSelected('signupRequiresEmailVerification') === required) return
|
||||
|
||||
const checkbox = await getCheckbox('signupRequiresEmailVerification')
|
||||
|
||||
await checkbox.waitForClickable()
|
||||
await checkbox.click()
|
||||
}
|
||||
|
||||
async save () {
|
||||
const button = $('input[type=submit]')
|
||||
|
||||
await button.waitForClickable()
|
||||
await button.click()
|
||||
|
||||
await browserSleep(1000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { browserSleep, go } from '../utils'
|
||||
|
||||
export class AdminPluginPage {
|
||||
|
||||
async navigateToPluginSearch () {
|
||||
await go('/admin/plugins/search')
|
||||
|
||||
await $('my-plugin-search').waitForDisplayed()
|
||||
}
|
||||
|
||||
async search (name: string) {
|
||||
const input = $('.search-bar input')
|
||||
await input.waitForDisplayed()
|
||||
await input.clearValue()
|
||||
await input.setValue(name)
|
||||
|
||||
await browserSleep(1000)
|
||||
}
|
||||
|
||||
async installHelloWorld () {
|
||||
$('.plugin-name=hello-world').waitForDisplayed()
|
||||
|
||||
await $('.card-body my-button[icon=cloud-download]').click()
|
||||
|
||||
const submitModalButton = $('.modal-content input[type=submit]')
|
||||
await submitModalButton.waitForClickable()
|
||||
await submitModalButton.click()
|
||||
|
||||
await $('.card-body my-edit-button').waitForDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { browserSleep, findParentElement, go } from '../utils'
|
||||
|
||||
export class AdminRegistrationPage {
|
||||
|
||||
async navigateToRegistratonsList () {
|
||||
await go('/admin/moderation/registrations/list')
|
||||
|
||||
await $('my-registration-list').waitForDisplayed()
|
||||
}
|
||||
|
||||
async accept (username: string, moderationResponse: string) {
|
||||
const usernameEl = await $('*=' + username)
|
||||
await usernameEl.waitForDisplayed()
|
||||
|
||||
const tr = await findParentElement(usernameEl, async el => await el.getTagName() === 'tr')
|
||||
|
||||
await tr.$('.action-cell .dropdown-root').click()
|
||||
|
||||
const accept = await $('span*=Accept this request')
|
||||
await accept.waitForClickable()
|
||||
await accept.click()
|
||||
|
||||
const moderationResponseTextarea = await $('#moderationResponse')
|
||||
await moderationResponseTextarea.waitForDisplayed()
|
||||
|
||||
await moderationResponseTextarea.setValue(moderationResponse)
|
||||
|
||||
const submitButton = $('.modal-footer input[type=submit]')
|
||||
await submitButton.waitForClickable()
|
||||
await submitButton.click()
|
||||
|
||||
await browserSleep(1000)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getCheckbox } from '../utils'
|
||||
|
||||
export class AnonymousSettingsPage {
|
||||
|
||||
async openSettings () {
|
||||
const link = await $$('.menu-link').filter(async i => {
|
||||
return await i.getText() === 'My settings'
|
||||
}).then(links => links[0])
|
||||
|
||||
await link.click()
|
||||
|
||||
await $('my-user-video-settings').waitForDisplayed()
|
||||
}
|
||||
|
||||
async clickOnP2PCheckbox () {
|
||||
const p2p = await getCheckbox('p2pEnabled')
|
||||
await p2p.waitForClickable()
|
||||
|
||||
await p2p.click()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { browserSleep, go, isAndroid } from '../utils'
|
||||
|
||||
export class LoginPage {
|
||||
|
||||
constructor (private isMobileDevice: boolean) {
|
||||
|
||||
}
|
||||
|
||||
async login (options: {
|
||||
username: string
|
||||
password: string
|
||||
displayName?: string
|
||||
url?: string
|
||||
}) {
|
||||
const { username, password, url = '/login', displayName = username } = options
|
||||
|
||||
await go(url)
|
||||
|
||||
await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`)
|
||||
await browser.execute(`window.localStorage.setItem('no_instance_config_warning_modal', 'true')`)
|
||||
await browser.execute(`window.localStorage.setItem('no_welcome_modal', 'true')`)
|
||||
|
||||
await $('input#username').setValue(username)
|
||||
await $('input#password').setValue(password)
|
||||
|
||||
await browserSleep(1000)
|
||||
|
||||
const submit = $('.login-form-and-externals > form input[type=submit]')
|
||||
await submit.click()
|
||||
|
||||
// Have to do this on Android, don't really know why
|
||||
// I think we need to "escape" from the password input, so click twice on the submit button
|
||||
if (isAndroid()) {
|
||||
await browserSleep(2000)
|
||||
await submit.click()
|
||||
}
|
||||
|
||||
if (this.isMobileDevice) {
|
||||
const menuToggle = $('.top-left-block button')
|
||||
|
||||
await $('h2=Our content selection').waitForDisplayed()
|
||||
|
||||
await menuToggle.click()
|
||||
|
||||
await this.ensureIsLoggedInAs(displayName)
|
||||
|
||||
await menuToggle.click()
|
||||
} else {
|
||||
await this.ensureIsLoggedInAs(displayName)
|
||||
}
|
||||
}
|
||||
|
||||
async getLoginError (username: string, password: string) {
|
||||
await go('/login')
|
||||
|
||||
await $('input#username').setValue(username)
|
||||
await $('input#password').setValue(password)
|
||||
|
||||
await browser.pause(1000)
|
||||
|
||||
await $('form input[type=submit]').click()
|
||||
|
||||
return $('.alert-danger').getText()
|
||||
}
|
||||
|
||||
async loginAsRootUser () {
|
||||
return this.login({ username: 'root', password: 'test' + this.getSuffix() })
|
||||
}
|
||||
|
||||
loginOnPeerTube2 () {
|
||||
if (!process.env.PEERTUBE2_E2E_PASSWORD) {
|
||||
throw new Error('PEERTUBE2_E2E_PASSWORD env is missing for user e2e on peertube2.cpy.re')
|
||||
}
|
||||
|
||||
return this.login({ username: 'e2e', password: process.env.PEERTUBE2_E2E_PASSWORD, url: 'https://peertube2.cpy.re/login' })
|
||||
}
|
||||
|
||||
async logout () {
|
||||
const loggedInDropdown = $('.logged-in-more .logged-in-info')
|
||||
|
||||
await loggedInDropdown.waitForClickable()
|
||||
await loggedInDropdown.click()
|
||||
|
||||
const logout = $('.dropdown-item*=Log out')
|
||||
|
||||
await logout.waitForClickable()
|
||||
await logout.click()
|
||||
|
||||
await browser.waitUntil(() => {
|
||||
return $$('.login-buttons-block, my-error-page a[href="/login"]').some(e => e.isDisplayed())
|
||||
})
|
||||
}
|
||||
|
||||
async ensureIsLoggedInAs (displayName: string) {
|
||||
await this.getLoggedInInfoElem().waitForExist()
|
||||
|
||||
await expect(this.getLoggedInInfoElem()).toHaveText(displayName)
|
||||
}
|
||||
|
||||
private getLoggedInInfoElem () {
|
||||
return $('.logged-in-display-name')
|
||||
}
|
||||
|
||||
private getSuffix () {
|
||||
return browser.options.baseUrl
|
||||
? browser.options.baseUrl.slice(-1)
|
||||
: '1'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { getCheckbox, go, selectCustomSelect } from '../utils'
|
||||
|
||||
export class MyAccountPage {
|
||||
|
||||
navigateToMyVideos () {
|
||||
return $('a[href="/my-library/videos"]').click()
|
||||
}
|
||||
|
||||
navigateToMyPlaylists () {
|
||||
return $('a[href="/my-library/video-playlists"]').click()
|
||||
}
|
||||
|
||||
navigateToMyHistory () {
|
||||
return $('a[href="/my-library/history/videos"]').click()
|
||||
}
|
||||
|
||||
// Settings
|
||||
|
||||
navigateToMySettings () {
|
||||
return $('a[href="/my-account"]').click()
|
||||
}
|
||||
|
||||
async updateNSFW (newValue: 'do_not_list' | 'blur' | 'display') {
|
||||
const nsfw = $('#nsfwPolicy')
|
||||
|
||||
await nsfw.waitForDisplayed()
|
||||
await nsfw.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await nsfw.waitForClickable()
|
||||
|
||||
await nsfw.selectByAttribute('value', newValue)
|
||||
|
||||
await this.submitVideoSettings()
|
||||
}
|
||||
|
||||
async clickOnP2PCheckbox () {
|
||||
const p2p = await getCheckbox('p2pEnabled')
|
||||
|
||||
await p2p.waitForClickable()
|
||||
await p2p.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
|
||||
await p2p.click()
|
||||
|
||||
await this.submitVideoSettings()
|
||||
}
|
||||
|
||||
private async submitVideoSettings () {
|
||||
const submit = $('my-user-video-settings input[type=submit]')
|
||||
|
||||
await submit.waitForClickable()
|
||||
await submit.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await submit.click()
|
||||
}
|
||||
|
||||
// My account Videos
|
||||
|
||||
async removeVideo (name: string) {
|
||||
const container = await this.getVideoElement(name)
|
||||
|
||||
await container.$('my-action-dropdown .dropdown-toggle').click()
|
||||
|
||||
const deleteItem = () => {
|
||||
return $$('.dropdown-menu .dropdown-item').find<WebdriverIO.Element>(async v => {
|
||||
const text = await v.getText()
|
||||
|
||||
return text.includes('Delete')
|
||||
})
|
||||
}
|
||||
|
||||
await (await deleteItem()).waitForClickable()
|
||||
|
||||
return (await deleteItem()).click()
|
||||
}
|
||||
|
||||
validRemove () {
|
||||
return $('input[type=submit]').click()
|
||||
}
|
||||
|
||||
async countVideos (names: string[]) {
|
||||
const elements = await $$('.video').filter(async e => {
|
||||
const t = await e.$('.video-miniature-name').getText()
|
||||
|
||||
return names.some(n => t.includes(n))
|
||||
})
|
||||
|
||||
return elements.length
|
||||
}
|
||||
|
||||
// My account playlists
|
||||
|
||||
async getPlaylistVideosText (name: string) {
|
||||
const elem = await this.getPlaylist(name)
|
||||
|
||||
return elem.$('.miniature-playlist-info-overlay').getText()
|
||||
}
|
||||
|
||||
async clickOnPlaylist (name: string) {
|
||||
const elem = await this.getPlaylist(name)
|
||||
|
||||
return elem.$('.miniature-thumbnail').click()
|
||||
}
|
||||
|
||||
async countTotalPlaylistElements () {
|
||||
await $('<my-video-playlist-element-miniature>').waitForDisplayed()
|
||||
|
||||
return $$('<my-video-playlist-element-miniature>').length
|
||||
}
|
||||
|
||||
playPlaylist () {
|
||||
return $('.playlist-info .miniature-thumbnail').click()
|
||||
}
|
||||
|
||||
async goOnAssociatedPlaylistEmbed () {
|
||||
let url = await browser.getUrl()
|
||||
url = url.replace('/w/p/', '/video-playlists/embed/')
|
||||
url = url.replace(':3333', ':9001')
|
||||
|
||||
return go(url)
|
||||
}
|
||||
|
||||
async updatePlaylistPrivacy (playlistUUID: string, privacy: 'Public' | 'Private' | 'Unlisted') {
|
||||
go('/my-library/video-playlists/update/' + playlistUUID)
|
||||
|
||||
await $('a[href*="/my-library/video-playlists/update/"]').waitForDisplayed()
|
||||
|
||||
await selectCustomSelect('videoChannelId', 'Main root channel')
|
||||
await selectCustomSelect('privacy', privacy)
|
||||
|
||||
const submit = await $('form input[type=submit]')
|
||||
await submit.waitForClickable()
|
||||
await submit.scrollIntoView()
|
||||
await submit.click()
|
||||
|
||||
return browser.waitUntil(async () => {
|
||||
return (await browser.getUrl()).includes('my-library/video-playlists')
|
||||
})
|
||||
}
|
||||
|
||||
// My account Videos
|
||||
|
||||
private async getVideoElement (name: string) {
|
||||
const video = async () => {
|
||||
const videos = await $$('.video').filter(async e => {
|
||||
const t = await e.$('.video-miniature-name').getText()
|
||||
|
||||
return t.includes(name)
|
||||
})
|
||||
|
||||
return videos[0]
|
||||
}
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
return (await video()).isDisplayed()
|
||||
})
|
||||
|
||||
return video()
|
||||
}
|
||||
|
||||
// My account playlists
|
||||
|
||||
private async getPlaylist (name: string) {
|
||||
const playlist = () => {
|
||||
return $$('my-video-playlist-miniature')
|
||||
.filter(async e => {
|
||||
const t = await e.$('.miniature-name').getText()
|
||||
|
||||
return t.includes(name)
|
||||
})
|
||||
.then(elems => elems[0])
|
||||
}
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const el = await playlist()
|
||||
|
||||
return el?.isDisplayed()
|
||||
})
|
||||
|
||||
return playlist()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { browserSleep, isIOS, isMobileDevice, isSafari } from '../utils'
|
||||
|
||||
export class PlayerPage {
|
||||
|
||||
getWatchVideoPlayerCurrentTime () {
|
||||
const elem = $('video')
|
||||
|
||||
const p = isIOS()
|
||||
? elem.getAttribute('currentTime')
|
||||
: elem.getProperty('currentTime')
|
||||
|
||||
return p.then(t => parseInt(t + '', 10))
|
||||
.then(t => Math.ceil(t))
|
||||
}
|
||||
|
||||
waitUntilPlaylistInfo (text: string, maxTime: number) {
|
||||
return browser.waitUntil(async () => {
|
||||
// Without this we have issues on iphone
|
||||
await $('.video-js').click()
|
||||
|
||||
return (await $('.video-js .vjs-playlist-info').getText()).includes(text)
|
||||
}, { timeout: maxTime })
|
||||
}
|
||||
|
||||
waitUntilPlayerWrapper () {
|
||||
return browser.waitUntil(async () => {
|
||||
return !!(await $('#placeholder-preview'))
|
||||
})
|
||||
}
|
||||
|
||||
async playAndPauseVideo (isAutoplay: boolean, waitUntilSec: number) {
|
||||
// Autoplay is disabled on mobile and Safari
|
||||
if (isIOS() || isSafari() || isMobileDevice() || isAutoplay === false) {
|
||||
await this.playVideo()
|
||||
}
|
||||
|
||||
await $('div.video-js.vjs-has-started').waitForExist()
|
||||
|
||||
await browserSleep(2000)
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
return (await this.getWatchVideoPlayerCurrentTime()) >= waitUntilSec
|
||||
}, { timeout: Math.max(waitUntilSec * 2 * 1000, 30000) })
|
||||
|
||||
// Pause video
|
||||
await $('div.video-js').click()
|
||||
}
|
||||
|
||||
async playVideo () {
|
||||
await $('div.video-js.vjs-paused, div.video-js.vjs-playing').waitForExist()
|
||||
|
||||
if (await $('div.video-js.vjs-playing').isExisting()) {
|
||||
if (!isIOS()) return
|
||||
|
||||
// On iOS, the web browser may have aborted player autoplay, so check the video is still autoplayed
|
||||
await browserSleep(5000)
|
||||
if (await $('div.video-js.vjs-playing').isExisting()) return
|
||||
}
|
||||
|
||||
// Autoplay is disabled on iOS and Safari
|
||||
if (isIOS() || isSafari() || isMobileDevice()) {
|
||||
// We can't play the video if it is not muted
|
||||
await browser.execute(`document.querySelector('video').muted = true`)
|
||||
}
|
||||
|
||||
return this.clickOnPlayButton()
|
||||
}
|
||||
|
||||
private async clickOnPlayButton () {
|
||||
const playButton = () => $('.vjs-big-play-button')
|
||||
|
||||
await playButton().waitForClickable()
|
||||
await playButton().click()
|
||||
}
|
||||
|
||||
async fillEmbedVideoPassword (videoPassword: string) {
|
||||
const videoPasswordInput = $('input#video-password-input')
|
||||
const confirmButton = await $('button#video-password-submit')
|
||||
|
||||
await videoPasswordInput.clearValue()
|
||||
await videoPasswordInput.setValue(videoPassword)
|
||||
await confirmButton.waitForClickable()
|
||||
|
||||
return confirmButton.click()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getCheckbox } from '../utils'
|
||||
|
||||
export class SignupPage {
|
||||
|
||||
getRegisterMenuButton () {
|
||||
return $('.create-account-button')
|
||||
}
|
||||
|
||||
async clickOnRegisterInMenu () {
|
||||
const button = this.getRegisterMenuButton()
|
||||
|
||||
await button.waitForClickable()
|
||||
await button.click()
|
||||
}
|
||||
|
||||
async validateStep () {
|
||||
const next = $('button[type=submit]')
|
||||
|
||||
await next.waitForClickable()
|
||||
await next.click()
|
||||
}
|
||||
|
||||
async checkTerms () {
|
||||
const terms = await getCheckbox('terms')
|
||||
await terms.waitForClickable()
|
||||
|
||||
return terms.click()
|
||||
}
|
||||
|
||||
async getEndMessage () {
|
||||
const alert = $('.pt-alert-primary')
|
||||
await alert.waitForDisplayed()
|
||||
|
||||
return alert.getText()
|
||||
}
|
||||
|
||||
async fillRegistrationReason (reason: string) {
|
||||
await $('#registrationReason').setValue(reason)
|
||||
}
|
||||
|
||||
async fillAccountStep (options: {
|
||||
username: string
|
||||
password?: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
}) {
|
||||
await $('#displayName').setValue(options.displayName || `${options.username} display name`)
|
||||
|
||||
await $('#username').setValue(options.username)
|
||||
await $('#password').setValue(options.password || 'password')
|
||||
|
||||
// Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
|
||||
await $('#email').scrollIntoView({ block: 'center' })
|
||||
await $('#email').waitForClickable()
|
||||
await $('#email').setValue(options.email || `${options.username}@example.com`)
|
||||
}
|
||||
|
||||
async fillChannelStep (options: {
|
||||
name: string
|
||||
displayName?: string
|
||||
}) {
|
||||
await $('#displayName').setValue(options.displayName || `${options.name} channel display name`)
|
||||
await $('#name').setValue(options.name)
|
||||
}
|
||||
|
||||
async fullSignup ({ accountInfo, channelInfo }: {
|
||||
accountInfo: {
|
||||
username: string
|
||||
password?: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
}
|
||||
channelInfo: {
|
||||
name: string
|
||||
}
|
||||
}) {
|
||||
await this.clickOnRegisterInMenu()
|
||||
await this.validateStep()
|
||||
await this.checkTerms()
|
||||
await this.validateStep()
|
||||
await this.fillAccountStep(accountInfo)
|
||||
await this.validateStep()
|
||||
await this.fillChannelStep(channelInfo)
|
||||
await this.validateStep()
|
||||
await this.getEndMessage()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { browserSleep, go } from '../utils'
|
||||
|
||||
export class VideoListPage {
|
||||
|
||||
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
|
||||
|
||||
}
|
||||
|
||||
async goOnVideosList () {
|
||||
let url: string
|
||||
|
||||
// We did not upload a file on a mobile device
|
||||
if (this.isMobileDevice === true || this.isSafari === true) {
|
||||
url = 'https://peertube2.cpy.re/videos/local'
|
||||
} else {
|
||||
url = '/videos/recently-added'
|
||||
}
|
||||
|
||||
await go(url)
|
||||
|
||||
// Waiting the following element does not work on Safari...
|
||||
if (this.isSafari) return browserSleep(3000)
|
||||
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async goOnLocal () {
|
||||
await $('.menu-link[href="/videos/local"]').click()
|
||||
await this.waitForTitle('Local videos')
|
||||
}
|
||||
|
||||
async goOnRecentlyAdded () {
|
||||
await $('.menu-link[href="/videos/recently-added"]').click()
|
||||
await this.waitForTitle('Recently added')
|
||||
}
|
||||
|
||||
async goOnTrending () {
|
||||
await $('.menu-link[href="/videos/trending"]').click()
|
||||
await this.waitForTitle('Trending')
|
||||
}
|
||||
|
||||
async goOnHomepage () {
|
||||
await go('/home')
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async goOnRootChannel () {
|
||||
await go('/c/root_channel/videos')
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async goOnRootAccount () {
|
||||
await go('/a/root/videos')
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async goOnRootAccountChannels () {
|
||||
await go('/a/root/video-channels')
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
getNSFWFilter () {
|
||||
return $$('.active-filter').filter(async a => {
|
||||
return (await a.getText()).includes('Sensitive')
|
||||
}).then(f => f[0])
|
||||
}
|
||||
|
||||
async getVideosListName () {
|
||||
const elems = await $$('.videos .video-miniature .video-miniature-name')
|
||||
const texts = await elems.map(e => e.getText())
|
||||
|
||||
return texts.map(t => t.trim())
|
||||
}
|
||||
|
||||
videoExists (name: string) {
|
||||
return $('.video-miniature-name=' + name).isDisplayed()
|
||||
}
|
||||
|
||||
async videoIsBlurred (name: string) {
|
||||
const filter = await $('.video-miniature-name=' + name).getCSSProperty('filter')
|
||||
|
||||
return filter.value !== 'none'
|
||||
}
|
||||
|
||||
async clickOnVideo (videoName: string) {
|
||||
const video = async () => {
|
||||
const videos = await $$('.videos .video-miniature .video-miniature-name').filter(async e => {
|
||||
const t = await e.getText()
|
||||
|
||||
return t === videoName
|
||||
})
|
||||
|
||||
return videos[0]
|
||||
}
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const elem = await video()
|
||||
|
||||
return elem?.isClickable()
|
||||
});
|
||||
|
||||
(await video()).click()
|
||||
|
||||
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
|
||||
}
|
||||
|
||||
async clickOnFirstVideo () {
|
||||
const video = () => $('.videos .video-miniature .video-thumbnail')
|
||||
const videoName = () => $('.videos .video-miniature .video-miniature-name')
|
||||
|
||||
await video().waitForClickable()
|
||||
|
||||
const textToReturn = await videoName().getText()
|
||||
await video().click()
|
||||
|
||||
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
|
||||
|
||||
return textToReturn
|
||||
}
|
||||
|
||||
private waitForList () {
|
||||
return $('.videos .video-miniature .video-miniature-name').waitForDisplayed()
|
||||
}
|
||||
|
||||
private waitForTitle (title: string) {
|
||||
return $('h1=' + title).waitForDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class VideoSearchPage {
|
||||
|
||||
async search (search: string) {
|
||||
await $('#search-video').setValue(search)
|
||||
await $('.search-button').click()
|
||||
|
||||
await browser.waitUntil(() => {
|
||||
return $('my-video-miniature').isDisplayed()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export class VideoUpdatePage {
|
||||
|
||||
async updateName (videoName: string) {
|
||||
const nameInput = $('input#name')
|
||||
|
||||
await nameInput.waitForDisplayed()
|
||||
await nameInput.clearValue()
|
||||
await nameInput.setValue(videoName)
|
||||
}
|
||||
|
||||
async validUpdate () {
|
||||
const submitButton = await this.getSubmitButton()
|
||||
|
||||
return submitButton.click()
|
||||
}
|
||||
|
||||
private getSubmitButton () {
|
||||
return $('.submit-container .action-button')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { join } from 'path'
|
||||
import { getCheckbox, selectCustomSelect } from '../utils'
|
||||
|
||||
export class VideoUploadPage {
|
||||
async navigateTo () {
|
||||
const publishButton = await $('.root-header .publish-button')
|
||||
|
||||
await publishButton.waitForClickable()
|
||||
await publishButton.click()
|
||||
|
||||
await $('.upload-video-container').waitForDisplayed()
|
||||
}
|
||||
|
||||
async uploadVideo (fixtureName: 'video.mp4' | 'video2.mp4' | 'video3.mp4') {
|
||||
const fileToUpload = join(__dirname, '../../fixtures/' + fixtureName)
|
||||
const fileInputSelector = '.upload-video-container input[type=file]'
|
||||
const parentFileInput = '.upload-video-container .button-file'
|
||||
|
||||
// Avoid sending keys on non visible element
|
||||
await browser.execute(`document.querySelector('${fileInputSelector}').style.opacity = 1`)
|
||||
await browser.execute(`document.querySelector('${parentFileInput}').style.overflow = 'initial'`)
|
||||
|
||||
await browser.pause(1000)
|
||||
|
||||
const elem = await $(fileInputSelector)
|
||||
await elem.chooseFile(fileToUpload)
|
||||
|
||||
// Wait for the upload to finish
|
||||
await browser.waitUntil(async () => {
|
||||
const warning = await $('=Publish will be available when upload is finished').isDisplayed()
|
||||
const progress = await $('.progress-bar=100%').isDisplayed()
|
||||
|
||||
return !warning && progress
|
||||
})
|
||||
}
|
||||
|
||||
async setAsNSFW () {
|
||||
const checkbox = await getCheckbox('nsfw')
|
||||
await checkbox.waitForClickable()
|
||||
|
||||
return checkbox.click()
|
||||
}
|
||||
|
||||
async validSecondUploadStep (videoName: string) {
|
||||
const nameInput = $('input#name')
|
||||
await nameInput.clearValue()
|
||||
await nameInput.setValue(videoName)
|
||||
|
||||
const button = this.getSecondStepSubmitButton()
|
||||
await button.waitForClickable()
|
||||
|
||||
await button.click()
|
||||
|
||||
return browser.waitUntil(async () => {
|
||||
return (await browser.getUrl()).includes('/w/')
|
||||
})
|
||||
}
|
||||
|
||||
setAsPublic () {
|
||||
return selectCustomSelect('privacy', 'Public')
|
||||
}
|
||||
|
||||
setAsPrivate () {
|
||||
return selectCustomSelect('privacy', 'Private')
|
||||
}
|
||||
|
||||
async setAsPasswordProtected (videoPassword: string) {
|
||||
selectCustomSelect('privacy', 'Password protected')
|
||||
|
||||
const videoPasswordInput = $('input#videoPassword')
|
||||
await videoPasswordInput.waitForClickable()
|
||||
await videoPasswordInput.clearValue()
|
||||
|
||||
return videoPasswordInput.setValue(videoPassword)
|
||||
}
|
||||
|
||||
private getSecondStepSubmitButton () {
|
||||
return $('.submit-container my-button')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { browserSleep, FIXTURE_URLS, go } from '../utils'
|
||||
|
||||
export class VideoWatchPage {
|
||||
|
||||
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
|
||||
|
||||
}
|
||||
|
||||
waitWatchVideoName (videoName: string) {
|
||||
if (this.isSafari) return browserSleep(5000)
|
||||
|
||||
// On mobile we display the first node, on desktop the second one
|
||||
const index = this.isMobileDevice ? 0 : 1
|
||||
|
||||
return browser.waitUntil(async () => {
|
||||
if (!await $('.video-info .video-info-name').isExisting()) return false
|
||||
|
||||
const elem = await $$('.video-info .video-info-name')[index]
|
||||
|
||||
return (await elem.getText()).includes(videoName) && elem.isDisplayed()
|
||||
})
|
||||
}
|
||||
|
||||
getVideoName () {
|
||||
return this.getVideoNameElement().then(e => e.getText())
|
||||
}
|
||||
|
||||
getPrivacy () {
|
||||
return $('.attribute-privacy .attribute-value').getText()
|
||||
}
|
||||
|
||||
getLicence () {
|
||||
return $('.attribute-licence .attribute-value').getText()
|
||||
}
|
||||
|
||||
async isDownloadEnabled () {
|
||||
try {
|
||||
await this.clickOnMoreDropdownIcon()
|
||||
|
||||
return await $('.dropdown-item .icon-download').isExisting()
|
||||
} catch {
|
||||
return $('.action-button-download').isDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
areCommentsEnabled () {
|
||||
return $('my-video-comment-add').isExisting()
|
||||
}
|
||||
|
||||
isPrivacyWarningDisplayed () {
|
||||
return $('my-privacy-concerns').isDisplayed()
|
||||
}
|
||||
|
||||
async goOnAssociatedEmbed (passwordProtected = false) {
|
||||
let url = await browser.getUrl()
|
||||
url = url.replace('/w/', '/videos/embed/')
|
||||
url = url.replace(':3333', ':9001')
|
||||
|
||||
await go(url)
|
||||
|
||||
if (passwordProtected) await this.waitEmbedForVideoPasswordForm()
|
||||
else await this.waitEmbedForDisplayed()
|
||||
}
|
||||
|
||||
waitEmbedForDisplayed () {
|
||||
return $('.vjs-big-play-button').waitForDisplayed()
|
||||
}
|
||||
|
||||
waitEmbedForVideoPasswordForm () {
|
||||
return $('#video-password-input').waitForDisplayed()
|
||||
}
|
||||
|
||||
isEmbedWarningDisplayed () {
|
||||
return $('.peertube-dock-description').isDisplayed()
|
||||
}
|
||||
|
||||
goOnP2PMediaLoaderEmbed () {
|
||||
return go(FIXTURE_URLS.HLS_EMBED)
|
||||
}
|
||||
|
||||
goOnP2PMediaLoaderPlaylistEmbed () {
|
||||
return go(FIXTURE_URLS.HLS_PLAYLIST_EMBED)
|
||||
}
|
||||
|
||||
async clickOnUpdate () {
|
||||
await this.clickOnMoreDropdownIcon()
|
||||
|
||||
const items = await $$('.dropdown-menu.show .dropdown-item')
|
||||
|
||||
for (const item of items) {
|
||||
const href = await item.getAttribute('href')
|
||||
|
||||
if (href?.includes('/update/')) {
|
||||
await item.click()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clickOnSave () {
|
||||
return $('.action-button-save').click()
|
||||
}
|
||||
|
||||
async createPlaylist (name: string) {
|
||||
const newPlaylistButton = () => $('.new-playlist-button')
|
||||
|
||||
await newPlaylistButton().waitForClickable()
|
||||
await newPlaylistButton().click()
|
||||
|
||||
const displayName = () => $('#displayName')
|
||||
|
||||
await displayName().waitForDisplayed()
|
||||
await displayName().setValue(name)
|
||||
|
||||
return $('.new-playlist-block input[type=submit]').click()
|
||||
}
|
||||
|
||||
async saveToPlaylist (name: string) {
|
||||
const playlist = () => $('my-video-add-to-playlist').$(`.playlist=${name}`)
|
||||
|
||||
await playlist().waitForDisplayed()
|
||||
|
||||
return playlist().click()
|
||||
}
|
||||
|
||||
waitUntilVideoName (name: string, maxTime: number) {
|
||||
return browser.waitUntil(async () => {
|
||||
return (await this.getVideoName()) === name
|
||||
}, { timeout: maxTime })
|
||||
}
|
||||
|
||||
async clickOnMoreDropdownIcon () {
|
||||
const dropdown = $('my-video-actions-dropdown .action-button')
|
||||
await dropdown.click()
|
||||
|
||||
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
|
||||
}
|
||||
|
||||
private async getVideoNameElement () {
|
||||
// We have 2 video info name block, pick the first that is not empty
|
||||
const elem = async () => {
|
||||
const elems = await $$('.video-info-first-row .video-info-name').filter(e => e.isDisplayed())
|
||||
|
||||
return elems[0]
|
||||
}
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const e = await elem()
|
||||
|
||||
return e?.isDisplayed()
|
||||
})
|
||||
|
||||
return elem()
|
||||
}
|
||||
|
||||
isPasswordProtected () {
|
||||
return $('#confirmInput').isExisting()
|
||||
}
|
||||
|
||||
async fillVideoPassword (videoPassword: string) {
|
||||
const videoPasswordInput = await $('input#confirmInput')
|
||||
await videoPasswordInput.waitForClickable()
|
||||
await videoPasswordInput.clearValue()
|
||||
await videoPasswordInput.setValue(videoPassword)
|
||||
|
||||
const confirmButton = await $('input[value="Confirm"]')
|
||||
await confirmButton.waitForClickable()
|
||||
return confirmButton.click()
|
||||
}
|
||||
|
||||
async like () {
|
||||
const likeButton = await $('.action-button-like')
|
||||
const isActivated = (await likeButton.getAttribute('class')).includes('activated')
|
||||
|
||||
let count: number
|
||||
try {
|
||||
count = parseInt(await $('.action-button-like > .count').getText())
|
||||
} catch (error) {
|
||||
count = 0
|
||||
}
|
||||
|
||||
await likeButton.waitForClickable()
|
||||
await likeButton.click()
|
||||
|
||||
if (isActivated) {
|
||||
if (count === 1) {
|
||||
return expect(!await $('.action-button-like > .count').isExisting())
|
||||
} else {
|
||||
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count - 1)
|
||||
}
|
||||
} else {
|
||||
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count + 1)
|
||||
}
|
||||
}
|
||||
|
||||
async createThread (comment: string) {
|
||||
const textarea = await $('my-video-comment-add textarea')
|
||||
await textarea.waitForClickable()
|
||||
|
||||
await textarea.setValue(comment)
|
||||
|
||||
const confirmButton = await $('.comment-buttons .orange-button')
|
||||
await confirmButton.waitForClickable()
|
||||
await confirmButton.click()
|
||||
|
||||
const createdComment = await (await $('.comment-html p')).getText()
|
||||
|
||||
return expect(createdComment).toBe(comment)
|
||||
}
|
||||
|
||||
async createReply (comment: string) {
|
||||
const replyButton = await $('button.comment-action-reply')
|
||||
await replyButton.waitForClickable()
|
||||
await replyButton.scrollIntoView()
|
||||
await replyButton.click()
|
||||
|
||||
const textarea = await $('my-video-comment my-video-comment-add textarea')
|
||||
await textarea.waitForClickable()
|
||||
await textarea.setValue(comment)
|
||||
|
||||
const confirmButton = await $('my-video-comment .comment-buttons .orange-button')
|
||||
await confirmButton.waitForClickable()
|
||||
await confirmButton.click()
|
||||
|
||||
const createdComment = await (await $('.is-child .comment-html p')).getText()
|
||||
|
||||
return expect(createdComment).toBe(comment)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { PlayerPage } from '../po/player.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { FIXTURE_URLS, go, isMobileDevice, isSafari } from '../utils'
|
||||
|
||||
describe('Live all workflow', () => {
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let playerPage: PlayerPage
|
||||
|
||||
beforeEach(async () => {
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
playerPage = new PlayerPage()
|
||||
|
||||
if (!isMobileDevice()) {
|
||||
await browser.maximizeWindow()
|
||||
}
|
||||
})
|
||||
|
||||
it('Should go to the live page', async () => {
|
||||
await go(FIXTURE_URLS.LIVE_VIDEO)
|
||||
|
||||
return videoWatchPage.waitWatchVideoName('E2E - Live')
|
||||
})
|
||||
|
||||
it('Should play the live', async () => {
|
||||
await playerPage.playAndPauseVideo(false, 45)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(45)
|
||||
})
|
||||
|
||||
it('Should watch the associated live embed', async () => {
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
|
||||
await playerPage.playAndPauseVideo(false, 45)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(45)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { PlayerPage } from '../po/player.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { FIXTURE_URLS, go, isMobileDevice, isSafari } from '../utils'
|
||||
|
||||
async function checkCorrectlyPlay (playerPage: PlayerPage) {
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
|
||||
}
|
||||
|
||||
describe('Private videos all workflow', () => {
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let loginPage: LoginPage
|
||||
let playerPage: PlayerPage
|
||||
|
||||
const internalVideoName = 'Internal E2E test'
|
||||
const internalHLSOnlyVideoName = 'Internal E2E test - HLS only'
|
||||
|
||||
beforeEach(async () => {
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
playerPage = new PlayerPage()
|
||||
|
||||
if (!isMobileDevice()) {
|
||||
await browser.maximizeWindow()
|
||||
}
|
||||
})
|
||||
|
||||
it('Should log in', async () => {
|
||||
return loginPage.loginOnPeerTube2()
|
||||
})
|
||||
|
||||
it('Should play an internal web video', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_WEB_VIDEO)
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(internalVideoName)
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal HLS video', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_HLS_VIDEO)
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(internalVideoName)
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal HLS only video', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_HLS_ONLY_VIDEO)
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(internalHLSOnlyVideoName)
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal Web Video in embed', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_EMBED_WEB_VIDEO)
|
||||
|
||||
await videoWatchPage.waitEmbedForDisplayed()
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal HLS video in embed', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_VIDEO)
|
||||
|
||||
await videoWatchPage.waitEmbedForDisplayed()
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal HLS only video in embed', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_ONLY_VIDEO)
|
||||
|
||||
await videoWatchPage.waitEmbedForDisplayed()
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import { PlayerPage } from '../po/player.po'
|
||||
import { VideoListPage } from '../po/video-list.po'
|
||||
import { VideoUpdatePage } from '../po/video-update.po'
|
||||
import { VideoUploadPage } from '../po/video-upload.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { FIXTURE_URLS, go, isIOS, isMobileDevice, isSafari, waitServerUp } from '../utils'
|
||||
|
||||
function isUploadUnsupported () {
|
||||
if (isMobileDevice() || isSafari()) {
|
||||
console.log('Skipping because we are on a real device or Safari and BrowserStack does not support file upload.')
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
describe('Videos all workflow', () => {
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let videoListPage: VideoListPage
|
||||
let videoUploadPage: VideoUploadPage
|
||||
let videoUpdatePage: VideoUpdatePage
|
||||
let myAccountPage: MyAccountPage
|
||||
let loginPage: LoginPage
|
||||
let playerPage: PlayerPage
|
||||
|
||||
let videoName = Math.random() + ' video'
|
||||
const video2Name = Math.random() + ' second video'
|
||||
const playlistName = Math.random() + ' playlist'
|
||||
let videoWatchUrl: string
|
||||
|
||||
before(async () => {
|
||||
if (isIOS()) {
|
||||
console.log('iOS detected')
|
||||
} else if (isMobileDevice()) {
|
||||
console.log('Android detected.')
|
||||
} else if (isSafari()) {
|
||||
console.log('Safari detected.')
|
||||
}
|
||||
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await waitServerUp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
videoUploadPage = new VideoUploadPage()
|
||||
videoUpdatePage = new VideoUpdatePage()
|
||||
myAccountPage = new MyAccountPage()
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
playerPage = new PlayerPage()
|
||||
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
|
||||
|
||||
if (!isMobileDevice()) {
|
||||
await browser.maximizeWindow()
|
||||
}
|
||||
})
|
||||
|
||||
it('Should log in', async () => {
|
||||
if (isMobileDevice() || isSafari()) {
|
||||
console.log('Skipping because we are on a real device or Safari and BrowserStack does not support file upload.')
|
||||
return
|
||||
}
|
||||
|
||||
return loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should upload a video', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await videoUploadPage.navigateTo()
|
||||
|
||||
await videoUploadPage.uploadVideo('video.mp4')
|
||||
return videoUploadPage.validSecondUploadStep(videoName)
|
||||
})
|
||||
|
||||
it('Should list videos', async () => {
|
||||
await videoListPage.goOnVideosList()
|
||||
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
const videoNames = await videoListPage.getVideosListName()
|
||||
expect(videoNames).toContain(videoName)
|
||||
})
|
||||
|
||||
it('Should go on video watch page', async () => {
|
||||
let videoNameToExcept = videoName
|
||||
|
||||
if (isMobileDevice() || isSafari()) {
|
||||
await go(FIXTURE_URLS.WEB_VIDEO)
|
||||
videoNameToExcept = 'E2E tests'
|
||||
} else {
|
||||
await videoListPage.clickOnVideo(videoName)
|
||||
}
|
||||
|
||||
return videoWatchPage.waitWatchVideoName(videoNameToExcept)
|
||||
})
|
||||
|
||||
it('Should play the video', async () => {
|
||||
videoWatchUrl = await browser.getUrl()
|
||||
|
||||
await playerPage.playAndPauseVideo(true, 2)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('Should watch the associated embed video', async () => {
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('Should watch the p2p media loader embed video', async () => {
|
||||
await videoWatchPage.goOnP2PMediaLoaderEmbed()
|
||||
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('Should update the video', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await go(videoWatchUrl)
|
||||
|
||||
await videoWatchPage.clickOnUpdate()
|
||||
|
||||
videoName += ' updated'
|
||||
await videoUpdatePage.updateName(videoName)
|
||||
|
||||
await videoUpdatePage.validUpdate()
|
||||
|
||||
const name = await videoWatchPage.getVideoName()
|
||||
expect(name).toEqual(videoName)
|
||||
})
|
||||
|
||||
it('Should add the video in my playlist', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await videoWatchPage.clickOnSave()
|
||||
|
||||
await videoWatchPage.createPlaylist(playlistName)
|
||||
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
await browser.pause(5000)
|
||||
|
||||
await videoUploadPage.navigateTo()
|
||||
|
||||
await videoUploadPage.uploadVideo('video2.mp4')
|
||||
await videoUploadPage.validSecondUploadStep(video2Name)
|
||||
|
||||
await videoWatchPage.clickOnSave()
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
})
|
||||
|
||||
it('Should have the playlist in my account', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await myAccountPage.navigateToMyPlaylists()
|
||||
|
||||
const videosNumberText = await myAccountPage.getPlaylistVideosText(playlistName)
|
||||
expect(videosNumberText).toEqual('2 videos')
|
||||
|
||||
await myAccountPage.clickOnPlaylist(playlistName)
|
||||
|
||||
const count = await myAccountPage.countTotalPlaylistElements()
|
||||
expect(count).toEqual(2)
|
||||
})
|
||||
|
||||
it('Should watch the playlist', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await myAccountPage.playPlaylist()
|
||||
|
||||
await videoWatchPage.waitUntilVideoName(video2Name, 40 * 1000)
|
||||
})
|
||||
|
||||
it('Should watch the Web Video playlist in the embed', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
const accessToken = await browser.execute(`return window.localStorage.getItem('access_token');`)
|
||||
const refreshToken = await browser.execute(`return window.localStorage.getItem('refresh_token');`)
|
||||
|
||||
await myAccountPage.goOnAssociatedPlaylistEmbed()
|
||||
|
||||
await playerPage.waitUntilPlayerWrapper()
|
||||
|
||||
console.log('Will set %s and %s tokens in local storage.', accessToken, refreshToken)
|
||||
|
||||
await browser.execute(`window.localStorage.setItem('access_token', '${accessToken}');`)
|
||||
await browser.execute(`window.localStorage.setItem('refresh_token', '${refreshToken}');`)
|
||||
await browser.execute(`window.localStorage.setItem('token_type', 'Bearer');`)
|
||||
|
||||
await browser.refresh()
|
||||
|
||||
await playerPage.playVideo()
|
||||
|
||||
await playerPage.waitUntilPlaylistInfo('2/2', 30 * 1000)
|
||||
})
|
||||
|
||||
it('Should watch the HLS playlist in the embed', async () => {
|
||||
await videoWatchPage.goOnP2PMediaLoaderPlaylistEmbed()
|
||||
|
||||
await playerPage.playVideo()
|
||||
|
||||
await playerPage.waitUntilPlaylistInfo('2/2', 30 * 1000)
|
||||
})
|
||||
|
||||
it('Should delete the video 2', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
// Go to the dev website
|
||||
await go(videoWatchUrl)
|
||||
|
||||
await myAccountPage.navigateToMyVideos()
|
||||
|
||||
await myAccountPage.removeVideo(video2Name)
|
||||
await myAccountPage.validRemove()
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const count = await myAccountPage.countVideos([ videoName, video2Name ])
|
||||
|
||||
return count === 1
|
||||
})
|
||||
})
|
||||
|
||||
it('Should delete the first video', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await myAccountPage.removeVideo(videoName)
|
||||
await myAccountPage.validRemove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { VideoUploadPage } from '../po/video-upload.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils'
|
||||
|
||||
describe('Custom server defaults', () => {
|
||||
let videoUploadPage: VideoUploadPage
|
||||
let loginPage: LoginPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoUploadPage = new VideoUploadPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
|
||||
await browser.maximizeWindow()
|
||||
})
|
||||
|
||||
describe('Publish default values', function () {
|
||||
before(async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should upload a video with custom default values', async function () {
|
||||
await videoUploadPage.navigateTo()
|
||||
await videoUploadPage.uploadVideo('video.mp4')
|
||||
await videoUploadPage.validSecondUploadStep('video')
|
||||
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
const videoUrl = await browser.getUrl()
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Unlisted')
|
||||
expect(await videoWatchPage.getLicence()).toBe('Attribution - Non Commercial')
|
||||
expect(await videoWatchPage.areCommentsEnabled()).toBeFalsy()
|
||||
|
||||
// Owners can download their videos
|
||||
expect(await videoWatchPage.isDownloadEnabled()).toBeTruthy()
|
||||
|
||||
// Logout to see if the download enabled is correct for anonymous users
|
||||
await loginPage.logout()
|
||||
await browser.url(videoUrl)
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
expect(await videoWatchPage.isDownloadEnabled()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('P2P', function () {
|
||||
let videoUrl: string
|
||||
|
||||
async function goOnVideoWatchPage () {
|
||||
await go(videoUrl)
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
}
|
||||
|
||||
async function checkP2P (enabled: boolean) {
|
||||
await goOnVideoWatchPage()
|
||||
expect(await videoWatchPage.isPrivacyWarningDisplayed()).toEqual(enabled)
|
||||
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
expect(await videoWatchPage.isEmbedWarningDisplayed()).toEqual(enabled)
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await videoUploadPage.navigateTo()
|
||||
await videoUploadPage.uploadVideo('video2.mp4')
|
||||
await videoUploadPage.setAsPublic()
|
||||
await videoUploadPage.validSecondUploadStep('video')
|
||||
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
videoUrl = await browser.getUrl()
|
||||
})
|
||||
|
||||
beforeEach(async function () {
|
||||
await goOnVideoWatchPage()
|
||||
})
|
||||
|
||||
it('Should have P2P disabled for a logged in user', async function () {
|
||||
await checkP2P(false)
|
||||
})
|
||||
|
||||
it('Should have P2P disabled for anonymous users', async function () {
|
||||
await loginPage.logout()
|
||||
|
||||
await checkP2P(false)
|
||||
})
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AdminPluginPage } from '../po/admin-plugin.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { VideoUploadPage } from '../po/video-upload.po'
|
||||
import { getCheckbox, isMobileDevice, waitServerUp } from '../utils'
|
||||
|
||||
describe('Plugins', () => {
|
||||
let videoUploadPage: VideoUploadPage
|
||||
let loginPage: LoginPage
|
||||
let adminPluginPage: AdminPluginPage
|
||||
|
||||
function getPluginCheckbox () {
|
||||
return getCheckbox('hello-world-field-4')
|
||||
}
|
||||
|
||||
async function expectSubmitState ({ disabled }: { disabled: boolean }) {
|
||||
const disabledSubmit = await $('my-button .disabled')
|
||||
|
||||
if (disabled) expect(await disabledSubmit.isDisplayed()).toBeTruthy()
|
||||
else expect(await disabledSubmit.isDisplayed()).toBeFalsy()
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoUploadPage = new VideoUploadPage()
|
||||
adminPluginPage = new AdminPluginPage()
|
||||
|
||||
await browser.maximizeWindow()
|
||||
})
|
||||
|
||||
it('Should install hello world plugin', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminPluginPage.navigateToPluginSearch()
|
||||
await adminPluginPage.search('hello-world')
|
||||
await adminPluginPage.installHelloWorld()
|
||||
await browser.refresh()
|
||||
})
|
||||
|
||||
it('Should have checkbox in video edit page', async () => {
|
||||
await videoUploadPage.navigateTo()
|
||||
await videoUploadPage.uploadVideo('video.mp4')
|
||||
|
||||
await $('span=Super field 4 in main tab').waitForDisplayed()
|
||||
|
||||
const checkbox = await getPluginCheckbox()
|
||||
expect(await checkbox.isDisplayed()).toBeTruthy()
|
||||
|
||||
await expectSubmitState({ disabled: true })
|
||||
})
|
||||
|
||||
it('Should check the checkbox and be able to submit the video', async function () {
|
||||
const checkbox = await getPluginCheckbox()
|
||||
|
||||
await checkbox.waitForClickable()
|
||||
await checkbox.click()
|
||||
|
||||
await expectSubmitState({ disabled: false })
|
||||
})
|
||||
|
||||
it('Should uncheck the checkbox and not be able to submit the video', async function () {
|
||||
const checkbox = await getPluginCheckbox()
|
||||
|
||||
await checkbox.waitForClickable()
|
||||
await checkbox.click()
|
||||
|
||||
await expectSubmitState({ disabled: true })
|
||||
|
||||
const error = await $('.form-error*=Should be enabled')
|
||||
|
||||
expect(await error.isDisplayed()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Should change the privacy and should hide the checkbox', async function () {
|
||||
await videoUploadPage.setAsPrivate()
|
||||
|
||||
await expectSubmitState({ disabled: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,413 @@
|
||||
import { AdminConfigPage } from '../po/admin-config.po'
|
||||
import { AdminRegistrationPage } from '../po/admin-registration.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { SignupPage } from '../po/signup.po'
|
||||
import {
|
||||
browserSleep,
|
||||
findEmailTo,
|
||||
getScreenshotPath,
|
||||
getVerificationLink,
|
||||
go,
|
||||
isMobileDevice,
|
||||
MockSMTPServer,
|
||||
waitServerUp
|
||||
} from '../utils'
|
||||
|
||||
function checkEndMessage (options: {
|
||||
message: string
|
||||
requiresEmailVerification: boolean
|
||||
requiresApproval: boolean
|
||||
afterEmailVerification: boolean
|
||||
}) {
|
||||
const { message, requiresApproval, requiresEmailVerification, afterEmailVerification } = options
|
||||
|
||||
{
|
||||
const created = 'account has been created'
|
||||
const request = 'account request has been sent'
|
||||
|
||||
if (requiresApproval) {
|
||||
expect(message).toContain(request)
|
||||
expect(message).not.toContain(created)
|
||||
} else {
|
||||
expect(message).not.toContain(request)
|
||||
expect(message).toContain(created)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const checkEmail = 'Check your email'
|
||||
|
||||
if (requiresEmailVerification) {
|
||||
expect(message).toContain(checkEmail)
|
||||
} else {
|
||||
expect(message).not.toContain(checkEmail)
|
||||
|
||||
const moderatorsApproval = 'moderator will check your registration request'
|
||||
if (requiresApproval) {
|
||||
expect(message).toContain(moderatorsApproval)
|
||||
} else {
|
||||
expect(message).not.toContain(moderatorsApproval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const emailVerified = 'email has been verified'
|
||||
|
||||
if (afterEmailVerification) {
|
||||
expect(message).toContain(emailVerified)
|
||||
} else {
|
||||
expect(message).not.toContain(emailVerified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Signup', () => {
|
||||
let loginPage: LoginPage
|
||||
let adminConfigPage: AdminConfigPage
|
||||
let signupPage: SignupPage
|
||||
let adminRegistrationPage: AdminRegistrationPage
|
||||
|
||||
async function prepareSignup (options: {
|
||||
enabled: boolean
|
||||
requiresApproval?: boolean
|
||||
requiresEmailVerification?: boolean
|
||||
}) {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminConfigPage.navigateTo('basic-configuration')
|
||||
await adminConfigPage.toggleSignup(options.enabled)
|
||||
|
||||
if (options.enabled) {
|
||||
if (options.requiresApproval !== undefined) {
|
||||
await adminConfigPage.toggleSignupApproval(options.requiresApproval)
|
||||
}
|
||||
|
||||
if (options.requiresEmailVerification !== undefined) {
|
||||
await adminConfigPage.toggleSignupEmailVerification(options.requiresEmailVerification)
|
||||
}
|
||||
}
|
||||
|
||||
await adminConfigPage.save()
|
||||
|
||||
await loginPage.logout()
|
||||
await browser.refresh()
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
adminConfigPage = new AdminConfigPage()
|
||||
signupPage = new SignupPage()
|
||||
adminRegistrationPage = new AdminRegistrationPage()
|
||||
|
||||
await browser.maximizeWindow()
|
||||
})
|
||||
|
||||
describe('Signup disabled', function () {
|
||||
it('Should disable signup', async () => {
|
||||
await prepareSignup({ enabled: false })
|
||||
|
||||
await expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Email verification disabled', function () {
|
||||
|
||||
describe('Direct registration', function () {
|
||||
|
||||
it('Should enable signup without approval', async () => {
|
||||
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: false })
|
||||
|
||||
await signupPage.getRegisterMenuButton().waitForDisplayed()
|
||||
})
|
||||
|
||||
it('Should go on signup page', async function () {
|
||||
await signupPage.clickOnRegisterInMenu()
|
||||
})
|
||||
|
||||
it('Should validate the first step (about page)', async function () {
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the second step (terms)', async function () {
|
||||
await signupPage.checkTerms()
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (account)', async function () {
|
||||
await signupPage.fillAccountStep({ username: 'user_1', displayName: 'user_1_dn' })
|
||||
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (channel)', async function () {
|
||||
await signupPage.fillChannelStep({ name: 'user_1_channel' })
|
||||
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should be logged in', async function () {
|
||||
await loginPage.ensureIsLoggedInAs('user_1_dn')
|
||||
})
|
||||
|
||||
it('Should have a valid end message', async function () {
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: false,
|
||||
requiresApproval: false,
|
||||
afterEmailVerification: false
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('direct-without-email.png'))
|
||||
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registration with approval', function () {
|
||||
|
||||
it('Should enable signup with approval', async () => {
|
||||
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: false })
|
||||
|
||||
await signupPage.getRegisterMenuButton().waitForDisplayed()
|
||||
})
|
||||
|
||||
it('Should go on signup page', async function () {
|
||||
await signupPage.clickOnRegisterInMenu()
|
||||
})
|
||||
|
||||
it('Should validate the first step (about page)', async function () {
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the second step (terms)', async function () {
|
||||
await signupPage.checkTerms()
|
||||
await signupPage.fillRegistrationReason('my super reason')
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (account)', async function () {
|
||||
await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'password' })
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (channel)', async function () {
|
||||
await signupPage.fillChannelStep({ name: 'user_2_channel' })
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should have a valid end message', async function () {
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: false,
|
||||
requiresApproval: true,
|
||||
afterEmailVerification: false
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('request-without-email.png'))
|
||||
})
|
||||
|
||||
it('Should display a message when trying to login with this account', async function () {
|
||||
const error = await loginPage.getLoginError('user_2', 'password')
|
||||
|
||||
expect(error).toContain('awaiting approval')
|
||||
})
|
||||
|
||||
it('Should accept the registration', async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminRegistrationPage.navigateToRegistratonsList()
|
||||
await adminRegistrationPage.accept('user_2', 'moderation response')
|
||||
|
||||
await loginPage.logout()
|
||||
})
|
||||
|
||||
it('Should be able to login with this new account', async function () {
|
||||
await loginPage.login({ username: 'user_2', password: 'password', displayName: 'user_2 display name' })
|
||||
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Email verification enabled', function () {
|
||||
const emails: any[] = []
|
||||
let emailPort: number
|
||||
|
||||
before(async () => {
|
||||
const key = browser.options.baseUrl + '-emailPort'
|
||||
// FIXME: typings are wrong, get returns a promise
|
||||
// FIXME: use * because the key is not properly escaped by the shared store when using get(key)
|
||||
emailPort = (await (browser.sharedStore.get('*') as unknown as Promise<number>))[key]
|
||||
|
||||
await MockSMTPServer.Instance.collectEmails(emailPort, emails)
|
||||
})
|
||||
|
||||
describe('Direct registration', function () {
|
||||
|
||||
it('Should enable signup without approval', async () => {
|
||||
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: true })
|
||||
|
||||
await signupPage.getRegisterMenuButton().waitForDisplayed()
|
||||
})
|
||||
|
||||
it('Should go on signup page', async function () {
|
||||
await signupPage.clickOnRegisterInMenu()
|
||||
})
|
||||
|
||||
it('Should validate the first step (about page)', async function () {
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the second step (terms)', async function () {
|
||||
await signupPage.checkTerms()
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (account)', async function () {
|
||||
await signupPage.fillAccountStep({ username: 'user_3', displayName: 'user_3 display name', email: 'user_3@example.com' })
|
||||
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (channel)', async function () {
|
||||
await signupPage.fillChannelStep({ name: 'user_3_channel' })
|
||||
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should have a valid end message', async function () {
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: true,
|
||||
requiresApproval: false,
|
||||
afterEmailVerification: false
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('direct-with-email.png'))
|
||||
})
|
||||
|
||||
it('Should validate the email', async function () {
|
||||
let email: { text: string }
|
||||
|
||||
while (!(email = findEmailTo(emails, 'user_3@example.com'))) {
|
||||
await browserSleep(100)
|
||||
}
|
||||
|
||||
await go(getVerificationLink(email))
|
||||
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: false,
|
||||
requiresApproval: false,
|
||||
afterEmailVerification: true
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('direct-after-email.png'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registration with approval', function () {
|
||||
|
||||
it('Should enable signup without approval', async () => {
|
||||
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: true })
|
||||
|
||||
await signupPage.getRegisterMenuButton().waitForDisplayed()
|
||||
})
|
||||
|
||||
it('Should go on signup page', async function () {
|
||||
await signupPage.clickOnRegisterInMenu()
|
||||
})
|
||||
|
||||
it('Should validate the first step (about page)', async function () {
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the second step (terms)', async function () {
|
||||
await signupPage.checkTerms()
|
||||
await signupPage.fillRegistrationReason('my super reason 2')
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (account)', async function () {
|
||||
await signupPage.fillAccountStep({
|
||||
username: 'user_4',
|
||||
displayName: 'user_4 display name',
|
||||
email: 'user_4@example.com',
|
||||
password: 'password'
|
||||
})
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (channel)', async function () {
|
||||
await signupPage.fillChannelStep({ name: 'user_4_channel' })
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should have a valid end message', async function () {
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: true,
|
||||
requiresApproval: true,
|
||||
afterEmailVerification: false
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('request-with-email.png'))
|
||||
})
|
||||
|
||||
it('Should display a message when trying to login with this account', async function () {
|
||||
const error = await loginPage.getLoginError('user_4', 'password')
|
||||
|
||||
expect(error).toContain('awaiting approval')
|
||||
})
|
||||
|
||||
it('Should accept the registration', async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminRegistrationPage.navigateToRegistratonsList()
|
||||
await adminRegistrationPage.accept('user_4', 'moderation response 2')
|
||||
|
||||
await loginPage.logout()
|
||||
})
|
||||
|
||||
it('Should validate the email', async function () {
|
||||
let email: { text: string }
|
||||
|
||||
while (!(email = findEmailTo(emails, 'user_4@example.com'))) {
|
||||
await browserSleep(100)
|
||||
}
|
||||
|
||||
await go(getVerificationLink(email))
|
||||
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: false,
|
||||
requiresApproval: true,
|
||||
afterEmailVerification: true
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('request-after-email.png'))
|
||||
})
|
||||
})
|
||||
|
||||
after(() => {
|
||||
MockSMTPServer.Instance.kill()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AnonymousSettingsPage } from '../po/anonymous-settings.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import { VideoUploadPage } from '../po/video-upload.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { go, isMobileDevice, isSafari, waitServerUp } from '../utils'
|
||||
|
||||
describe('User settings', () => {
|
||||
let videoUploadPage: VideoUploadPage
|
||||
let loginPage: LoginPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let myAccountPage: MyAccountPage
|
||||
let anonymousSettingsPage: AnonymousSettingsPage
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoUploadPage = new VideoUploadPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
myAccountPage = new MyAccountPage()
|
||||
anonymousSettingsPage = new AnonymousSettingsPage()
|
||||
|
||||
await browser.maximizeWindow()
|
||||
})
|
||||
|
||||
describe('P2P', function () {
|
||||
let videoUrl: string
|
||||
|
||||
async function goOnVideoWatchPage () {
|
||||
await go(videoUrl)
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
}
|
||||
|
||||
async function checkP2P (enabled: boolean) {
|
||||
await goOnVideoWatchPage()
|
||||
expect(await videoWatchPage.isPrivacyWarningDisplayed()).toEqual(enabled)
|
||||
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
expect(await videoWatchPage.isEmbedWarningDisplayed()).toEqual(enabled)
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await videoUploadPage.navigateTo()
|
||||
await videoUploadPage.uploadVideo('video.mp4')
|
||||
await videoUploadPage.validSecondUploadStep('video')
|
||||
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
videoUrl = await browser.getUrl()
|
||||
})
|
||||
|
||||
beforeEach(async function () {
|
||||
await goOnVideoWatchPage()
|
||||
})
|
||||
|
||||
it('Should have P2P enabled for a logged in user', async function () {
|
||||
await checkP2P(true)
|
||||
})
|
||||
|
||||
it('Should disable P2P for a logged in user', async function () {
|
||||
await myAccountPage.navigateToMySettings()
|
||||
await myAccountPage.clickOnP2PCheckbox()
|
||||
|
||||
await checkP2P(false)
|
||||
})
|
||||
|
||||
it('Should have P2P enabled for anonymous users', async function () {
|
||||
await loginPage.logout()
|
||||
|
||||
await checkP2P(true)
|
||||
})
|
||||
|
||||
it('Should disable P2P for an anonymous user', async function () {
|
||||
await anonymousSettingsPage.openSettings()
|
||||
await anonymousSettingsPage.clickOnP2PCheckbox()
|
||||
|
||||
await checkP2P(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import { PlayerPage } from '../po/player.po'
|
||||
import { SignupPage } from '../po/signup.po'
|
||||
import { VideoUploadPage } from '../po/video-upload.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils'
|
||||
|
||||
describe('Password protected videos', () => {
|
||||
let videoUploadPage: VideoUploadPage
|
||||
let loginPage: LoginPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let signupPage: SignupPage
|
||||
let playerPage: PlayerPage
|
||||
let myAccountPage: MyAccountPage
|
||||
let passwordProtectedVideoUrl: string
|
||||
let playlistUrl: string
|
||||
|
||||
const seed = Math.random()
|
||||
const passwordProtectedVideoName = seed + ' - password protected'
|
||||
const publicVideoName1 = seed + ' - public 1'
|
||||
const publicVideoName2 = seed + ' - public 2'
|
||||
const videoPassword = 'password'
|
||||
const regularUsername = 'user_1'
|
||||
const regularUserPassword = 'user password'
|
||||
const playlistName = seed + ' - playlist'
|
||||
|
||||
function testRateAndComment () {
|
||||
it('Should add and remove like on video', async function () {
|
||||
await videoWatchPage.like()
|
||||
await videoWatchPage.like()
|
||||
})
|
||||
|
||||
it('Should create thread on video', async function () {
|
||||
await videoWatchPage.createThread('My first comment')
|
||||
})
|
||||
|
||||
it('Should reply to thread on video', async function () {
|
||||
await videoWatchPage.createReply('My first reply')
|
||||
})
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoUploadPage = new VideoUploadPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
signupPage = new SignupPage()
|
||||
playerPage = new PlayerPage()
|
||||
myAccountPage = new MyAccountPage()
|
||||
|
||||
await browser.maximizeWindow()
|
||||
})
|
||||
|
||||
describe('Owner', function () {
|
||||
before(async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should login, upload a public video and save it to a playlist', async () => {
|
||||
await videoUploadPage.navigateTo()
|
||||
await videoUploadPage.uploadVideo('video.mp4')
|
||||
await videoUploadPage.validSecondUploadStep(publicVideoName1)
|
||||
|
||||
await videoWatchPage.clickOnSave()
|
||||
|
||||
await videoWatchPage.createPlaylist(playlistName)
|
||||
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
await browser.pause(5000)
|
||||
|
||||
})
|
||||
|
||||
it('Should upload a password protected video', async () => {
|
||||
await videoUploadPage.navigateTo()
|
||||
await videoUploadPage.uploadVideo('video2.mp4')
|
||||
await videoUploadPage.setAsPasswordProtected(videoPassword)
|
||||
await videoUploadPage.validSecondUploadStep(passwordProtectedVideoName)
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
|
||||
|
||||
passwordProtectedVideoUrl = await browser.getUrl()
|
||||
})
|
||||
|
||||
it('Should save to playlist the password protected video', async () => {
|
||||
await videoWatchPage.clickOnSave()
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
})
|
||||
|
||||
it('Should upload a second public video and save it to playlist', async () => {
|
||||
await videoUploadPage.navigateTo()
|
||||
|
||||
await videoUploadPage.uploadVideo('video3.mp4')
|
||||
await videoUploadPage.validSecondUploadStep(publicVideoName2)
|
||||
|
||||
await videoWatchPage.clickOnSave()
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
})
|
||||
|
||||
it('Should play video without password', async function () {
|
||||
await go(passwordProtectedVideoUrl)
|
||||
|
||||
expect(!await videoWatchPage.isPasswordProtected())
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
})
|
||||
|
||||
testRateAndComment()
|
||||
|
||||
it('Should play video on embed without password', async function () {
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
})
|
||||
|
||||
it('Should have the playlist in my account', async function () {
|
||||
await go('/')
|
||||
await myAccountPage.navigateToMyPlaylists()
|
||||
const videosNumberText = await myAccountPage.getPlaylistVideosText(playlistName)
|
||||
|
||||
expect(videosNumberText).toEqual('3 videos')
|
||||
await myAccountPage.clickOnPlaylist(playlistName)
|
||||
|
||||
const count = await myAccountPage.countTotalPlaylistElements()
|
||||
expect(count).toEqual(3)
|
||||
})
|
||||
|
||||
it('Should update the playlist to public', async () => {
|
||||
const url = await browser.getUrl()
|
||||
const regex = /\/my-library\/video-playlists\/([^/]+)/i
|
||||
const match = url.match(regex)
|
||||
const uuid = match ? match[1] : null
|
||||
|
||||
expect(uuid).not.toBeNull()
|
||||
|
||||
await myAccountPage.updatePlaylistPrivacy(uuid, 'Public')
|
||||
})
|
||||
|
||||
it('Should watch the playlist', async () => {
|
||||
await myAccountPage.clickOnPlaylist(playlistName)
|
||||
await myAccountPage.playPlaylist()
|
||||
|
||||
await videoWatchPage.waitUntilVideoName(publicVideoName1, 40 * 1000)
|
||||
playlistUrl = await browser.getUrl()
|
||||
|
||||
await videoWatchPage.waitUntilVideoName(passwordProtectedVideoName, 40 * 1000)
|
||||
await videoWatchPage.waitUntilVideoName(publicVideoName2, 40 * 1000)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Regular users', function () {
|
||||
|
||||
before(async () => {
|
||||
await signupPage.fullSignup({
|
||||
accountInfo: {
|
||||
username: regularUsername,
|
||||
password: regularUserPassword
|
||||
},
|
||||
channelInfo: {
|
||||
name: 'user_1_channel'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Should requires password to play video', async function () {
|
||||
await go(passwordProtectedVideoUrl)
|
||||
|
||||
expect(await videoWatchPage.isPasswordProtected())
|
||||
|
||||
await videoWatchPage.fillVideoPassword(videoPassword)
|
||||
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
|
||||
await playerPage.playAndPauseVideo(true, 2)
|
||||
})
|
||||
|
||||
testRateAndComment()
|
||||
|
||||
it('Should requires password to play video on embed', async function () {
|
||||
await videoWatchPage.goOnAssociatedEmbed(true)
|
||||
await playerPage.fillEmbedVideoPassword(videoPassword)
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
})
|
||||
|
||||
it('Should watch the playlist without password protected video', async () => {
|
||||
await go(playlistUrl)
|
||||
await playerPage.playVideo()
|
||||
await videoWatchPage.waitUntilVideoName(publicVideoName2, 40 * 1000)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Anonymous users', function () {
|
||||
it('Should requires password to play video', async function () {
|
||||
await go(passwordProtectedVideoUrl)
|
||||
|
||||
expect(await videoWatchPage.isPasswordProtected())
|
||||
|
||||
await videoWatchPage.fillVideoPassword(videoPassword)
|
||||
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
|
||||
await playerPage.playAndPauseVideo(true, 2)
|
||||
})
|
||||
|
||||
it('Should requires password to play video on embed', async function () {
|
||||
await videoWatchPage.goOnAssociatedEmbed(true)
|
||||
await playerPage.fillEmbedVideoPassword(videoPassword)
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
})
|
||||
|
||||
it('Should watch the playlist without password protected video', async () => {
|
||||
await go(playlistUrl)
|
||||
await playerPage.playVideo()
|
||||
await videoWatchPage.waitUntilVideoName(publicVideoName2, 40 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { AdminConfigPage } from '../po/admin-config.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import { VideoListPage } from '../po/video-list.po'
|
||||
import { VideoSearchPage } from '../po/video-search.po'
|
||||
import { VideoUploadPage } from '../po/video-upload.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { NSFWPolicy } from '../types/common'
|
||||
import { isMobileDevice, isSafari, waitServerUp } from '../utils'
|
||||
|
||||
describe('Videos list', () => {
|
||||
let videoListPage: VideoListPage
|
||||
let videoUploadPage: VideoUploadPage
|
||||
let adminConfigPage: AdminConfigPage
|
||||
let loginPage: LoginPage
|
||||
let myAccountPage: MyAccountPage
|
||||
let videoSearchPage: VideoSearchPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
|
||||
const seed = Math.random()
|
||||
const nsfwVideo = seed + ' - nsfw'
|
||||
const normalVideo = seed + ' - normal'
|
||||
|
||||
async function checkNormalVideo () {
|
||||
expect(await videoListPage.videoExists(normalVideo)).toBeTruthy()
|
||||
expect(await videoListPage.videoIsBlurred(normalVideo)).toBeFalsy()
|
||||
}
|
||||
|
||||
async function checkNSFWVideo (policy: NSFWPolicy, filterText?: string) {
|
||||
if (policy === 'do_not_list') {
|
||||
if (filterText) expect(filterText).toContain('hidden')
|
||||
|
||||
expect(await videoListPage.videoExists(nsfwVideo)).toBeFalsy()
|
||||
return
|
||||
}
|
||||
|
||||
if (policy === 'blur') {
|
||||
if (filterText) expect(filterText).toContain('blurred')
|
||||
|
||||
expect(await videoListPage.videoExists(nsfwVideo)).toBeTruthy()
|
||||
expect(await videoListPage.videoIsBlurred(nsfwVideo)).toBeTruthy()
|
||||
return
|
||||
}
|
||||
|
||||
// display
|
||||
if (filterText) expect(filterText).toContain('displayed')
|
||||
|
||||
expect(await videoListPage.videoExists(nsfwVideo)).toBeTruthy()
|
||||
expect(await videoListPage.videoIsBlurred(nsfwVideo)).toBeFalsy()
|
||||
}
|
||||
|
||||
async function checkCommonVideoListPages (policy: NSFWPolicy) {
|
||||
const promisesWithFilters = [
|
||||
videoListPage.goOnRootAccount.bind(videoListPage),
|
||||
videoListPage.goOnLocal.bind(videoListPage),
|
||||
videoListPage.goOnRecentlyAdded.bind(videoListPage),
|
||||
videoListPage.goOnTrending.bind(videoListPage),
|
||||
videoListPage.goOnRootChannel.bind(videoListPage)
|
||||
]
|
||||
|
||||
for (const p of promisesWithFilters) {
|
||||
await p()
|
||||
|
||||
const filter = await videoListPage.getNSFWFilter()
|
||||
const filterText = await filter.getText()
|
||||
|
||||
await checkNormalVideo()
|
||||
await checkNSFWVideo(policy, filterText)
|
||||
}
|
||||
|
||||
const promisesWithoutFilters = [
|
||||
videoListPage.goOnRootAccountChannels.bind(videoListPage),
|
||||
videoListPage.goOnHomepage.bind(videoListPage)
|
||||
]
|
||||
for (const p of promisesWithoutFilters) {
|
||||
await p()
|
||||
|
||||
await checkNormalVideo()
|
||||
await checkNSFWVideo(policy)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSearchPage (policy: NSFWPolicy) {
|
||||
await videoSearchPage.search(normalVideo)
|
||||
await checkNormalVideo()
|
||||
|
||||
await videoSearchPage.search(nsfwVideo)
|
||||
await checkNSFWVideo(policy)
|
||||
}
|
||||
|
||||
async function updateAdminNSFW (nsfw: NSFWPolicy) {
|
||||
await adminConfigPage.navigateTo('instance-information')
|
||||
await adminConfigPage.updateNSFWSetting(nsfw)
|
||||
await adminConfigPage.save()
|
||||
}
|
||||
|
||||
async function updateUserNSFW (nsfw: NSFWPolicy) {
|
||||
await myAccountPage.navigateToMySettings()
|
||||
await myAccountPage.updateNSFW(nsfw)
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
|
||||
adminConfigPage = new AdminConfigPage()
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoUploadPage = new VideoUploadPage()
|
||||
myAccountPage = new MyAccountPage()
|
||||
videoSearchPage = new VideoSearchPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
|
||||
await browser.maximizeWindow()
|
||||
})
|
||||
|
||||
it('Should login and disable NSFW', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateUserNSFW('display')
|
||||
})
|
||||
|
||||
it('Should set the homepage', async () => {
|
||||
await adminConfigPage.navigateTo('instance-homepage')
|
||||
await adminConfigPage.updateHomepage('<peertube-videos-list data-sort="-publishedAt"></peertube-videos-list>')
|
||||
await adminConfigPage.save()
|
||||
})
|
||||
|
||||
it('Should upload 2 videos (NSFW and classic videos)', async () => {
|
||||
await videoUploadPage.navigateTo()
|
||||
await videoUploadPage.uploadVideo('video.mp4')
|
||||
await videoUploadPage.setAsNSFW()
|
||||
await videoUploadPage.validSecondUploadStep(nsfwVideo)
|
||||
|
||||
await videoUploadPage.navigateTo()
|
||||
await videoUploadPage.uploadVideo('video2.mp4')
|
||||
await videoUploadPage.validSecondUploadStep(normalVideo)
|
||||
})
|
||||
|
||||
it('Should logout', async function () {
|
||||
await loginPage.logout()
|
||||
})
|
||||
|
||||
describe('Anonymous users', function () {
|
||||
|
||||
it('Should correctly handle do not list', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateAdminNSFW('do_not_list')
|
||||
|
||||
await loginPage.logout()
|
||||
await checkCommonVideoListPages('do_not_list')
|
||||
await checkSearchPage('do_not_list')
|
||||
})
|
||||
|
||||
it('Should correctly handle blur', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateAdminNSFW('blur')
|
||||
|
||||
await loginPage.logout()
|
||||
await checkCommonVideoListPages('blur')
|
||||
await checkSearchPage('blur')
|
||||
})
|
||||
|
||||
it('Should correctly handle display', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateAdminNSFW('display')
|
||||
|
||||
await loginPage.logout()
|
||||
await checkCommonVideoListPages('display')
|
||||
await checkSearchPage('display')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Logged in users', function () {
|
||||
|
||||
before(async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should correctly handle do not list', async () => {
|
||||
await updateUserNSFW('do_not_list')
|
||||
await checkCommonVideoListPages('do_not_list')
|
||||
await checkSearchPage('do_not_list')
|
||||
})
|
||||
|
||||
it('Should correctly handle blur', async () => {
|
||||
await updateUserNSFW('blur')
|
||||
await checkCommonVideoListPages('blur')
|
||||
await checkSearchPage('blur')
|
||||
})
|
||||
|
||||
it('Should correctly handle display', async () => {
|
||||
await updateUserNSFW('display')
|
||||
await checkCommonVideoListPages('display')
|
||||
await checkSearchPage('display')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default upload values', function () {
|
||||
|
||||
it('Should have default video values', async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
await videoUploadPage.navigateTo()
|
||||
await videoUploadPage.uploadVideo('video3.mp4')
|
||||
await videoUploadPage.validSecondUploadStep('video')
|
||||
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Public')
|
||||
expect(await videoWatchPage.getLicence()).toBe('Unknown')
|
||||
expect(await videoWatchPage.isDownloadEnabled()).toBeTruthy()
|
||||
expect(await videoWatchPage.areCommentsEnabled()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export type NSFWPolicy = 'do_not_list' | 'blur' | 'display'
|
||||
ベンダーファイル
+9
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
namespace WebdriverIO {
|
||||
interface Element {
|
||||
chooseFile: (path: string) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,53 @@
|
||||
async function browserSleep (amount: number) {
|
||||
await browser.pause(amount)
|
||||
}
|
||||
|
||||
function isMobileDevice () {
|
||||
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
|
||||
|
||||
return platformName === 'android' || platformName === 'ios'
|
||||
}
|
||||
|
||||
function isAndroid () {
|
||||
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
|
||||
|
||||
return platformName === 'android'
|
||||
}
|
||||
|
||||
function isSafari () {
|
||||
return browser.capabilities['browserName'] &&
|
||||
browser.capabilities['browserName'].toLowerCase() === 'safari'
|
||||
}
|
||||
|
||||
function isIOS () {
|
||||
return isMobileDevice() && isSafari()
|
||||
}
|
||||
|
||||
async function go (url: string) {
|
||||
await browser.url(url)
|
||||
|
||||
await browser.execute(() => {
|
||||
const style = document.createElement('style')
|
||||
style.innerHTML = 'p-toast { display: none }'
|
||||
document.head.appendChild(style)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitServerUp () {
|
||||
await browser.waitUntil(async () => {
|
||||
await go('/')
|
||||
await browserSleep(500)
|
||||
|
||||
return $('<my-app>').isDisplayed()
|
||||
}, { timeout: 20 * 1000 })
|
||||
}
|
||||
|
||||
export {
|
||||
isMobileDevice,
|
||||
isSafari,
|
||||
isIOS,
|
||||
isAndroid,
|
||||
waitServerUp,
|
||||
go,
|
||||
browserSleep
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
async function getCheckbox (name: string) {
|
||||
const input = $(`my-peertube-checkbox input[id=${name}]`)
|
||||
await input.waitForExist()
|
||||
|
||||
return input.parentElement()
|
||||
}
|
||||
|
||||
function isCheckboxSelected (name: string) {
|
||||
return $(`input[id=${name}]`).isSelected()
|
||||
}
|
||||
|
||||
async function selectCustomSelect (id: string, valueLabel: string) {
|
||||
const wrapper = $(`[formcontrolname=${id}] .ng-arrow-wrapper`)
|
||||
|
||||
await wrapper.waitForClickable()
|
||||
await wrapper.click()
|
||||
|
||||
const option = await $$(`[formcontrolname=${id}] .ng-option`).filter(async o => {
|
||||
const text = await o.getText()
|
||||
|
||||
return text.trimStart().startsWith(valueLabel)
|
||||
}).then(options => options[0])
|
||||
|
||||
await option.waitForDisplayed()
|
||||
|
||||
return option.click()
|
||||
}
|
||||
|
||||
async function findParentElement (
|
||||
el: WebdriverIO.Element,
|
||||
finder: (el: WebdriverIO.Element) => Promise<boolean>
|
||||
) {
|
||||
if (await finder(el) === true) return el
|
||||
|
||||
return findParentElement(await el.parentElement(), finder)
|
||||
}
|
||||
|
||||
export {
|
||||
getCheckbox,
|
||||
isCheckboxSelected,
|
||||
selectCustomSelect,
|
||||
findParentElement
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
function getVerificationLink (email: { text: string }) {
|
||||
const { text } = email
|
||||
|
||||
const regexp = /\[(?<link>http:\/\/[^\]]+)\]/g
|
||||
const matched = text.matchAll(regexp)
|
||||
|
||||
if (!matched) throw new Error('Could not find verification link in email')
|
||||
|
||||
for (const match of matched) {
|
||||
const link = match.groups.link
|
||||
|
||||
if (link.includes('/verify-account/')) return link
|
||||
}
|
||||
|
||||
throw new Error('Could not find /verify-account/ link')
|
||||
}
|
||||
|
||||
function findEmailTo (emails: { text: string, to: { address: string }[] }[], to: string) {
|
||||
for (const email of emails) {
|
||||
for (const { address } of email.to) {
|
||||
if (address === to) return email
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export {
|
||||
getVerificationLink,
|
||||
findEmailTo
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const SCREENSHOTS_DIRECTORY = 'screenshots'
|
||||
|
||||
function createScreenshotsDirectory () {
|
||||
mkdirSync(SCREENSHOTS_DIRECTORY, { recursive: true })
|
||||
}
|
||||
|
||||
function getScreenshotPath (filename: string) {
|
||||
return join(SCREENSHOTS_DIRECTORY, filename)
|
||||
}
|
||||
|
||||
export {
|
||||
createScreenshotsDirectory,
|
||||
getScreenshotPath
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ChildProcessWithoutNullStreams } from 'child_process'
|
||||
import { basename } from 'path'
|
||||
import { setValue } from '@wdio/shared-store-service'
|
||||
import { createScreenshotsDirectory } from './files'
|
||||
import { runCommand, runServer } from './server'
|
||||
|
||||
let appInstance: number
|
||||
let app: ChildProcessWithoutNullStreams
|
||||
|
||||
let emailPort: number
|
||||
|
||||
async function beforeLocalSuite (suite: any) {
|
||||
const config = buildConfig(suite.file)
|
||||
|
||||
await runCommand('npm run clean:server:test -- ' + appInstance)
|
||||
app = runServer(appInstance, config)
|
||||
}
|
||||
|
||||
function afterLocalSuite () {
|
||||
app.kill()
|
||||
app = undefined
|
||||
}
|
||||
|
||||
async function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
|
||||
createScreenshotsDirectory()
|
||||
|
||||
appInstance = capabilities['browserName'] === 'chrome'
|
||||
? 1
|
||||
: 2
|
||||
|
||||
emailPort = 1025 + appInstance
|
||||
|
||||
config.baseUrl = 'http://localhost:900' + appInstance
|
||||
|
||||
await setValue(config.baseUrl + '-emailPort', emailPort)
|
||||
}
|
||||
|
||||
async function onBrowserStackPrepare () {
|
||||
const appInstance = 1
|
||||
|
||||
await runCommand('npm run clean:server:test -- ' + appInstance)
|
||||
app = runServer(appInstance)
|
||||
}
|
||||
|
||||
function onBrowserStackComplete () {
|
||||
app.kill()
|
||||
app = undefined
|
||||
}
|
||||
|
||||
export {
|
||||
beforeLocalSession,
|
||||
afterLocalSuite,
|
||||
beforeLocalSuite,
|
||||
onBrowserStackPrepare,
|
||||
onBrowserStackComplete
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildConfig (suiteFile: string = undefined) {
|
||||
const filename = basename(suiteFile)
|
||||
|
||||
if (filename === 'custom-server-defaults.e2e-spec.ts') {
|
||||
return {
|
||||
defaults: {
|
||||
publish: {
|
||||
download_enabled: false,
|
||||
comments_policy: 2,
|
||||
privacy: 2,
|
||||
licence: 4
|
||||
},
|
||||
p2p: {
|
||||
webapp: {
|
||||
enabled: false
|
||||
},
|
||||
embed: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filename === 'signup.e2e-spec.ts') {
|
||||
return {
|
||||
signup: {
|
||||
limit: -1
|
||||
},
|
||||
smtp: {
|
||||
hostname: '127.0.0.1',
|
||||
port: emailPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filename === 'video-password.e2e-spec.ts') {
|
||||
return {
|
||||
signup: {
|
||||
enabled: true,
|
||||
limit: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './common'
|
||||
export * from './elements'
|
||||
export * from './email'
|
||||
export * from './files'
|
||||
export * from './hooks'
|
||||
export * from './mock-smtp'
|
||||
export * from './server'
|
||||
export * from './urls'
|
||||
@@ -0,0 +1,57 @@
|
||||
import MailDev from '@peertube/maildev'
|
||||
|
||||
class MockSMTPServer {
|
||||
|
||||
private static instance: MockSMTPServer
|
||||
private started = false
|
||||
private maildev: any
|
||||
private emails: object[]
|
||||
|
||||
collectEmails (port: number, emailsCollection: object[]) {
|
||||
return new Promise<number>((res, rej) => {
|
||||
this.emails = emailsCollection
|
||||
|
||||
if (this.started) {
|
||||
return res(undefined)
|
||||
}
|
||||
|
||||
this.maildev = new MailDev({
|
||||
ip: '127.0.0.1',
|
||||
smtp: port,
|
||||
disableWeb: true,
|
||||
silent: true
|
||||
})
|
||||
|
||||
this.maildev.on('new', email => {
|
||||
this.emails.push(email)
|
||||
})
|
||||
|
||||
this.maildev.listen(err => {
|
||||
if (err) return rej(err)
|
||||
|
||||
this.started = true
|
||||
|
||||
return res(port)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
kill () {
|
||||
if (!this.maildev) return
|
||||
|
||||
this.maildev.close()
|
||||
|
||||
this.maildev = null
|
||||
MockSMTPServer.instance = null
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
MockSMTPServer
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { exec, spawn } from 'child_process'
|
||||
import { join, resolve } from 'path'
|
||||
|
||||
function runServer (appInstance: number, config: any = {}) {
|
||||
const env = Object.create(process.env)
|
||||
|
||||
env['NODE_OPTIONS'] = ''
|
||||
env['NODE_ENV'] = 'test'
|
||||
env['NODE_APP_INSTANCE'] = appInstance + ''
|
||||
|
||||
env['NODE_CONFIG'] = JSON.stringify({
|
||||
rates_limit: {
|
||||
api: {
|
||||
max: 5000
|
||||
},
|
||||
login: {
|
||||
max: 5000
|
||||
}
|
||||
},
|
||||
log: {
|
||||
level: 'warn'
|
||||
},
|
||||
transcoding: {
|
||||
enabled: false
|
||||
},
|
||||
video_studio: {
|
||||
enabled: false
|
||||
},
|
||||
|
||||
...config
|
||||
})
|
||||
|
||||
const forkOptions = {
|
||||
env,
|
||||
cwd: getRootCWD(),
|
||||
detached: false
|
||||
}
|
||||
|
||||
const p = spawn('node', [ join('dist', 'server.js') ], forkOptions)
|
||||
p.stderr.on('data', data => console.error(data.toString()))
|
||||
p.stdout.on('data', data => console.error(data.toString()))
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
function runCommand (command: string) {
|
||||
return new Promise<void>((res, rej) => {
|
||||
// Reset NODE_OPTIONS env set by webdriverio
|
||||
const env = { ...process.env, NODE_OPTIONS: '' }
|
||||
|
||||
const p = exec(command, { env, cwd: getRootCWD() })
|
||||
|
||||
p.stderr.on('data', data => console.error(data.toString()))
|
||||
p.on('error', err => rej(err))
|
||||
p.on('exit', () => res())
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
runServer,
|
||||
runCommand
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getRootCWD () {
|
||||
return resolve('../..')
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const FIXTURE_URLS = {
|
||||
INTERNAL_WEB_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
|
||||
INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?start=0',
|
||||
|
||||
INTERNAL_EMBED_WEB_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
|
||||
INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?start=0',
|
||||
|
||||
INTERNAL_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/w/tKQmHcqdYZRdCszLUiWM3V?start=0',
|
||||
INTERNAL_EMBED_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/videos/embed/tKQmHcqdYZRdCszLUiWM3V?start=0',
|
||||
|
||||
WEB_VIDEO: 'https://peertube2.cpy.re/w/122d093a-1ede-43bd-bd34-59d2931ffc5e',
|
||||
|
||||
HLS_EMBED: 'https://peertube2.cpy.re/videos/embed/969bf103-7818-43b5-94a0-de159e13de50',
|
||||
HLS_PLAYLIST_EMBED: 'https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a',
|
||||
|
||||
LIVE_VIDEO: 'https://peertube2.cpy.re/w/oBw6LwsMWWRkmXYfuYRpJd'
|
||||
}
|
||||
|
||||
export {
|
||||
FIXTURE_URLS
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/app",
|
||||
"noImplicitAny": false,
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
"target": "ES2015",
|
||||
"typeRoots": [
|
||||
"../node_modules/@types",
|
||||
"../node_modules"
|
||||
],
|
||||
"types": [
|
||||
"node",
|
||||
"@wdio/globals/types",
|
||||
"@wdio/mocha-framework",
|
||||
"expect-webdriverio"
|
||||
]
|
||||
},
|
||||
"ts-node": {
|
||||
"files": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"./*.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { onBrowserStackComplete, onBrowserStackPrepare } from './src/utils'
|
||||
import { config as mainConfig } from './wdio.main.conf'
|
||||
|
||||
const user = process.env.BROWSERSTACK_USER
|
||||
const key = process.env.BROWSERSTACK_KEY
|
||||
|
||||
if (!user) throw new Error('Miss browser stack user')
|
||||
if (!key) throw new Error('Miss browser stack key')
|
||||
|
||||
function buildMainOptions (sessionName: string) {
|
||||
return {
|
||||
projectName: 'PeerTube',
|
||||
buildName: 'Main E2E - ' + new Date().toISOString(),
|
||||
sessionName,
|
||||
consoleLogs: 'info',
|
||||
networkLogs: true
|
||||
}
|
||||
}
|
||||
|
||||
function buildBStackDesktopOptions (options: {
|
||||
sessionName: string
|
||||
resolution: string
|
||||
os?: string
|
||||
osVersion?: string
|
||||
}) {
|
||||
const { sessionName, resolution, os, osVersion } = options
|
||||
|
||||
return {
|
||||
'bstack:options': {
|
||||
...buildMainOptions(sessionName),
|
||||
|
||||
os,
|
||||
osVersion,
|
||||
resolution
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildBStackMobileOptions (options: {
|
||||
sessionName: string
|
||||
deviceName: string
|
||||
osVersion: string
|
||||
}) {
|
||||
const { sessionName, deviceName, osVersion } = options
|
||||
|
||||
return {
|
||||
'bstack:options': {
|
||||
...buildMainOptions(sessionName),
|
||||
|
||||
realMobile: true,
|
||||
osVersion,
|
||||
deviceName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
config: {
|
||||
...mainConfig,
|
||||
|
||||
user,
|
||||
key,
|
||||
|
||||
maxInstances: 5,
|
||||
|
||||
capabilities: [
|
||||
{
|
||||
browserName: 'Chrome',
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Latest Chrome Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
|
||||
},
|
||||
{
|
||||
browserName: 'Firefox',
|
||||
browserVersion: '78', // Very old ESR
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Firefox ESR Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
|
||||
},
|
||||
{
|
||||
browserName: 'Safari',
|
||||
browserVersion: '12.1',
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Safari Desktop', resolution: '1280x1024' })
|
||||
},
|
||||
{
|
||||
browserName: 'Firefox',
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Firefox Latest', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
|
||||
},
|
||||
{
|
||||
browserName: 'Edge',
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Edge Latest', resolution: '1280x1024' })
|
||||
},
|
||||
|
||||
{
|
||||
browserName: 'Chrome',
|
||||
|
||||
...buildBStackMobileOptions({ sessionName: 'Latest Chrome Android', deviceName: 'Samsung Galaxy S8', osVersion: '7.0' })
|
||||
},
|
||||
{
|
||||
browserName: 'Safari',
|
||||
|
||||
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 8', osVersion: '13' })
|
||||
},
|
||||
{
|
||||
browserName: 'Safari',
|
||||
|
||||
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad 7th', osVersion: '13' })
|
||||
}
|
||||
],
|
||||
|
||||
host: 'hub-cloud.browserstack.com',
|
||||
connectionRetryTimeout: 240000,
|
||||
waitforTimeout: 20000,
|
||||
|
||||
specs: [
|
||||
// We don't want to test "local" tests
|
||||
'./src/suites-all/*.e2e-spec.ts'
|
||||
],
|
||||
|
||||
services: [
|
||||
[
|
||||
'browserstack', { browserstackLocal: true }
|
||||
]
|
||||
],
|
||||
|
||||
onWorkerStart: function (_cid, capabilities) {
|
||||
if (capabilities['bstack:options'].realMobile === true) {
|
||||
capabilities['bstack:options'].local = false
|
||||
}
|
||||
},
|
||||
|
||||
onPrepare: onBrowserStackPrepare,
|
||||
onComplete: onBrowserStackComplete
|
||||
|
||||
} as WebdriverIO.Config
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterLocalSuite, beforeLocalSuite, beforeLocalSession } from './src/utils'
|
||||
import { config as mainConfig } from './wdio.main.conf'
|
||||
|
||||
const prefs = {
|
||||
'intl.accept_languages': 'en'
|
||||
}
|
||||
|
||||
// Chrome headless does not support prefs
|
||||
process.env.LANG = 'en'
|
||||
|
||||
// https://github.com/mozilla/geckodriver/issues/1354#issuecomment-479456411
|
||||
process.env.MOZ_HEADLESS_WIDTH = '1280'
|
||||
process.env.MOZ_HEADLESS_HEIGHT = '1024'
|
||||
|
||||
const windowSizeArg = `--window-size=${process.env.MOZ_HEADLESS_WIDTH},${process.env.MOZ_HEADLESS_HEIGHT}`
|
||||
|
||||
module.exports = {
|
||||
config: {
|
||||
...mainConfig,
|
||||
|
||||
runner: 'local',
|
||||
|
||||
maxInstances: 1,
|
||||
specFileRetries: 0,
|
||||
|
||||
capabilities: [
|
||||
{
|
||||
'browserName': 'chrome',
|
||||
'acceptInsecureCerts': true,
|
||||
'goog:chromeOptions': {
|
||||
args: [ '--headless', '--disable-gpu', windowSizeArg ],
|
||||
prefs
|
||||
}
|
||||
},
|
||||
{
|
||||
'browserName': 'firefox',
|
||||
'moz:firefoxOptions': {
|
||||
binary: '/usr/bin/firefox-developer-edition',
|
||||
args: [ '--headless', windowSizeArg ],
|
||||
|
||||
prefs
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
services: [ 'shared-store' ],
|
||||
|
||||
beforeSession: beforeLocalSession,
|
||||
beforeSuite: beforeLocalSuite,
|
||||
afterSuite: afterLocalSuite
|
||||
} as WebdriverIO.Config
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterLocalSuite, beforeLocalSession, beforeLocalSuite } from './src/utils'
|
||||
import { config as mainConfig } from './wdio.main.conf'
|
||||
|
||||
const prefs = { 'intl.accept_languages': 'en' }
|
||||
process.env.LANG = 'en'
|
||||
|
||||
// https://github.com/mozilla/geckodriver/issues/1354#issuecomment-479456411
|
||||
process.env.MOZ_HEADLESS_WIDTH = '1280'
|
||||
process.env.MOZ_HEADLESS_HEIGHT = '1024'
|
||||
|
||||
const windowSizeArg = `--window-size=${process.env.MOZ_HEADLESS_WIDTH},${process.env.MOZ_HEADLESS_HEIGHT}`
|
||||
|
||||
module.exports = {
|
||||
config: {
|
||||
...mainConfig,
|
||||
|
||||
runner: 'local',
|
||||
|
||||
maxInstancesPerCapability: 1,
|
||||
|
||||
capabilities: [
|
||||
{
|
||||
'browserName': 'chrome',
|
||||
'goog:chromeOptions': {
|
||||
binary: '/usr/bin/google-chrome-stable',
|
||||
args: [ '--headless', '--disable-gpu', windowSizeArg ],
|
||||
prefs
|
||||
}
|
||||
},
|
||||
{
|
||||
'browserName': 'firefox',
|
||||
'moz:firefoxOptions': {
|
||||
binary: '/usr/bin/firefox-developer-edition',
|
||||
args: [ '--headless', windowSizeArg ],
|
||||
|
||||
prefs
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
services: [ 'shared-store' ],
|
||||
|
||||
beforeSession: beforeLocalSession,
|
||||
beforeSuite: beforeLocalSuite,
|
||||
afterSuite: afterLocalSuite
|
||||
} as WebdriverIO.Config
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
export const config = {
|
||||
//
|
||||
// ====================
|
||||
// Runner Configuration
|
||||
// ====================
|
||||
//
|
||||
//
|
||||
// ==================
|
||||
// Specify Test Files
|
||||
// ==================
|
||||
// Define which test specs should run. The pattern is relative to the directory
|
||||
// from which `wdio` was called.
|
||||
//
|
||||
// The specs are defined as an array of spec files (optionally using wildcards
|
||||
// that will be expanded). The test for each spec file will be run in a separate
|
||||
// worker process. In order to have a group of spec files run in the same worker
|
||||
// process simply enclose them in an array within the specs array.
|
||||
//
|
||||
// If you are calling `wdio` from an NPM script (see https://docs.npmjs.com/cli/run-script),
|
||||
// then the current working directory is where your `package.json` resides, so `wdio`
|
||||
// will be called from there.
|
||||
//
|
||||
specs: [
|
||||
'./src/suites-all/*.e2e-spec.ts',
|
||||
'./src/suites-local/*.e2e-spec.ts'
|
||||
],
|
||||
// Patterns to exclude.
|
||||
exclude: [
|
||||
// 'path/to/excluded/files'
|
||||
],
|
||||
//
|
||||
// ===================
|
||||
// Test Configurations
|
||||
// ===================
|
||||
// Define all options that are relevant for the WebdriverIO instance here
|
||||
//
|
||||
// Level of logging verbosity: trace | debug | info | warn | error | silent
|
||||
logLevel: 'info',
|
||||
//
|
||||
// Set specific log levels per logger
|
||||
// loggers:
|
||||
// - webdriver, webdriverio
|
||||
// - @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service
|
||||
// - @wdio/mocha-framework, @wdio/jasmine-framework
|
||||
// - @wdio/local-runner
|
||||
// - @wdio/sumologic-reporter
|
||||
// - @wdio/cli, @wdio/config, @wdio/utils
|
||||
// Level of logging verbosity: trace | debug | info | warn | error | silent
|
||||
// logLevels: {
|
||||
// webdriver: 'info',
|
||||
// '@wdio/appium-service': 'info'
|
||||
// },
|
||||
//
|
||||
// If you only want to run your tests until a specific amount of tests have failed use
|
||||
// bail (default is 0 - don't bail, run all tests).
|
||||
bail: 0,
|
||||
//
|
||||
// Set a base URL in order to shorten url command calls. If your `url` parameter starts
|
||||
// with `/`, the base url gets prepended, not including the path portion of your baseUrl.
|
||||
// If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
|
||||
// gets prepended directly.
|
||||
baseUrl: 'http://127.0.0.1:9001',
|
||||
//
|
||||
// Default timeout for all waitFor* commands.
|
||||
waitforTimeout: 5000,
|
||||
//
|
||||
// Default timeout in milliseconds for request
|
||||
// if browser driver or grid doesn't send response
|
||||
connectionRetryTimeout: 120000,
|
||||
//
|
||||
// Default request retries count
|
||||
connectionRetryCount: 3,
|
||||
|
||||
// Framework you want to run your specs with.
|
||||
// The following are supported: Mocha, Jasmine, and Cucumber
|
||||
// see also: https://webdriver.io/docs/frameworks
|
||||
//
|
||||
// Make sure you have the wdio adapter package for the specific framework installed
|
||||
// before running any tests.
|
||||
framework: 'mocha',
|
||||
//
|
||||
// The number of times to retry the entire specfile when it fails as a whole
|
||||
specFileRetries: 2,
|
||||
//
|
||||
// Delay in seconds between the spec file retry attempts
|
||||
// specFileRetriesDelay: 0,
|
||||
//
|
||||
// Whether or not retried specfiles should be retried immediately or deferred to the end of the queue
|
||||
// specFileRetriesDeferred: false,
|
||||
//
|
||||
// Test reporter for stdout.
|
||||
// The only one supported by default is 'dot'
|
||||
// see also: https://webdriver.io/docs/dot-reporter
|
||||
reporters: [ 'spec' ],
|
||||
|
||||
//
|
||||
// Options to be passed to Mocha.
|
||||
// See the full list at http://mochajs.org/
|
||||
mochaOpts: {
|
||||
ui: 'bdd',
|
||||
timeout: 60000,
|
||||
bail: true
|
||||
},
|
||||
|
||||
autoCompileOpts: {
|
||||
autoCompile: true,
|
||||
|
||||
tsNodeOpts: {
|
||||
project: require('path').join(__dirname, './tsconfig.json')
|
||||
}
|
||||
},
|
||||
|
||||
before: function () {
|
||||
require('./src/commands/upload')
|
||||
}
|
||||
} as Partial<WebdriverIO.Config>
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"name": "peertube-client",
|
||||
"version": "6.2.0-rc.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
"name": "Chocobozzz",
|
||||
"email": "chocobozzz@framasoft.org",
|
||||
"url": "http://github.com/Chocobozzz"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Chocobozzz/PeerTube.git"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "npm run lint-ts && npm run lint-scss",
|
||||
"lint-ts": "eslint --cache --ext .ts src/standalone/**/*.ts && npm run ng lint",
|
||||
"lint-scss": "stylelint 'src/**/*.scss'",
|
||||
"eslint": "eslint",
|
||||
"ng": "ng",
|
||||
"webdriver-manager": "webdriver-manager",
|
||||
"ngx-extractor": "ngx-extractor",
|
||||
"stylelint": "stylelint"
|
||||
},
|
||||
"browser": {
|
||||
"net": false,
|
||||
"stream": false,
|
||||
"os": false,
|
||||
"util": false
|
||||
},
|
||||
"workspaces": [
|
||||
"../packages/*"
|
||||
],
|
||||
"typings": "*.d.ts",
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^18.0.6",
|
||||
"@angular-eslint/builder": "^18.0.1",
|
||||
"@angular-eslint/eslint-plugin": "^18.0.1",
|
||||
"@angular-eslint/eslint-plugin-template": "^18.0.1",
|
||||
"@angular-eslint/schematics": "^18.0.1",
|
||||
"@angular-eslint/template-parser": "^18.0.1",
|
||||
"@angular/animations": "^18.0.4",
|
||||
"@angular/build": "^18.0.5",
|
||||
"@angular/cdk": "^18.0.4",
|
||||
"@angular/cli": "^18.0.5",
|
||||
"@angular/common": "^18.0.4",
|
||||
"@angular/compiler": "^18.0.4",
|
||||
"@angular/compiler-cli": "^18.0.4",
|
||||
"@angular/core": "^18.0.4",
|
||||
"@angular/forms": "^18.0.4",
|
||||
"@angular/localize": "^18.0.4",
|
||||
"@angular/platform-browser": "^18.0.4",
|
||||
"@angular/platform-browser-dynamic": "^18.0.4",
|
||||
"@angular/router": "^18.0.4",
|
||||
"@angular/service-worker": "^18.0.4",
|
||||
"@formatjs/intl-locale": "^4.0.0",
|
||||
"@formatjs/intl-pluralrules": "^5.2.2",
|
||||
"@ng-bootstrap/ng-bootstrap": "^17.0.0",
|
||||
"@ng-select/ng-select": "^13.3.0",
|
||||
"@ngx-loading-bar/core": "^6.0.0",
|
||||
"@ngx-loading-bar/http-client": "^6.0.0",
|
||||
"@ngx-loading-bar/router": "^6.0.0",
|
||||
"@peertube/maildev": "^1.2.0",
|
||||
"@peertube/p2p-media-loader-core": "^1.0.20",
|
||||
"@peertube/p2p-media-loader-hlsjs": "^1.0.20",
|
||||
"@peertube/xliffmerge": "^2.0.3",
|
||||
"@popperjs/core": "^2.11.5",
|
||||
"@types/chart.js": "^2.9.37",
|
||||
"@types/core-js": "^2.5.2",
|
||||
"@types/debug": "^4.1.5",
|
||||
"@types/jschannel": "^1.0.0",
|
||||
"@types/linkifyjs": "^2.1.2",
|
||||
"@types/lodash-es": "^4.17.0",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/node": "^18.13.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/sanitize-html": "2.11.0",
|
||||
"@types/sha.js": "^2.4.0",
|
||||
"@types/video.js": "^7.3.40",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"@wdio/browserstack-service": "^8.10.5",
|
||||
"@wdio/cli": "^8.10.5",
|
||||
"@wdio/local-runner": "^8.10.5",
|
||||
"@wdio/mocha-framework": "^8.10.4",
|
||||
"@wdio/shared-store-service": "^8.10.5",
|
||||
"@wdio/spec-reporter": "^8.10.5",
|
||||
"angularx-qrcode": "17.0.1",
|
||||
"bootstrap": "^5.1.3",
|
||||
"buffer": "^6.0.3",
|
||||
"chart.js": "^4.3.0",
|
||||
"chartjs-plugin-zoom": "~2.0.1",
|
||||
"core-js": "^3.22.8",
|
||||
"debug": "^4.3.1",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-jsdoc": "^48.1.0",
|
||||
"eslint-plugin-prefer-arrow": "latest",
|
||||
"expect-webdriverio": "^4.2.3",
|
||||
"hls.js": "~1.5.11",
|
||||
"intl-messageformat": "^10.1.0",
|
||||
"jschannel": "^1.0.2",
|
||||
"linkify-html": "^4.0.2",
|
||||
"linkifyjs": "^4.0.2",
|
||||
"lodash-es": "^4.17.4",
|
||||
"markdown-it": "14.1.0",
|
||||
"markdown-it-emoji": "^3.0.0",
|
||||
"ngx-uploadx": "^6.1.0",
|
||||
"primeng": "^17.3.1",
|
||||
"rxjs": "^7.3.0",
|
||||
"sanitize-html": "^2.1.2",
|
||||
"sha.js": "^2.4.11",
|
||||
"socket.io-client": "^4.5.4",
|
||||
"stylelint": "^16.2.1",
|
||||
"stylelint-config-sass-guidelines": "^11.0.0",
|
||||
"tinykeys": "^2.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "~5.4.5",
|
||||
"video.js": "^7.19.2",
|
||||
"vite": "^5.3.1",
|
||||
"vite-plugin-checker": "^0.6.4",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"zone.js": "~0.14.2"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://127.0.0.1:9000",
|
||||
"secure": false
|
||||
},
|
||||
"/plugins": {
|
||||
"target": "http://127.0.0.1:9000",
|
||||
"secure": false
|
||||
},
|
||||
"/themes": {
|
||||
"target": "http://127.0.0.1:9000",
|
||||
"secure": false
|
||||
},
|
||||
"/static": {
|
||||
"target": "http://127.0.0.1:9000",
|
||||
"secure": false
|
||||
},
|
||||
"/lazy-static": {
|
||||
"target": "http://127.0.0.1:9000",
|
||||
"secure": false
|
||||
},
|
||||
"/socket.io": {
|
||||
"target": "ws://127.0.0.1:9000",
|
||||
"secure": false,
|
||||
"ws": true
|
||||
},
|
||||
"/client/assets": {
|
||||
"target": "http://127.0.0.1:9000",
|
||||
"secure": false
|
||||
},
|
||||
"/client/locales": {
|
||||
"target": "http://127.0.0.1:9000",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<div class="margin-content mt-4">
|
||||
<div class="row">
|
||||
<h1 class="visually-hidden" i18n>Follows</h1>
|
||||
|
||||
<div class="col-xl-6 col-md-12">
|
||||
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Followers of {{ instanceName }} ({{ followersPagination.totalItems }})</h2>
|
||||
|
||||
<div i18n class="no-results" *ngIf="followersPagination.totalItems === 0">{{ instanceName }} does not have followers.</div>
|
||||
|
||||
<a *ngFor="let follower of followers" [href]="follower.url" target="_blank" rel="noopener noreferrer">
|
||||
{{ follower.name }}
|
||||
</a>
|
||||
|
||||
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-6 col-md-12">
|
||||
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Subscriptions of {{ instanceName }} ({{ followingsPagination.totalItems }})</h2>
|
||||
|
||||
<div i18n class="no-results" *ngIf="followingsPagination.totalItems === 0">{{ instanceName }} does not have subscriptions.</div>
|
||||
|
||||
<a *ngFor="let following of followings" [href]="following.url" target="_blank" rel="noopener noreferrer">
|
||||
{{ following.name }}
|
||||
</a>
|
||||
|
||||
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowings && canLoadMoreFollowings()" (click)="loadAllFollowings()">Show full list</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { SortMeta } from 'primeng/api'
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { ComponentPagination, hasMoreItems, Notifier, RestService, ServerService } from '@app/core'
|
||||
import { Actor } from '@peertube/peertube-models'
|
||||
import { NgIf, NgFor } from '@angular/common'
|
||||
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
|
||||
|
||||
@Component({
|
||||
selector: 'my-about-follows',
|
||||
templateUrl: './about-follows.component.html',
|
||||
styleUrls: [ './about-follows.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [ NgIf, NgFor ]
|
||||
})
|
||||
|
||||
export class AboutFollowsComponent implements OnInit {
|
||||
instanceName: string
|
||||
|
||||
followers: { name: string, url: string }[] = []
|
||||
followings: { name: string, url: string }[] = []
|
||||
|
||||
loadedAllFollowers = false
|
||||
loadedAllFollowings = false
|
||||
|
||||
followersPagination: ComponentPagination = {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 20,
|
||||
totalItems: 0
|
||||
}
|
||||
|
||||
followingsPagination: ComponentPagination = {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 20,
|
||||
totalItems: 0
|
||||
}
|
||||
|
||||
sort: SortMeta = {
|
||||
field: 'createdAt',
|
||||
order: -1
|
||||
}
|
||||
|
||||
constructor (
|
||||
private server: ServerService,
|
||||
private restService: RestService,
|
||||
private notifier: Notifier,
|
||||
private followService: InstanceFollowService
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.loadMoreFollowers()
|
||||
|
||||
this.loadMoreFollowings()
|
||||
|
||||
this.instanceName = this.server.getHTMLConfig().instance.name
|
||||
}
|
||||
|
||||
loadAllFollowings () {
|
||||
if (this.loadedAllFollowings) return
|
||||
|
||||
this.loadedAllFollowings = true
|
||||
this.followingsPagination.itemsPerPage = 100
|
||||
|
||||
this.loadMoreFollowings(true)
|
||||
|
||||
while (hasMoreItems(this.followingsPagination)) {
|
||||
this.followingsPagination.currentPage += 1
|
||||
|
||||
this.loadMoreFollowings()
|
||||
}
|
||||
}
|
||||
|
||||
loadAllFollowers () {
|
||||
if (this.loadedAllFollowers) return
|
||||
|
||||
this.loadedAllFollowers = true
|
||||
this.followersPagination.itemsPerPage = 100
|
||||
|
||||
this.loadMoreFollowers(true)
|
||||
|
||||
while (hasMoreItems(this.followersPagination)) {
|
||||
this.followersPagination.currentPage += 1
|
||||
|
||||
this.loadMoreFollowers()
|
||||
}
|
||||
}
|
||||
|
||||
buildLink (host: string) {
|
||||
return window.location.protocol + '//' + host
|
||||
}
|
||||
|
||||
canLoadMoreFollowers () {
|
||||
return this.loadedAllFollowers || this.followersPagination.totalItems > this.followersPagination.itemsPerPage
|
||||
}
|
||||
|
||||
canLoadMoreFollowings () {
|
||||
return this.loadedAllFollowings || this.followingsPagination.totalItems > this.followingsPagination.itemsPerPage
|
||||
}
|
||||
|
||||
private loadMoreFollowers (reset = false) {
|
||||
const pagination = this.restService.componentToRestPagination(this.followersPagination)
|
||||
|
||||
this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' })
|
||||
.subscribe({
|
||||
next: resultList => {
|
||||
if (reset) this.followers = []
|
||||
|
||||
const newFollowers = resultList.data.map(r => this.formatFollow(r.follower))
|
||||
this.followers = this.followers.concat(newFollowers)
|
||||
|
||||
this.followersPagination.totalItems = resultList.total
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
private loadMoreFollowings (reset = false) {
|
||||
const pagination = this.restService.componentToRestPagination(this.followingsPagination)
|
||||
|
||||
this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' })
|
||||
.subscribe({
|
||||
next: resultList => {
|
||||
if (reset) this.followings = []
|
||||
|
||||
const newFollowings = resultList.data.map(r => this.formatFollow(r.following))
|
||||
this.followings = this.followings.concat(newFollowings)
|
||||
|
||||
this.followingsPagination.totalItems = resultList.total
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
private formatFollow (actor: Actor) {
|
||||
return {
|
||||
// Instance follow, only display host
|
||||
name: actor.name === 'peertube'
|
||||
? actor.host
|
||||
: actor.name + '@' + actor.host,
|
||||
|
||||
url: actor.url
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<div class="banner" *ngIf="instanceBannerUrl">
|
||||
<img [src]="instanceBannerUrl" alt="Instance banner">
|
||||
</div>
|
||||
|
||||
<div class="margin-content mt-4">
|
||||
<div class="row ">
|
||||
<div class="col-md-12 col-xl-6">
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<h1 i18n class="fw-semibold fs-5">About {{ instanceName }}</h1>
|
||||
|
||||
<a routerLink="/about/contact" i18n *ngIf="isContactFormEnabled" class="peertube-button-link orange-button h-100 d-flex align-items-center">Contact us</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-4" *ngIf="categories.length !== 0 || languages.length !== 0">
|
||||
<span *ngFor="let category of categories" class="pt-badge badge-primary">{{ category }}</span>
|
||||
|
||||
<span *ngFor="let language of languages" class="pt-badge badge-secondary">{{ language }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class="block">{{ shortDescription }}</div>
|
||||
|
||||
<div i18n *ngIf="isNSFW" class="block mt-4 fw-semibold">This instance is dedicated to sensitive/NSFW content.</div>
|
||||
</div>
|
||||
|
||||
<div class="anchor" id="administrators-and-sustainability"></div>
|
||||
<a
|
||||
*ngIf="aboutHTML.administrator || aboutHTML.maintenanceLifetime || aboutHTML.businessModel"
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="administrators-and-sustainability"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)"
|
||||
>
|
||||
<h2 i18n class="middle-title">
|
||||
ADMINISTRATORS & SUSTAINABILITY
|
||||
</h2>
|
||||
</a>
|
||||
|
||||
<div class="block administrator" *ngIf="aboutHTML.administrator">
|
||||
<div class="anchor" id="administrators"></div>
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="administrators"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h3 i18n class="section-title">Who we are</h3>
|
||||
</a>
|
||||
|
||||
<div [innerHTML]="aboutHTML.administrator"></div>
|
||||
</div>
|
||||
|
||||
<div class="block creation-reason" *ngIf="aboutHTML.creationReason">
|
||||
<div class="anchor" id="creation-reason"></div>
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="creation-reason"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h3 i18n class="section-title">Why we created this instance</h3>
|
||||
</a>
|
||||
|
||||
<div [innerHTML]="aboutHTML.creationReason"></div>
|
||||
</div>
|
||||
|
||||
<div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
|
||||
<div class="anchor" id="maintenance-lifetime"></div>
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="maintenance-lifetime"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h3 i18n class="section-title">How long we plan to maintain this instance</h3>
|
||||
</a>
|
||||
|
||||
<div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
|
||||
</div>
|
||||
|
||||
<div class="block business-model" *ngIf="aboutHTML.businessModel">
|
||||
<div class="anchor" id="business-model"></div>
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="business-model"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h3 i18n class="section-title">How we will pay for keeping our instance running</h3>
|
||||
</a>
|
||||
|
||||
<div [innerHTML]="aboutHTML.businessModel"></div>
|
||||
</div>
|
||||
|
||||
<div class="anchor" id="information"></div>
|
||||
<a
|
||||
*ngIf="descriptionElement"
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="information"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h2 i18n class="middle-title">
|
||||
INFORMATION
|
||||
</h2>
|
||||
</a>
|
||||
|
||||
<div class="block description">
|
||||
<div class="anchor" id="description"></div>
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="description"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h3 i18n class="section-title">Description</h3>
|
||||
</a>
|
||||
|
||||
<my-custom-markup-container [content]="descriptionElement"></my-custom-markup-container>
|
||||
</div>
|
||||
|
||||
<div myPluginSelector pluginSelectorId="about-instance-moderation">
|
||||
<div class="anchor" id="moderation"></div>
|
||||
<a
|
||||
*ngIf="aboutHTML.moderationInformation || aboutHTML.codeOfConduct || aboutHTML.terms"
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="moderation"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h2 i18n class="middle-title">
|
||||
MODERATION
|
||||
</h2>
|
||||
</a>
|
||||
|
||||
<div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
|
||||
<div class="anchor" id="moderation-information"></div>
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="moderation-information"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h3 i18n class="section-title">Moderation information</h3>
|
||||
</a>
|
||||
|
||||
<div [innerHTML]="aboutHTML.moderationInformation"></div>
|
||||
</div>
|
||||
|
||||
<div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
|
||||
<div class="anchor" id="code-of-conduct"></div>
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="code-of-conduct"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h3 i18n class="section-title">Code of conduct</h3>
|
||||
</a>
|
||||
|
||||
<div [innerHTML]="aboutHTML.codeOfConduct"></div>
|
||||
</div>
|
||||
|
||||
<div class="block terms">
|
||||
<div class="anchor" id="terms"></div>
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="terms"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h3 i18n class="section-title">Terms</h3>
|
||||
</a>
|
||||
|
||||
<div [innerHTML]="aboutHTML.terms"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div myPluginSelector pluginSelectorId="about-instance-other-information">
|
||||
<div class="anchor" id="other-information"></div>
|
||||
<a
|
||||
*ngIf="aboutHTML.hardwareInformation"
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="other-information"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h2 i18n class="middle-title">
|
||||
OTHER INFORMATION
|
||||
</h2>
|
||||
</a>
|
||||
|
||||
<div class="block hardware-information" *ngIf="aboutHTML.hardwareInformation">
|
||||
<div class="anchor" id="hardware-information"></div>
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="hardware-information"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h3 i18n class="section-title">Hardware information</h3>
|
||||
</a>
|
||||
|
||||
<div [innerHTML]="aboutHTML.hardwareInformation"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-12 col-xl-6" myPluginSelector pluginSelectorId="about-instance-features">
|
||||
<h2 class="visually-hidden" i18n>FEATURES</h2>
|
||||
<my-instance-features-table></my-instance-features-table>
|
||||
</div>
|
||||
|
||||
<div class="col" myPluginSelector pluginSelectorId="about-instance-statistics">
|
||||
<div class="anchor" id="statistics"></div>
|
||||
|
||||
<a
|
||||
class="anchor-link"
|
||||
routerLink="/about/instance"
|
||||
fragment="statistics"
|
||||
#anchorLink
|
||||
(click)="onClickCopyLink(anchorLink)">
|
||||
<h2 i18n class="middle-title">STATISTICS</h2>
|
||||
</a>
|
||||
|
||||
<my-instance-statistics [serverStats]="serverStats"></my-instance-statistics>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<my-contact-admin-modal #contactAdminModal></my-contact-admin-modal>
|
||||
@@ -0,0 +1,50 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
.pt-badge {
|
||||
@include margin-right(5px);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: $font-semibold;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.middle-title {
|
||||
@include in-content-small-title;
|
||||
@include margin-bottom(1.5rem);
|
||||
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.block {
|
||||
@include margin-bottom(4.5rem);
|
||||
}
|
||||
|
||||
.anchor-link {
|
||||
@include disable-outline;
|
||||
|
||||
position: relative;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
&::after {
|
||||
@include margin-left(0.2em);
|
||||
|
||||
content: '#';
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.middle-title,
|
||||
.section-title {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: var(--mainForegroundColor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NgFor, NgIf, ViewportScroller } from '@angular/common'
|
||||
import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router'
|
||||
import { Notifier, ServerService } from '@app/core'
|
||||
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
|
||||
import { maxBy } from '@peertube/peertube-core-utils'
|
||||
import { HTMLServerConfig, ServerStats } from '@peertube/peertube-models'
|
||||
import { copyToClipboard } from '@root-helpers/utils'
|
||||
import { CustomMarkupContainerComponent } from '../../shared/shared-custom-markup/custom-markup-container.component'
|
||||
import { InstanceFeaturesTableComponent } from '../../shared/shared-instance/instance-features-table.component'
|
||||
import { PluginSelectorDirective } from '../../shared/shared-main/plugins/plugin-selector.directive'
|
||||
import { ResolverData } from './about-instance.resolver'
|
||||
import { ContactAdminModalComponent } from './contact-admin-modal.component'
|
||||
import { InstanceStatisticsComponent } from './instance-statistics.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-about-instance',
|
||||
templateUrl: './about-instance.component.html',
|
||||
styleUrls: [ './about-instance.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgIf,
|
||||
RouterLink,
|
||||
NgFor,
|
||||
CustomMarkupContainerComponent,
|
||||
PluginSelectorDirective,
|
||||
InstanceFeaturesTableComponent,
|
||||
InstanceStatisticsComponent,
|
||||
ContactAdminModalComponent
|
||||
]
|
||||
})
|
||||
export class AboutInstanceComponent implements OnInit, AfterViewChecked {
|
||||
@ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement>
|
||||
@ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
|
||||
|
||||
aboutHTML: AboutHTML
|
||||
descriptionElement: HTMLDivElement
|
||||
|
||||
instanceBannerUrl: string
|
||||
|
||||
languages: string[] = []
|
||||
categories: string[] = []
|
||||
shortDescription = ''
|
||||
|
||||
initialized = false
|
||||
|
||||
serverStats: ServerStats
|
||||
|
||||
private serverConfig: HTMLServerConfig
|
||||
|
||||
private lastScrollHash: string
|
||||
|
||||
constructor (
|
||||
private viewportScroller: ViewportScroller,
|
||||
private route: ActivatedRoute,
|
||||
private notifier: Notifier,
|
||||
private serverService: ServerService
|
||||
) {}
|
||||
|
||||
get instanceName () {
|
||||
return this.serverConfig.instance.name
|
||||
}
|
||||
|
||||
get isContactFormEnabled () {
|
||||
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
|
||||
}
|
||||
|
||||
get isNSFW () {
|
||||
return this.serverConfig.instance.isNSFW
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
const { about, languages, categories, aboutHTML, descriptionElement, serverStats }: ResolverData = this.route.snapshot.data.instanceData
|
||||
|
||||
this.serverStats = serverStats
|
||||
|
||||
this.aboutHTML = aboutHTML
|
||||
this.descriptionElement = descriptionElement
|
||||
|
||||
this.languages = languages
|
||||
this.categories = categories
|
||||
|
||||
this.shortDescription = about.instance.shortDescription
|
||||
|
||||
this.instanceBannerUrl = about.instance.banners.length !== 0
|
||||
? maxBy(about.instance.banners, 'width').path
|
||||
: undefined
|
||||
|
||||
this.serverConfig = this.serverService.getHTMLConfig()
|
||||
|
||||
this.route.data.subscribe(data => {
|
||||
if (!data?.isContact) return
|
||||
|
||||
const prefill = this.route.snapshot.queryParams
|
||||
|
||||
this.contactAdminModal.show(prefill)
|
||||
})
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
ngAfterViewChecked () {
|
||||
if (this.initialized && window.location.hash && window.location.hash !== this.lastScrollHash) {
|
||||
this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
|
||||
|
||||
this.lastScrollHash = window.location.hash
|
||||
}
|
||||
}
|
||||
|
||||
onClickCopyLink (anchor: HTMLAnchorElement) {
|
||||
const link = anchor.href
|
||||
copyToClipboard(link)
|
||||
this.notifier.success(link, $localize`Link copied`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { forkJoin, Observable } from 'rxjs'
|
||||
import { map, switchMap } from 'rxjs/operators'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ServerService } from '@app/core'
|
||||
import { About, ServerStats } from '@peertube/peertube-models'
|
||||
import { AboutHTML, InstanceService } from '@app/shared/shared-main/instance/instance.service'
|
||||
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
|
||||
|
||||
export type ResolverData = {
|
||||
serverStats: ServerStats
|
||||
about: About
|
||||
languages: string[]
|
||||
categories: string[]
|
||||
aboutHTML: AboutHTML
|
||||
descriptionElement: HTMLDivElement
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AboutInstanceResolver {
|
||||
|
||||
constructor (
|
||||
private instanceService: InstanceService,
|
||||
private customMarkupService: CustomMarkupService,
|
||||
private serverService: ServerService
|
||||
) {}
|
||||
|
||||
resolve (): Observable<ResolverData> {
|
||||
return forkJoin([
|
||||
this.buildInstanceAboutObservable(),
|
||||
this.buildInstanceStatsObservable()
|
||||
]).pipe(
|
||||
map(([
|
||||
[ about, languages, categories, aboutHTML, { rootElement } ],
|
||||
serverStats
|
||||
]) => {
|
||||
return {
|
||||
serverStats,
|
||||
about,
|
||||
languages,
|
||||
categories,
|
||||
aboutHTML,
|
||||
descriptionElement: rootElement
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private buildInstanceAboutObservable () {
|
||||
return this.instanceService.getAbout()
|
||||
.pipe(
|
||||
switchMap(about => {
|
||||
return forkJoin([
|
||||
Promise.resolve(about),
|
||||
this.instanceService.buildTranslatedLanguages(about),
|
||||
this.instanceService.buildTranslatedCategories(about),
|
||||
this.instanceService.buildHtml(about),
|
||||
this.customMarkupService.buildElement(about.instance.description)
|
||||
])
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private buildInstanceStatsObservable () {
|
||||
return this.serverService.getServerStats()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<ng-template #modal>
|
||||
<div class="modal-header">
|
||||
<h1 i18n class="modal-title">Contact the administrator(s)<p class="modal-subtitle">{{ instanceName }}</p></h1>
|
||||
|
||||
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
|
||||
<my-global-icon iconName="cross"></my-global-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
|
||||
<form *ngIf="isContactFormEnabled()" novalidate [formGroup]="form" (ngSubmit)="sendForm()">
|
||||
<div class="form-group">
|
||||
<label i18n for="fromName">Your name</label>
|
||||
<input
|
||||
type="text" id="fromName" class="form-control"
|
||||
formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
|
||||
autocomplete="name"
|
||||
>
|
||||
<div *ngIf="formErrors.fromName" class="form-error" role="alert">{{ formErrors.fromName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="fromEmail">Your email</label>
|
||||
<input
|
||||
type="text" id="fromEmail" class="form-control"
|
||||
formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }"
|
||||
i18n-placeholder placeholder="Example: john@example.com" autocomplete="email"
|
||||
>
|
||||
<div *ngIf="formErrors.fromEmail" class="form-error" role="alert">{{ formErrors.fromEmail }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="subject">Subject</label>
|
||||
<input
|
||||
type="text" id="subject" class="form-control"
|
||||
formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }"
|
||||
>
|
||||
<div *ngIf="formErrors.subject" class="form-error" role="alert">{{ formErrors.subject }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="body">Your message</label>
|
||||
<textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }">
|
||||
</textarea>
|
||||
<div *ngIf="formErrors.body" class="form-error" role="alert">{{ formErrors.body }}</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="error" class="alert alert-danger">{{ error }}</div>
|
||||
|
||||
<div class="form-group inputs">
|
||||
<input
|
||||
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
|
||||
(click)="hide()" (key.enter)="hide()"
|
||||
>
|
||||
|
||||
<input type="submit" i18n-value value="Submit" class="peertube-button orange-button" [disabled]="!form.valid" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div *ngIf="!isContactFormEnabled()" class="alert alert-danger" i18n>The contact form is not enabled on this instance.</div>
|
||||
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,21 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
.modal-subtitle {
|
||||
line-height: 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
input[type=text] {
|
||||
@include peertube-input-text(340px);
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@include peertube-textarea(100%, 200px);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Component, OnInit, ViewChild } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { Notifier, ServerService } from '@app/core'
|
||||
import {
|
||||
BODY_VALIDATOR,
|
||||
FROM_EMAIL_VALIDATOR,
|
||||
FROM_NAME_VALIDATOR,
|
||||
SUBJECT_VALIDATOR
|
||||
} from '@app/shared/form-validators/instance-validators'
|
||||
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
|
||||
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
|
||||
import { HTMLServerConfig, HttpStatusCode } from '@peertube/peertube-models'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgIf, NgClass } from '@angular/common'
|
||||
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
|
||||
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
|
||||
|
||||
type Prefill = {
|
||||
subject?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'my-contact-admin-modal',
|
||||
templateUrl: './contact-admin-modal.component.html',
|
||||
styleUrls: [ './contact-admin-modal.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [ GlobalIconComponent, NgIf, FormsModule, ReactiveFormsModule, NgClass ]
|
||||
})
|
||||
export class ContactAdminModalComponent extends FormReactive implements OnInit {
|
||||
@ViewChild('modal', { static: true }) modal: NgbModal
|
||||
|
||||
error: string
|
||||
|
||||
private openedModal: NgbModalRef
|
||||
private serverConfig: HTMLServerConfig
|
||||
|
||||
constructor (
|
||||
protected formReactiveService: FormReactiveService,
|
||||
private router: Router,
|
||||
private modalService: NgbModal,
|
||||
private instanceService: InstanceService,
|
||||
private serverService: ServerService,
|
||||
private notifier: Notifier
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
get instanceName () {
|
||||
return this.serverConfig.instance.name
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.serverConfig = this.serverService.getHTMLConfig()
|
||||
|
||||
this.buildForm({
|
||||
fromName: FROM_NAME_VALIDATOR,
|
||||
fromEmail: FROM_EMAIL_VALIDATOR,
|
||||
subject: SUBJECT_VALIDATOR,
|
||||
body: BODY_VALIDATOR
|
||||
})
|
||||
}
|
||||
|
||||
isContactFormEnabled () {
|
||||
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
|
||||
}
|
||||
|
||||
show (prefill: Prefill = {}) {
|
||||
this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
|
||||
|
||||
this.openedModal.shown.subscribe(() => this.prefillForm(prefill))
|
||||
this.openedModal.result.finally(() => this.router.navigateByUrl('/about/instance'))
|
||||
}
|
||||
|
||||
hide () {
|
||||
this.form.reset()
|
||||
this.error = undefined
|
||||
|
||||
this.openedModal.close()
|
||||
this.openedModal = null
|
||||
}
|
||||
|
||||
sendForm () {
|
||||
const fromName = this.form.value['fromName']
|
||||
const fromEmail = this.form.value['fromEmail']
|
||||
const subject = this.form.value['subject']
|
||||
const body = this.form.value['body']
|
||||
|
||||
this.instanceService.contactAdministrator(fromEmail, fromName, subject, body)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.notifier.success($localize`Your message has been sent.`)
|
||||
this.hide()
|
||||
},
|
||||
|
||||
error: err => {
|
||||
this.error = err.status === HttpStatusCode.FORBIDDEN_403
|
||||
? $localize`You already sent this form recently`
|
||||
: err.message
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private prefillForm (prefill: Prefill) {
|
||||
if (prefill.subject) {
|
||||
this.form.get('subject').setValue(prefill.subject)
|
||||
}
|
||||
|
||||
if (prefill.body) {
|
||||
this.form.get('body').setValue(prefill.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<p i18n *ngIf="null === serverStats">Loading instance statistics...</p>
|
||||
|
||||
<section *ngIf="null !== serverStats">
|
||||
<h3 i18n>By users on this instance</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6 col-lg-4 col-xl-3">
|
||||
<div class="card stat">
|
||||
<div class="card-body">
|
||||
<p class="stat-value">{{ serverStats.totalUsers | number }}</p>
|
||||
<p class="stat-label" i18n>users</p>
|
||||
</div>
|
||||
<my-global-icon iconName="user"></my-global-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-lg-4 col-xl-3">
|
||||
<div class="card stat">
|
||||
<div class="card-body">
|
||||
<p class="stat-value">{{ serverStats.totalLocalVideos | number }}</p>
|
||||
<p class="stat-label" i18n>videos</p>
|
||||
</div>
|
||||
<my-global-icon iconName="film"></my-global-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-lg-4 col-xl-3">
|
||||
<div class="card stat">
|
||||
<div class="card-body">
|
||||
<p class="stat-value">{{ serverStats.totalLocalVideoViews | number }}</p>
|
||||
<p class="stat-label" i18n>views</p>
|
||||
</div>
|
||||
<my-global-icon iconName="eye-open"></my-global-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-lg-4 col-xl-3">
|
||||
<div class="card stat">
|
||||
<div class="card-body">
|
||||
<p class="stat-value">{{ serverStats.totalLocalVideoComments | number }}</p>
|
||||
<p class="stat-label" i18n>comments</p>
|
||||
</div>
|
||||
<my-global-icon iconName="message-circle"></my-global-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-lg-4 col-xl-3">
|
||||
<div class="card stat">
|
||||
<div class="card-body">
|
||||
<p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p>
|
||||
<p class="stat-label" i18n>hosted video</p>
|
||||
</div>
|
||||
<my-global-icon iconName="home"></my-global-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 i18n>In this instance federation</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6 col-lg-4 col-xl-3">
|
||||
<div class="card stat">
|
||||
<div class="card-body">
|
||||
<p class="stat-value">{{ serverStats.totalVideos | number }}</p>
|
||||
<p class="stat-label" i18n>videos</p>
|
||||
</div>
|
||||
<my-global-icon iconName="film"></my-global-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-lg-4 col-xl-3">
|
||||
<div class="card stat">
|
||||
<div class="card-body">
|
||||
<p class="stat-value">{{ serverStats.totalVideoComments | number }}</p>
|
||||
<p class="stat-label" i18n>comments</p>
|
||||
</div>
|
||||
<my-global-icon iconName="message-circle"></my-global-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-lg-4 col-xl-3">
|
||||
<div class="card stat">
|
||||
<div class="card-body">
|
||||
<p class="stat-value">{{ serverStats.totalInstanceFollowers | number }}</p>
|
||||
<p class="stat-label" i18n>followers</p>
|
||||
</div>
|
||||
<my-global-icon iconName="share"></my-global-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6 col-lg-4 col-xl-3">
|
||||
<div class="card stat">
|
||||
<div class="card-body">
|
||||
<p class="stat-value">{{ serverStats.totalInstanceFollowing | number }}</p>
|
||||
<p class="stat-label" i18n>following</p>
|
||||
</div>
|
||||
<my-global-icon iconName="globe"></my-global-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,39 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
margin-bottom: 1em;
|
||||
overflow: hidden;
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.25em;
|
||||
line-height: 1em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 1.15em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
my-global-icon {
|
||||
opacity: 0.12;
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: -24px;
|
||||
width: 110px;
|
||||
|
||||
&.icon-bottom {
|
||||
top: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ServerStats } from '@peertube/peertube-models'
|
||||
import { BytesPipe } from '../../shared/shared-main/angular/bytes.pipe'
|
||||
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
|
||||
import { NgIf, DecimalPipe } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'my-instance-statistics',
|
||||
templateUrl: './instance-statistics.component.html',
|
||||
styleUrls: [ './instance-statistics.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [ NgIf, GlobalIconComponent, DecimalPipe, BytesPipe ]
|
||||
})
|
||||
export class InstanceStatisticsComponent {
|
||||
@Input() serverStats: ServerStats
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<div class="margin-content mt-4">
|
||||
<h1 i18n class="fs-3 text-center fw-semibold mb-3">
|
||||
This website is powered by PeerTube
|
||||
</h1>
|
||||
|
||||
<img class="d-block my-4 mx-auto" width="121px" height="147px" src="/client/assets/images/mascot/default.svg" alt="mascot"/>
|
||||
|
||||
<div class="text-center">
|
||||
<p i18n>
|
||||
PeerTube is a self-hosted ActivityPub-federated video streaming platform using P2P directly in your web browser.
|
||||
</p>
|
||||
|
||||
<p i18n>
|
||||
It is free and open-source software, under <a class="link-orange" href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE">AGPLv3
|
||||
licence</a>.
|
||||
</p>
|
||||
|
||||
<p i18n>
|
||||
For more information, please visit <a class="link-orange" target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">joinpeertube.org</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-center my-5">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<a i18n class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/setup-account">Use PeerTube documentation</a>
|
||||
</div>
|
||||
|
||||
<div i18n class="card-text">
|
||||
Discover how to setup your account, what is a channel, how to create a playlist and more!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<a i18n class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/third-party-application">PeerTube Applications</a>
|
||||
</div>
|
||||
|
||||
<div i18n class="card-text">
|
||||
Discover unofficial Android applications or browser addons!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<a i18n class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/contribute/getting-started">Contribute on PeerTube</a>
|
||||
</div>
|
||||
|
||||
<div i18n class="card-text">
|
||||
Want to help to improve PeerTube? You can translate the web interface, give your feedback or directly contribute to the code!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column">
|
||||
<h2 class="mb-4 mt-5 text-center fs-5 fw-semibold">
|
||||
<div class="anchor" id="privacy"></div> <!-- privacy anchor -->
|
||||
<ng-container i18n>P2P & Privacy</ng-container>
|
||||
</h2>
|
||||
|
||||
<p i18n>
|
||||
PeerTube uses the BitTorrent protocol to share bandwidth between users by default to help lower the load on the server,
|
||||
but ultimately leaves you the choice to switch back to regular streaming exclusively from the server of the video. What
|
||||
follows applies only if you want to keep using the P2P mode of PeerTube.
|
||||
</p>
|
||||
|
||||
<p i18n>
|
||||
The main threat to your privacy induced by BitTorrent lies in your IP address being stored in the instance's BitTorrent
|
||||
tracker as long as you download or watch the video.
|
||||
</p>
|
||||
|
||||
<h3 i18n class="fs-5">What are the consequences?</h3>
|
||||
|
||||
<p i18n>
|
||||
In theory, someone with enough technical skills could create a script that tracks which IP is downloading which video.
|
||||
In practice, this is much more difficult because:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li i18n>
|
||||
An HTTP request has to be sent on each tracker for each video to spy.
|
||||
If we want to spy all PeerTube's videos, we have to send as many requests as there are videos (so potentially a lot)
|
||||
</li>
|
||||
|
||||
<li i18n>
|
||||
For each request sent, the tracker returns random peers at a limited number.
|
||||
For instance, if there are 1000 peers in the swarm and the tracker sends only 20 peers for each request, there must be at least 50
|
||||
requests sent to know every peer in the swarm
|
||||
</li>
|
||||
|
||||
<li i18n>
|
||||
Those requests have to be sent regularly to know who starts/stops watching a video. It is easy to detect that kind of behaviour
|
||||
</li>
|
||||
|
||||
<li i18n>
|
||||
If an IP address is stored in the tracker, it doesn't mean that the person behind the IP (if this person exists) has watched the
|
||||
video
|
||||
</li>
|
||||
|
||||
<li i18n>
|
||||
The IP address is a vague information: usually, it regularly changes and can represent many persons or entities
|
||||
</li>
|
||||
|
||||
<li i18n>
|
||||
Web peers are not publicly accessible: because we use the websocket transport, the protocol is different from classic BitTorrent tracker.
|
||||
When you are in a web browser, you send a signal containing your IP address to the tracker that will randomly choose other peers
|
||||
to forward the information to.
|
||||
See <a class="link-orange" href="https://github.com/yciabaud/webtorrent/blob/beps/bep_webrtc.rst">this document</a> for more information
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p i18n>
|
||||
The worst-case scenario of an average person spying on their friends is quite unlikely.
|
||||
There are much more effective ways to get that kind of information.
|
||||
</p>
|
||||
|
||||
<h3 i18n class="p2p-privacy-title">How does PeerTube compare with YouTube?</h3>
|
||||
|
||||
<p i18n>
|
||||
The threats to privacy with YouTube are different from PeerTube's.
|
||||
In YouTube's case, the platform gathers a huge amount of your personal information (not only your IP) to analyze them and track you.
|
||||
Moreover, YouTube is owned by Google/Alphabet, a company that tracks you across many websites (via AdSense or Google Analytics).
|
||||
</p>
|
||||
|
||||
<h3 i18n class="p2p-privacy-title">What can I do to limit the exposure of my IP address?</h3>
|
||||
|
||||
<p i18n>
|
||||
Your IP address is public so every time you consult a website, there is a number of actors (in addition to the final website) seeing
|
||||
your IP in their connection logs: ISP/routers/trackers/CDN and more.
|
||||
PeerTube is transparent about it: we warn you that if you want to keep your IP private, you must use a VPN or Tor Browser.
|
||||
Thinking that removing P2P from PeerTube will give you back anonymity doesn't make sense.
|
||||
</p>
|
||||
|
||||
<h3 i18n class="p2p-privacy-title">What will be done to mitigate this problem?</h3>
|
||||
|
||||
<p i18n>
|
||||
PeerTube wants to deliver the best countermeasures possible, to give you more choice
|
||||
and render attacks less likely. Here is what we put in place so far:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li i18n>We set a limit to the number of peers sent by the tracker</li>
|
||||
<li i18n>We set a limit on the request frequency received by the tracker</li>
|
||||
<li i18n>Allow instance admins to disable P2P from the administration interface</li>
|
||||
</ul>
|
||||
|
||||
<p i18n>
|
||||
Ultimately, remember you can always disable P2P by toggling it in the video player, or just by disabling
|
||||
WebRTC in your browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
.margin-content {
|
||||
max-width: 1200px;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
@include margin(2rem);
|
||||
|
||||
flex-basis: 300px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Component, AfterViewChecked } from '@angular/core'
|
||||
import { ViewportScroller } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'my-about-peertube',
|
||||
templateUrl: './about-peertube.component.html',
|
||||
styleUrls: [ './about-peertube.component.scss' ],
|
||||
standalone: true
|
||||
})
|
||||
|
||||
export class AboutPeertubeComponent implements AfterViewChecked {
|
||||
private lastScrollHash: string
|
||||
|
||||
constructor (
|
||||
private viewportScroller: ViewportScroller
|
||||
) {}
|
||||
|
||||
ngAfterViewChecked () {
|
||||
if (window.location.hash && window.location.hash !== this.lastScrollHash) {
|
||||
this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
|
||||
|
||||
this.lastScrollHash = window.location.hash
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<div>
|
||||
<div class="sub-menu mb-0" [ngClass]="{ 'sub-menu-fixed': !isBroadcastMessageDisplayed }">
|
||||
<a myPluginSelector pluginSelectorId="about-menu-instance" i18n routerLink="instance" routerLinkActive="active" class="sub-menu-entry">Instance</a>
|
||||
|
||||
<a myPluginSelector pluginSelectorId="about-menu-peertube" i18n routerLink="peertube" routerLinkActive="active" class="sub-menu-entry">PeerTube</a>
|
||||
|
||||
<a myPluginSelector pluginSelectorId="about-menu-network" i18n routerLink="follows" routerLinkActive="active" class="sub-menu-entry">Network</a>
|
||||
</div>
|
||||
|
||||
<div [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ScreenService } from '@app/core'
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'
|
||||
import { PluginSelectorDirective } from '../shared/shared-main/plugins/plugin-selector.directive'
|
||||
import { NgClass } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'my-about',
|
||||
templateUrl: './about.component.html',
|
||||
standalone: true,
|
||||
imports: [ NgClass, PluginSelectorDirective, RouterLink, RouterLinkActive, RouterOutlet ]
|
||||
})
|
||||
|
||||
export class AboutComponent {
|
||||
constructor (
|
||||
private screenService: ScreenService
|
||||
) { }
|
||||
|
||||
get isBroadcastMessageDisplayed () {
|
||||
return this.screenService.isBroadcastMessageDisplayed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Routes } from '@angular/router'
|
||||
import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
|
||||
import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
|
||||
import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
|
||||
import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
|
||||
import { AboutComponent } from './about.component'
|
||||
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
|
||||
import { DynamicElementService } from '@app/shared/shared-custom-markup/dynamic-element.service'
|
||||
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '',
|
||||
component: AboutComponent,
|
||||
providers: [
|
||||
AboutInstanceResolver,
|
||||
InstanceFollowService,
|
||||
CustomMarkupService,
|
||||
DynamicElementService
|
||||
],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'instance',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'instance',
|
||||
component: AboutInstanceComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`About this instance`
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
instanceData: AboutInstanceResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'contact',
|
||||
component: AboutInstanceComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Contact`
|
||||
},
|
||||
isContact: true
|
||||
},
|
||||
resolve: {
|
||||
instanceData: AboutInstanceResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'peertube',
|
||||
component: AboutPeertubeComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`About PeerTube`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'follows',
|
||||
component: AboutFollowsComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`About this instance's network`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
] satisfies Routes
|
||||
@@ -0,0 +1,56 @@
|
||||
<h1 class="visually-hidden" i18n>Video channels</h1>
|
||||
|
||||
<div class="margin-content">
|
||||
|
||||
<div class="no-results" i18n *ngIf="channelPagination.totalItems === 0">This account does not have channels.</div>
|
||||
|
||||
<div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onChannelDataSubject.asObservable()">
|
||||
<div class="channel" *ngFor="let videoChannel of videoChannels">
|
||||
|
||||
<div class="channel-avatar-row">
|
||||
<my-actor-avatar
|
||||
[actor]="videoChannel" actorType="channel"
|
||||
[internalHref]="getVideoChannelLink(videoChannel)"
|
||||
i18n-title
|
||||
title="See this video channel"
|
||||
size="75"
|
||||
></my-actor-avatar>
|
||||
|
||||
<h2 class="fs-5 lh-1 fw-bold m-0">
|
||||
<a [routerLink]="getVideoChannelLink(videoChannel)" i18n-title title="See this video channel">
|
||||
{{ videoChannel.displayName }}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<div class="actor-counters">
|
||||
<div class="followers" i18n>{videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
|
||||
|
||||
<span class="videos-count" *ngIf="getTotalVideosOf(videoChannel) !== undefined" i18n>
|
||||
{getTotalVideosOf(videoChannel), plural, =0 {No videos} =1 {1 video} other {{{ getTotalVideosOf(videoChannel) }} videos}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="description-html" [innerHTML]="getChannelDescription(videoChannel)"></div>
|
||||
</div>
|
||||
|
||||
<my-subscribe-button [videoChannels]="[videoChannel]"></my-subscribe-button>
|
||||
|
||||
<a i18n class="button-show-channel peertube-button-link orange-button-inverted" [routerLink]="getVideoChannelLink(videoChannel)">Show this channel</a>
|
||||
|
||||
<div class="videos-overflow-workaround">
|
||||
<div class="videos">
|
||||
<div class="no-results h-auto" i18n *ngIf="getTotalVideosOf(videoChannel) === 0">This channel doesn't have any videos.</div>
|
||||
|
||||
<my-video-miniature
|
||||
*ngFor="let video of getVideosOf(videoChannel)"
|
||||
[video]="video" [user]="userMiniature" [displayVideoActions]="true" [displayOptions]="miniatureDisplayOptions"
|
||||
></my-video-miniature>
|
||||
|
||||
<div *ngIf="getTotalVideosOf(videoChannel)" class="miniature-show-channel">
|
||||
<a i18n [routerLink]="getVideoChannelLink(videoChannel)">SHOW THIS CHANNEL ></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,157 @@
|
||||
@use 'sass:math';
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_miniature' as *;
|
||||
|
||||
.margin-content {
|
||||
@include grid-videos-miniature-margins;
|
||||
}
|
||||
|
||||
.channel {
|
||||
@include padding(1.75rem);
|
||||
@include margin(2rem, 0);
|
||||
|
||||
max-width: $max-channels-width;
|
||||
background-color: pvar(--channelBackgroundColor);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 15px;
|
||||
}
|
||||
|
||||
.channel-avatar-row {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
|
||||
my-actor-avatar {
|
||||
@include margin-right(15px);
|
||||
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
a {
|
||||
@include peertube-word-wrap;
|
||||
|
||||
color: pvar(--mainForegroundColor);
|
||||
}
|
||||
|
||||
h2 {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.actor-counters {
|
||||
@include margin-left(15px);
|
||||
@include actor-counters;
|
||||
|
||||
grid-row: 1;
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.description-html {
|
||||
@include fade-text(50px, pvar(--channelBackgroundColor));
|
||||
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 2;
|
||||
|
||||
max-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
my-subscribe-button {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.videos {
|
||||
display: flex;
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
|
||||
position: relative;
|
||||
|
||||
my-video-miniature {
|
||||
@include margin-right(15px);
|
||||
|
||||
min-width: $video-thumbnail-medium-width;
|
||||
max-width: $video-thumbnail-medium-width;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.videos-overflow-workaround {
|
||||
@include margin-top(2rem);
|
||||
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.miniature-show-channel {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, transparent 0, pvar(--channelBackgroundColor) 45px);
|
||||
padding: (math.div($video-thumbnail-medium-height, 2) - 10px) 15px 0 60px;
|
||||
z-index: z(miniature) + 1;
|
||||
|
||||
a {
|
||||
color: pvar(--mainColor);
|
||||
font-weight: $font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
.button-show-channel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include on-small-main-col {
|
||||
.channel-avatar-row {
|
||||
grid-template-columns: auto auto auto 1fr;
|
||||
|
||||
.avatar-link {
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
|
||||
.actor-counters {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
grid-row: 2;
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
|
||||
.description-html {
|
||||
grid-row: 3;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.show-channel a {
|
||||
@include peertube-button-link;
|
||||
@include orange-button-inverted;
|
||||
}
|
||||
|
||||
.videos {
|
||||
display: none;
|
||||
}
|
||||
|
||||
my-subscribe-button,
|
||||
.button-show-channel {
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 3;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
my-subscribe-button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.button-show-channel {
|
||||
display: block;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { from, Subject, Subscription } from 'rxjs'
|
||||
import { concatMap, map, switchMap, tap } from 'rxjs/operators'
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { ComponentPagination, hasMoreItems, MarkdownService, User, UserService } from '@app/core'
|
||||
import { SimpleMemoize } from '@app/helpers'
|
||||
import { NSFWPolicyType, VideoSortField } from '@peertube/peertube-models'
|
||||
import { MiniatureDisplayOptions, VideoMiniatureComponent } from '../../shared/shared-video-miniature/video-miniature.component'
|
||||
import { SubscribeButtonComponent } from '../../shared/shared-user-subscription/subscribe-button.component'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { ActorAvatarComponent } from '../../shared/shared-actor-image/actor-avatar.component'
|
||||
import { InfiniteScrollerDirective } from '../../shared/shared-main/angular/infinite-scroller.directive'
|
||||
import { NgIf, NgFor } from '@angular/common'
|
||||
import { AccountService } from '@app/shared/shared-main/account/account.service'
|
||||
import { VideoChannelService } from '@app/shared/shared-main/video-channel/video-channel.service'
|
||||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
|
||||
import { Account } from '@app/shared/shared-main/account/account.model'
|
||||
import { Video } from '@app/shared/shared-main/video/video.model'
|
||||
|
||||
@Component({
|
||||
selector: 'my-account-video-channels',
|
||||
templateUrl: './account-video-channels.component.html',
|
||||
styleUrls: [ './account-video-channels.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [ NgIf, InfiniteScrollerDirective, NgFor, ActorAvatarComponent, RouterLink, SubscribeButtonComponent, VideoMiniatureComponent ]
|
||||
})
|
||||
export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
|
||||
account: Account
|
||||
videoChannels: VideoChannel[] = []
|
||||
|
||||
videos: { [id: number]: { total: number, videos: Video[] } } = {}
|
||||
|
||||
channelsDescriptionHTML: { [ id: number ]: string } = {}
|
||||
|
||||
channelPagination: ComponentPagination = {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 2,
|
||||
totalItems: null
|
||||
}
|
||||
|
||||
videosPagination: ComponentPagination = {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 5,
|
||||
totalItems: null
|
||||
}
|
||||
videosSort: VideoSortField = '-publishedAt'
|
||||
|
||||
onChannelDataSubject = new Subject<any>()
|
||||
|
||||
userMiniature: User
|
||||
nsfwPolicy: NSFWPolicyType
|
||||
miniatureDisplayOptions: MiniatureDisplayOptions = {
|
||||
date: true,
|
||||
views: true,
|
||||
by: false,
|
||||
avatar: false,
|
||||
privacyLabel: false,
|
||||
privacyText: false,
|
||||
state: false,
|
||||
blacklistInfo: false
|
||||
}
|
||||
|
||||
private accountSub: Subscription
|
||||
|
||||
constructor (
|
||||
private accountService: AccountService,
|
||||
private videoChannelService: VideoChannelService,
|
||||
private videoService: VideoService,
|
||||
private markdown: MarkdownService,
|
||||
private userService: UserService
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
// Parent get the account for us
|
||||
this.accountSub = this.accountService.accountLoaded
|
||||
.subscribe(account => {
|
||||
this.account = account
|
||||
this.videoChannels = []
|
||||
|
||||
this.loadMoreChannels()
|
||||
})
|
||||
|
||||
this.userService.getAnonymousOrLoggedUser()
|
||||
.subscribe(user => {
|
||||
this.userMiniature = user
|
||||
|
||||
this.nsfwPolicy = user.nsfwPolicy
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.accountSub) this.accountSub.unsubscribe()
|
||||
}
|
||||
|
||||
loadMoreChannels () {
|
||||
const options = {
|
||||
account: this.account,
|
||||
componentPagination: this.channelPagination,
|
||||
sort: '-updatedAt'
|
||||
}
|
||||
|
||||
this.videoChannelService.listAccountVideoChannels(options)
|
||||
.pipe(
|
||||
tap(res => {
|
||||
this.channelPagination.totalItems = res.total
|
||||
}),
|
||||
switchMap(res => from(res.data)),
|
||||
concatMap(videoChannel => {
|
||||
const options = {
|
||||
videoChannel,
|
||||
videoPagination: this.videosPagination,
|
||||
sort: this.videosSort,
|
||||
nsfw: this.videoService.nsfwPolicyToParam(this.nsfwPolicy)
|
||||
}
|
||||
|
||||
return this.videoService.getVideoChannelVideos(options)
|
||||
.pipe(map(data => ({ videoChannel, videos: data.data, total: data.total })))
|
||||
})
|
||||
)
|
||||
.subscribe(async ({ videoChannel, videos, total }) => {
|
||||
this.channelsDescriptionHTML[videoChannel.id] = await this.markdown.textMarkdownToHTML({
|
||||
markdown: videoChannel.description,
|
||||
withEmoji: true,
|
||||
withHtml: true
|
||||
})
|
||||
|
||||
this.videoChannels.push(videoChannel)
|
||||
|
||||
this.videos[videoChannel.id] = { videos, total }
|
||||
|
||||
this.onChannelDataSubject.next([ videoChannel ])
|
||||
})
|
||||
}
|
||||
|
||||
getVideosOf (videoChannel: VideoChannel) {
|
||||
const obj = this.videos[videoChannel.id]
|
||||
if (!obj) return []
|
||||
|
||||
return obj.videos
|
||||
}
|
||||
|
||||
getTotalVideosOf (videoChannel: VideoChannel) {
|
||||
const obj = this.videos[videoChannel.id]
|
||||
if (!obj) return undefined
|
||||
|
||||
return obj.total
|
||||
}
|
||||
|
||||
getChannelDescription (videoChannel: VideoChannel) {
|
||||
return this.channelsDescriptionHTML[videoChannel.id]
|
||||
}
|
||||
|
||||
onNearOfBottom () {
|
||||
if (!hasMoreItems(this.channelPagination)) return
|
||||
|
||||
this.channelPagination.currentPage += 1
|
||||
|
||||
this.loadMoreChannels()
|
||||
}
|
||||
|
||||
@SimpleMemoize()
|
||||
getVideoChannelLink (videoChannel: VideoChannel) {
|
||||
return [ '/c', videoChannel.nameWithHost ]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<my-videos-list
|
||||
#videosList
|
||||
|
||||
*ngIf="account"
|
||||
|
||||
[title]="title"
|
||||
displayTitle="false"
|
||||
|
||||
[getVideosObservableFunction]="getVideosObservableFunction"
|
||||
[getSyndicationItemsFunction]="getSyndicationItemsFunction"
|
||||
|
||||
[defaultSort]="defaultSort"
|
||||
|
||||
displayFilters="true"
|
||||
displayModerationBlock="true"
|
||||
[displayAsRow]="displayAsRow()"
|
||||
|
||||
hideScopeFilter="true"
|
||||
|
||||
loadUserVideoPreferences="true"
|
||||
|
||||
highlightLives="true"
|
||||
|
||||
[disabled]="disabled"
|
||||
>
|
||||
</my-videos-list>
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NgIf } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
|
||||
import { Account } from '@app/shared/shared-main/account/account.model'
|
||||
import { AccountService } from '@app/shared/shared-main/account/account.service'
|
||||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
import { VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model'
|
||||
import { VideoSortField } from '@peertube/peertube-models'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { VideosListComponent } from '../../shared/shared-video-miniature/videos-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-account-videos',
|
||||
templateUrl: './account-videos.component.html',
|
||||
standalone: true,
|
||||
imports: [ NgIf, VideosListComponent ]
|
||||
})
|
||||
export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
|
||||
@ViewChild('videosList') videosList: VideosListComponent
|
||||
|
||||
getVideosObservableFunction = this.getVideosObservable.bind(this)
|
||||
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
|
||||
|
||||
title = $localize`Videos`
|
||||
defaultSort = '-publishedAt' as VideoSortField
|
||||
|
||||
account: Account
|
||||
disabled = false
|
||||
|
||||
private alreadyLoaded = false
|
||||
|
||||
private accountSub: Subscription
|
||||
|
||||
constructor (
|
||||
private screenService: ScreenService,
|
||||
private accountService: AccountService,
|
||||
private videoService: VideoService
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
// Parent get the account for us
|
||||
this.accountSub = this.accountService.accountLoaded
|
||||
.subscribe(account => {
|
||||
this.account = account
|
||||
if (this.alreadyLoaded) this.videosList.reloadVideos()
|
||||
|
||||
this.alreadyLoaded = true
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.accountSub) this.accountSub.unsubscribe()
|
||||
}
|
||||
|
||||
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
||||
const options = {
|
||||
...filters.toVideosAPIObject(),
|
||||
|
||||
videoPagination: pagination,
|
||||
account: this.account,
|
||||
skipCount: true
|
||||
}
|
||||
|
||||
return this.videoService.getAccountVideos(options)
|
||||
}
|
||||
|
||||
getSyndicationItems () {
|
||||
return this.videoService.getAccountFeedUrls(this.account.id)
|
||||
}
|
||||
|
||||
displayAsRow () {
|
||||
return this.screenService.isInMobileView()
|
||||
}
|
||||
|
||||
disableForReuse () {
|
||||
this.disabled = true
|
||||
}
|
||||
|
||||
enabledForReuse () {
|
||||
this.disabled = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<div *ngIf="account" class="root">
|
||||
<div class="account-info d-md-grid d-block">
|
||||
|
||||
<div class="account-avatar-row">
|
||||
<my-actor-avatar [size]="getAccountAvatarSize()" actorType="account" [actor]="account"></my-actor-avatar>
|
||||
|
||||
<div>
|
||||
<div class="section-label" i18n>ACCOUNT</div>
|
||||
|
||||
<div class="actor-info">
|
||||
<div>
|
||||
<div class="actor-display-name align-items-center">
|
||||
<h1 i18n-title [title]="'Created on ' + (account.createdAt | date)">{{ account.displayName }}</h1>
|
||||
|
||||
<my-user-moderation-dropdown
|
||||
class="mx-3" [prependActions]="prependModerationActions"
|
||||
buttonSize="small" [account]="account" [user]="accountUser" placement="bottom-left auto"
|
||||
(userChanged)="onUserChanged()" (userDeleted)="onUserDeleted()"
|
||||
></my-user-moderation-dropdown>
|
||||
|
||||
<span *ngIf="accountUser?.blocked" tabindex="0" [ngbTooltip]="accountUser.blockedReason" class="pt-badge badge-danger" i18n>Banned</span>
|
||||
|
||||
<my-account-block-badges [account]="account"></my-account-block-badges>
|
||||
</div>
|
||||
|
||||
<div class="actor-handle">
|
||||
<span>@{{ account.nameWithHost }}</span>
|
||||
|
||||
<my-copy-button
|
||||
[value]="account.nameWithHostForced" i18n-notification notification="Username copied"
|
||||
title="Copy account handle" i18n-title
|
||||
></my-copy-button>
|
||||
</div>
|
||||
|
||||
<div class="actor-counters">
|
||||
<span i18n>{naiveAggregatedSubscribers(), plural, =0 {No subscribers} =1 {1 subscriber} other {{{ naiveAggregatedSubscribers() }} subscribers}}</span>
|
||||
|
||||
<span class="videos-count" *ngIf="accountVideosCount !== undefined" i18n>
|
||||
{accountVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ accountVideosCount }} videos}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="description" [ngClass]="{ expanded: accountDescriptionExpanded }">
|
||||
<div class="description-html" [innerHTML]="accountDescriptionHTML"></div>
|
||||
</div>
|
||||
|
||||
<button *ngIf="hasShowMoreDescription()" class="show-more d-md-none d-block button-unstyle"
|
||||
(click)="accountDescriptionExpanded = !accountDescriptionExpanded"
|
||||
title="Show the complete description" i18n-title i18n
|
||||
>
|
||||
Show more...
|
||||
</button>
|
||||
|
||||
<div class="buttons">
|
||||
<a *ngIf="isManageable()" routerLink="/my-account" class="peertube-button-link orange-button" i18n>
|
||||
Manage account
|
||||
</a>
|
||||
|
||||
<my-subscribe-button *ngIf="hasVideoChannels() && !isManageable()" [account]="account" [videoChannels]="videoChannels"></my-subscribe-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links" [ngClass]="{ 'on-channel-page': isOnChannelPage() }">
|
||||
<ng-template #linkTemplate let-item="item">
|
||||
<a [routerLink]="item.routerLink" routerLinkActive="active" class="sub-menu-entry">{{ item.label }}</a>
|
||||
</ng-template>
|
||||
|
||||
<my-list-overflow [hidden]="hideMenu" [items]="links" [itemTemplate]="linkTemplate"></my-list-overflow>
|
||||
|
||||
<my-simple-search-input
|
||||
[alwaysShow]="!isInSmallView()" (searchChanged)="searchChanged($event)"
|
||||
(inputDisplayChanged)="onSearchInputDisplayChanged($event)" name="search-videos"
|
||||
i18n-iconTitle icon-title="Search account videos"
|
||||
i18n-placeholder placeholder="Search account videos"
|
||||
></my-simple-search-input>
|
||||
</div>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="prependModerationActions">
|
||||
<my-account-report #accountReportModal></my-account-report>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,114 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_account-channel-page' as *;
|
||||
@use '_miniature' as *;
|
||||
|
||||
.root {
|
||||
--myFontSize: 1rem;
|
||||
--myGreyFontSize: 1rem;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
@include section-label-responsive;
|
||||
}
|
||||
|
||||
.links {
|
||||
@include grid-videos-miniature-margins;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&.on-channel-page {
|
||||
max-width: $max-channels-width;
|
||||
}
|
||||
|
||||
simple-search-input {
|
||||
@include margin-left(auto);
|
||||
}
|
||||
}
|
||||
|
||||
my-copy-button {
|
||||
@include margin-left(3px);
|
||||
}
|
||||
|
||||
.account-info {
|
||||
@include grid-videos-miniature-margins(false, 15px);
|
||||
@include padding-top(3.75rem);
|
||||
@include padding-bottom(3.75rem);
|
||||
@include margin-bottom(3rem);
|
||||
@include font-size(1rem);
|
||||
|
||||
grid-template-columns: 1fr min-content;
|
||||
grid-template-rows: auto auto;
|
||||
|
||||
background-color: pvar(--submenuBackgroundColor);
|
||||
}
|
||||
|
||||
.account-avatar-row {
|
||||
@include avatar-row-responsive(2rem, var(--myGreyFontSize));
|
||||
}
|
||||
|
||||
.actor-display-name {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-column: 1 / 3;
|
||||
max-width: 1000px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
@include show-more-description;
|
||||
@include padding-bottom(3.75rem);
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-content: flex-start;
|
||||
|
||||
>*:not(:last-child) {
|
||||
@include margin-bottom(1rem);
|
||||
}
|
||||
|
||||
>a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.pt-badge {
|
||||
@include margin-right(5px);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-view) {
|
||||
|
||||
.description:not(.expanded) {
|
||||
@include fade-text(30px, pvar(--submenuBackgroundColor));
|
||||
|
||||
max-height: 70px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $mobile-view) {
|
||||
.root {
|
||||
--myFontSize: 14px;
|
||||
--myGreyFontSize: 13px;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin: auto !important;
|
||||
width: min-content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { DatePipe, NgClass, NgIf } from '@angular/common'
|
||||
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'
|
||||
import { AuthService, MarkdownService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
|
||||
import { Account } from '@app/shared/shared-main/account/account.model'
|
||||
import { AccountService } from '@app/shared/shared-main/account/account.service'
|
||||
import { DropdownAction } from '@app/shared/shared-main/buttons/action-dropdown.component'
|
||||
import { VideoChannel } from '@app/shared/shared-main/video-channel/video-channel.model'
|
||||
import { VideoChannelService } from '@app/shared/shared-main/video-channel/video-channel.service'
|
||||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
|
||||
import { AccountReportComponent } from '@app/shared/shared-moderation/report-modals'
|
||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { HttpStatusCode, User, UserRight } from '@peertube/peertube-models'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
|
||||
import { ActorAvatarComponent } from '../shared/shared-actor-image/actor-avatar.component'
|
||||
import { CopyButtonComponent } from '../shared/shared-main/buttons/copy-button.component'
|
||||
import { ListOverflowComponent, ListOverflowItem } from '../shared/shared-main/misc/list-overflow.component'
|
||||
import { SimpleSearchInputComponent } from '../shared/shared-main/misc/simple-search-input.component'
|
||||
import { AccountBlockBadgesComponent } from '../shared/shared-moderation/account-block-badges.component'
|
||||
import { UserModerationDropdownComponent } from '../shared/shared-moderation/user-moderation-dropdown.component'
|
||||
import { SubscribeButtonComponent } from '../shared/shared-user-subscription/subscribe-button.component'
|
||||
|
||||
@Component({
|
||||
templateUrl: './accounts.component.html',
|
||||
styleUrls: [ './accounts.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgIf,
|
||||
ActorAvatarComponent,
|
||||
UserModerationDropdownComponent,
|
||||
NgbTooltip,
|
||||
AccountBlockBadgesComponent,
|
||||
CopyButtonComponent,
|
||||
NgClass,
|
||||
RouterLink,
|
||||
SubscribeButtonComponent,
|
||||
RouterLinkActive,
|
||||
ListOverflowComponent,
|
||||
SimpleSearchInputComponent,
|
||||
RouterOutlet,
|
||||
AccountReportComponent,
|
||||
DatePipe
|
||||
]
|
||||
})
|
||||
export class AccountsComponent implements OnInit, OnDestroy {
|
||||
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent
|
||||
|
||||
account: Account
|
||||
accountUser: User
|
||||
|
||||
videoChannels: VideoChannel[] = []
|
||||
|
||||
links: ListOverflowItem[] = []
|
||||
hideMenu = false
|
||||
|
||||
accountVideosCount: number
|
||||
accountDescriptionHTML = ''
|
||||
accountDescriptionExpanded = false
|
||||
|
||||
prependModerationActions: DropdownAction<any>[] = []
|
||||
|
||||
private routeSub: Subscription
|
||||
|
||||
constructor (
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private userService: UserService,
|
||||
private accountService: AccountService,
|
||||
private videoChannelService: VideoChannelService,
|
||||
private notifier: Notifier,
|
||||
private restExtractor: RestExtractor,
|
||||
private redirectService: RedirectService,
|
||||
private authService: AuthService,
|
||||
private videoService: VideoService,
|
||||
private markdown: MarkdownService,
|
||||
private blocklist: BlocklistService,
|
||||
private screenService: ScreenService
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.routeSub = this.route.params
|
||||
.pipe(
|
||||
map(params => params['accountId']),
|
||||
distinctUntilChanged(),
|
||||
switchMap(accountId => this.accountService.getAccount(accountId)),
|
||||
tap(account => this.onAccount(account)),
|
||||
switchMap(account => this.videoChannelService.listAccountVideoChannels({ account })),
|
||||
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, 'other', [
|
||||
HttpStatusCode.BAD_REQUEST_400,
|
||||
HttpStatusCode.NOT_FOUND_404
|
||||
]))
|
||||
)
|
||||
.subscribe({
|
||||
next: videoChannels => {
|
||||
this.videoChannels = videoChannels.data
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
|
||||
this.links = [
|
||||
{ label: $localize`CHANNELS`, routerLink: 'video-channels' },
|
||||
{ label: $localize`VIDEOS`, routerLink: 'videos' }
|
||||
]
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.routeSub) this.routeSub.unsubscribe()
|
||||
}
|
||||
|
||||
naiveAggregatedSubscribers () {
|
||||
return this.videoChannels.reduce(
|
||||
(acc, val) => acc + val.followersCount,
|
||||
this.account.followersCount // accumulator starts with the base number of subscribers the account has
|
||||
)
|
||||
}
|
||||
|
||||
isUserLoggedIn () {
|
||||
return this.authService.isLoggedIn()
|
||||
}
|
||||
|
||||
isInSmallView () {
|
||||
return this.screenService.isInSmallView()
|
||||
}
|
||||
|
||||
getAccountAvatarSize () {
|
||||
if (this.isInSmallView()) return 80
|
||||
|
||||
return 120
|
||||
}
|
||||
|
||||
isManageable () {
|
||||
if (!this.isUserLoggedIn()) return false
|
||||
|
||||
return this.account?.userId === this.authService.getUser().id
|
||||
}
|
||||
|
||||
onUserChanged () {
|
||||
this.loadUserIfNeeded(this.account)
|
||||
}
|
||||
|
||||
onUserDeleted () {
|
||||
this.redirectService.redirectToHomepage()
|
||||
}
|
||||
|
||||
searchChanged (search: string) {
|
||||
const queryParams = { search }
|
||||
|
||||
this.router.navigate([ './videos' ], { queryParams, relativeTo: this.route, queryParamsHandling: 'merge' })
|
||||
}
|
||||
|
||||
onSearchInputDisplayChanged (displayed: boolean) {
|
||||
this.hideMenu = this.isInSmallView() && displayed
|
||||
}
|
||||
|
||||
hasVideoChannels () {
|
||||
return this.videoChannels.length !== 0
|
||||
}
|
||||
|
||||
hasShowMoreDescription () {
|
||||
return !this.accountDescriptionExpanded && this.accountDescriptionHTML.length > 100
|
||||
}
|
||||
|
||||
isOnChannelPage () {
|
||||
return this.route.children[0].snapshot.url[0].path === 'video-channels'
|
||||
}
|
||||
|
||||
private async onAccount (account: Account) {
|
||||
this.accountDescriptionHTML = await this.markdown.textMarkdownToHTML({
|
||||
markdown: account.description,
|
||||
withEmoji: true,
|
||||
withHtml: true
|
||||
})
|
||||
|
||||
// After the markdown renderer to avoid layout changes
|
||||
this.account = account
|
||||
|
||||
this.updateModerationActions()
|
||||
this.loadUserIfNeeded(account)
|
||||
this.loadAccountVideosCount()
|
||||
this.loadAccountBlockStatus()
|
||||
}
|
||||
|
||||
private showReportModal () {
|
||||
this.accountReportModal.show(this.account)
|
||||
}
|
||||
|
||||
private loadUserIfNeeded (account: Account) {
|
||||
if (!account.userId || !this.authService.isLoggedIn()) return
|
||||
|
||||
const user = this.authService.getUser()
|
||||
if (user.hasRight(UserRight.MANAGE_USERS)) {
|
||||
this.userService.getUser(account.userId)
|
||||
.subscribe({
|
||||
next: accountUser => {
|
||||
this.accountUser = accountUser
|
||||
},
|
||||
|
||||
error: err => this.notifier.error(err.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private updateModerationActions () {
|
||||
this.prependModerationActions = []
|
||||
|
||||
if (!this.authService.isLoggedIn()) return
|
||||
if (this.isManageable()) return
|
||||
|
||||
// It's not our account, we can report it
|
||||
this.prependModerationActions = [
|
||||
{
|
||||
label: $localize`Report`,
|
||||
isHeader: true
|
||||
},
|
||||
{
|
||||
label: $localize`Report this account`,
|
||||
handler: () => this.showReportModal()
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
private loadAccountVideosCount () {
|
||||
this.videoService.getAccountVideos({
|
||||
account: this.account,
|
||||
videoPagination: {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 0
|
||||
},
|
||||
sort: '-publishedAt'
|
||||
}).subscribe(res => {
|
||||
this.accountVideosCount = res.total
|
||||
})
|
||||
}
|
||||
|
||||
private loadAccountBlockStatus () {
|
||||
this.blocklist.getStatus({ accounts: [ this.account.nameWithHostForced ], hosts: [ this.account.host ] })
|
||||
.subscribe(status => this.account.updateBlockStatus(status))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Routes } from '@angular/router'
|
||||
import { AbuseService } from '@app/shared/shared-moderation/abuse.service'
|
||||
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
|
||||
import { BulkService } from '@app/shared/shared-moderation/bulk.service'
|
||||
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
|
||||
import { UserSubscriptionService } from '@app/shared/shared-user-subscription/user-subscription.service'
|
||||
import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
|
||||
import { VideoPlaylistService } from '@app/shared/shared-video-playlist/video-playlist.service'
|
||||
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
|
||||
import { AccountVideosComponent } from './account-videos/account-videos.component'
|
||||
import { AccountsComponent } from './accounts.component'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: 'peertube',
|
||||
redirectTo: '/videos/local'
|
||||
},
|
||||
{
|
||||
path: ':accountId',
|
||||
component: AccountsComponent,
|
||||
providers: [
|
||||
UserSubscriptionService,
|
||||
BlocklistService,
|
||||
VideoPlaylistService,
|
||||
VideoBlockService,
|
||||
AbuseService,
|
||||
UserAdminService,
|
||||
BulkService
|
||||
],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'video-channels',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'video-channels',
|
||||
component: AccountVideoChannelsComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Account video channels`
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'videos',
|
||||
component: AccountVideosComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Account videos`
|
||||
},
|
||||
reuse: {
|
||||
enabled: true,
|
||||
key: 'account-videos-list'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Old URL redirection
|
||||
{
|
||||
path: 'search',
|
||||
redirectTo: 'videos'
|
||||
}
|
||||
]
|
||||
}
|
||||
] satisfies Routes
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="root">
|
||||
<my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown>
|
||||
|
||||
<div class="margin-content" [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
my-top-menu-dropdown {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.root {
|
||||
@include sub-menu-h1;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { NgClass } from '@angular/common'
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { RouterOutlet } from '@angular/router'
|
||||
import { AuthService, ScreenService, ServerService } from '@app/core'
|
||||
import { ListOverflowItem } from '@app/shared/shared-main/misc/list-overflow.component'
|
||||
import { TopMenuDropdownParam } from '@app/shared/shared-main/misc/top-menu-dropdown.component'
|
||||
import { UserRight, UserRightType } from '@peertube/peertube-models'
|
||||
import { TopMenuDropdownComponent } from '../shared/shared-main/misc/top-menu-dropdown.component'
|
||||
|
||||
@Component({
|
||||
templateUrl: './admin.component.html',
|
||||
styleUrls: [ './admin.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [ TopMenuDropdownComponent, NgClass, RouterOutlet ]
|
||||
})
|
||||
export class AdminComponent implements OnInit {
|
||||
items: ListOverflowItem[] = []
|
||||
menuEntries: TopMenuDropdownParam[] = []
|
||||
|
||||
constructor (
|
||||
private auth: AuthService,
|
||||
private screen: ScreenService,
|
||||
private server: ServerService
|
||||
) { }
|
||||
|
||||
get isBroadcastMessageDisplayed () {
|
||||
return this.screen.isBroadcastMessageDisplayed
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.server.configReloaded.subscribe(() => this.buildMenu())
|
||||
|
||||
this.buildMenu()
|
||||
}
|
||||
|
||||
private buildMenu () {
|
||||
this.menuEntries = []
|
||||
|
||||
this.buildOverviewItems()
|
||||
this.buildFederationItems()
|
||||
this.buildModerationItems()
|
||||
this.buildConfigurationItems()
|
||||
this.buildPluginItems()
|
||||
this.buildSystemItems()
|
||||
}
|
||||
|
||||
private buildOverviewItems () {
|
||||
const overviewItems: TopMenuDropdownParam = {
|
||||
label: $localize`Overview`,
|
||||
children: []
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_USERS)) {
|
||||
overviewItems.children.push({
|
||||
label: $localize`Users`,
|
||||
routerLink: '/admin/users',
|
||||
iconName: 'user'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.SEE_ALL_VIDEOS)) {
|
||||
overviewItems.children.push({
|
||||
label: $localize`Videos`,
|
||||
routerLink: '/admin/videos',
|
||||
queryParams: {
|
||||
search: 'isLocal:true'
|
||||
},
|
||||
iconName: 'videos'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.SEE_ALL_COMMENTS)) {
|
||||
overviewItems.children.push({
|
||||
label: $localize`Comments`,
|
||||
routerLink: '/admin/comments',
|
||||
iconName: 'message-circle'
|
||||
})
|
||||
}
|
||||
|
||||
if (overviewItems.children.length !== 0) {
|
||||
this.menuEntries.push(overviewItems)
|
||||
}
|
||||
}
|
||||
|
||||
private buildFederationItems () {
|
||||
if (!this.hasRight(UserRight.MANAGE_SERVER_FOLLOW)) return
|
||||
|
||||
this.menuEntries.push({
|
||||
label: $localize`Federation`,
|
||||
children: [
|
||||
{
|
||||
label: $localize`Following`,
|
||||
routerLink: '/admin/follows/following-list',
|
||||
iconName: 'following'
|
||||
},
|
||||
{
|
||||
label: $localize`Followers`,
|
||||
routerLink: '/admin/follows/followers-list',
|
||||
iconName: 'follower'
|
||||
},
|
||||
{
|
||||
label: $localize`Video redundancies`,
|
||||
routerLink: '/admin/follows/video-redundancies-list',
|
||||
iconName: 'videos'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
private buildModerationItems () {
|
||||
const moderationItems: TopMenuDropdownParam = {
|
||||
label: $localize`Moderation`,
|
||||
children: []
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_REGISTRATIONS)) {
|
||||
moderationItems.children.push({
|
||||
label: $localize`Registrations`,
|
||||
routerLink: '/admin/moderation/registrations/list',
|
||||
iconName: 'user'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_ABUSES)) {
|
||||
moderationItems.children.push({
|
||||
label: $localize`Reports`,
|
||||
routerLink: '/admin/moderation/abuses/list',
|
||||
iconName: 'flag'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) {
|
||||
moderationItems.children.push({
|
||||
label: $localize`Video blocks`,
|
||||
routerLink: '/admin/moderation/video-blocks/list',
|
||||
iconName: 'cross'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) {
|
||||
moderationItems.children.push({
|
||||
label: $localize`Muted accounts`,
|
||||
routerLink: '/admin/moderation/blocklist/accounts',
|
||||
iconName: 'user-x'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) {
|
||||
moderationItems.children.push({
|
||||
label: $localize`Muted servers`,
|
||||
routerLink: '/admin/moderation/blocklist/servers',
|
||||
iconName: 'peertube-x'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS)) {
|
||||
moderationItems.children.push({
|
||||
label: $localize`Watched words`,
|
||||
routerLink: '/admin/moderation/watched-words/list',
|
||||
iconName: 'eye-open'
|
||||
})
|
||||
}
|
||||
|
||||
if (moderationItems.children.length !== 0) this.menuEntries.push(moderationItems)
|
||||
}
|
||||
|
||||
private buildConfigurationItems () {
|
||||
if (this.hasRight(UserRight.MANAGE_CONFIGURATION)) {
|
||||
this.menuEntries.push({ label: $localize`Configuration`, routerLink: '/admin/config' })
|
||||
}
|
||||
}
|
||||
|
||||
private buildPluginItems () {
|
||||
if (this.hasRight(UserRight.MANAGE_PLUGINS)) {
|
||||
this.menuEntries.push({ label: $localize`Plugins/Themes`, routerLink: '/admin/plugins' })
|
||||
}
|
||||
}
|
||||
|
||||
private buildSystemItems () {
|
||||
const systemItems: TopMenuDropdownParam = {
|
||||
label: $localize`System`,
|
||||
children: []
|
||||
}
|
||||
|
||||
if (this.isRemoteRunnersEnabled() && this.hasRight(UserRight.MANAGE_RUNNERS)) {
|
||||
systemItems.children.push({
|
||||
label: $localize`Remote runners`,
|
||||
iconName: 'codesandbox',
|
||||
routerLink: '/admin/system/runners/runners-list'
|
||||
})
|
||||
|
||||
systemItems.children.push({
|
||||
label: $localize`Runner jobs`,
|
||||
iconName: 'globe',
|
||||
routerLink: '/admin/system/runners/jobs-list'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_JOBS)) {
|
||||
systemItems.children.push({
|
||||
label: $localize`Local jobs`,
|
||||
iconName: 'circle-tick',
|
||||
routerLink: '/admin/system/jobs'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_LOGS)) {
|
||||
systemItems.children.push({
|
||||
label: $localize`Logs`,
|
||||
iconName: 'playlists',
|
||||
routerLink: '/admin/system/logs'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.hasRight(UserRight.MANAGE_DEBUG)) {
|
||||
systemItems.children.push({
|
||||
label: $localize`Debug`,
|
||||
iconName: 'cog',
|
||||
routerLink: '/admin/system/debug'
|
||||
})
|
||||
}
|
||||
|
||||
if (systemItems.children.length !== 0) {
|
||||
this.menuEntries.push(systemItems)
|
||||
}
|
||||
}
|
||||
|
||||
private hasRight (right: UserRightType) {
|
||||
return this.auth.getUser().hasRight(right)
|
||||
}
|
||||
|
||||
private isRemoteRunnersEnabled () {
|
||||
const config = this.server.getHTMLConfig()
|
||||
|
||||
return config.transcoding.remoteRunners.enabled ||
|
||||
config.live.transcoding.remoteRunners.enabled ||
|
||||
config.videoStudio.remoteRunners.enabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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'
|
||||
|
||||
export const ConfigRoutes: Routes = [
|
||||
{
|
||||
path: 'config',
|
||||
canActivate: [ UserRightGuard ],
|
||||
data: {
|
||||
userRight: UserRight.MANAGE_CONFIGURATION
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'edit-custom',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'edit-custom',
|
||||
component: EditCustomConfigComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Edit custom configuration`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<ng-container [formGroup]="form">
|
||||
|
||||
<div class="pt-two-cols mt-5"> <!-- cache grid -->
|
||||
|
||||
<div class="title-col">
|
||||
<h2 i18n>CACHE</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Some files are not federated, and fetched when necessary. Define their caching policies.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
<ng-container formGroupName="cache">
|
||||
<div class="form-group" formGroupName="previews">
|
||||
<label i18n for="cachePreviewsSize">Number of previews to keep in cache</label>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input
|
||||
type="number" min="0" id="cachePreviewsSize" class="form-control"
|
||||
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.previews.size'] }"
|
||||
>
|
||||
<span i18n>{getCacheSize('previews'), plural, =1 {cached image} other {cached images}}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.cache.previews.size" class="form-error" role="alert">{{ formErrors.cache.previews.size }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="captions">
|
||||
<label i18n for="cacheCaptionsSize">Number of video captions to keep in cache</label>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input
|
||||
type="number" min="0" id="cacheCaptionsSize" class="form-control"
|
||||
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.captions.size'] }"
|
||||
>
|
||||
<span i18n>{getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.cache.captions.size" class="form-error" role="alert">{{ formErrors.cache.captions.size }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="torrents">
|
||||
<label i18n for="cacheTorrentsSize">Number of video torrents to keep in cache</label>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input
|
||||
type="number" min="0" id="cacheTorrentsSize" class="form-control"
|
||||
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.torrents.size'] }"
|
||||
>
|
||||
<span i18n>{getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.cache.torrents.size" class="form-error" role="alert">{{ formErrors.cache.torrents.size }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="torrents">
|
||||
<label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input
|
||||
type="number" min="0" id="cacheStoryboardsSize" class="form-control"
|
||||
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.storyboards.size'] }"
|
||||
>
|
||||
<span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.cache.storyboards.size" class="form-error" role="alert">{{ formErrors.cache.storyboards.size }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- cache grid -->
|
||||
<div class="title-col">
|
||||
<div class="anchor" id="customizations"></div> <!-- customizations anchor -->
|
||||
<h2 i18n>CUSTOMIZATIONS</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Slight modifications to your PeerTube instance for when creating a plugin or theme is overkill.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
<ng-container formGroupName="instance">
|
||||
<ng-container formGroupName="customizations">
|
||||
<div class="form-group">
|
||||
<label i18n for="customizationJavascript">JavaScript</label>
|
||||
<my-help>
|
||||
<ng-template ptTemplate="customHtml">
|
||||
<ng-container i18n>
|
||||
<p class="mb-2">Write JavaScript code directly. Example:</p>
|
||||
<pre>console.log('my instance is amazing');</pre>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</my-help>
|
||||
|
||||
<textarea
|
||||
id="customizationJavascript" formControlName="javascript" class="form-control" dir="ltr"
|
||||
[ngClass]="{ 'input-error': formErrors['instance.customizations.javascript'] }"
|
||||
></textarea>
|
||||
|
||||
<div *ngIf="formErrors.instance.customizations.javascript" class="form-error" role="alert">{{ formErrors.instance.customizations.javascript }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="customizationCSS">CSS</label>
|
||||
|
||||
<my-help>
|
||||
<ng-template ptTemplate="customHtml">
|
||||
<ng-container i18n>
|
||||
<p class="mb-2">Write CSS code directly. Example:</p>
|
||||
<pre>
|
||||
#custom-css {{ '{' }}
|
||||
color: red;
|
||||
{{ '}' }}
|
||||
</pre>
|
||||
<p class="mb-2">Prepend with <em>#custom-css</em> to override styles. Example:</p>
|
||||
<pre>
|
||||
#custom-css .logged-in-email {{ '{' }}
|
||||
color: red;
|
||||
{{ '}' }}
|
||||
</pre>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</my-help>
|
||||
|
||||
<textarea
|
||||
id="customizationCSS" formControlName="css" class="form-control" dir="ltr"
|
||||
[ngClass]="{ 'input-error': formErrors['instance.customizations.css'] }"
|
||||
></textarea>
|
||||
<div *ngIf="formErrors.instance.customizations.css" class="form-error" role="alert">{{ formErrors.instance.customizations.css }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
|
||||
import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
|
||||
import { NgClass, NgIf } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'my-edit-advanced-configuration',
|
||||
templateUrl: './edit-advanced-configuration.component.html',
|
||||
styleUrls: [ './edit-custom-config.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [ FormsModule, ReactiveFormsModule, NgClass, NgIf, HelpComponent, PeerTubeTemplateDirective ]
|
||||
})
|
||||
export class EditAdvancedConfigurationComponent {
|
||||
@Input() form: FormGroup
|
||||
@Input() formErrors: any
|
||||
|
||||
getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
|
||||
return this.form.value['cache'][type]['size']
|
||||
}
|
||||
}
|
||||
+735
@@ -0,0 +1,735 @@
|
||||
<ng-container [formGroup]="form">
|
||||
<div class="pt-two-cols mt-5"> <!-- appearance grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>APPEARANCE</h2>
|
||||
|
||||
<div i18n class="inner-form-description">
|
||||
Use <a class="link-orange" routerLink="/admin/plugins">plugins & themes</a> for more involved changes, or add slight <a class="link-orange" routerLink="/admin/config/edit-custom" fragment="advanced-configuration">customizations</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="theme">
|
||||
<div class="form-group">
|
||||
<label i18n for="themeDefault">Theme</label>
|
||||
|
||||
<div class="peertube-select-container">
|
||||
<select formControlName="default" id="themeDefault" class="form-control">
|
||||
<option i18n value="default">{{ getDefaultThemeLabel() }}</option>
|
||||
|
||||
<option *ngFor="let theme of availableThemes" [value]="theme.id">{{ theme.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="form-group" formGroupName="instance">
|
||||
<label i18n for="instanceDefaultClientRoute">Landing page</label>
|
||||
|
||||
<my-select-custom-value
|
||||
id="instanceDefaultClientRoute"
|
||||
[items]="defaultLandingPageOptions"
|
||||
formControlName="defaultClientRoute"
|
||||
inputType="text"
|
||||
[clearable]="false"
|
||||
></my-select-custom-value>
|
||||
|
||||
<div *ngIf="formErrors.instance.defaultClientRoute" class="form-error" role="alert">{{ formErrors.instance.defaultClientRoute }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="trending">
|
||||
<ng-container formGroupName="videos">
|
||||
<ng-container formGroupName="algorithms">
|
||||
<label i18n for="trendingVideosAlgorithmsDefault">Default trending page</label>
|
||||
|
||||
<div class="peertube-select-container">
|
||||
<select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
|
||||
<option i18n value="hot">Hot videos</option>
|
||||
<option i18n value="most-viewed">Recent views</option>
|
||||
<option i18n value="most-liked">Most liked videos</option>
|
||||
<option i18n value="views">Global views</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.trending.videos.algorithms.default" class="form-error" role="alert">{{ formErrors.trending.videos.algorithms.default }}</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<ng-container formGroupName="client">
|
||||
|
||||
<ng-container formGroupName="videos">
|
||||
<ng-container formGroupName="miniature">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="clientVideosMiniaturePreferAuthorDisplayName" formControlName="preferAuthorDisplayName"
|
||||
i18n-labelText labelText="Prefer author display name in video miniature"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="menu">
|
||||
<ng-container formGroupName="login">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="clientMenuLoginRedirectOnSingleExternalAuth" formControlName="redirectOnSingleExternalAuth"
|
||||
i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span *ngIf="countExternalAuth() === 0" i18n>⚠️ You don't have any external auth plugin enabled.</span>
|
||||
<span *ngIf="countExternalAuth() > 1" i18n>⚠️ You have multiple external auth plugins enabled.</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- broadcast grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>BROADCAST MESSAGE</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Display a message on your instance
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="broadcastMessage">
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="broadcastMessageEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable broadcast message"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="broadcastMessageDismissable" formControlName="dismissable"
|
||||
i18n-labelText labelText="Allow users to dismiss the broadcast message "
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="broadcastMessageLevel">Broadcast message level</label>
|
||||
|
||||
<div class="peertube-select-container">
|
||||
<select id="broadcastMessageLevel" formControlName="level" class="form-control">
|
||||
<option i18n value="info">info</option>
|
||||
<option i18n value="warning">warning</option>
|
||||
<option i18n value="error">error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.broadcastMessage.level" class="form-error" role="alert">{{ formErrors.broadcastMessage.level }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="broadcastMessageMessage">Message</label><my-help helpType="markdownText"></my-help>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="broadcastMessageMessage" formControlName="message"
|
||||
[formError]="formErrors['broadcastMessage.message']" markdownType="to-unsafe-html"
|
||||
></my-markdown-textarea>
|
||||
|
||||
<div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- new users grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>NEW USERS</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Manage <a class="link-orange" routerLink="/admin/users">users</a> to set their quota individually.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="signup">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="signupEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable Signup"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
|
||||
|
||||
<div class="alert pt-alert-primary alert-signup" *ngIf="signupAlertMessage">{{ signupAlertMessage }}</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngProjectAs="extra">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
|
||||
inputName="signupRequiresApproval" formControlName="requiresApproval"
|
||||
i18n-labelText labelText="Signup requires approval by moderators"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
|
||||
inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
|
||||
i18n-labelText labelText="Signup requires email verification"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div [ngClass]="getDisabledSignupClass()">
|
||||
<label i18n for="signupLimit">Signup limit</label>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input
|
||||
type="number" min="-1" id="signupLimit" class="form-control"
|
||||
formControlName="limit" [ngClass]="{ 'input-error': formErrors['signup.limit'] }"
|
||||
>
|
||||
<span i18n>{form.value['signup']['limit'], plural, =1 {user} other {users}}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.signup.limit" class="form-error" role="alert">{{ formErrors.signup.limit }}</div>
|
||||
|
||||
<small i18n *ngIf="hasUnlimitedSignup()" class="muted small">Signup won't be limited to a fixed number of users.</small>
|
||||
</div>
|
||||
|
||||
<div [ngClass]="getDisabledSignupClass()" class="mt-3">
|
||||
<label i18n for="signupMinimumAge">Minimum required age to create an account</label>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input
|
||||
type="number" min="1" id="signupMinimumAge" class="form-control"
|
||||
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors['signup.minimumAge'] }"
|
||||
>
|
||||
<span i18n>{form.value['signup']['minimumAge'], plural, =1 {year old} other {years old}}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.signup.minimumAge" class="form-error" role="alert">{{ formErrors.signup.minimumAge }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="user">
|
||||
<div class="form-group">
|
||||
<label i18n for="userVideoQuota">Default video quota per user</label>
|
||||
|
||||
<my-select-custom-value
|
||||
id="userVideoQuota"
|
||||
[items]="getVideoQuotaOptions()"
|
||||
formControlName="videoQuota"
|
||||
i18n-inputSuffix inputSuffix="bytes" inputType="number"
|
||||
[clearable]="false"
|
||||
></my-select-custom-value>
|
||||
|
||||
<my-user-real-quota-info class="mt-2 d-block small muted" [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
|
||||
|
||||
<div *ngIf="formErrors.user.videoQuota" class="form-error" role="alert">{{ formErrors.user.videoQuota }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="userVideoQuotaDaily">Default daily upload limit per user</label>
|
||||
|
||||
<my-select-custom-value
|
||||
id="userVideoQuotaDaily"
|
||||
[items]="getVideoQuotaDailyOptions()"
|
||||
formControlName="videoQuotaDaily"
|
||||
i18n-inputSuffix inputSuffix="bytes" inputType="number"
|
||||
[clearable]="false"
|
||||
></my-select-custom-value>
|
||||
|
||||
<div *ngIf="formErrors.user.videoQuotaDaily" class="form-error" role="alert">{{ formErrors.user.videoQuotaDaily }}</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<ng-container formGroupName="history">
|
||||
<ng-container formGroupName="videos">
|
||||
<my-peertube-checkbox
|
||||
inputName="videosHistoryEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Automatically enable video history for new users"
|
||||
>
|
||||
</my-peertube-checkbox>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- videos grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>VIDEOS</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="import">
|
||||
|
||||
<ng-container formGroupName="videos">
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="importConcurrency">Import jobs concurrency</label>
|
||||
<span i18n class="small muted ms-1">allows to import multiple videos in parallel. ⚠️ Requires a PeerTube restart.</span>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input type="number" name="importConcurrency" formControlName="concurrency" />
|
||||
<span i18n>jobs in parallel</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.import.concurrency" class="form-error" role="alert">{{ formErrors.import.concurrency }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="http">
|
||||
<my-peertube-checkbox
|
||||
inputName="importVideosHttpEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Allow import with HTTP URL (e.g. YouTube)"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>⚠️ If enabled, we recommend to use <a class="link-orange" href="https://docs.joinpeertube.org/maintain/configuration#security">a HTTP proxy</a> to prevent private URL access from your PeerTube server</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="torrent">
|
||||
<my-peertube-checkbox
|
||||
inputName="importVideosTorrentEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Allow import with a torrent file or a magnet URI"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>⚠️ We don't recommend to enable this feature if you don't trust your users</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="videoChannelSynchronization">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="importSynchronizationEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Allow channel synchronization with channel of other platforms like YouTube"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n [hidden]="isImportVideosHttpEnabled()">
|
||||
⛔ You need to allow import with HTTP URL to be able to activate this feature.
|
||||
</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="autoBlacklist">
|
||||
<ng-container formGroupName="videos">
|
||||
<ng-container formGroupName="ofUsers">
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="autoBlacklistVideosOfUsersEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Block new videos automatically"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>Unless a user is marked as trusted, their videos will stay private until a moderator reviews them.</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="videoFile">
|
||||
<ng-container formGroupName="update">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="videoFileUpdateEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Allow users to upload a new version of their video"
|
||||
>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="storyboards">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="storyboardsEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable video storyboards"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>Generate storyboards of local videos using ffmpeg so users can see the video preview in the player while scrubbing the video</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="videoTranscription">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="videoTranscriptionEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable video transcription"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>Automatically create a subtitle file of uploaded/imported VOD videos</span>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngProjectAs="extra">
|
||||
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getTranscriptionRunnerDisabledClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="videoTranscriptionRemoteRunnersEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable remote runners for transcription"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>
|
||||
Use <a routerLink="/admin/system/runners/runners-list">remote runners</a> to process transcription tasks.
|
||||
Remote runners has to register on your instance first.
|
||||
</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- video channels grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>VIDEO CHANNELS</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
<div class="form-group" formGroupName="videoChannels">
|
||||
<label i18n for="videoChannelsMaxPerUser">Max video channels per user</label>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input
|
||||
type="number" min="1" id="videoChannelsMaxPerUser" class="form-control"
|
||||
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['videoChannels.maxPerUser'] }"
|
||||
>
|
||||
<span i18n>{form.value['videoChannels']['maxPerUser'], plural, =1 {channel} other {channels}}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.videoChannels.maxPerUser" class="form-error" role="alert">{{ formErrors.videoChannels.maxPerUser }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- search grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>SEARCH</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="search">
|
||||
<ng-container formGroupName="remoteUri">
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="searchRemoteUriUsers" formControlName="users"
|
||||
i18n-labelText labelText="Allow users to do remote URI/handle search"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your instance</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="searchRemoteUriAnonymous" formControlName="anonymous"
|
||||
i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your instance</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="searchIndex">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="searchIndexEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable global search"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<div i18n>⚠️ This functionality depends heavily on the moderation of instances followed by the search index you select.</div>
|
||||
|
||||
<div i18n>
|
||||
You should only use moderated search indexes in production, or <a class="link-orange" href="https://framagit.org/framasoft/peertube/search-index">host your own</a>.
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngProjectAs="extra">
|
||||
<div [ngClass]="getDisabledSearchIndexClass()">
|
||||
<label i18n for="searchIndexUrl">Search index URL</label>
|
||||
|
||||
<input
|
||||
type="text" id="searchIndexUrl" class="form-control"
|
||||
formControlName="url" [ngClass]="{ 'input-error': formErrors['search.searchIndex.url'] }"
|
||||
>
|
||||
|
||||
<div *ngIf="formErrors.search.searchIndex.url" class="form-error" role="alert">{{ formErrors.search.searchIndex.url }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()"
|
||||
inputName="searchIndexDisableLocalSearch" formControlName="disableLocalSearch"
|
||||
i18n-labelText labelText="Disable local search in search bar"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<my-peertube-checkbox [ngClass]="getDisabledSearchIndexClass()"
|
||||
inputName="searchIndexIsDefaultSearch" formControlName="isDefaultSearch"
|
||||
i18n-labelText labelText="Search bar uses the global search index by default"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>Otherwise the local search stays used by default</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- import/export grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>USER IMPORT/EXPORT</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="import">
|
||||
<ng-container formGroupName="users">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="importUsersEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Allow your users to import a data archive"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<div i18n>Video quota is checked on import so the user doesn't upload a too big archive file</div>
|
||||
<div i18n>Video quota (daily quota is not taken into account) is also checked for each video when PeerTube is processing the import</div>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="export">
|
||||
|
||||
<ng-container formGroupName="users">
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="exportUsersEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Allow your users to export their data"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>Users can export their PeerTube data in a .zip for backup or re-import. Only one export at a time is allowed per user</span>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngProjectAs="extra">
|
||||
|
||||
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
|
||||
<label i18n for="exportUsersMaxUserVideoQuota">Max user video quota allowed to generate the export</label>
|
||||
|
||||
<span i18n class="ms-2 small muted">If the user decides to include the video files in the archive</span>
|
||||
|
||||
<my-select-custom-value
|
||||
id="exportUsersMaxUserVideoQuota"
|
||||
[items]="exportMaxUserVideoQuotaOptions"
|
||||
formControlName="maxUserVideoQuota"
|
||||
i18n-inputSuffix inputSuffix="bytes" inputType="number"
|
||||
[clearable]="false"
|
||||
></my-select-custom-value>
|
||||
|
||||
<div *ngIf="formErrors.export.users.maxUserVideoQuota" class="form-error" role="alert">{{ formErrors.export.users.maxUserVideoQuota }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
|
||||
<label i18n for="exportUsersExportExpiration">User export expiration</label>
|
||||
|
||||
<my-select-options
|
||||
labelForId="exportUsersExportExpiration" [items]="exportExpirationOptions" formControlName="exportExpiration"
|
||||
bindLabel="label" bindValue="value" [clearable]="false" [searchable]="false"
|
||||
></my-select-options>
|
||||
|
||||
<div i18n class="mt-1 small muted">The archive file is deleted after this period.</div>
|
||||
|
||||
<div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- federation grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>FEDERATION</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Manage <a class="link-orange" routerLink="/admin/follows">relations</a> with other instances.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="followers">
|
||||
<ng-container formGroupName="instance">
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="followersInstanceEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Other instances can follow yours"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="followersInstanceManualApproval" formControlName="manualApproval"
|
||||
i18n-labelText labelText="Manually approve new instance followers"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="followings">
|
||||
<ng-container formGroupName="instance">
|
||||
|
||||
<ng-container formGroupName="autoFollowBack">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Automatically follow back instances"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="autoFollowIndex">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Automatically follow instances of a public index"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
|
||||
|
||||
<span i18n>
|
||||
See <a class="link-orange" href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances" rel="noopener noreferrer" target="_blank">the documentation</a> for more information about the expected URL
|
||||
</span>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngProjectAs="extra">
|
||||
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
|
||||
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
|
||||
<input
|
||||
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
|
||||
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors['followings.instance.autoFollowIndex.indexUrl'] }"
|
||||
>
|
||||
<div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- administrators grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>ADMINISTRATORS</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<div class="form-group" formGroupName="admin">
|
||||
<label i18n for="adminEmail">Admin email</label>
|
||||
|
||||
<input
|
||||
type="text" id="adminEmail" class="form-control"
|
||||
formControlName="email" [ngClass]="{ 'input-error': formErrors['admin.email'] }"
|
||||
>
|
||||
|
||||
<div *ngIf="formErrors.admin.email" class="form-error" role="alert">{{ formErrors.admin.email }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="contactForm">
|
||||
<my-peertube-checkbox
|
||||
inputName="enableContactForm" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable contact form"
|
||||
></my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- Twitter grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>TWITTER/X</h2>
|
||||
|
||||
<div i18n class="inner-form-description">
|
||||
Extra configuration required by Twitter/X. All other social media (Facebook, Mastodon, etc.) are supported out of the box.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="services">
|
||||
<ng-container formGroupName="twitter">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="servicesTwitterUsername" i18n>Your Twitter/X username</label>
|
||||
|
||||
<div class="label-small-info">
|
||||
<p i18n class="mb-0">Indicates the Twitter/X account for the website or platform where the content was published.</p>
|
||||
|
||||
<p i18n>This is just an extra information injected in PeerTube HTML that is required by Twitter/X. If you don't have a Twitter/X account, just leave the default value.</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text" id="servicesTwitterUsername" class="form-control"
|
||||
formControlName="username" [ngClass]="{ 'input-error': formErrors['services.twitter.username'] }"
|
||||
>
|
||||
|
||||
<div *ngIf="formErrors.services.twitter.username" class="form-error" role="alert">{{ formErrors.services.twitter.username }}</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,209 @@
|
||||
import { pairwise } from 'rxjs/operators'
|
||||
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
|
||||
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { MenuService, ThemeService } from '@app/core'
|
||||
import { HTMLServerConfig } from '@peertube/peertube-models'
|
||||
import { ConfigService } from '../shared/config.service'
|
||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
|
||||
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
|
||||
import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component'
|
||||
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
|
||||
import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
|
||||
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
|
||||
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
|
||||
import { NgFor, NgIf, NgClass } from '@angular/common'
|
||||
import { RouterLink } from '@angular/router'
|
||||
|
||||
@Component({
|
||||
selector: 'my-edit-basic-configuration',
|
||||
templateUrl: './edit-basic-configuration.component.html',
|
||||
styleUrls: [ './edit-custom-config.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
NgFor,
|
||||
SelectCustomValueComponent,
|
||||
NgIf,
|
||||
PeertubeCheckboxComponent,
|
||||
HelpComponent,
|
||||
MarkdownTextareaComponent,
|
||||
NgClass,
|
||||
UserRealQuotaInfoComponent,
|
||||
SelectOptionsComponent,
|
||||
PeerTubeTemplateDirective
|
||||
]
|
||||
})
|
||||
export class EditBasicConfigurationComponent implements OnInit, OnChanges {
|
||||
@Input() form: FormGroup
|
||||
@Input() formErrors: any
|
||||
|
||||
@Input() serverConfig: HTMLServerConfig
|
||||
|
||||
signupAlertMessage: string
|
||||
defaultLandingPageOptions: SelectOptionsItem[] = []
|
||||
availableThemes: SelectOptionsItem[]
|
||||
|
||||
exportExpirationOptions: SelectOptionsItem[] = []
|
||||
exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = []
|
||||
|
||||
constructor (
|
||||
private configService: ConfigService,
|
||||
private menuService: MenuService,
|
||||
private themeService: ThemeService
|
||||
) {}
|
||||
|
||||
ngOnInit () {
|
||||
this.buildLandingPageOptions()
|
||||
this.checkSignupField()
|
||||
this.checkImportSyncField()
|
||||
|
||||
this.availableThemes = 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 () {
|
||||
this.defaultLandingPageOptions = this.menuService.buildCommonLinks(this.serverConfig)
|
||||
.links
|
||||
.map(o => ({
|
||||
id: o.path,
|
||||
label: o.label,
|
||||
description: o.path
|
||||
}))
|
||||
}
|
||||
|
||||
getDefaultThemeLabel () {
|
||||
return this.themeService.getDefaultThemeLabel()
|
||||
}
|
||||
|
||||
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) {
|
||||
/* eslint-disable max-len */
|
||||
this.signupAlertMessage = $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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 {
|
||||
|
||||
getVODResolutions () {
|
||||
return [
|
||||
{
|
||||
id: '0p',
|
||||
label: $localize`Audio-only`,
|
||||
description: $localize`A <code>.mp4</code> that keeps the original audio track, with no video`
|
||||
},
|
||||
{
|
||||
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`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getLiveResolutions () {
|
||||
return this.getVODResolutions().filter(r => r.id !== '0p')
|
||||
}
|
||||
|
||||
isTranscodingEnabled (form: FormGroup) {
|
||||
return form.value['transcoding']['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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<h1 class="visually-hidden" i18n>Configuration</h1>
|
||||
|
||||
<div class="alert alert-warning" *ngIf="!isUpdateAllowed()" i18n>
|
||||
Updating instance configuration from the web interface is disabled by the system administrator.
|
||||
</div>
|
||||
|
||||
<form role="form" [formGroup]="form">
|
||||
|
||||
<div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
|
||||
|
||||
<ng-container ngbNavItem="instance-homepage">
|
||||
<a ngbNavLink i18n>Homepage</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem="instance-information">
|
||||
<a ngbNavLink i18n>Information</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
|
||||
</my-edit-instance-information>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem="basic-configuration">
|
||||
<a ngbNavLink i18n>Basic</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
|
||||
</my-edit-basic-configuration>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem="transcoding">
|
||||
<a ngbNavLink i18n>VOD Transcoding</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-vod-transcoding [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
|
||||
</my-edit-vod-transcoding>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem="live">
|
||||
<a ngbNavLink i18n>Live streaming</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-live-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
|
||||
</my-edit-live-configuration>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngbNavItem="advanced-configuration">
|
||||
<a ngbNavLink i18n>Advanced</a>
|
||||
|
||||
<ng-template ngbNavContent>
|
||||
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">
|
||||
</my-edit-advanced-configuration>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
|
||||
<div class="row mt-4"> <!-- submit placement block -->
|
||||
<div class="col-md-7 col-xl-5"></div>
|
||||
<div class="col-md-5 col-xl-5">
|
||||
|
||||
<div role="alert" class="form-error submit-error" i18n *ngIf="!form.valid && isUpdateAllowed()">
|
||||
There are errors in the form:
|
||||
|
||||
<ul>
|
||||
<li *ngFor="let error of grabAllErrors()">
|
||||
{{ error }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<span role="alert" class="form-error submit-error" i18n *ngIf="!hasLiveAllowReplayConsistentOptions()">
|
||||
You cannot allow live replay if you don't enable transcoding.
|
||||
</span>
|
||||
|
||||
<span i18n *ngIf="!isUpdateAllowed()">
|
||||
You cannot change the server configuration because it's managed externally.
|
||||
</span>
|
||||
|
||||
<input
|
||||
(click)="formValidated()" type="submit" i18n-value value="Update configuration"
|
||||
[disabled]="!form.valid || !hasConsistentOptions() || !isUpdateAllowed()"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,154 @@
|
||||
@use '_variables' as *;
|
||||
@use '_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);
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
.number-with-unit {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
|
||||
input[type=number] + span {
|
||||
position: absolute;
|
||||
top: 0.2em;
|
||||
right: 2.5rem;
|
||||
|
||||
@media screen and (max-width: $mobile-view) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
input[disabled] {
|
||||
background-color: #f9f9f9;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
@include peertube-checkbox(1px);
|
||||
}
|
||||
|
||||
.peertube-select-container {
|
||||
@include peertube-select-container($form-base-input-width);
|
||||
}
|
||||
|
||||
my-select-options,
|
||||
my-select-custom-value,
|
||||
my-select-checkbox {
|
||||
@include responsive-width($form-base-input-width);
|
||||
|
||||
display: block;
|
||||
}
|
||||
|
||||
input[type=submit] {
|
||||
@include peertube-button;
|
||||
@include orange-button;
|
||||
@include margin-left(auto);
|
||||
|
||||
display: flex;
|
||||
|
||||
+ .form-error {
|
||||
@include margin-left(5px);
|
||||
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-form-description {
|
||||
font-size: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@include peertube-textarea(500px, 150px);
|
||||
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
|
||||
&.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;
|
||||
|
||||
.callout-link {
|
||||
@include peertube-button-link;
|
||||
|
||||
position: relative;
|
||||
right: 3.3em;
|
||||
top: .3em;
|
||||
font-size: 90%;
|
||||
color: pvar(--mainColor);
|
||||
background-color: pvar(--mainBackgroundColor);
|
||||
padding: 0 .3em;
|
||||
}
|
||||
}
|
||||
|
||||
my-actor-banner-edit {
|
||||
max-width: $form-max-width;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import omit from 'lodash-es/omit'
|
||||
import { forkJoin } from 'rxjs'
|
||||
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
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 {
|
||||
ADMIN_EMAIL_VALIDATOR,
|
||||
CACHE_SIZE_VALIDATOR,
|
||||
CONCURRENCY_VALIDATOR,
|
||||
EXPORT_EXPIRATION_VALIDATOR,
|
||||
EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
|
||||
INDEX_URL_VALIDATOR,
|
||||
INSTANCE_NAME_VALIDATOR,
|
||||
INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
|
||||
MAX_INSTANCE_LIVES_VALIDATOR,
|
||||
MAX_LIVE_DURATION_VALIDATOR,
|
||||
MAX_USER_LIVES_VALIDATOR,
|
||||
MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
|
||||
SEARCH_INDEX_URL_VALIDATOR,
|
||||
SERVICES_TWITTER_USERNAME_VALIDATOR,
|
||||
SIGNUP_LIMIT_VALIDATOR,
|
||||
SIGNUP_MINIMUM_AGE_VALIDATOR,
|
||||
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 { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models'
|
||||
import { EditConfigurationService } from './edit-configuration.service'
|
||||
import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component'
|
||||
import { EditLiveConfigurationComponent } from './edit-live-configuration.component'
|
||||
import { EditVODTranscodingComponent } from './edit-vod-transcoding.component'
|
||||
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
|
||||
import { EditInstanceInformationComponent } from './edit-instance-information.component'
|
||||
import { EditHomepageComponent } from './edit-homepage.component'
|
||||
import { NgbNav, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavContent, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgIf, NgFor } from '@angular/common'
|
||||
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
|
||||
|
||||
type ComponentCustomConfig = CustomConfig & {
|
||||
instanceCustomHomepage: CustomPage
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'my-edit-custom-config',
|
||||
templateUrl: './edit-custom-config.component.html',
|
||||
styleUrls: [ './edit-custom-config.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgIf,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbNav,
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
NgbNavLinkBase,
|
||||
NgbNavContent,
|
||||
EditHomepageComponent,
|
||||
EditInstanceInformationComponent,
|
||||
EditBasicConfigurationComponent,
|
||||
EditVODTranscodingComponent,
|
||||
EditLiveConfigurationComponent,
|
||||
EditAdvancedConfigurationComponent,
|
||||
NgbNavOutlet,
|
||||
NgFor
|
||||
]
|
||||
})
|
||||
export class EditCustomConfigComponent extends FormReactive implements OnInit {
|
||||
activeNav: string
|
||||
|
||||
customConfig: ComponentCustomConfig
|
||||
serverConfig: HTMLServerConfig
|
||||
|
||||
homepage: CustomPage
|
||||
|
||||
languageItems: SelectOptionsItem[] = []
|
||||
categoryItems: SelectOptionsItem[] = []
|
||||
|
||||
constructor (
|
||||
protected formReactiveService: FormReactiveService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private notifier: Notifier,
|
||||
private configService: ConfigService,
|
||||
private customPage: CustomPageService,
|
||||
private serverService: ServerService,
|
||||
private editConfigurationService: EditConfigurationService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
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
|
||||
},
|
||||
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
|
||||
},
|
||||
webVideos: {
|
||||
enabled: null
|
||||
},
|
||||
remoteRunners: {
|
||||
enabled: null
|
||||
}
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
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: INDEX_URL_VALIDATOR
|
||||
}
|
||||
}
|
||||
},
|
||||
broadcastMessage: {
|
||||
enabled: null,
|
||||
level: null,
|
||||
dismissable: null,
|
||||
message: null
|
||||
},
|
||||
search: {
|
||||
remoteUri: {
|
||||
users: null,
|
||||
anonymous: null
|
||||
},
|
||||
searchIndex: {
|
||||
enabled: null,
|
||||
url: SEARCH_INDEX_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.getVODResolutions()) {
|
||||
defaultValues.transcoding.resolutions[resolution.id] = 'false'
|
||||
formGroupData.transcoding.resolutions[resolution.id] = null
|
||||
}
|
||||
|
||||
for (const resolution of this.editConfigurationService.getLiveResolutions()) {
|
||||
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 = 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 (errorObjectArg?: any) {
|
||||
const errorObject = errorObjectArg || this.formErrors
|
||||
|
||||
let acc: string[] = []
|
||||
|
||||
for (const key of Object.keys(errorObject)) {
|
||||
const value = errorObject[key]
|
||||
if (!value) continue
|
||||
|
||||
if (typeof value === 'string') {
|
||||
acc.push(value)
|
||||
} else {
|
||||
acc = acc.concat(this.grabAllErrors(value))
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<ng-container [formGroup]="form">
|
||||
|
||||
<ng-container formGroupName="instanceCustomHomepage">
|
||||
|
||||
<div class="homepage pt-two-cols mt-5"> <!-- homepage grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>INSTANCE HOMEPAGE</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceCustomHomepageContent">Homepage</label>
|
||||
<div class="label-small-info">
|
||||
<my-custom-markup-help></my-custom-markup-help>
|
||||
</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceCustomHomepageContent" formControlName="content"
|
||||
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
|
||||
[formError]="formErrors['instanceCustomHomepage.content']"
|
||||
dir="ltr"
|
||||
></my-markdown-textarea>
|
||||
|
||||
<div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error" role="alert">{{ formErrors.instanceCustomHomepage.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Component, 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' ],
|
||||
standalone: true,
|
||||
imports: [ FormsModule, ReactiveFormsModule, CustomMarkupHelpComponent, MarkdownTextareaComponent, NgIf ]
|
||||
})
|
||||
export class EditHomepageComponent {
|
||||
@Input() form: FormGroup
|
||||
@Input() formErrors: any
|
||||
|
||||
customMarkdownRenderer: (text: string) => Promise<HTMLElement>
|
||||
|
||||
constructor (private customMarkup: CustomMarkupService) {
|
||||
|
||||
}
|
||||
|
||||
getCustomMarkdownRenderer () {
|
||||
return this.customMarkup.getCustomMarkdownRenderer()
|
||||
}
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
<ng-container [formGroup]="form">
|
||||
|
||||
<ng-container formGroupName="instance">
|
||||
|
||||
<div class="pt-two-cols mt-5"> <!-- instance grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>INSTANCE</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
<div class="form-group">
|
||||
<label i18n for="avatarfile">Square icon</label>
|
||||
|
||||
<div class="label-small-info">
|
||||
<p i18n class="mb-0">Square icon can be used on your custom homepage.</p>
|
||||
</div>
|
||||
|
||||
<my-actor-avatar-edit
|
||||
class="d-block mb-4"
|
||||
actorType="account" previewImage="false" [username]="instanceName" displayUsername="false"
|
||||
[avatars]="instanceAvatars" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
|
||||
></my-actor-avatar-edit>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="bannerfile">Banner</label>
|
||||
|
||||
<div class="label-small-info">
|
||||
<p i18n class="mb-0">Banner is displayed in the about, login and registration pages and be used on your custom homepage.</p>
|
||||
<p i18n>It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances">JoinPeerTube.org</a>.</p>
|
||||
</div>
|
||||
|
||||
<my-actor-banner-edit
|
||||
[previewImage]="false" class="d-block mb-4"
|
||||
[bannerUrl]="instanceBannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
|
||||
></my-actor-banner-edit>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceName">Name</label>
|
||||
|
||||
<input
|
||||
type="text" id="instanceName" class="form-control"
|
||||
formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }"
|
||||
>
|
||||
|
||||
<div *ngIf="formErrors.instance.name" class="form-error" role="alert">{{ formErrors.instance.name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceShortDescription">Short description</label>
|
||||
|
||||
<textarea
|
||||
id="instanceShortDescription" formControlName="shortDescription" class="form-control small"
|
||||
[ngClass]="{ 'input-error': formErrors['instance.shortDescription'] }"
|
||||
></textarea>
|
||||
|
||||
<div *ngIf="formErrors.instance.shortDescription" class="form-error" role="alert">{{ formErrors.instance.shortDescription }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceDescription">Description</label>
|
||||
<div class="label-small-info">
|
||||
<my-custom-markup-help></my-custom-markup-help>
|
||||
</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceDescription" formControlName="description"
|
||||
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
|
||||
[formError]="formErrors['instance.description']"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceCategories">Main instance categories</label>
|
||||
|
||||
<div>
|
||||
<my-select-checkbox
|
||||
id="instanceCategories"
|
||||
formControlName="categories" [availableItems]="categoryItems"
|
||||
[selectableGroup]="false"
|
||||
i18n-placeholder placeholder="Add a new category"
|
||||
>
|
||||
</my-select-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceLanguages">Main languages you/your moderators speak</label>
|
||||
|
||||
<div>
|
||||
<my-select-checkbox
|
||||
id="instanceLanguages"
|
||||
formControlName="languages" [availableItems]="languageItems"
|
||||
[selectableGroup]="false"
|
||||
i18n-placeholder placeholder="Add a new language"
|
||||
>
|
||||
</my-select-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- moderation & nsfw grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>MODERATION & NSFW</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Manage <a class="link-orange" routerLink="/admin/users">users</a> to build a moderation team.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox inputName="instanceIsNSFW" formControlName="isNSFW">
|
||||
<ng-template ptTemplate="label">
|
||||
<ng-container i18n>This instance is dedicated to sensitive or NSFW content</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template ptTemplate="help">
|
||||
<ng-container i18n>
|
||||
Enabling it will allow other administrators to know that you are mainly federating sensitive content.<br />
|
||||
Moreover, the NSFW checkbox on video upload will be automatically checked by default.
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceDefaultNSFWPolicy">Policy on videos containing sensitive content</label>
|
||||
|
||||
<my-help>
|
||||
<ng-template ptTemplate="customHtml">
|
||||
<ng-container i18n>
|
||||
With <strong>Hide</strong> or <strong>Blur thumbnails</strong>, a confirmation will be requested to watch the video.
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</my-help>
|
||||
|
||||
<div class="peertube-select-container">
|
||||
<select id="instanceDefaultNSFWPolicy" formControlName="defaultNSFWPolicy" class="form-control">
|
||||
<option i18n value="do_not_list">Hide</option>
|
||||
<option i18n value="blur">Blur thumbnails</option>
|
||||
<option i18n value="display">Display</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors.instance.defaultNSFWPolicy }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceTerms">Terms</label><my-help helpType="markdownText"></my-help>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceTerms" formControlName="terms" markdownType="enhanced"
|
||||
[formError]="formErrors['instance.terms']"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceCodeOfConduct">Code of conduct</label><my-help helpType="markdownText"></my-help>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceCodeOfConduct" formControlName="codeOfConduct" markdownType="enhanced"
|
||||
[formError]="formErrors['instance.codeOfConduct']"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help>
|
||||
<div i18n class="label-small-info">Who moderates the instance? What is the policy regarding NSFW videos? Political videos? etc</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced"
|
||||
[formError]="formErrors['instance.moderationInformation']"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- you and your instance grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>YOU AND YOUR INSTANCE</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceAdministrator">Who is behind the instance?</label><my-help helpType="markdownText"></my-help>
|
||||
<div i18n class="label-small-info">A single person? A non-profit? A company?</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceAdministrator" formControlName="administrator" markdownType="enhanced"
|
||||
[formError]="formErrors['instance.administrator']"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceCreationReason">Why did you create this instance?</label><my-help helpType="markdownText"></my-help>
|
||||
<div i18n class="label-small-info">To share your personal videos? To open registrations and allow people to upload what they want?</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceCreationReason" formControlName="creationReason" markdownType="enhanced"
|
||||
[formError]="formErrors['instance.creationReason']"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label><my-help helpType="markdownText"></my-help>
|
||||
<div i18n class="label-small-info">It's important to know for users who want to register on your instance</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" markdownType="enhanced"
|
||||
[formError]="formErrors['instance.maintenanceLifetime']"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label><my-help helpType="markdownText"></my-help>
|
||||
<div i18n class="label-small-info">With your own funds? With user donations? Advertising?</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceBusinessModel" formControlName="businessModel" markdownType="enhanced"
|
||||
[formError]="formErrors['instance.businessModel']"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4"> <!-- other information grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>OTHER INFORMATION</h2>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<div class="form-group">
|
||||
<label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label>
|
||||
<div i18n class="label-small-info">i.e. 2vCore 2GB RAM, a direct the link to the server you rent, etc.</div>
|
||||
|
||||
<my-markdown-textarea
|
||||
name="instanceHardwareInformation" formControlName="hardwareInformation" markdownType="enhanced"
|
||||
[formError]="formErrors['instance.hardwareInformation']"
|
||||
></my-markdown-textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
@@ -0,0 +1,144 @@
|
||||
import { NgClass, NgIf } from '@angular/common'
|
||||
import { HttpErrorResponse } from '@angular/common/http'
|
||||
import { Component, Input, OnInit } 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 { 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 { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
|
||||
import { HelpComponent } from '../../../shared/shared-main/misc/help.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-edit-instance-information',
|
||||
templateUrl: './edit-instance-information.component.html',
|
||||
styleUrls: [ './edit-custom-config.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
ActorAvatarEditComponent,
|
||||
ActorBannerEditComponent,
|
||||
NgClass,
|
||||
NgIf,
|
||||
CustomMarkupHelpComponent,
|
||||
MarkdownTextareaComponent,
|
||||
SelectCheckboxComponent,
|
||||
RouterLink,
|
||||
PeertubeCheckboxComponent,
|
||||
PeerTubeTemplateDirective,
|
||||
HelpComponent
|
||||
]
|
||||
})
|
||||
export class EditInstanceInformationComponent implements OnInit {
|
||||
@Input() form: FormGroup
|
||||
@Input() formErrors: any
|
||||
|
||||
@Input() languageItems: SelectOptionsItem[] = []
|
||||
@Input() categoryItems: SelectOptionsItem[] = []
|
||||
|
||||
instanceBannerUrl: string
|
||||
instanceAvatars: ActorImage[] = []
|
||||
|
||||
private serverConfig: HTMLServerConfig
|
||||
|
||||
constructor (
|
||||
private customMarkup: CustomMarkupService,
|
||||
private notifier: Notifier,
|
||||
private instanceService: InstanceService,
|
||||
private server: ServerService
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
<ng-container [formGroup]="form">
|
||||
|
||||
<div class="pt-two-cols mt-5">
|
||||
<div class="title-col">
|
||||
<h2 i18n>LIVE</h2>
|
||||
|
||||
<div i18n class="inner-form-description">
|
||||
Enable users of your instance to stream live.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="live">
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox inputName="liveEnabled" formControlName="enabled">
|
||||
<ng-template ptTemplate="label">
|
||||
<ng-container i18n>Allow live streaming</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-container ngProjectAs="description">
|
||||
<div i18n>⚠️ Enabling live streaming requires trust in your users and extra moderation work</div>
|
||||
<div i18n>If enabled, your server needs to accept incoming TCP traffic on port {{ getLiveRTMPPort() }}</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container ngProjectAs="extra">
|
||||
|
||||
<div class="form-group" [ngClass]="getDisabledLiveClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="liveAllowReplay" formControlName="allowReplay"
|
||||
i18n-labelText labelText="Allow your users to automatically publish a replay of their live"
|
||||
>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="latencySetting" [ngClass]="getDisabledLiveClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="liveLatencySettingEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Allow your users to change live latency"
|
||||
>
|
||||
<ng-container ngProjectAs="description" i18n>
|
||||
Small latency disables P2P and high latency can increase P2P ratio
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group" [ngClass]="getDisabledLiveClass()">
|
||||
<label i18n for="liveMaxInstanceLives">Max simultaneous lives created on your instance</label>
|
||||
|
||||
<span class="ms-2 small muted">(-1 for "unlimited")</span>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input type="number" name="liveMaxInstanceLives" formControlName="maxInstanceLives" />
|
||||
<span i18n>{form.value['live']['maxInstanceLives'], plural, =1 {live} other {lives}}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.live.maxInstanceLives" class="form-error" role="alert">{{ formErrors.live.maxInstanceLives }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" [ngClass]="getDisabledLiveClass()">
|
||||
<label i18n for="liveMaxUserLives">Max simultaneous lives created per user</label>
|
||||
<span class="ms-2 small muted">(-1 for "unlimited")</span>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input type="number" name="liveMaxUserLives" formControlName="maxUserLives" />
|
||||
<span i18n>{form.value['live']['maxUserLives'], plural, =1 {live} other {lives}}</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.live.maxUserLives" class="form-error" role="alert">{{ formErrors.live.maxUserLives }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" [ngClass]="getDisabledLiveClass()">
|
||||
<label i18n for="liveMaxDuration">Max live duration</label>
|
||||
|
||||
<my-select-options
|
||||
labelForId="liveMaxDuration" [items]="liveMaxDurationOptions" formControlName="maxDuration"
|
||||
bindLabel="label" bindValue="value" [clearable]="false" [searchable]="true"
|
||||
></my-select-options>
|
||||
|
||||
<div *ngIf="formErrors.live.maxDuration" class="form-error" role="alert">{{ formErrors.live.maxDuration }}</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols"> <!-- transcoding live streams grid -->
|
||||
<div class="title-col">
|
||||
<h2 i18n>TRANSCODING</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Same as VOD transcoding, transcoding live streams so that they are in a streamable form that any device can play. Requires a beefy CPU, and then some.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="live">
|
||||
<ng-container formGroupName="transcoding">
|
||||
|
||||
<div class="form-group" [ngClass]="getDisabledLiveClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="liveTranscodingEnabled" formControlName="enabled"
|
||||
>
|
||||
<ng-template ptTemplate="label">
|
||||
<ng-container i18n>Transcoding enabled for live streams</ng-container>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="callout callout-light pt-2 mt-2 pb-0">
|
||||
<h3 class="callout-title" i18n>Output formats</h3>
|
||||
|
||||
<div class="form-group" [ngClass]="getDisabledLiveTranscodingClass()">
|
||||
<label i18n for="liveTranscodingThreads">Live resolutions to generate</label>
|
||||
|
||||
<div class="ms-2 mt-2 d-flex flex-column">
|
||||
|
||||
<ng-container formGroupName="resolutions">
|
||||
<div class="form-group" *ngFor="let resolution of liveResolutions">
|
||||
<my-peertube-checkbox
|
||||
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
|
||||
labelText="{{resolution.label}}"
|
||||
>
|
||||
<ng-template *ngIf="resolution.description" ptTemplate="help">
|
||||
<div [innerHTML]="resolution.description"></div>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="form-group">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
|
||||
i18n-labelText labelText="Also transcode original resolution"
|
||||
>
|
||||
<ng-container i18n ngProjectAs="description">
|
||||
Even if it's above your maximum enabled resolution
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4" formGroupName="remoteRunners" [ngClass]="getDisabledLiveTranscodingClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingRemoteRunnersEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable remote runners for lives"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>
|
||||
Use <a routerLink="/admin/system/runners/runners-list">remote runners</a> to process live transcoding.
|
||||
Remote runners has to register on your instance first.
|
||||
</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group" [ngClass]="getDisabledLiveLocalTranscodingClass()">
|
||||
<label i18n for="liveTranscodingThreads">Live transcoding threads</label>
|
||||
|
||||
<span class="small muted ms-1">
|
||||
<ng-container *ngIf="getTotalTranscodingThreads().atMost" i18n>
|
||||
will claim at most {{ getTotalTranscodingThreads().value }} {{ getTotalTranscodingThreads().unit }} with VOD transcoding
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!getTotalTranscodingThreads().atMost" i18n>
|
||||
will claim at least {{ getTotalTranscodingThreads().value }} {{ getTotalTranscodingThreads().unit }} with VOD transcoding
|
||||
</ng-container>
|
||||
</span>
|
||||
|
||||
<my-select-custom-value
|
||||
id="liveTranscodingThreads"
|
||||
[items]="transcodingThreadOptions"
|
||||
formControlName="threads"
|
||||
[clearable]="false"
|
||||
></my-select-custom-value>
|
||||
<div *ngIf="formErrors.live.transcoding.threads" class="form-error" role="alert">{{ formErrors.live.transcoding.threads }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()">
|
||||
<label i18n for="liveTranscodingProfile">Live transcoding profile</label>
|
||||
<span class="small muted ms-1" i18n>new live transcoding profiles can be added by PeerTube plugins</span>
|
||||
|
||||
<my-select-options
|
||||
id="liveTranscodingProfile"
|
||||
formControlName="profile"
|
||||
[items]="transcodingProfiles"
|
||||
[clearable]="false"
|
||||
>
|
||||
</my-select-options>
|
||||
|
||||
<div *ngIf="formErrors.live.transcoding.profile" class="form-error" role="alert">{{ formErrors.live.transcoding.profile }}</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,115 @@
|
||||
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||
import { Component, Input, OnChanges, OnInit, SimpleChanges } 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/angular/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' ],
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
PeertubeCheckboxComponent,
|
||||
PeerTubeTemplateDirective,
|
||||
NgClass,
|
||||
NgIf,
|
||||
SelectOptionsComponent,
|
||||
NgFor,
|
||||
RouterLink,
|
||||
SelectCustomValueComponent
|
||||
]
|
||||
})
|
||||
export class EditLiveConfigurationComponent implements OnInit, OnChanges {
|
||||
@Input() form: FormGroup
|
||||
@Input() formErrors: any
|
||||
@Input() serverConfig: HTMLServerConfig
|
||||
|
||||
transcodingThreadOptions: SelectOptionsItem[] = []
|
||||
transcodingProfiles: SelectOptionsItem[] = []
|
||||
|
||||
liveMaxDurationOptions: SelectOptionsItem[] = []
|
||||
liveResolutions: ResolutionOption[] = []
|
||||
|
||||
constructor (
|
||||
private configService: ConfigService,
|
||||
private editConfigurationService: EditConfigurationService
|
||||
) { }
|
||||
|
||||
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.getLiveResolutions()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<ng-container [formGroup]="form">
|
||||
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col"></div>
|
||||
<div class="content-col">
|
||||
|
||||
<div class="callout callout-orange">
|
||||
<span i18n>
|
||||
Estimating a server's capacity to transcode and stream videos isn't easy and we can't tune PeerTube automatically.
|
||||
</span>
|
||||
|
||||
<span i18n>
|
||||
However, you may want to read <a class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/admin/configuration#vod-transcoding">our guidelines</a> before tweaking the following values.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-4">
|
||||
<div class="title-col">
|
||||
<h2 i18n>TRANSCODING</h2>
|
||||
|
||||
<div i18n class="inner-form-description">
|
||||
Process uploaded videos so that they are in a streamable form that any device can play. Though costly in
|
||||
resources, this is a critical part of PeerTube, so tread carefully.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="transcoding">
|
||||
|
||||
<div>
|
||||
<my-peertube-checkbox inputName="transcodingEnabled" formControlName="enabled" [recommended]="true">
|
||||
<ng-template ptTemplate="label">
|
||||
<ng-container i18n>Transcoding enabled</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-container ngProjectAs="extra">
|
||||
|
||||
<div class="callout callout-light pt-2 pb-0">
|
||||
<h3 class="callout-title" i18n>Input</h3>
|
||||
|
||||
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingAllowAdditionalExtensions" formControlName="allowAdditionalExtensions"
|
||||
i18n-labelText labelText="Allow additional extensions"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>Allows users to upload videos with additional extensions than .mp4, .ogv and .webm (for example: .avi, .mov, .mkv etc).</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingAllowAudioFiles" formControlName="allowAudioFiles"
|
||||
i18n-labelText labelText="Allow audio files upload"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<div i18n>Allows users to upload .mp3, .ogg, .wma, .flac, .aac, or .ac3 audio files.</div>
|
||||
<div i18n>The file will be merged in a still image video with the preview file on upload.</div>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="originalFile" [ngClass]="getTranscodingDisabledClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingOriginalFileKeep" formControlName="keep"
|
||||
i18n-labelText labelText="Keep a version of the input file"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<div i18n>If enabled, the input file is not deleted after transcoding but moved in a dedicated folder or object storage</div>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout callout-light pt-2 mt-2 pb-0">
|
||||
<h3 class="callout-title" i18n>Output</h3>
|
||||
|
||||
<ng-container formGroupName="webVideos">
|
||||
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingWebVideosEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Web Videos enabled"
|
||||
>
|
||||
<ng-template ptTemplate="help">
|
||||
<ng-container>
|
||||
<p i18n>If you also enabled HLS support, it will multiply videos storage by 2</p>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container formGroupName="hls">
|
||||
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingHlsEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="HLS with P2P support enabled"
|
||||
[recommended]="true"
|
||||
>
|
||||
<ng-template ptTemplate="help">
|
||||
<ng-container i18n>
|
||||
<strong>Requires ffmpeg >= 4.1</strong>
|
||||
|
||||
<p>Generate HLS playlists and fragmented MP4 files resulting in a better playback than with Web Videos:</p>
|
||||
<ul>
|
||||
<li>Resolution change is smoother</li>
|
||||
<li>Faster playback especially with long videos</li>
|
||||
<li>More stable playback (less bugs/infinite loading)</li>
|
||||
</ul>
|
||||
|
||||
<p>If you also enabled Web Videos support, it will multiply videos storage by 2</p>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
||||
<div class="mb-2 fw-bold" i18n>Resolutions to generate</div>
|
||||
|
||||
<div class="ms-2 d-flex flex-column">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingAlwaysTranscodeOriginalResolution" formControlName="alwaysTranscodeOriginalResolution"
|
||||
i18n-labelText labelText="Always transcode original resolution"
|
||||
>
|
||||
</my-peertube-checkbox>
|
||||
|
||||
<span class="mt-3 mb-2 small muted" i18n>
|
||||
The original file resolution will be the default target if no option is selected.
|
||||
</span>
|
||||
|
||||
<ng-container formGroupName="resolutions">
|
||||
<div class="form-group" *ngFor="let resolution of resolutions">
|
||||
<my-peertube-checkbox
|
||||
[inputName]="getResolutionKey(resolution.id)" [formControlName]="resolution.id"
|
||||
labelText="{{ resolution.label }}"
|
||||
>
|
||||
<ng-template *ngIf="resolution.description" ptTemplate="help">
|
||||
<div [innerHTML]="resolution.description"></div>
|
||||
</ng-template>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4" formGroupName="remoteRunners" [ngClass]="getTranscodingDisabledClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="transcodingRemoteRunnersEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable remote runners for VOD"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>
|
||||
Use <a routerLink="/admin/system/runners/runners-list">remote runners</a> to process VOD transcoding.
|
||||
Remote runners has to register on your instance first.
|
||||
</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4" [ngClass]="getLocalTranscodingDisabledClass()">
|
||||
<label i18n for="transcodingThreads">Transcoding threads</label>
|
||||
|
||||
<span class="small muted ms-1">
|
||||
<ng-container *ngIf="getTotalTranscodingThreads().atMost" i18n>
|
||||
will claim at most {{ getTotalTranscodingThreads().value }} {{ getTotalTranscodingThreads().unit }} with live transcoding
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!getTotalTranscodingThreads().atMost" i18n>
|
||||
will claim at least {{ getTotalTranscodingThreads().value }} {{ getTotalTranscodingThreads().unit }} with live transcoding
|
||||
</ng-container>
|
||||
</span>
|
||||
|
||||
<my-select-custom-value
|
||||
id="transcodingThreads"
|
||||
[items]="transcodingThreadOptions"
|
||||
formControlName="threads"
|
||||
[clearable]="false"
|
||||
></my-select-custom-value>
|
||||
|
||||
<div *ngIf="formErrors.transcoding.threads" class="form-error" role="alert">{{ formErrors.transcoding.threads }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
|
||||
<label i18n for="transcodingConcurrency">Transcoding jobs concurrency</label>
|
||||
<span class="small muted ms-1" i18n>allows to transcode multiple files in parallel. ⚠️ Requires a PeerTube restart</span>
|
||||
|
||||
<div class="number-with-unit">
|
||||
<input type="number" name="transcodingConcurrency" formControlName="concurrency" />
|
||||
<span i18n>jobs in parallel</span>
|
||||
</div>
|
||||
|
||||
<div *ngIf="formErrors.transcoding.concurrency" class="form-error" role="alert">{{ formErrors.transcoding.concurrency }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
|
||||
<label i18n for="transcodingProfile">Transcoding profile</label>
|
||||
<span class="small muted ms-1" i18n>new transcoding profiles can be added by PeerTube plugins</span>
|
||||
|
||||
<my-select-options
|
||||
id="transcodingProfile"
|
||||
formControlName="profile"
|
||||
[items]="transcodingProfiles"
|
||||
[clearable]="false"
|
||||
></my-select-options>
|
||||
|
||||
<div *ngIf="formErrors.transcoding.profile" class="form-error" role="alert">{{ formErrors.transcoding.profile }}</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-two-cols mt-2">
|
||||
<div class="title-col">
|
||||
<h2 i18n>VIDEO STUDIO</h2>
|
||||
<div i18n class="inner-form-description">
|
||||
Allows your users to edit their video (cut, add intro/outro, add a watermark etc)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-col">
|
||||
|
||||
<ng-container formGroupName="videoStudio">
|
||||
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="videoStudioEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable video studio"
|
||||
>
|
||||
<ng-container ngProjectAs="description" *ngIf="!isTranscodingEnabled()">
|
||||
<span i18n>⚠️ You need to enable transcoding first to enable video studio</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
|
||||
<div class="form-group" formGroupName="remoteRunners" [ngClass]="getStudioDisabledClass()">
|
||||
<my-peertube-checkbox
|
||||
inputName="videoStudioRemoteRunnersEnabled" formControlName="enabled"
|
||||
i18n-labelText labelText="Enable remote runners for studio"
|
||||
>
|
||||
<ng-container ngProjectAs="description">
|
||||
<span i18n>
|
||||
Use <a routerLink="/admin/system/runners/runners-list">remote runners</a> to process studio transcoding tasks.
|
||||
Remote runners has to register on your instance first.
|
||||
</span>
|
||||
</ng-container>
|
||||
</my-peertube-checkbox>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,147 @@
|
||||
import { SelectOptionsItem } from 'src/types/select-options-item.model'
|
||||
import { Component, Input, OnChanges, OnInit, SimpleChanges } 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 { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
|
||||
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { NgClass, NgFor, NgIf } from '@angular/common'
|
||||
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/angular/peertube-template.directive'
|
||||
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-edit-vod-transcoding',
|
||||
templateUrl: './edit-vod-transcoding.component.html',
|
||||
styleUrls: [ './edit-custom-config.component.scss' ],
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
PeertubeCheckboxComponent,
|
||||
PeerTubeTemplateDirective,
|
||||
NgClass,
|
||||
NgFor,
|
||||
NgIf,
|
||||
RouterLink,
|
||||
SelectCustomValueComponent,
|
||||
SelectOptionsComponent
|
||||
]
|
||||
})
|
||||
export class EditVODTranscodingComponent implements OnInit, OnChanges {
|
||||
@Input() form: FormGroup
|
||||
@Input() formErrors: any
|
||||
@Input() serverConfig: HTMLServerConfig
|
||||
|
||||
transcodingThreadOptions: SelectOptionsItem[] = []
|
||||
transcodingProfiles: SelectOptionsItem[] = []
|
||||
resolutions: ResolutionOption[] = []
|
||||
|
||||
additionalVideoExtensions = ''
|
||||
|
||||
constructor (
|
||||
private configService: ConfigService,
|
||||
private editConfigurationService: EditConfigurationService
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
|
||||
this.resolutions = this.editConfigurationService.getVODResolutions()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
isStudioEnabled () {
|
||||
return this.editConfigurationService.isStudioEnabled(this.form)
|
||||
}
|
||||
|
||||
getTranscodingDisabledClass () {
|
||||
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
|
||||
}
|
||||
|
||||
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.disabled) {
|
||||
hlsControl.disable()
|
||||
}
|
||||
|
||||
if (newValue === true && !hlsControl.enabled) {
|
||||
hlsControl.enable()
|
||||
}
|
||||
})
|
||||
|
||||
hlsControl.valueChanges
|
||||
.subscribe(newValue => {
|
||||
if (newValue === false && !webVideosControl.disabled) {
|
||||
webVideosControl.disable()
|
||||
}
|
||||
|
||||
if (newValue === true && !webVideosControl.enabled) {
|
||||
webVideosControl.enable()
|
||||
}
|
||||
})
|
||||
|
||||
transcodingControl.valueChanges
|
||||
.subscribe(newValue => {
|
||||
if (newValue === false) {
|
||||
videoStudioControl.setValue(false)
|
||||
}
|
||||
})
|
||||
|
||||
transcodingControl.updateValueAndValidity()
|
||||
webVideosControl.updateValueAndValidity()
|
||||
videoStudioControl.updateValueAndValidity()
|
||||
hlsControl.updateValueAndValidity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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'
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './edit-custom-config'
|
||||
export * from './config.routes'
|
||||
変更されたファイルが多すぎるため,一部のファイルは表示されません さらに表示
新しい課題から参照
ユーザをブロックする