From 67ed6552b831df66713bac9e672738796128d33f Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 23 Jun 2020 14:10:17 +0200 Subject: Reorganize client shared modules --- .../src/app/shared/shared-forms/form-reactive.ts | 69 ++++++ .../batch-domains-validators.service.ts | 69 ++++++ .../custom-config-validators.service.ts | 98 ++++++++ .../form-validators/form-validator.service.ts | 87 +++++++ .../shared/shared-forms/form-validators/host.ts | 8 + .../shared/shared-forms/form-validators/index.ts | 17 ++ .../form-validators/instance-validators.service.ts | 62 +++++ .../form-validators/login-validators.service.ts | 30 +++ .../reset-password-validators.service.ts | 20 ++ .../form-validators/user-validators.service.ts | 151 +++++++++++++ .../video-abuse-validators.service.ts | 30 +++ .../video-accept-ownership-validators.service.ts | 18 ++ .../video-block-validators.service.ts | 19 ++ .../video-captions-validators.service.ts | 27 +++ .../video-change-ownership-validators.service.ts | 27 +++ .../video-channel-validators.service.ts | 64 ++++++ .../video-comment-validators.service.ts | 20 ++ .../video-playlist-validators.service.ts | 66 ++++++ .../form-validators/video-validators.service.ts | 102 +++++++++ client/src/app/shared/shared-forms/index.ts | 10 + .../input-readonly-copy.component.html | 9 + .../input-readonly-copy.component.scss | 3 + .../shared-forms/input-readonly-copy.component.ts | 21 ++ .../shared-forms/markdown-textarea.component.html | 36 +++ .../shared-forms/markdown-textarea.component.scss | 251 +++++++++++++++++++++ .../shared-forms/markdown-textarea.component.ts | 110 +++++++++ .../shared-forms/peertube-checkbox.component.html | 45 ++++ .../shared-forms/peertube-checkbox.component.scss | 52 +++++ .../shared-forms/peertube-checkbox.component.ts | 73 ++++++ .../shared-forms/preview-upload.component.html | 11 + .../shared-forms/preview-upload.component.scss | 29 +++ .../shared-forms/preview-upload.component.ts | 92 ++++++++ .../shared-forms/reactive-file.component.html | 15 ++ .../shared-forms/reactive-file.component.scss | 22 ++ .../shared/shared-forms/reactive-file.component.ts | 91 ++++++++ .../app/shared/shared-forms/shared-form.module.ts | 84 +++++++ .../shared-forms/textarea-autoresize.directive.ts | 25 ++ .../shared-forms/timestamp-input.component.html | 4 + .../shared-forms/timestamp-input.component.scss | 15 ++ .../shared-forms/timestamp-input.component.ts | 61 +++++ 40 files changed, 2043 insertions(+) create mode 100644 client/src/app/shared/shared-forms/form-reactive.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/form-validator.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/host.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/index.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/login-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/user-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/form-validators/video-validators.service.ts create mode 100644 client/src/app/shared/shared-forms/index.ts create mode 100644 client/src/app/shared/shared-forms/input-readonly-copy.component.html create mode 100644 client/src/app/shared/shared-forms/input-readonly-copy.component.scss create mode 100644 client/src/app/shared/shared-forms/input-readonly-copy.component.ts create mode 100644 client/src/app/shared/shared-forms/markdown-textarea.component.html create mode 100644 client/src/app/shared/shared-forms/markdown-textarea.component.scss create mode 100644 client/src/app/shared/shared-forms/markdown-textarea.component.ts create mode 100644 client/src/app/shared/shared-forms/peertube-checkbox.component.html create mode 100644 client/src/app/shared/shared-forms/peertube-checkbox.component.scss create mode 100644 client/src/app/shared/shared-forms/peertube-checkbox.component.ts create mode 100644 client/src/app/shared/shared-forms/preview-upload.component.html create mode 100644 client/src/app/shared/shared-forms/preview-upload.component.scss create mode 100644 client/src/app/shared/shared-forms/preview-upload.component.ts create mode 100644 client/src/app/shared/shared-forms/reactive-file.component.html create mode 100644 client/src/app/shared/shared-forms/reactive-file.component.scss create mode 100644 client/src/app/shared/shared-forms/reactive-file.component.ts create mode 100644 client/src/app/shared/shared-forms/shared-form.module.ts create mode 100644 client/src/app/shared/shared-forms/textarea-autoresize.directive.ts create mode 100644 client/src/app/shared/shared-forms/timestamp-input.component.html create mode 100644 client/src/app/shared/shared-forms/timestamp-input.component.scss create mode 100644 client/src/app/shared/shared-forms/timestamp-input.component.ts (limited to 'client/src/app/shared/shared-forms') diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts new file mode 100644 index 000000000..caa31d831 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-reactive.ts @@ -0,0 +1,69 @@ +import { FormGroup } from '@angular/forms' +import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from './form-validators' + +export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } +export type FormReactiveValidationMessages = { + [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages +} + +export abstract class FormReactive { + protected abstract formValidatorService: FormValidatorService + protected formChanged = false + + form: FormGroup + formErrors: any // To avoid casting in template because of string | FormReactiveErrors + validationMessages: FormReactiveValidationMessages + + buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { + const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues) + + this.form = form + this.formErrors = formErrors + this.validationMessages = validationMessages + + this.form.valueChanges.subscribe(() => this.onValueChanged(this.form, this.formErrors, this.validationMessages, false)) + } + + protected forceCheck () { + return this.onValueChanged(this.form, this.formErrors, this.validationMessages, true) + } + + protected check () { + return this.onValueChanged(this.form, this.formErrors, this.validationMessages, false) + } + + private onValueChanged ( + form: FormGroup, + formErrors: FormReactiveErrors, + validationMessages: FormReactiveValidationMessages, + forceCheck = false + ) { + for (const field of Object.keys(formErrors)) { + if (formErrors[field] && typeof formErrors[field] === 'object') { + this.onValueChanged( + form.controls[field] as FormGroup, + formErrors[field] as FormReactiveErrors, + validationMessages[field] as FormReactiveValidationMessages, + forceCheck + ) + continue + } + + // clear previous error message (if any) + formErrors[ field ] = '' + const control = form.get(field) + + if (control.dirty) this.formChanged = true + + // Don't care if dirty on force check + const isDirty = control.dirty || forceCheck === true + if (control && isDirty && control.enabled && !control.valid) { + const messages = validationMessages[ field ] + for (const key of Object.keys(control.errors)) { + formErrors[ field ] += messages[ key ] + ' ' + } + } + } + } + +} diff --git a/client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts new file mode 100644 index 000000000..f270b602b --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core' +import { ValidatorFn, Validators } from '@angular/forms' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { BuildFormValidator } from './form-validator.service' +import { validateHost } from './host' + +@Injectable() +export class BatchDomainsValidatorsService { + readonly DOMAINS: BuildFormValidator + + constructor (private i18n: I18n) { + this.DOMAINS = { + VALIDATORS: [ Validators.required, this.validDomains, this.isHostsUnique ], + MESSAGES: { + 'required': this.i18n('Domain is required.'), + 'validDomains': this.i18n('Domains entered are invalid.'), + 'uniqueDomains': this.i18n('Domains entered contain duplicates.') + } + } + } + + getNotEmptyHosts (hosts: string) { + return hosts + .split('\n') + .filter((host: string) => host && host.length !== 0) // Eject empty hosts + } + + private validDomains: ValidatorFn = (control) => { + if (!control.value) return null + + const newHostsErrors = [] + const hosts = this.getNotEmptyHosts(control.value) + + for (const host of hosts) { + if (validateHost(host) === false) { + newHostsErrors.push(this.i18n('{{host}} is not valid', { host })) + } + } + + /* Is not valid. */ + if (newHostsErrors.length !== 0) { + return { + 'validDomains': { + reason: 'invalid', + value: newHostsErrors.join('. ') + '.' + } + } + } + + /* Is valid. */ + return null + } + + private isHostsUnique: ValidatorFn = (control) => { + if (!control.value) return null + + const hosts = this.getNotEmptyHosts(control.value) + + if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) { + return null + } else { + return { + 'uniqueDomains': { + reason: 'invalid' + } + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts new file mode 100644 index 000000000..c77aba6a1 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts @@ -0,0 +1,98 @@ +import { Validators } from '@angular/forms' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { BuildFormValidator } from './form-validator.service' +import { Injectable } from '@angular/core' + +@Injectable() +export class CustomConfigValidatorsService { + readonly INSTANCE_NAME: BuildFormValidator + readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator + readonly SERVICES_TWITTER_USERNAME: BuildFormValidator + readonly CACHE_PREVIEWS_SIZE: BuildFormValidator + readonly CACHE_CAPTIONS_SIZE: BuildFormValidator + readonly SIGNUP_LIMIT: BuildFormValidator + readonly ADMIN_EMAIL: BuildFormValidator + readonly TRANSCODING_THREADS: BuildFormValidator + readonly INDEX_URL: BuildFormValidator + readonly SEARCH_INDEX_URL: BuildFormValidator + + constructor (private i18n: I18n) { + this.INSTANCE_NAME = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Instance name is required.') + } + } + + this.INSTANCE_SHORT_DESCRIPTION = { + VALIDATORS: [ Validators.max(250) ], + MESSAGES: { + 'max': this.i18n('Short description should not be longer than 250 characters.') + } + } + + this.SERVICES_TWITTER_USERNAME = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Twitter username is required.') + } + } + + this.CACHE_PREVIEWS_SIZE = { + VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], + MESSAGES: { + 'required': this.i18n('Previews cache size is required.'), + 'min': this.i18n('Previews cache size must be greater than 1.'), + 'pattern': this.i18n('Previews cache size must be a number.') + } + } + + this.CACHE_CAPTIONS_SIZE = { + VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ], + MESSAGES: { + 'required': this.i18n('Captions cache size is required.'), + 'min': this.i18n('Captions cache size must be greater than 1.'), + 'pattern': this.i18n('Captions cache size must be a number.') + } + } + + this.SIGNUP_LIMIT = { + VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ], + MESSAGES: { + 'required': this.i18n('Signup limit is required.'), + 'min': this.i18n('Signup limit must be greater than 1.'), + 'pattern': this.i18n('Signup limit must be a number.') + } + } + + this.ADMIN_EMAIL = { + VALIDATORS: [ Validators.required, Validators.email ], + MESSAGES: { + 'required': this.i18n('Admin email is required.'), + 'email': this.i18n('Admin email must be valid.') + } + } + + this.TRANSCODING_THREADS = { + VALIDATORS: [ Validators.required, Validators.min(0) ], + MESSAGES: { + 'required': this.i18n('Transcoding threads is required.'), + 'min': this.i18n('Transcoding threads must be greater or equal to 0.') + } + } + + this.INDEX_URL = { + VALIDATORS: [ Validators.pattern(/^https:\/\//) ], + MESSAGES: { + 'pattern': this.i18n('Index URL should be a URL') + } + } + + this.SEARCH_INDEX_URL = { + VALIDATORS: [ Validators.pattern(/^https?:\/\//) ], + MESSAGES: { + 'pattern': this.i18n('Search index URL should be a URL') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validators/form-validator.service.ts new file mode 100644 index 000000000..dec7d8d9a --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/form-validator.service.ts @@ -0,0 +1,87 @@ +import { FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' +import { Injectable } from '@angular/core' +import { FormReactiveErrors, FormReactiveValidationMessages } from '../form-reactive' + +export type BuildFormValidator = { + VALIDATORS: ValidatorFn[], + MESSAGES: { [ name: string ]: string } +} +export type BuildFormArgument = { + [ id: string ]: BuildFormValidator | BuildFormArgument +} +export type BuildFormDefaultValues = { + [ name: string ]: string | string[] | BuildFormDefaultValues +} + +@Injectable() +export class FormValidatorService { + + constructor ( + private formBuilder: FormBuilder + ) {} + + buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { + const formErrors: FormReactiveErrors = {} + const validationMessages: FormReactiveValidationMessages = {} + const group: { [key: string]: any } = {} + + for (const name of Object.keys(obj)) { + formErrors[name] = '' + + const field = obj[name] + if (this.isRecursiveField(field)) { + const result = this.buildForm(field as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues) + group[name] = result.form + formErrors[name] = result.formErrors + validationMessages[name] = result.validationMessages + + continue + } + + if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } + + const defaultValue = defaultValues[name] || '' + + if (field && field.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ] + else group[name] = [ defaultValue ] + } + + const form = this.formBuilder.group(group) + return { form, formErrors, validationMessages } + } + + updateForm ( + form: FormGroup, + formErrors: FormReactiveErrors, + validationMessages: FormReactiveValidationMessages, + obj: BuildFormArgument, + defaultValues: BuildFormDefaultValues = {} + ) { + for (const name of Object.keys(obj)) { + formErrors[name] = '' + + const field = obj[name] + if (this.isRecursiveField(field)) { + this.updateForm( + form[name], + formErrors[name] as FormReactiveErrors, + validationMessages[name] as FormReactiveValidationMessages, + obj[name] as BuildFormArgument, + defaultValues[name] as BuildFormDefaultValues + ) + continue + } + + if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string } + + const defaultValue = defaultValues[name] || '' + + if (field && field.VALIDATORS) form.addControl(name, new FormControl(defaultValue, field.VALIDATORS as ValidatorFn[])) + else form.addControl(name, new FormControl(defaultValue)) + } + } + + private isRecursiveField (field: any) { + return field && typeof field === 'object' && !field.MESSAGES && !field.VALIDATORS + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/host.ts b/client/src/app/shared/shared-forms/form-validators/host.ts new file mode 100644 index 000000000..c18a35f9b --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/host.ts @@ -0,0 +1,8 @@ +export function validateHost (value: string) { + // Thanks to http://stackoverflow.com/a/106223 + const HOST_REGEXP = new RegExp( + '^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$' + ) + + return HOST_REGEXP.test(value) +} diff --git a/client/src/app/shared/shared-forms/form-validators/index.ts b/client/src/app/shared/shared-forms/form-validators/index.ts new file mode 100644 index 000000000..8b71841a9 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/index.ts @@ -0,0 +1,17 @@ +export * from './batch-domains-validators.service' +export * from './custom-config-validators.service' +export * from './form-validator.service' +export * from './host' +export * from './instance-validators.service' +export * from './login-validators.service' +export * from './reset-password-validators.service' +export * from './user-validators.service' +export * from './video-abuse-validators.service' +export * from './video-accept-ownership-validators.service' +export * from './video-block-validators.service' +export * from './video-captions-validators.service' +export * from './video-change-ownership-validators.service' +export * from './video-channel-validators.service' +export * from './video-comment-validators.service' +export * from './video-playlist-validators.service' +export * from './video-validators.service' diff --git a/client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts new file mode 100644 index 000000000..96a35a48f --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts @@ -0,0 +1,62 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { BuildFormValidator } from './form-validator.service' +import { Injectable } from '@angular/core' + +@Injectable() +export class InstanceValidatorsService { + readonly FROM_EMAIL: BuildFormValidator + readonly FROM_NAME: BuildFormValidator + readonly SUBJECT: BuildFormValidator + readonly BODY: BuildFormValidator + + constructor (private i18n: I18n) { + + this.FROM_EMAIL = { + VALIDATORS: [ Validators.required, Validators.email ], + MESSAGES: { + 'required': this.i18n('Email is required.'), + 'email': this.i18n('Email must be valid.') + } + } + + this.FROM_NAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(120) + ], + MESSAGES: { + 'required': this.i18n('Your name is required.'), + 'minlength': this.i18n('Your name must be at least 1 character long.'), + 'maxlength': this.i18n('Your name cannot be more than 120 characters long.') + } + } + + this.SUBJECT = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(120) + ], + MESSAGES: { + 'required': this.i18n('A subject is required.'), + 'minlength': this.i18n('The subject must be at least 1 character long.'), + 'maxlength': this.i18n('The subject cannot be more than 120 characters long.') + } + } + + this.BODY = { + VALIDATORS: [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(5000) + ], + MESSAGES: { + 'required': this.i18n('A message is required.'), + 'minlength': this.i18n('The message must be at least 3 characters long.'), + 'maxlength': this.i18n('The message cannot be more than 5000 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/login-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/login-validators.service.ts new file mode 100644 index 000000000..a5837357e --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/login-validators.service.ts @@ -0,0 +1,30 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class LoginValidatorsService { + readonly LOGIN_USERNAME: BuildFormValidator + readonly LOGIN_PASSWORD: BuildFormValidator + + constructor (private i18n: I18n) { + this.LOGIN_USERNAME = { + VALIDATORS: [ + Validators.required + ], + MESSAGES: { + 'required': this.i18n('Username is required.') + } + } + + this.LOGIN_PASSWORD = { + VALIDATORS: [ + Validators.required + ], + MESSAGES: { + 'required': this.i18n('Password is required.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts new file mode 100644 index 000000000..d2085a309 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts @@ -0,0 +1,20 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class ResetPasswordValidatorsService { + readonly RESET_PASSWORD_CONFIRM: BuildFormValidator + + constructor (private i18n: I18n) { + this.RESET_PASSWORD_CONFIRM = { + VALIDATORS: [ + Validators.required + ], + MESSAGES: { + 'required': this.i18n('Confirmation of the password is required.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/user-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/user-validators.service.ts new file mode 100644 index 000000000..bd3030a54 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/user-validators.service.ts @@ -0,0 +1,151 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { BuildFormValidator } from './form-validator.service' +import { Injectable } from '@angular/core' + +@Injectable() +export class UserValidatorsService { + readonly USER_USERNAME: BuildFormValidator + readonly USER_EMAIL: BuildFormValidator + readonly USER_PASSWORD: BuildFormValidator + readonly USER_PASSWORD_OPTIONAL: BuildFormValidator + readonly USER_CONFIRM_PASSWORD: BuildFormValidator + readonly USER_VIDEO_QUOTA: BuildFormValidator + readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator + readonly USER_ROLE: BuildFormValidator + readonly USER_DISPLAY_NAME_REQUIRED: BuildFormValidator + readonly USER_DESCRIPTION: BuildFormValidator + readonly USER_TERMS: BuildFormValidator + + readonly USER_BAN_REASON: BuildFormValidator + + constructor (private i18n: I18n) { + + this.USER_USERNAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(50), + Validators.pattern(/^[a-z0-9][a-z0-9._]*$/) + ], + MESSAGES: { + 'required': this.i18n('Username is required.'), + 'minlength': this.i18n('Username must be at least 1 character long.'), + 'maxlength': this.i18n('Username cannot be more than 50 characters long.'), + 'pattern': this.i18n('Username should be lowercase alphanumeric; dots and underscores are allowed.') + } + } + + this.USER_EMAIL = { + VALIDATORS: [ Validators.required, Validators.email ], + MESSAGES: { + 'required': this.i18n('Email is required.'), + 'email': this.i18n('Email must be valid.') + } + } + + this.USER_PASSWORD = { + VALIDATORS: [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(255) + ], + MESSAGES: { + 'required': this.i18n('Password is required.'), + 'minlength': this.i18n('Password must be at least 6 characters long.'), + 'maxlength': this.i18n('Password cannot be more than 255 characters long.') + } + } + + this.USER_PASSWORD_OPTIONAL = { + VALIDATORS: [ + Validators.minLength(6), + Validators.maxLength(255) + ], + MESSAGES: { + 'minlength': this.i18n('Password must be at least 6 characters long.'), + 'maxlength': this.i18n('Password cannot be more than 255 characters long.') + } + } + + this.USER_CONFIRM_PASSWORD = { + VALIDATORS: [], + MESSAGES: { + 'matchPassword': this.i18n('The new password and the confirmed password do not correspond.') + } + } + + this.USER_VIDEO_QUOTA = { + VALIDATORS: [ Validators.required, Validators.min(-1) ], + MESSAGES: { + 'required': this.i18n('Video quota is required.'), + 'min': this.i18n('Quota must be greater than -1.') + } + } + this.USER_VIDEO_QUOTA_DAILY = { + VALIDATORS: [ Validators.required, Validators.min(-1) ], + MESSAGES: { + 'required': this.i18n('Daily upload limit is required.'), + 'min': this.i18n('Daily upload limit must be greater than -1.') + } + } + + this.USER_ROLE = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('User role is required.') + } + } + + this.USER_DISPLAY_NAME_REQUIRED = this.getDisplayName(true) + + this.USER_DESCRIPTION = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(1000) + ], + MESSAGES: { + 'minlength': this.i18n('Description must be at least 3 characters long.'), + 'maxlength': this.i18n('Description cannot be more than 1000 characters long.') + } + } + + this.USER_TERMS = { + VALIDATORS: [ + Validators.requiredTrue + ], + MESSAGES: { + 'required': this.i18n('You must agree with the instance terms in order to register on it.') + } + } + + this.USER_BAN_REASON = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(250) + ], + MESSAGES: { + 'minlength': this.i18n('Ban reason must be at least 3 characters long.'), + 'maxlength': this.i18n('Ban reason cannot be more than 250 characters long.') + } + } + } + + private getDisplayName (required: boolean) { + const control = { + VALIDATORS: [ + Validators.minLength(1), + Validators.maxLength(120) + ], + MESSAGES: { + 'required': this.i18n('Display name is required.'), + 'minlength': this.i18n('Display name must be at least 1 character long.'), + 'maxlength': this.i18n('Display name cannot be more than 50 characters long.') + } + } + + if (required) control.VALIDATORS.push(Validators.required) + + return control + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts new file mode 100644 index 000000000..aae56d607 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts @@ -0,0 +1,30 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoAbuseValidatorsService { + readonly VIDEO_ABUSE_REASON: BuildFormValidator + readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_ABUSE_REASON = { + VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], + MESSAGES: { + 'required': this.i18n('Report reason is required.'), + 'minlength': this.i18n('Report reason must be at least 2 characters long.'), + 'maxlength': this.i18n('Report reason cannot be more than 3000 characters long.') + } + } + + this.VIDEO_ABUSE_MODERATION_COMMENT = { + VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], + MESSAGES: { + 'required': this.i18n('Moderation comment is required.'), + 'minlength': this.i18n('Moderation comment must be at least 2 characters long.'), + 'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts new file mode 100644 index 000000000..998d616ec --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts @@ -0,0 +1,18 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoAcceptOwnershipValidatorsService { + readonly CHANNEL: BuildFormValidator + + constructor (private i18n: I18n) { + this.CHANNEL = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('The channel is required.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts new file mode 100644 index 000000000..ddf0ab5eb --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts @@ -0,0 +1,19 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoBlockValidatorsService { + readonly VIDEO_BLOCK_REASON: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_BLOCK_REASON = { + VALIDATORS: [ Validators.minLength(2), Validators.maxLength(300) ], + MESSAGES: { + 'minlength': this.i18n('Block reason must be at least 2 characters long.'), + 'maxlength': this.i18n('Block reason cannot be more than 300 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts new file mode 100644 index 000000000..280d28414 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts @@ -0,0 +1,27 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoCaptionsValidatorsService { + readonly VIDEO_CAPTION_LANGUAGE: BuildFormValidator + readonly VIDEO_CAPTION_FILE: BuildFormValidator + + constructor (private i18n: I18n) { + + this.VIDEO_CAPTION_LANGUAGE = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Video caption language is required.') + } + } + + this.VIDEO_CAPTION_FILE = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Video caption file is required.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts new file mode 100644 index 000000000..59659defd --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts @@ -0,0 +1,27 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { AbstractControl, ValidationErrors, Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoChangeOwnershipValidatorsService { + readonly USERNAME: BuildFormValidator + + constructor (private i18n: I18n) { + this.USERNAME = { + VALIDATORS: [ Validators.required, this.localAccountValidator ], + MESSAGES: { + 'required': this.i18n('The username is required.'), + 'localAccountOnly': this.i18n('You can only transfer ownership to a local account') + } + } + } + + localAccountValidator (control: AbstractControl): ValidationErrors { + if (control.value.includes('@')) { + return { 'localAccountOnly': true } + } + + return null + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts new file mode 100644 index 000000000..bb650b149 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts @@ -0,0 +1,64 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoChannelValidatorsService { + readonly VIDEO_CHANNEL_NAME: BuildFormValidator + readonly VIDEO_CHANNEL_DISPLAY_NAME: BuildFormValidator + readonly VIDEO_CHANNEL_DESCRIPTION: BuildFormValidator + readonly VIDEO_CHANNEL_SUPPORT: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_CHANNEL_NAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(50), + Validators.pattern(/^[a-z0-9][a-z0-9._]*$/) + ], + MESSAGES: { + 'required': this.i18n('Name is required.'), + 'minlength': this.i18n('Name must be at least 1 character long.'), + 'maxlength': this.i18n('Name cannot be more than 50 characters long.'), + 'pattern': this.i18n('Name should be lowercase alphanumeric; dots and underscores are allowed.') + } + } + + this.VIDEO_CHANNEL_DISPLAY_NAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(50) + ], + MESSAGES: { + 'required': i18n('Display name is required.'), + 'minlength': i18n('Display name must be at least 1 character long.'), + 'maxlength': i18n('Display name cannot be more than 50 characters long.') + } + } + + this.VIDEO_CHANNEL_DESCRIPTION = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(1000) + ], + MESSAGES: { + 'minlength': i18n('Description must be at least 3 characters long.'), + 'maxlength': i18n('Description cannot be more than 1000 characters long.') + } + } + + this.VIDEO_CHANNEL_SUPPORT = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(1000) + ], + MESSAGES: { + 'minlength': i18n('Support text must be at least 3 characters long.'), + 'maxlength': i18n('Support text cannot be more than 1000 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts new file mode 100644 index 000000000..97c8e967e --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts @@ -0,0 +1,20 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoCommentValidatorsService { + readonly VIDEO_COMMENT_TEXT: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_COMMENT_TEXT = { + VALIDATORS: [ Validators.required, Validators.minLength(1), Validators.maxLength(3000) ], + MESSAGES: { + 'required': this.i18n('Comment is required.'), + 'minlength': this.i18n('Comment must be at least 2 characters long.'), + 'maxlength': this.i18n('Comment cannot be more than 3000 characters long.') + } + } + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts new file mode 100644 index 000000000..ab9c43625 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts @@ -0,0 +1,66 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { AbstractControl, FormControl, Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' +import { VideoPlaylistPrivacy } from '@shared/models' + +@Injectable() +export class VideoPlaylistValidatorsService { + readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator + readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator + readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator + readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator + + constructor (private i18n: I18n) { + this.VIDEO_PLAYLIST_DISPLAY_NAME = { + VALIDATORS: [ + Validators.required, + Validators.minLength(1), + Validators.maxLength(120) + ], + MESSAGES: { + 'required': this.i18n('Display name is required.'), + 'minlength': this.i18n('Display name must be at least 1 character long.'), + 'maxlength': this.i18n('Display name cannot be more than 120 characters long.') + } + } + + this.VIDEO_PLAYLIST_PRIVACY = { + VALIDATORS: [ + Validators.required + ], + MESSAGES: { + 'required': this.i18n('Privacy is required.') + } + } + + this.VIDEO_PLAYLIST_DESCRIPTION = { + VALIDATORS: [ + Validators.minLength(3), + Validators.maxLength(1000) + ], + MESSAGES: { + 'minlength': i18n('Description must be at least 3 characters long.'), + 'maxlength': i18n('Description cannot be more than 1000 characters long.') + } + } + + this.VIDEO_PLAYLIST_CHANNEL_ID = { + VALIDATORS: [ ], + MESSAGES: { + 'required': this.i18n('The channel is required when the playlist is public.') + } + } + } + + setChannelValidator (channelControl: AbstractControl, privacy: VideoPlaylistPrivacy) { + if (privacy.toString() === VideoPlaylistPrivacy.PUBLIC.toString()) { + channelControl.setValidators([ Validators.required ]) + } else { + channelControl.setValidators(null) + } + + channelControl.markAsDirty() + channelControl.updateValueAndValidity() + } +} diff --git a/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts b/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts new file mode 100644 index 000000000..9b24e4f62 --- /dev/null +++ b/client/src/app/shared/shared-forms/form-validators/video-validators.service.ts @@ -0,0 +1,102 @@ +import { I18n } from '@ngx-translate/i18n-polyfill' +import { Validators } from '@angular/forms' +import { Injectable } from '@angular/core' +import { BuildFormValidator } from './form-validator.service' + +@Injectable() +export class VideoValidatorsService { + readonly VIDEO_NAME: BuildFormValidator + readonly VIDEO_PRIVACY: BuildFormValidator + readonly VIDEO_CATEGORY: BuildFormValidator + readonly VIDEO_LICENCE: BuildFormValidator + readonly VIDEO_LANGUAGE: BuildFormValidator + readonly VIDEO_IMAGE: BuildFormValidator + readonly VIDEO_CHANNEL: BuildFormValidator + readonly VIDEO_DESCRIPTION: BuildFormValidator + readonly VIDEO_TAGS: BuildFormValidator + readonly VIDEO_SUPPORT: BuildFormValidator + readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator + readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator + + constructor (private i18n: I18n) { + + this.VIDEO_NAME = { + VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(120) ], + MESSAGES: { + 'required': this.i18n('Video name is required.'), + 'minlength': this.i18n('Video name must be at least 3 characters long.'), + 'maxlength': this.i18n('Video name cannot be more than 120 characters long.') + } + } + + this.VIDEO_PRIVACY = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Video privacy is required.') + } + } + + this.VIDEO_CATEGORY = { + VALIDATORS: [ ], + MESSAGES: {} + } + + this.VIDEO_LICENCE = { + VALIDATORS: [ ], + MESSAGES: {} + } + + this.VIDEO_LANGUAGE = { + VALIDATORS: [ ], + MESSAGES: {} + } + + this.VIDEO_IMAGE = { + VALIDATORS: [ ], + MESSAGES: {} + } + + this.VIDEO_CHANNEL = { + VALIDATORS: [ Validators.required ], + MESSAGES: { + 'required': this.i18n('Video channel is required.') + } + } + + this.VIDEO_DESCRIPTION = { + VALIDATORS: [ Validators.minLength(3), Validators.maxLength(10000) ], + MESSAGES: { + 'minlength': this.i18n('Video description must be at least 3 characters long.'), + 'maxlength': this.i18n('Video description cannot be more than 10000 characters long.') + } + } + + this.VIDEO_TAGS = { + VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ], + MESSAGES: { + 'minlength': this.i18n('A tag should be more than 2 characters long.'), + 'maxlength': this.i18n('A tag should be less than 30 characters long.') + } + } + + this.VIDEO_SUPPORT = { + VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ], + MESSAGES: { + 'minlength': this.i18n('Video support must be at least 3 characters long.'), + 'maxlength': this.i18n('Video support cannot be more than 1000 characters long.') + } + } + + this.VIDEO_SCHEDULE_PUBLICATION_AT = { + VALIDATORS: [ ], + MESSAGES: { + 'required': this.i18n('A date is required to schedule video update.') + } + } + + this.VIDEO_ORIGINALLY_PUBLISHED_AT = { + VALIDATORS: [ ], + MESSAGES: {} + } + } +} diff --git a/client/src/app/shared/shared-forms/index.ts b/client/src/app/shared/shared-forms/index.ts new file mode 100644 index 000000000..aa0ee015a --- /dev/null +++ b/client/src/app/shared/shared-forms/index.ts @@ -0,0 +1,10 @@ +export * from './form-validators' +export * from './form-reactive' +export * from './input-readonly-copy.component' +export * from './markdown-textarea.component' +export * from './peertube-checkbox.component' +export * from './preview-upload.component' +export * from './reactive-file.component' +export * from './textarea-autoresize.directive' +export * from './timestamp-input.component' +export * from './shared-form.module' diff --git a/client/src/app/shared/shared-forms/input-readonly-copy.component.html b/client/src/app/shared/shared-forms/input-readonly-copy.component.html new file mode 100644 index 000000000..9566e9741 --- /dev/null +++ b/client/src/app/shared/shared-forms/input-readonly-copy.component.html @@ -0,0 +1,9 @@ +
+ + +
+ +
+
diff --git a/client/src/app/shared/shared-forms/input-readonly-copy.component.scss b/client/src/app/shared/shared-forms/input-readonly-copy.component.scss new file mode 100644 index 000000000..8dc4f113c --- /dev/null +++ b/client/src/app/shared/shared-forms/input-readonly-copy.component.scss @@ -0,0 +1,3 @@ +input.readonly { + font-size: 15px; +} diff --git a/client/src/app/shared/shared-forms/input-readonly-copy.component.ts b/client/src/app/shared/shared-forms/input-readonly-copy.component.ts new file mode 100644 index 000000000..7528fb7a1 --- /dev/null +++ b/client/src/app/shared/shared-forms/input-readonly-copy.component.ts @@ -0,0 +1,21 @@ +import { Component, Input } from '@angular/core' +import { Notifier } from '@app/core' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'my-input-readonly-copy', + templateUrl: './input-readonly-copy.component.html', + styleUrls: [ './input-readonly-copy.component.scss' ] +}) +export class InputReadonlyCopyComponent { + @Input() value = '' + + constructor ( + private notifier: Notifier, + private i18n: I18n + ) { } + + activateCopiedMessage () { + this.notifier.success(this.i18n('Copied')) + } +} diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html new file mode 100644 index 000000000..a519f3e0a --- /dev/null +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html @@ -0,0 +1,36 @@ +
+ + + + +
+
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.scss b/client/src/app/shared/shared-forms/markdown-textarea.component.scss new file mode 100644 index 000000000..f2c76f7a1 --- /dev/null +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.scss @@ -0,0 +1,251 @@ +@import '_variables'; +@import '_mixins'; + +$nav-preview-tab-height: 30px; +$base-padding: 15px; +$input-border-color: #C6C6C6; +$input-border-radius: 3px; + +@mixin in-small-view { + .root { + display: flex; + flex-direction: column; + + textarea { + @include peertube-textarea(100%, 150px); + + background-color: pvar(--markdownTextareaBackgroundColor); + + font-family: monospace; + font-size: 13px; + border-bottom: none; + border-bottom-left-radius: unset; + border-bottom-right-radius: unset; + } + + .nav-preview { + display: block; + text-align: right; + padding-top: 10px; + padding-bottom: 10px; + padding-left: 10px; + padding-right: 10px; + border-top: 1px dashed $input-border-color; + border-left: 1px solid $input-border-color; + border-right: 1px solid $input-border-color; + border-bottom: 1px solid $input-border-color; + border-bottom-right-radius: $input-border-radius; + + border-bottom-left-radius: $input-border-radius; + ::ng-deep { + .nav-link { + display: none !important; + } + + .grey-button { + padding: 0 12px 0 12px; + } + } + } + + ::ng-deep { + .tab-content { + display: none; + } + } + } +} + +@mixin nav-preview-medium { + display: flex; + flex-grow: 1; + border-bottom-left-radius: unset; + border-bottom-right-radius: unset; + border-bottom: 2px solid pvar(--mainColor); + + :first-child { + margin-left: auto; + } + + ::ng-deep { + .nav-link { + display: flex !important; + align-items: center; + height: $nav-preview-tab-height !important; + padding: 0 15px !important; + font-size: 85% !important; + opacity: .7; + } + + .grey-button { + margin-left: 5px; + } + } +} + +@mixin content-preview-base { + display: block; + min-height: 75px; + padding: $base-padding; + overflow-y: auto; + font-size: 15px; + word-wrap: break-word; +} + +@mixin maximized-base { + flex-direction: row; + z-index: #{z(header) - 1}; + position: fixed; + top: $header-height; + left: $menu-width; + max-height: none !important; + max-width: none !important; + width: calc(100% - #{$menu-width}); + height: calc(100vh - #{$header-height}) !important; + + $nav-preview-vertical-padding: 40px; + + .nav-preview { + @include nav-preview-medium(); + padding-top: #{$nav-preview-vertical-padding / 2}; + padding-bottom: #{$nav-preview-vertical-padding / 2}; + padding-left: 0px; + padding-right: 0px; + position: absolute; + background-color: pvar(--mainBackgroundColor); + width: 100% !important; + border-top: none; + border-left: none; + border-right: none; + + :last-child { + margin-right: $not-expanded-horizontal-margins; + } + } + + ::ng-deep .tab-content { + @include content-preview-base(); + background-color: pvar(--mainBackgroundColor); + scrollbar-color: pvar(--actionButtonColor) pvar(--mainBackgroundColor); + } + + textarea, + ::ng-deep .tab-content { + max-height: none !important; + max-width: none !important; + margin-top: #{$nav-preview-tab-height + $nav-preview-vertical-padding} !important; + height: calc(100vh - #{$header-height + $nav-preview-tab-height + $nav-preview-vertical-padding}) !important; + width: 50% !important; + border: none !important; + border-radius: unset !important; + } + + :host-context(.expanded) { + .root.maximized { + left: 0; + width: 100%; + } + } +} + +@mixin maximized-in-small-view { + .root.maximized { + @include maximized-base(); + + textarea { + display: none; + } + + ::ng-deep .tab-content { + width: 100% !important; + } + } +} + +@mixin maximized-tabs-in-mobile-view { + // Ellipsis on tabs for mobile view + .root.maximized { + .nav-preview { + ::ng-deep .nav-link { + @include ellipsis(); + + display: block !important; + max-width: 45% !important; + padding: 5px 0 !important; + margin-right: 10px !important; + text-align: center; + + &:not(.active) { + max-width: 15% !important; + } + + &.active { + padding: 5px 15px !important; + } + } + } + } +} + +@mixin in-medium-view { + .root { + .nav-preview { + @include nav-preview-medium(); + } + + ::ng-deep .tab-content { + @include content-preview-base(); + max-height: 210px; + border-bottom: 1px solid $input-border-color; + border-left: 1px solid $input-border-color; + border-right: 1px solid $input-border-color; + border-bottom-left-radius: $input-border-radius; + border-bottom-right-radius: $input-border-radius; + } + } +} + +@mixin maximized-in-medium-view { + .root.maximized { + @include maximized-base(); + + textarea { + display: block; + padding: $base-padding; + border-right: 1px dashed $input-border-color !important; + resize: none; + scrollbar-color: pvar(--actionButtonColor) pvar(--markdownTextareaBackgroundColor); + + &:focus { + box-shadow: none; + } + } + } +} + +@include in-small-view(); +@include maximized-in-small-view(); + +@media only screen and (max-width: $mobile-view) { + @include maximized-tabs-in-mobile-view(); +} + +@media only screen and (max-width: #{$mobile-view + $menu-width}) { + :host-context(.main-col:not(.expanded)) { + @include maximized-tabs-in-mobile-view(); + } +} + +@media only screen and (min-width: $small-view) { + :host-context(.expanded) { + @include in-medium-view(); + } + + @include maximized-in-medium-view(); +} + +@media only screen and (min-width: #{$small-view + $menu-width}) { + :host-context(.main-col:not(.expanded)) { + @include in-medium-view(); + } +} diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts new file mode 100644 index 000000000..8dad5314c --- /dev/null +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts @@ -0,0 +1,110 @@ +import truncate from 'lodash-es/truncate' +import { Subject } from 'rxjs' +import { debounceTime, distinctUntilChanged } from 'rxjs/operators' +import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { MarkdownService } from '@app/core' + +@Component({ + selector: 'my-markdown-textarea', + templateUrl: './markdown-textarea.component.html', + styleUrls: [ './markdown-textarea.component.scss' ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkdownTextareaComponent), + multi: true + } + ] +}) + +export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { + @Input() content = '' + @Input() classes: string[] | { [klass: string]: any[] | any } = [] + @Input() textareaMaxWidth = '100%' + @Input() textareaHeight = '150px' + @Input() truncate: number + @Input() markdownType: 'text' | 'enhanced' = 'text' + @Input() markdownVideo = false + @Input() name = 'description' + + @ViewChild('textarea') textareaElement: ElementRef + + truncatedPreviewHTML = '' + previewHTML = '' + isMaximized = false + + private contentChanged = new Subject() + + constructor (private markdownService: MarkdownService) {} + + ngOnInit () { + this.contentChanged + .pipe( + debounceTime(150), + distinctUntilChanged() + ) + .subscribe(() => this.updatePreviews()) + + this.contentChanged.next(this.content) + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (description: string) { + this.content = description + + this.contentChanged.next(this.content) + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.propagateChange(this.content) + + this.contentChanged.next(this.content) + } + + onMaximizeClick () { + this.isMaximized = !this.isMaximized + + // Make sure textarea have the focus + this.textareaElement.nativeElement.focus() + + // Make sure the window has no scrollbars + if (!this.isMaximized) { + this.unlockBodyScroll() + } else { + this.lockBodyScroll() + } + } + + private lockBodyScroll () { + document.getElementById('content').classList.add('lock-scroll') + } + + private unlockBodyScroll () { + document.getElementById('content').classList.remove('lock-scroll') + } + + private async updatePreviews () { + if (this.content === null || this.content === undefined) return + + this.truncatedPreviewHTML = await this.markdownRender(truncate(this.content, { length: this.truncate })) + this.previewHTML = await this.markdownRender(this.content) + } + + private async markdownRender (text: string) { + const html = this.markdownType === 'text' ? + await this.markdownService.textMarkdownToHTML(text) : + await this.markdownService.enhancedMarkdownToHTML(text) + + return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html + } +} diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.html b/client/src/app/shared/shared-forms/peertube-checkbox.component.html new file mode 100644 index 000000000..704f3e696 --- /dev/null +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.html @@ -0,0 +1,45 @@ +
+
+ + + + + + + + + +
+ +
+ + + + + + + +
+
diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.scss b/client/src/app/shared/shared-forms/peertube-checkbox.component.scss new file mode 100644 index 000000000..cf8540dc3 --- /dev/null +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.scss @@ -0,0 +1,52 @@ +@import '_variables'; +@import '_mixins'; + +.root { + display: flex; + + .form-group-checkbox { + display: flex; + align-items: center; + + .label-text { + font-weight: $font-regular; + margin: 0; + } + + input { + @include peertube-checkbox(1px); + } + } + + label { + margin-bottom: 0; + } + + my-help { + position: relative; + top: 2px; + } + + small { + font-size: 90%; + } + + .wrapper:empty { + display: none; + } + + .recommended { + margin-left: .5rem; + align-self: baseline; + display: inline-block; + padding: 4px 6px; + cursor: default; + border-radius: 3px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: pvar(--inputPlaceholderColor); + background-color: rgba(217,225,232,.1); + border: 1px solid rgba(217,225,232,.5); + } +} \ No newline at end of file diff --git a/client/src/app/shared/shared-forms/peertube-checkbox.component.ts b/client/src/app/shared/shared-forms/peertube-checkbox.component.ts new file mode 100644 index 000000000..76ef77e5a --- /dev/null +++ b/client/src/app/shared/shared-forms/peertube-checkbox.component.ts @@ -0,0 +1,73 @@ +import { AfterContentInit, ChangeDetectorRef, Component, ContentChildren, forwardRef, Input, QueryList, TemplateRef } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { PeerTubeTemplateDirective } from '@app/shared/shared-main' + +@Component({ + selector: 'my-peertube-checkbox', + styleUrls: [ './peertube-checkbox.component.scss' ], + templateUrl: './peertube-checkbox.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PeertubeCheckboxComponent), + multi: true + } + ] +}) +export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterContentInit { + @Input() checked = false + @Input() inputName: string + @Input() labelText: string + @Input() labelInnerHTML: string + @Input() helpPlacement = 'top auto' + @Input() disabled = false + @Input() recommended = false + + @ContentChildren(PeerTubeTemplateDirective) templates: QueryList> + + // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836 + @Input() onPushWorkaround = false + + labelTemplate: TemplateRef + helpTemplate: TemplateRef + + constructor (private cdr: ChangeDetectorRef) { } + + ngAfterContentInit () { + { + const t = this.templates.find(t => t.name === 'label') + if (t) this.labelTemplate = t.template + } + + { + const t = this.templates.find(t => t.name === 'help') + if (t) this.helpTemplate = t.template + } + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (checked: boolean) { + this.checked = checked + + if (this.onPushWorkaround) { + this.cdr.markForCheck() + } + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.propagateChange(this.checked) + } + + setDisabledState (isDisabled: boolean) { + this.disabled = isDisabled + } +} diff --git a/client/src/app/shared/shared-forms/preview-upload.component.html b/client/src/app/shared/shared-forms/preview-upload.component.html new file mode 100644 index 000000000..7c3a2b588 --- /dev/null +++ b/client/src/app/shared/shared-forms/preview-upload.component.html @@ -0,0 +1,11 @@ +
+
+ + + +
+
+
diff --git a/client/src/app/shared/shared-forms/preview-upload.component.scss b/client/src/app/shared/shared-forms/preview-upload.component.scss new file mode 100644 index 000000000..88eccd5f7 --- /dev/null +++ b/client/src/app/shared/shared-forms/preview-upload.component.scss @@ -0,0 +1,29 @@ +@import '_variables'; +@import '_mixins'; + +.root { + height: auto; + display: flex; + flex-direction: column; + + .preview-container { + position: relative; + + my-reactive-file { + position: absolute; + bottom: 10px; + left: 10px; + } + + .preview { + object-fit: cover; + border-radius: 4px; + max-width: 100%; + + &.no-image { + border: 2px solid grey; + background-color: pvar(--mainBackgroundColor); + } + } + } +} diff --git a/client/src/app/shared/shared-forms/preview-upload.component.ts b/client/src/app/shared/shared-forms/preview-upload.component.ts new file mode 100644 index 000000000..7519734ba --- /dev/null +++ b/client/src/app/shared/shared-forms/preview-upload.component.ts @@ -0,0 +1,92 @@ +import { Component, forwardRef, Input, OnInit } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' +import { ServerService } from '@app/core' +import { ServerConfig } from '@shared/models' +import { BytesPipe } from 'ngx-pipes' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'my-preview-upload', + styleUrls: [ './preview-upload.component.scss' ], + templateUrl: './preview-upload.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PreviewUploadComponent), + multi: true + } + ] +}) +export class PreviewUploadComponent implements OnInit, ControlValueAccessor { + @Input() inputLabel: string + @Input() inputName: string + @Input() previewWidth: string + @Input() previewHeight: string + + imageSrc: SafeResourceUrl + allowedExtensionsMessage = '' + maxSizeText: string + + private serverConfig: ServerConfig + private bytesPipe: BytesPipe + private file: Blob + + constructor ( + private sanitizer: DomSanitizer, + private serverService: ServerService, + private i18n: I18n + ) { + this.bytesPipe = new BytesPipe() + this.maxSizeText = this.i18n('max size') + } + + get videoImageExtensions () { + return this.serverConfig.video.image.extensions + } + + get maxVideoImageSize () { + return this.serverConfig.video.image.size.max + } + + get maxVideoImageSizeInBytes () { + return this.bytesPipe.transform(this.maxVideoImageSize) + } + + ngOnInit () { + this.serverConfig = this.serverService.getTmpConfig() + this.serverService.getConfig() + .subscribe(config => this.serverConfig = config) + + this.allowedExtensionsMessage = this.videoImageExtensions.join(', ') + } + + onFileChanged (file: Blob) { + this.file = file + + this.propagateChange(this.file) + this.updatePreview() + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (file: any) { + this.file = file + this.updatePreview() + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + private updatePreview () { + if (this.file) { + const url = URL.createObjectURL(this.file) + this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url) + } + } +} diff --git a/client/src/app/shared/shared-forms/reactive-file.component.html b/client/src/app/shared/shared-forms/reactive-file.component.html new file mode 100644 index 000000000..f6bf5f9ae --- /dev/null +++ b/client/src/app/shared/shared-forms/reactive-file.component.html @@ -0,0 +1,15 @@ +
+
+ + + {{ inputLabel }} + + +
+ +
{{ filename }}
+
diff --git a/client/src/app/shared/shared-forms/reactive-file.component.scss b/client/src/app/shared/shared-forms/reactive-file.component.scss new file mode 100644 index 000000000..84c23c1d6 --- /dev/null +++ b/client/src/app/shared/shared-forms/reactive-file.component.scss @@ -0,0 +1,22 @@ +@import '_variables'; +@import '_mixins'; + +.root { + height: auto; + display: flex; + align-items: center; + + .button-file { + @include peertube-button-file(auto); + @include grey-button; + + &.with-icon { + @include button-with-icon; + } + } + + .filename { + font-weight: $font-semibold; + margin-left: 5px; + } +} diff --git a/client/src/app/shared/shared-forms/reactive-file.component.ts b/client/src/app/shared/shared-forms/reactive-file.component.ts new file mode 100644 index 000000000..9ebf487ce --- /dev/null +++ b/client/src/app/shared/shared-forms/reactive-file.component.ts @@ -0,0 +1,91 @@ +import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { Notifier } from '@app/core' +import { GlobalIconName } from '@app/shared/shared-icons' +import { I18n } from '@ngx-translate/i18n-polyfill' + +@Component({ + selector: 'my-reactive-file', + styleUrls: [ './reactive-file.component.scss' ], + templateUrl: './reactive-file.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ReactiveFileComponent), + multi: true + } + ] +}) +export class ReactiveFileComponent implements OnInit, ControlValueAccessor { + @Input() inputLabel: string + @Input() inputName: string + @Input() extensions: string[] = [] + @Input() maxFileSize: number + @Input() displayFilename = false + @Input() icon: GlobalIconName + + @Output() fileChanged = new EventEmitter() + + allowedExtensionsMessage = '' + fileInputValue: any + + private file: File + + constructor ( + private notifier: Notifier, + private i18n: I18n + ) {} + + get filename () { + if (!this.file) return '' + + return this.file.name + } + + ngOnInit () { + this.allowedExtensionsMessage = this.extensions.join(', ') + } + + fileChange (event: any) { + if (event.target.files && event.target.files.length) { + const [ file ] = event.target.files + + if (file.size > this.maxFileSize) { + this.notifier.error(this.i18n('This file is too large.')) + return + } + + const extension = '.' + file.name.split('.').pop() + if (this.extensions.includes(extension) === false) { + const message = this.i18n( + 'PeerTube cannot handle this kind of file. Accepted extensions are {{extensions}}.', + { extensions: this.allowedExtensionsMessage } + ) + this.notifier.error(message) + + return + } + + this.file = file + + this.propagateChange(this.file) + this.fileChanged.emit(this.file) + } + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (file: any) { + this.file = file + + if (!this.file) this.fileInputValue = null + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } +} diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts new file mode 100644 index 000000000..e82fa97d4 --- /dev/null +++ b/client/src/app/shared/shared-forms/shared-form.module.ts @@ -0,0 +1,84 @@ + +import { NgModule } from '@angular/core' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { BatchDomainsValidatorsService } from '@app/shared/shared-forms/form-validators/batch-domains-validators.service' +import { SharedGlobalIconModule } from '../shared-icons' +import { SharedMainModule } from '../shared-main/shared-main.module' +import { + CustomConfigValidatorsService, + FormValidatorService, + InstanceValidatorsService, + LoginValidatorsService, + ResetPasswordValidatorsService, + UserValidatorsService, + VideoAbuseValidatorsService, + VideoAcceptOwnershipValidatorsService, + VideoBlockValidatorsService, + VideoCaptionsValidatorsService, + VideoChangeOwnershipValidatorsService, + VideoChannelValidatorsService, + VideoCommentValidatorsService, + VideoPlaylistValidatorsService, + VideoValidatorsService +} from './form-validators' +import { InputReadonlyCopyComponent } from './input-readonly-copy.component' +import { MarkdownTextareaComponent } from './markdown-textarea.component' +import { PeertubeCheckboxComponent } from './peertube-checkbox.component' +import { PreviewUploadComponent } from './preview-upload.component' +import { ReactiveFileComponent } from './reactive-file.component' +import { TextareaAutoResizeDirective } from './textarea-autoresize.directive' +import { TimestampInputComponent } from './timestamp-input.component' + +@NgModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + + SharedMainModule, + SharedGlobalIconModule + ], + + declarations: [ + InputReadonlyCopyComponent, + MarkdownTextareaComponent, + PeertubeCheckboxComponent, + PreviewUploadComponent, + ReactiveFileComponent, + TextareaAutoResizeDirective, + TimestampInputComponent + ], + + exports: [ + FormsModule, + ReactiveFormsModule, + + InputReadonlyCopyComponent, + MarkdownTextareaComponent, + PeertubeCheckboxComponent, + PreviewUploadComponent, + ReactiveFileComponent, + TextareaAutoResizeDirective, + TimestampInputComponent + ], + + providers: [ + CustomConfigValidatorsService, + FormValidatorService, + LoginValidatorsService, + InstanceValidatorsService, + LoginValidatorsService, + ResetPasswordValidatorsService, + UserValidatorsService, + VideoAbuseValidatorsService, + VideoAcceptOwnershipValidatorsService, + VideoBlockValidatorsService, + VideoCaptionsValidatorsService, + VideoChangeOwnershipValidatorsService, + VideoChannelValidatorsService, + VideoCommentValidatorsService, + VideoPlaylistValidatorsService, + VideoValidatorsService, + BatchDomainsValidatorsService + ] +}) +export class SharedFormModule { } diff --git a/client/src/app/shared/shared-forms/textarea-autoresize.directive.ts b/client/src/app/shared/shared-forms/textarea-autoresize.directive.ts new file mode 100644 index 000000000..f8c855c16 --- /dev/null +++ b/client/src/app/shared/shared-forms/textarea-autoresize.directive.ts @@ -0,0 +1,25 @@ +// Thanks: https://github.com/evseevdev/ngx-textarea-autosize +import { AfterViewInit, Directive, ElementRef, HostBinding, HostListener } from '@angular/core' + +@Directive({ + selector: 'textarea[myAutoResize]' +}) +export class TextareaAutoResizeDirective implements AfterViewInit { + @HostBinding('attr.rows') rows = '1' + @HostBinding('style.overflow') overflow = 'hidden' + + constructor (private elem: ElementRef) { } + + public ngAfterViewInit () { + this.resize() + } + + @HostListener('input') + resize () { + const textarea = this.elem.nativeElement as HTMLTextAreaElement + // Reset textarea height to auto that correctly calculate the new height + textarea.style.height = 'auto' + // Set new height + textarea.style.height = `${textarea.scrollHeight}px` + } +} diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.html b/client/src/app/shared/shared-forms/timestamp-input.component.html new file mode 100644 index 000000000..c57a4b32c --- /dev/null +++ b/client/src/app/shared/shared-forms/timestamp-input.component.html @@ -0,0 +1,4 @@ + diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.scss b/client/src/app/shared/shared-forms/timestamp-input.component.scss new file mode 100644 index 000000000..8092b095b --- /dev/null +++ b/client/src/app/shared/shared-forms/timestamp-input.component.scss @@ -0,0 +1,15 @@ +@import 'variables'; + +p-inputmask { + ::ng-deep input { + width: 80px; + font-size: 15px; + + border: none; + + &:focus-within, + &:focus { + box-shadow: #{$focus-box-shadow-form} pvar(--mainColorLightest); + } + } +} diff --git a/client/src/app/shared/shared-forms/timestamp-input.component.ts b/client/src/app/shared/shared-forms/timestamp-input.component.ts new file mode 100644 index 000000000..8d67a96ac --- /dev/null +++ b/client/src/app/shared/shared-forms/timestamp-input.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core' +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' +import { secondsToTime, timeToInt } from '../../../assets/player/utils' + +@Component({ + selector: 'my-timestamp-input', + styleUrls: [ './timestamp-input.component.scss' ], + templateUrl: './timestamp-input.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimestampInputComponent), + multi: true + } + ] +}) +export class TimestampInputComponent implements ControlValueAccessor, OnInit { + @Input() maxTimestamp: number + @Input() timestamp: number + @Input() disabled = false + + timestampString: string + + constructor (private changeDetector: ChangeDetectorRef) {} + + ngOnInit () { + this.writeValue(this.timestamp || 0) + } + + propagateChange = (_: any) => { /* empty */ } + + writeValue (timestamp: number) { + this.timestamp = timestamp + + this.timestampString = secondsToTime(this.timestamp, true, ':') + } + + registerOnChange (fn: (_: any) => void) { + this.propagateChange = fn + } + + registerOnTouched () { + // Unused + } + + onModelChange () { + this.timestamp = timeToInt(this.timestampString) + + this.propagateChange(this.timestamp) + } + + onBlur () { + if (this.maxTimestamp && this.timestamp > this.maxTimestamp) { + this.writeValue(this.maxTimestamp) + + this.changeDetector.detectChanges() + + this.propagateChange(this.timestamp) + } + } +} -- cgit v1.2.3