aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/shared/shared-forms
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2020-06-23 14:10:17 +0200
committerChocobozzz <chocobozzz@cpy.re>2020-06-23 16:00:49 +0200
commit67ed6552b831df66713bac9e672738796128d33f (patch)
tree59c97d41e0b49d75a90aa3de987968ab9b1ff447 /client/src/app/shared/shared-forms
parent0c4bacbff53bc732f5a2677d62a6ead7752e2405 (diff)
downloadPeerTube-67ed6552b831df66713bac9e672738796128d33f.tar.gz
PeerTube-67ed6552b831df66713bac9e672738796128d33f.tar.zst
PeerTube-67ed6552b831df66713bac9e672738796128d33f.zip
Reorganize client shared modules
Diffstat (limited to 'client/src/app/shared/shared-forms')
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.ts69
-rw-r--r--client/src/app/shared/shared-forms/form-validators/batch-domains-validators.service.ts69
-rw-r--r--client/src/app/shared/shared-forms/form-validators/custom-config-validators.service.ts98
-rw-r--r--client/src/app/shared/shared-forms/form-validators/form-validator.service.ts87
-rw-r--r--client/src/app/shared/shared-forms/form-validators/host.ts8
-rw-r--r--client/src/app/shared/shared-forms/form-validators/index.ts17
-rw-r--r--client/src/app/shared/shared-forms/form-validators/instance-validators.service.ts62
-rw-r--r--client/src/app/shared/shared-forms/form-validators/login-validators.service.ts30
-rw-r--r--client/src/app/shared/shared-forms/form-validators/reset-password-validators.service.ts20
-rw-r--r--client/src/app/shared/shared-forms/form-validators/user-validators.service.ts151
-rw-r--r--client/src/app/shared/shared-forms/form-validators/video-abuse-validators.service.ts30
-rw-r--r--client/src/app/shared/shared-forms/form-validators/video-accept-ownership-validators.service.ts18
-rw-r--r--client/src/app/shared/shared-forms/form-validators/video-block-validators.service.ts19
-rw-r--r--client/src/app/shared/shared-forms/form-validators/video-captions-validators.service.ts27
-rw-r--r--client/src/app/shared/shared-forms/form-validators/video-change-ownership-validators.service.ts27
-rw-r--r--client/src/app/shared/shared-forms/form-validators/video-channel-validators.service.ts64
-rw-r--r--client/src/app/shared/shared-forms/form-validators/video-comment-validators.service.ts20
-rw-r--r--client/src/app/shared/shared-forms/form-validators/video-playlist-validators.service.ts66
-rw-r--r--client/src/app/shared/shared-forms/form-validators/video-validators.service.ts102
-rw-r--r--client/src/app/shared/shared-forms/index.ts10
-rw-r--r--client/src/app/shared/shared-forms/input-readonly-copy.component.html9
-rw-r--r--client/src/app/shared/shared-forms/input-readonly-copy.component.scss3
-rw-r--r--client/src/app/shared/shared-forms/input-readonly-copy.component.ts21
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.html36
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.scss251
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.ts110
-rw-r--r--client/src/app/shared/shared-forms/peertube-checkbox.component.html45
-rw-r--r--client/src/app/shared/shared-forms/peertube-checkbox.component.scss52
-rw-r--r--client/src/app/shared/shared-forms/peertube-checkbox.component.ts73
-rw-r--r--client/src/app/shared/shared-forms/preview-upload.component.html11
-rw-r--r--client/src/app/shared/shared-forms/preview-upload.component.scss29
-rw-r--r--client/src/app/shared/shared-forms/preview-upload.component.ts92
-rw-r--r--client/src/app/shared/shared-forms/reactive-file.component.html15
-rw-r--r--client/src/app/shared/shared-forms/reactive-file.component.scss22
-rw-r--r--client/src/app/shared/shared-forms/reactive-file.component.ts91
-rw-r--r--client/src/app/shared/shared-forms/shared-form.module.ts84
-rw-r--r--client/src/app/shared/shared-forms/textarea-autoresize.directive.ts25
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.html4
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.scss15
-rw-r--r--client/src/app/shared/shared-forms/timestamp-input.component.ts61
40 files changed, 2043 insertions, 0 deletions
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 @@
1import { FormGroup } from '@angular/forms'
2import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from './form-validators'
3
4export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
5export type FormReactiveValidationMessages = {
6 [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
7}
8
9export abstract class FormReactive {
10 protected abstract formValidatorService: FormValidatorService
11 protected formChanged = false
12
13 form: FormGroup
14 formErrors: any // To avoid casting in template because of string | FormReactiveErrors
15 validationMessages: FormReactiveValidationMessages
16
17 buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
18 const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
19
20 this.form = form
21 this.formErrors = formErrors
22 this.validationMessages = validationMessages
23
24 this.form.valueChanges.subscribe(() => this.onValueChanged(this.form, this.formErrors, this.validationMessages, false))
25 }
26
27 protected forceCheck () {
28 return this.onValueChanged(this.form, this.formErrors, this.validationMessages, true)
29 }
30
31 protected check () {
32 return this.onValueChanged(this.form, this.formErrors, this.validationMessages, false)
33 }
34
35 private onValueChanged (
36 form: FormGroup,
37 formErrors: FormReactiveErrors,
38 validationMessages: FormReactiveValidationMessages,
39 forceCheck = false
40 ) {
41 for (const field of Object.keys(formErrors)) {
42 if (formErrors[field] && typeof formErrors[field] === 'object') {
43 this.onValueChanged(
44 form.controls[field] as FormGroup,
45 formErrors[field] as FormReactiveErrors,
46 validationMessages[field] as FormReactiveValidationMessages,
47 forceCheck
48 )
49 continue
50 }
51
52 // clear previous error message (if any)
53 formErrors[ field ] = ''
54 const control = form.get(field)
55
56 if (control.dirty) this.formChanged = true
57
58 // Don't care if dirty on force check
59 const isDirty = control.dirty || forceCheck === true
60 if (control && isDirty && control.enabled && !control.valid) {
61 const messages = validationMessages[ field ]
62 for (const key of Object.keys(control.errors)) {
63 formErrors[ field ] += messages[ key ] + ' '
64 }
65 }
66 }
67 }
68
69}
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 @@
1import { Injectable } from '@angular/core'
2import { ValidatorFn, Validators } from '@angular/forms'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4import { BuildFormValidator } from './form-validator.service'
5import { validateHost } from './host'
6
7@Injectable()
8export class BatchDomainsValidatorsService {
9 readonly DOMAINS: BuildFormValidator
10
11 constructor (private i18n: I18n) {
12 this.DOMAINS = {
13 VALIDATORS: [ Validators.required, this.validDomains, this.isHostsUnique ],
14 MESSAGES: {
15 'required': this.i18n('Domain is required.'),
16 'validDomains': this.i18n('Domains entered are invalid.'),
17 'uniqueDomains': this.i18n('Domains entered contain duplicates.')
18 }
19 }
20 }
21
22 getNotEmptyHosts (hosts: string) {
23 return hosts
24 .split('\n')
25 .filter((host: string) => host && host.length !== 0) // Eject empty hosts
26 }
27
28 private validDomains: ValidatorFn = (control) => {
29 if (!control.value) return null
30
31 const newHostsErrors = []
32 const hosts = this.getNotEmptyHosts(control.value)
33
34 for (const host of hosts) {
35 if (validateHost(host) === false) {
36 newHostsErrors.push(this.i18n('{{host}} is not valid', { host }))
37 }
38 }
39
40 /* Is not valid. */
41 if (newHostsErrors.length !== 0) {
42 return {
43 'validDomains': {
44 reason: 'invalid',
45 value: newHostsErrors.join('. ') + '.'
46 }
47 }
48 }
49
50 /* Is valid. */
51 return null
52 }
53
54 private isHostsUnique: ValidatorFn = (control) => {
55 if (!control.value) return null
56
57 const hosts = this.getNotEmptyHosts(control.value)
58
59 if (hosts.every((host: string) => hosts.indexOf(host) === hosts.lastIndexOf(host))) {
60 return null
61 } else {
62 return {
63 'uniqueDomains': {
64 reason: 'invalid'
65 }
66 }
67 }
68 }
69}
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 @@
1import { Validators } from '@angular/forms'
2import { I18n } from '@ngx-translate/i18n-polyfill'
3import { BuildFormValidator } from './form-validator.service'
4import { Injectable } from '@angular/core'
5
6@Injectable()
7export class CustomConfigValidatorsService {
8 readonly INSTANCE_NAME: BuildFormValidator
9 readonly INSTANCE_SHORT_DESCRIPTION: BuildFormValidator
10 readonly SERVICES_TWITTER_USERNAME: BuildFormValidator
11 readonly CACHE_PREVIEWS_SIZE: BuildFormValidator
12 readonly CACHE_CAPTIONS_SIZE: BuildFormValidator
13 readonly SIGNUP_LIMIT: BuildFormValidator
14 readonly ADMIN_EMAIL: BuildFormValidator
15 readonly TRANSCODING_THREADS: BuildFormValidator
16 readonly INDEX_URL: BuildFormValidator
17 readonly SEARCH_INDEX_URL: BuildFormValidator
18
19 constructor (private i18n: I18n) {
20 this.INSTANCE_NAME = {
21 VALIDATORS: [ Validators.required ],
22 MESSAGES: {
23 'required': this.i18n('Instance name is required.')
24 }
25 }
26
27 this.INSTANCE_SHORT_DESCRIPTION = {
28 VALIDATORS: [ Validators.max(250) ],
29 MESSAGES: {
30 'max': this.i18n('Short description should not be longer than 250 characters.')
31 }
32 }
33
34 this.SERVICES_TWITTER_USERNAME = {
35 VALIDATORS: [ Validators.required ],
36 MESSAGES: {
37 'required': this.i18n('Twitter username is required.')
38 }
39 }
40
41 this.CACHE_PREVIEWS_SIZE = {
42 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
43 MESSAGES: {
44 'required': this.i18n('Previews cache size is required.'),
45 'min': this.i18n('Previews cache size must be greater than 1.'),
46 'pattern': this.i18n('Previews cache size must be a number.')
47 }
48 }
49
50 this.CACHE_CAPTIONS_SIZE = {
51 VALIDATORS: [ Validators.required, Validators.min(1), Validators.pattern('[0-9]+') ],
52 MESSAGES: {
53 'required': this.i18n('Captions cache size is required.'),
54 'min': this.i18n('Captions cache size must be greater than 1.'),
55 'pattern': this.i18n('Captions cache size must be a number.')
56 }
57 }
58
59 this.SIGNUP_LIMIT = {
60 VALIDATORS: [ Validators.required, Validators.min(-1), Validators.pattern('-?[0-9]+') ],
61 MESSAGES: {
62 'required': this.i18n('Signup limit is required.'),
63 'min': this.i18n('Signup limit must be greater than 1.'),
64 'pattern': this.i18n('Signup limit must be a number.')
65 }
66 }
67
68 this.ADMIN_EMAIL = {
69 VALIDATORS: [ Validators.required, Validators.email ],
70 MESSAGES: {
71 'required': this.i18n('Admin email is required.'),
72 'email': this.i18n('Admin email must be valid.')
73 }
74 }
75
76 this.TRANSCODING_THREADS = {
77 VALIDATORS: [ Validators.required, Validators.min(0) ],
78 MESSAGES: {
79 'required': this.i18n('Transcoding threads is required.'),
80 'min': this.i18n('Transcoding threads must be greater or equal to 0.')
81 }
82 }
83
84 this.INDEX_URL = {
85 VALIDATORS: [ Validators.pattern(/^https:\/\//) ],
86 MESSAGES: {
87 'pattern': this.i18n('Index URL should be a URL')
88 }
89 }
90
91 this.SEARCH_INDEX_URL = {
92 VALIDATORS: [ Validators.pattern(/^https?:\/\//) ],
93 MESSAGES: {
94 'pattern': this.i18n('Search index URL should be a URL')
95 }
96 }
97 }
98}
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 @@
1import { FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
2import { Injectable } from '@angular/core'
3import { FormReactiveErrors, FormReactiveValidationMessages } from '../form-reactive'
4
5export type BuildFormValidator = {
6 VALIDATORS: ValidatorFn[],
7 MESSAGES: { [ name: string ]: string }
8}
9export type BuildFormArgument = {
10 [ id: string ]: BuildFormValidator | BuildFormArgument
11}
12export type BuildFormDefaultValues = {
13 [ name: string ]: string | string[] | BuildFormDefaultValues
14}
15
16@Injectable()
17export class FormValidatorService {
18
19 constructor (
20 private formBuilder: FormBuilder
21 ) {}
22
23 buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
24 const formErrors: FormReactiveErrors = {}
25 const validationMessages: FormReactiveValidationMessages = {}
26 const group: { [key: string]: any } = {}
27
28 for (const name of Object.keys(obj)) {
29 formErrors[name] = ''
30
31 const field = obj[name]
32 if (this.isRecursiveField(field)) {
33 const result = this.buildForm(field as BuildFormArgument, defaultValues[name] as BuildFormDefaultValues)
34 group[name] = result.form
35 formErrors[name] = result.formErrors
36 validationMessages[name] = result.validationMessages
37
38 continue
39 }
40
41 if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
42
43 const defaultValue = defaultValues[name] || ''
44
45 if (field && field.VALIDATORS) group[name] = [ defaultValue, field.VALIDATORS ]
46 else group[name] = [ defaultValue ]
47 }
48
49 const form = this.formBuilder.group(group)
50 return { form, formErrors, validationMessages }
51 }
52
53 updateForm (
54 form: FormGroup,
55 formErrors: FormReactiveErrors,
56 validationMessages: FormReactiveValidationMessages,
57 obj: BuildFormArgument,
58 defaultValues: BuildFormDefaultValues = {}
59 ) {
60 for (const name of Object.keys(obj)) {
61 formErrors[name] = ''
62
63 const field = obj[name]
64 if (this.isRecursiveField(field)) {
65 this.updateForm(
66 form[name],
67 formErrors[name] as FormReactiveErrors,
68 validationMessages[name] as FormReactiveValidationMessages,
69 obj[name] as BuildFormArgument,
70 defaultValues[name] as BuildFormDefaultValues
71 )
72 continue
73 }
74
75 if (field && field.MESSAGES) validationMessages[name] = field.MESSAGES as { [ name: string ]: string }
76
77 const defaultValue = defaultValues[name] || ''
78
79 if (field && field.VALIDATORS) form.addControl(name, new FormControl(defaultValue, field.VALIDATORS as ValidatorFn[]))
80 else form.addControl(name, new FormControl(defaultValue))
81 }
82 }
83
84 private isRecursiveField (field: any) {
85 return field && typeof field === 'object' && !field.MESSAGES && !field.VALIDATORS
86 }
87}
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 @@
1export function validateHost (value: string) {
2 // Thanks to http://stackoverflow.com/a/106223
3 const HOST_REGEXP = new RegExp(
4 '^(([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])$'
5 )
6
7 return HOST_REGEXP.test(value)
8}
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 @@
1export * from './batch-domains-validators.service'
2export * from './custom-config-validators.service'
3export * from './form-validator.service'
4export * from './host'
5export * from './instance-validators.service'
6export * from './login-validators.service'
7export * from './reset-password-validators.service'
8export * from './user-validators.service'
9export * from './video-abuse-validators.service'
10export * from './video-accept-ownership-validators.service'
11export * from './video-block-validators.service'
12export * from './video-captions-validators.service'
13export * from './video-change-ownership-validators.service'
14export * from './video-channel-validators.service'
15export * from './video-comment-validators.service'
16export * from './video-playlist-validators.service'
17export * 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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { BuildFormValidator } from './form-validator.service'
4import { Injectable } from '@angular/core'
5
6@Injectable()
7export class InstanceValidatorsService {
8 readonly FROM_EMAIL: BuildFormValidator
9 readonly FROM_NAME: BuildFormValidator
10 readonly SUBJECT: BuildFormValidator
11 readonly BODY: BuildFormValidator
12
13 constructor (private i18n: I18n) {
14
15 this.FROM_EMAIL = {
16 VALIDATORS: [ Validators.required, Validators.email ],
17 MESSAGES: {
18 'required': this.i18n('Email is required.'),
19 'email': this.i18n('Email must be valid.')
20 }
21 }
22
23 this.FROM_NAME = {
24 VALIDATORS: [
25 Validators.required,
26 Validators.minLength(1),
27 Validators.maxLength(120)
28 ],
29 MESSAGES: {
30 'required': this.i18n('Your name is required.'),
31 'minlength': this.i18n('Your name must be at least 1 character long.'),
32 'maxlength': this.i18n('Your name cannot be more than 120 characters long.')
33 }
34 }
35
36 this.SUBJECT = {
37 VALIDATORS: [
38 Validators.required,
39 Validators.minLength(1),
40 Validators.maxLength(120)
41 ],
42 MESSAGES: {
43 'required': this.i18n('A subject is required.'),
44 'minlength': this.i18n('The subject must be at least 1 character long.'),
45 'maxlength': this.i18n('The subject cannot be more than 120 characters long.')
46 }
47 }
48
49 this.BODY = {
50 VALIDATORS: [
51 Validators.required,
52 Validators.minLength(3),
53 Validators.maxLength(5000)
54 ],
55 MESSAGES: {
56 'required': this.i18n('A message is required.'),
57 'minlength': this.i18n('The message must be at least 3 characters long.'),
58 'maxlength': this.i18n('The message cannot be more than 5000 characters long.')
59 }
60 }
61 }
62}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class LoginValidatorsService {
8 readonly LOGIN_USERNAME: BuildFormValidator
9 readonly LOGIN_PASSWORD: BuildFormValidator
10
11 constructor (private i18n: I18n) {
12 this.LOGIN_USERNAME = {
13 VALIDATORS: [
14 Validators.required
15 ],
16 MESSAGES: {
17 'required': this.i18n('Username is required.')
18 }
19 }
20
21 this.LOGIN_PASSWORD = {
22 VALIDATORS: [
23 Validators.required
24 ],
25 MESSAGES: {
26 'required': this.i18n('Password is required.')
27 }
28 }
29 }
30}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class ResetPasswordValidatorsService {
8 readonly RESET_PASSWORD_CONFIRM: BuildFormValidator
9
10 constructor (private i18n: I18n) {
11 this.RESET_PASSWORD_CONFIRM = {
12 VALIDATORS: [
13 Validators.required
14 ],
15 MESSAGES: {
16 'required': this.i18n('Confirmation of the password is required.')
17 }
18 }
19 }
20}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { BuildFormValidator } from './form-validator.service'
4import { Injectable } from '@angular/core'
5
6@Injectable()
7export class UserValidatorsService {
8 readonly USER_USERNAME: BuildFormValidator
9 readonly USER_EMAIL: BuildFormValidator
10 readonly USER_PASSWORD: BuildFormValidator
11 readonly USER_PASSWORD_OPTIONAL: BuildFormValidator
12 readonly USER_CONFIRM_PASSWORD: BuildFormValidator
13 readonly USER_VIDEO_QUOTA: BuildFormValidator
14 readonly USER_VIDEO_QUOTA_DAILY: BuildFormValidator
15 readonly USER_ROLE: BuildFormValidator
16 readonly USER_DISPLAY_NAME_REQUIRED: BuildFormValidator
17 readonly USER_DESCRIPTION: BuildFormValidator
18 readonly USER_TERMS: BuildFormValidator
19
20 readonly USER_BAN_REASON: BuildFormValidator
21
22 constructor (private i18n: I18n) {
23
24 this.USER_USERNAME = {
25 VALIDATORS: [
26 Validators.required,
27 Validators.minLength(1),
28 Validators.maxLength(50),
29 Validators.pattern(/^[a-z0-9][a-z0-9._]*$/)
30 ],
31 MESSAGES: {
32 'required': this.i18n('Username is required.'),
33 'minlength': this.i18n('Username must be at least 1 character long.'),
34 'maxlength': this.i18n('Username cannot be more than 50 characters long.'),
35 'pattern': this.i18n('Username should be lowercase alphanumeric; dots and underscores are allowed.')
36 }
37 }
38
39 this.USER_EMAIL = {
40 VALIDATORS: [ Validators.required, Validators.email ],
41 MESSAGES: {
42 'required': this.i18n('Email is required.'),
43 'email': this.i18n('Email must be valid.')
44 }
45 }
46
47 this.USER_PASSWORD = {
48 VALIDATORS: [
49 Validators.required,
50 Validators.minLength(6),
51 Validators.maxLength(255)
52 ],
53 MESSAGES: {
54 'required': this.i18n('Password is required.'),
55 'minlength': this.i18n('Password must be at least 6 characters long.'),
56 'maxlength': this.i18n('Password cannot be more than 255 characters long.')
57 }
58 }
59
60 this.USER_PASSWORD_OPTIONAL = {
61 VALIDATORS: [
62 Validators.minLength(6),
63 Validators.maxLength(255)
64 ],
65 MESSAGES: {
66 'minlength': this.i18n('Password must be at least 6 characters long.'),
67 'maxlength': this.i18n('Password cannot be more than 255 characters long.')
68 }
69 }
70
71 this.USER_CONFIRM_PASSWORD = {
72 VALIDATORS: [],
73 MESSAGES: {
74 'matchPassword': this.i18n('The new password and the confirmed password do not correspond.')
75 }
76 }
77
78 this.USER_VIDEO_QUOTA = {
79 VALIDATORS: [ Validators.required, Validators.min(-1) ],
80 MESSAGES: {
81 'required': this.i18n('Video quota is required.'),
82 'min': this.i18n('Quota must be greater than -1.')
83 }
84 }
85 this.USER_VIDEO_QUOTA_DAILY = {
86 VALIDATORS: [ Validators.required, Validators.min(-1) ],
87 MESSAGES: {
88 'required': this.i18n('Daily upload limit is required.'),
89 'min': this.i18n('Daily upload limit must be greater than -1.')
90 }
91 }
92
93 this.USER_ROLE = {
94 VALIDATORS: [ Validators.required ],
95 MESSAGES: {
96 'required': this.i18n('User role is required.')
97 }
98 }
99
100 this.USER_DISPLAY_NAME_REQUIRED = this.getDisplayName(true)
101
102 this.USER_DESCRIPTION = {
103 VALIDATORS: [
104 Validators.minLength(3),
105 Validators.maxLength(1000)
106 ],
107 MESSAGES: {
108 'minlength': this.i18n('Description must be at least 3 characters long.'),
109 'maxlength': this.i18n('Description cannot be more than 1000 characters long.')
110 }
111 }
112
113 this.USER_TERMS = {
114 VALIDATORS: [
115 Validators.requiredTrue
116 ],
117 MESSAGES: {
118 'required': this.i18n('You must agree with the instance terms in order to register on it.')
119 }
120 }
121
122 this.USER_BAN_REASON = {
123 VALIDATORS: [
124 Validators.minLength(3),
125 Validators.maxLength(250)
126 ],
127 MESSAGES: {
128 'minlength': this.i18n('Ban reason must be at least 3 characters long.'),
129 'maxlength': this.i18n('Ban reason cannot be more than 250 characters long.')
130 }
131 }
132 }
133
134 private getDisplayName (required: boolean) {
135 const control = {
136 VALIDATORS: [
137 Validators.minLength(1),
138 Validators.maxLength(120)
139 ],
140 MESSAGES: {
141 'required': this.i18n('Display name is required.'),
142 'minlength': this.i18n('Display name must be at least 1 character long.'),
143 'maxlength': this.i18n('Display name cannot be more than 50 characters long.')
144 }
145 }
146
147 if (required) control.VALIDATORS.push(Validators.required)
148
149 return control
150 }
151}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class VideoAbuseValidatorsService {
8 readonly VIDEO_ABUSE_REASON: BuildFormValidator
9 readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator
10
11 constructor (private i18n: I18n) {
12 this.VIDEO_ABUSE_REASON = {
13 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
14 MESSAGES: {
15 'required': this.i18n('Report reason is required.'),
16 'minlength': this.i18n('Report reason must be at least 2 characters long.'),
17 'maxlength': this.i18n('Report reason cannot be more than 3000 characters long.')
18 }
19 }
20
21 this.VIDEO_ABUSE_MODERATION_COMMENT = {
22 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
23 MESSAGES: {
24 'required': this.i18n('Moderation comment is required.'),
25 'minlength': this.i18n('Moderation comment must be at least 2 characters long.'),
26 'maxlength': this.i18n('Moderation comment cannot be more than 3000 characters long.')
27 }
28 }
29 }
30}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class VideoAcceptOwnershipValidatorsService {
8 readonly CHANNEL: BuildFormValidator
9
10 constructor (private i18n: I18n) {
11 this.CHANNEL = {
12 VALIDATORS: [ Validators.required ],
13 MESSAGES: {
14 'required': this.i18n('The channel is required.')
15 }
16 }
17 }
18}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class VideoBlockValidatorsService {
8 readonly VIDEO_BLOCK_REASON: BuildFormValidator
9
10 constructor (private i18n: I18n) {
11 this.VIDEO_BLOCK_REASON = {
12 VALIDATORS: [ Validators.minLength(2), Validators.maxLength(300) ],
13 MESSAGES: {
14 'minlength': this.i18n('Block reason must be at least 2 characters long.'),
15 'maxlength': this.i18n('Block reason cannot be more than 300 characters long.')
16 }
17 }
18 }
19}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class VideoCaptionsValidatorsService {
8 readonly VIDEO_CAPTION_LANGUAGE: BuildFormValidator
9 readonly VIDEO_CAPTION_FILE: BuildFormValidator
10
11 constructor (private i18n: I18n) {
12
13 this.VIDEO_CAPTION_LANGUAGE = {
14 VALIDATORS: [ Validators.required ],
15 MESSAGES: {
16 'required': this.i18n('Video caption language is required.')
17 }
18 }
19
20 this.VIDEO_CAPTION_FILE = {
21 VALIDATORS: [ Validators.required ],
22 MESSAGES: {
23 'required': this.i18n('Video caption file is required.')
24 }
25 }
26 }
27}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { AbstractControl, ValidationErrors, Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class VideoChangeOwnershipValidatorsService {
8 readonly USERNAME: BuildFormValidator
9
10 constructor (private i18n: I18n) {
11 this.USERNAME = {
12 VALIDATORS: [ Validators.required, this.localAccountValidator ],
13 MESSAGES: {
14 'required': this.i18n('The username is required.'),
15 'localAccountOnly': this.i18n('You can only transfer ownership to a local account')
16 }
17 }
18 }
19
20 localAccountValidator (control: AbstractControl): ValidationErrors {
21 if (control.value.includes('@')) {
22 return { 'localAccountOnly': true }
23 }
24
25 return null
26 }
27}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class VideoChannelValidatorsService {
8 readonly VIDEO_CHANNEL_NAME: BuildFormValidator
9 readonly VIDEO_CHANNEL_DISPLAY_NAME: BuildFormValidator
10 readonly VIDEO_CHANNEL_DESCRIPTION: BuildFormValidator
11 readonly VIDEO_CHANNEL_SUPPORT: BuildFormValidator
12
13 constructor (private i18n: I18n) {
14 this.VIDEO_CHANNEL_NAME = {
15 VALIDATORS: [
16 Validators.required,
17 Validators.minLength(1),
18 Validators.maxLength(50),
19 Validators.pattern(/^[a-z0-9][a-z0-9._]*$/)
20 ],
21 MESSAGES: {
22 'required': this.i18n('Name is required.'),
23 'minlength': this.i18n('Name must be at least 1 character long.'),
24 'maxlength': this.i18n('Name cannot be more than 50 characters long.'),
25 'pattern': this.i18n('Name should be lowercase alphanumeric; dots and underscores are allowed.')
26 }
27 }
28
29 this.VIDEO_CHANNEL_DISPLAY_NAME = {
30 VALIDATORS: [
31 Validators.required,
32 Validators.minLength(1),
33 Validators.maxLength(50)
34 ],
35 MESSAGES: {
36 'required': i18n('Display name is required.'),
37 'minlength': i18n('Display name must be at least 1 character long.'),
38 'maxlength': i18n('Display name cannot be more than 50 characters long.')
39 }
40 }
41
42 this.VIDEO_CHANNEL_DESCRIPTION = {
43 VALIDATORS: [
44 Validators.minLength(3),
45 Validators.maxLength(1000)
46 ],
47 MESSAGES: {
48 'minlength': i18n('Description must be at least 3 characters long.'),
49 'maxlength': i18n('Description cannot be more than 1000 characters long.')
50 }
51 }
52
53 this.VIDEO_CHANNEL_SUPPORT = {
54 VALIDATORS: [
55 Validators.minLength(3),
56 Validators.maxLength(1000)
57 ],
58 MESSAGES: {
59 'minlength': i18n('Support text must be at least 3 characters long.'),
60 'maxlength': i18n('Support text cannot be more than 1000 characters long.')
61 }
62 }
63 }
64}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class VideoCommentValidatorsService {
8 readonly VIDEO_COMMENT_TEXT: BuildFormValidator
9
10 constructor (private i18n: I18n) {
11 this.VIDEO_COMMENT_TEXT = {
12 VALIDATORS: [ Validators.required, Validators.minLength(1), Validators.maxLength(3000) ],
13 MESSAGES: {
14 'required': this.i18n('Comment is required.'),
15 'minlength': this.i18n('Comment must be at least 2 characters long.'),
16 'maxlength': this.i18n('Comment cannot be more than 3000 characters long.')
17 }
18 }
19 }
20}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { AbstractControl, FormControl, Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5import { VideoPlaylistPrivacy } from '@shared/models'
6
7@Injectable()
8export class VideoPlaylistValidatorsService {
9 readonly VIDEO_PLAYLIST_DISPLAY_NAME: BuildFormValidator
10 readonly VIDEO_PLAYLIST_PRIVACY: BuildFormValidator
11 readonly VIDEO_PLAYLIST_DESCRIPTION: BuildFormValidator
12 readonly VIDEO_PLAYLIST_CHANNEL_ID: BuildFormValidator
13
14 constructor (private i18n: I18n) {
15 this.VIDEO_PLAYLIST_DISPLAY_NAME = {
16 VALIDATORS: [
17 Validators.required,
18 Validators.minLength(1),
19 Validators.maxLength(120)
20 ],
21 MESSAGES: {
22 'required': this.i18n('Display name is required.'),
23 'minlength': this.i18n('Display name must be at least 1 character long.'),
24 'maxlength': this.i18n('Display name cannot be more than 120 characters long.')
25 }
26 }
27
28 this.VIDEO_PLAYLIST_PRIVACY = {
29 VALIDATORS: [
30 Validators.required
31 ],
32 MESSAGES: {
33 'required': this.i18n('Privacy is required.')
34 }
35 }
36
37 this.VIDEO_PLAYLIST_DESCRIPTION = {
38 VALIDATORS: [
39 Validators.minLength(3),
40 Validators.maxLength(1000)
41 ],
42 MESSAGES: {
43 'minlength': i18n('Description must be at least 3 characters long.'),
44 'maxlength': i18n('Description cannot be more than 1000 characters long.')
45 }
46 }
47
48 this.VIDEO_PLAYLIST_CHANNEL_ID = {
49 VALIDATORS: [ ],
50 MESSAGES: {
51 'required': this.i18n('The channel is required when the playlist is public.')
52 }
53 }
54 }
55
56 setChannelValidator (channelControl: AbstractControl, privacy: VideoPlaylistPrivacy) {
57 if (privacy.toString() === VideoPlaylistPrivacy.PUBLIC.toString()) {
58 channelControl.setValidators([ Validators.required ])
59 } else {
60 channelControl.setValidators(null)
61 }
62
63 channelControl.markAsDirty()
64 channelControl.updateValueAndValidity()
65 }
66}
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 @@
1import { I18n } from '@ngx-translate/i18n-polyfill'
2import { Validators } from '@angular/forms'
3import { Injectable } from '@angular/core'
4import { BuildFormValidator } from './form-validator.service'
5
6@Injectable()
7export class VideoValidatorsService {
8 readonly VIDEO_NAME: BuildFormValidator
9 readonly VIDEO_PRIVACY: BuildFormValidator
10 readonly VIDEO_CATEGORY: BuildFormValidator
11 readonly VIDEO_LICENCE: BuildFormValidator
12 readonly VIDEO_LANGUAGE: BuildFormValidator
13 readonly VIDEO_IMAGE: BuildFormValidator
14 readonly VIDEO_CHANNEL: BuildFormValidator
15 readonly VIDEO_DESCRIPTION: BuildFormValidator
16 readonly VIDEO_TAGS: BuildFormValidator
17 readonly VIDEO_SUPPORT: BuildFormValidator
18 readonly VIDEO_SCHEDULE_PUBLICATION_AT: BuildFormValidator
19 readonly VIDEO_ORIGINALLY_PUBLISHED_AT: BuildFormValidator
20
21 constructor (private i18n: I18n) {
22
23 this.VIDEO_NAME = {
24 VALIDATORS: [ Validators.required, Validators.minLength(3), Validators.maxLength(120) ],
25 MESSAGES: {
26 'required': this.i18n('Video name is required.'),
27 'minlength': this.i18n('Video name must be at least 3 characters long.'),
28 'maxlength': this.i18n('Video name cannot be more than 120 characters long.')
29 }
30 }
31
32 this.VIDEO_PRIVACY = {
33 VALIDATORS: [ Validators.required ],
34 MESSAGES: {
35 'required': this.i18n('Video privacy is required.')
36 }
37 }
38
39 this.VIDEO_CATEGORY = {
40 VALIDATORS: [ ],
41 MESSAGES: {}
42 }
43
44 this.VIDEO_LICENCE = {
45 VALIDATORS: [ ],
46 MESSAGES: {}
47 }
48
49 this.VIDEO_LANGUAGE = {
50 VALIDATORS: [ ],
51 MESSAGES: {}
52 }
53
54 this.VIDEO_IMAGE = {
55 VALIDATORS: [ ],
56 MESSAGES: {}
57 }
58
59 this.VIDEO_CHANNEL = {
60 VALIDATORS: [ Validators.required ],
61 MESSAGES: {
62 'required': this.i18n('Video channel is required.')
63 }
64 }
65
66 this.VIDEO_DESCRIPTION = {
67 VALIDATORS: [ Validators.minLength(3), Validators.maxLength(10000) ],
68 MESSAGES: {
69 'minlength': this.i18n('Video description must be at least 3 characters long.'),
70 'maxlength': this.i18n('Video description cannot be more than 10000 characters long.')
71 }
72 }
73
74 this.VIDEO_TAGS = {
75 VALIDATORS: [ Validators.minLength(2), Validators.maxLength(30) ],
76 MESSAGES: {
77 'minlength': this.i18n('A tag should be more than 2 characters long.'),
78 'maxlength': this.i18n('A tag should be less than 30 characters long.')
79 }
80 }
81
82 this.VIDEO_SUPPORT = {
83 VALIDATORS: [ Validators.minLength(3), Validators.maxLength(1000) ],
84 MESSAGES: {
85 'minlength': this.i18n('Video support must be at least 3 characters long.'),
86 'maxlength': this.i18n('Video support cannot be more than 1000 characters long.')
87 }
88 }
89
90 this.VIDEO_SCHEDULE_PUBLICATION_AT = {
91 VALIDATORS: [ ],
92 MESSAGES: {
93 'required': this.i18n('A date is required to schedule video update.')
94 }
95 }
96
97 this.VIDEO_ORIGINALLY_PUBLISHED_AT = {
98 VALIDATORS: [ ],
99 MESSAGES: {}
100 }
101 }
102}
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 @@
1export * from './form-validators'
2export * from './form-reactive'
3export * from './input-readonly-copy.component'
4export * from './markdown-textarea.component'
5export * from './peertube-checkbox.component'
6export * from './preview-upload.component'
7export * from './reactive-file.component'
8export * from './textarea-autoresize.directive'
9export * from './timestamp-input.component'
10export * 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 @@
1<div class="input-group input-group-sm">
2 <input #urlInput (click)="urlInput.select()" type="text" class="form-control readonly" readonly [value]="value" />
3
4 <div class="input-group-append">
5 <button [cdkCopyToClipboard]="urlInput.value" (click)="activateCopiedMessage()" type="button" class="btn btn-outline-secondary">
6 <span class="glyphicon glyphicon-copy"></span>
7 </button>
8 </div>
9</div>
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 @@
1input.readonly {
2 font-size: 15px;
3}
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 @@
1import { Component, Input } from '@angular/core'
2import { Notifier } from '@app/core'
3import { I18n } from '@ngx-translate/i18n-polyfill'
4
5@Component({
6 selector: 'my-input-readonly-copy',
7 templateUrl: './input-readonly-copy.component.html',
8 styleUrls: [ './input-readonly-copy.component.scss' ]
9})
10export class InputReadonlyCopyComponent {
11 @Input() value = ''
12
13 constructor (
14 private notifier: Notifier,
15 private i18n: I18n
16 ) { }
17
18 activateCopiedMessage () {
19 this.notifier.success(this.i18n('Copied'))
20 }
21}
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 @@
1<div class="root" [ngClass]="{ 'maximized': isMaximized }" [ngStyle]="{ 'max-width': textareaMaxWidth }">
2 <textarea #textarea
3 [(ngModel)]="content" (ngModelChange)="onModelChange()"
4 class="form-control" [ngClass]="classes"
5 [ngStyle]="{ height: textareaHeight }"
6 [id]="name" [name]="name">
7 </textarea>
8
9 <div ngbNav #nav="ngbNav" class="nav-pills nav-preview">
10 <ng-container ngbNavItem *ngIf="truncate !== undefined">
11 <a ngbNavLink i18n>Truncated preview</a>
12
13 <ng-template ngbNavContent>
14 <div [innerHTML]="truncatedPreviewHTML"></div>
15 </ng-template>
16 </ng-container>
17
18 <ng-container ngbNavItem>
19 <a ngbNavLink i18n>Complete preview</a>
20
21 <ng-template ngbNavContent>
22 <div [innerHTML]="previewHTML"></div>
23 </ng-template>
24 </ng-container>
25
26 <my-button
27 *ngIf="!isMaximized" icon="fullscreen" (click)="onMaximizeClick()"
28 ></my-button>
29
30 <my-button
31 *ngIf="isMaximized" icon="exit-fullscreen" (click)="onMaximizeClick()"
32 ></my-button>
33 </div>
34
35 <div [ngbNavOutlet]="nav"></div>
36</div>
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 @@
1@import '_variables';
2@import '_mixins';
3
4$nav-preview-tab-height: 30px;
5$base-padding: 15px;
6$input-border-color: #C6C6C6;
7$input-border-radius: 3px;
8
9@mixin in-small-view {
10 .root {
11 display: flex;
12 flex-direction: column;
13
14 textarea {
15 @include peertube-textarea(100%, 150px);
16
17 background-color: pvar(--markdownTextareaBackgroundColor);
18
19 font-family: monospace;
20 font-size: 13px;
21 border-bottom: none;
22 border-bottom-left-radius: unset;
23 border-bottom-right-radius: unset;
24 }
25
26 .nav-preview {
27 display: block;
28 text-align: right;
29 padding-top: 10px;
30 padding-bottom: 10px;
31 padding-left: 10px;
32 padding-right: 10px;
33 border-top: 1px dashed $input-border-color;
34 border-left: 1px solid $input-border-color;
35 border-right: 1px solid $input-border-color;
36 border-bottom: 1px solid $input-border-color;
37 border-bottom-right-radius: $input-border-radius;
38
39 border-bottom-left-radius: $input-border-radius;
40 ::ng-deep {
41 .nav-link {
42 display: none !important;
43 }
44
45 .grey-button {
46 padding: 0 12px 0 12px;
47 }
48 }
49 }
50
51 ::ng-deep {
52 .tab-content {
53 display: none;
54 }
55 }
56 }
57}
58
59@mixin nav-preview-medium {
60 display: flex;
61 flex-grow: 1;
62 border-bottom-left-radius: unset;
63 border-bottom-right-radius: unset;
64 border-bottom: 2px solid pvar(--mainColor);
65
66 :first-child {
67 margin-left: auto;
68 }
69
70 ::ng-deep {
71 .nav-link {
72 display: flex !important;
73 align-items: center;
74 height: $nav-preview-tab-height !important;
75 padding: 0 15px !important;
76 font-size: 85% !important;
77 opacity: .7;
78 }
79
80 .grey-button {
81 margin-left: 5px;
82 }
83 }
84}
85
86@mixin content-preview-base {
87 display: block;
88 min-height: 75px;
89 padding: $base-padding;
90 overflow-y: auto;
91 font-size: 15px;
92 word-wrap: break-word;
93}
94
95@mixin maximized-base {
96 flex-direction: row;
97 z-index: #{z(header) - 1};
98 position: fixed;
99 top: $header-height;
100 left: $menu-width;
101 max-height: none !important;
102 max-width: none !important;
103 width: calc(100% - #{$menu-width});
104 height: calc(100vh - #{$header-height}) !important;
105
106 $nav-preview-vertical-padding: 40px;
107
108 .nav-preview {
109 @include nav-preview-medium();
110 padding-top: #{$nav-preview-vertical-padding / 2};
111 padding-bottom: #{$nav-preview-vertical-padding / 2};
112 padding-left: 0px;
113 padding-right: 0px;
114 position: absolute;
115 background-color: pvar(--mainBackgroundColor);
116 width: 100% !important;
117 border-top: none;
118 border-left: none;
119 border-right: none;
120
121 :last-child {
122 margin-right: $not-expanded-horizontal-margins;
123 }
124 }
125
126 ::ng-deep .tab-content {
127 @include content-preview-base();
128 background-color: pvar(--mainBackgroundColor);
129 scrollbar-color: pvar(--actionButtonColor) pvar(--mainBackgroundColor);
130 }
131
132 textarea,
133 ::ng-deep .tab-content {
134 max-height: none !important;
135 max-width: none !important;
136 margin-top: #{$nav-preview-tab-height + $nav-preview-vertical-padding} !important;
137 height: calc(100vh - #{$header-height + $nav-preview-tab-height + $nav-preview-vertical-padding}) !important;
138 width: 50% !important;
139 border: none !important;
140 border-radius: unset !important;
141 }
142
143 :host-context(.expanded) {
144 .root.maximized {
145 left: 0;
146 width: 100%;
147 }
148 }
149}
150
151@mixin maximized-in-small-view {
152 .root.maximized {
153 @include maximized-base();
154
155 textarea {
156 display: none;
157 }
158
159 ::ng-deep .tab-content {
160 width: 100% !important;
161 }
162 }
163}
164
165@mixin maximized-tabs-in-mobile-view {
166 // Ellipsis on tabs for mobile view
167 .root.maximized {
168 .nav-preview {
169 ::ng-deep .nav-link {
170 @include ellipsis();
171
172 display: block !important;
173 max-width: 45% !important;
174 padding: 5px 0 !important;
175 margin-right: 10px !important;
176 text-align: center;
177
178 &:not(.active) {
179 max-width: 15% !important;
180 }
181
182 &.active {
183 padding: 5px 15px !important;
184 }
185 }
186 }
187 }
188}
189
190@mixin in-medium-view {
191 .root {
192 .nav-preview {
193 @include nav-preview-medium();
194 }
195
196 ::ng-deep .tab-content {
197 @include content-preview-base();
198 max-height: 210px;
199 border-bottom: 1px solid $input-border-color;
200 border-left: 1px solid $input-border-color;
201 border-right: 1px solid $input-border-color;
202 border-bottom-left-radius: $input-border-radius;
203 border-bottom-right-radius: $input-border-radius;
204 }
205 }
206}
207
208@mixin maximized-in-medium-view {
209 .root.maximized {
210 @include maximized-base();
211
212 textarea {
213 display: block;
214 padding: $base-padding;
215 border-right: 1px dashed $input-border-color !important;
216 resize: none;
217 scrollbar-color: pvar(--actionButtonColor) pvar(--markdownTextareaBackgroundColor);
218
219 &:focus {
220 box-shadow: none;
221 }
222 }
223 }
224}
225
226@include in-small-view();
227@include maximized-in-small-view();
228
229@media only screen and (max-width: $mobile-view) {
230 @include maximized-tabs-in-mobile-view();
231}
232
233@media only screen and (max-width: #{$mobile-view + $menu-width}) {
234 :host-context(.main-col:not(.expanded)) {
235 @include maximized-tabs-in-mobile-view();
236 }
237}
238
239@media only screen and (min-width: $small-view) {
240 :host-context(.expanded) {
241 @include in-medium-view();
242 }
243
244 @include maximized-in-medium-view();
245}
246
247@media only screen and (min-width: #{$small-view + $menu-width}) {
248 :host-context(.main-col:not(.expanded)) {
249 @include in-medium-view();
250 }
251}
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 @@
1import truncate from 'lodash-es/truncate'
2import { Subject } from 'rxjs'
3import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
4import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
5import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
6import { MarkdownService } from '@app/core'
7
8@Component({
9 selector: 'my-markdown-textarea',
10 templateUrl: './markdown-textarea.component.html',
11 styleUrls: [ './markdown-textarea.component.scss' ],
12 providers: [
13 {
14 provide: NG_VALUE_ACCESSOR,
15 useExisting: forwardRef(() => MarkdownTextareaComponent),
16 multi: true
17 }
18 ]
19})
20
21export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
22 @Input() content = ''
23 @Input() classes: string[] | { [klass: string]: any[] | any } = []
24 @Input() textareaMaxWidth = '100%'
25 @Input() textareaHeight = '150px'
26 @Input() truncate: number
27 @Input() markdownType: 'text' | 'enhanced' = 'text'
28 @Input() markdownVideo = false
29 @Input() name = 'description'
30
31 @ViewChild('textarea') textareaElement: ElementRef
32
33 truncatedPreviewHTML = ''
34 previewHTML = ''
35 isMaximized = false
36
37 private contentChanged = new Subject<string>()
38
39 constructor (private markdownService: MarkdownService) {}
40
41 ngOnInit () {
42 this.contentChanged
43 .pipe(
44 debounceTime(150),
45 distinctUntilChanged()
46 )
47 .subscribe(() => this.updatePreviews())
48
49 this.contentChanged.next(this.content)
50 }
51
52 propagateChange = (_: any) => { /* empty */ }
53
54 writeValue (description: string) {
55 this.content = description
56
57 this.contentChanged.next(this.content)
58 }
59
60 registerOnChange (fn: (_: any) => void) {
61 this.propagateChange = fn
62 }
63
64 registerOnTouched () {
65 // Unused
66 }
67
68 onModelChange () {
69 this.propagateChange(this.content)
70
71 this.contentChanged.next(this.content)
72 }
73
74 onMaximizeClick () {
75 this.isMaximized = !this.isMaximized
76
77 // Make sure textarea have the focus
78 this.textareaElement.nativeElement.focus()
79
80 // Make sure the window has no scrollbars
81 if (!this.isMaximized) {
82 this.unlockBodyScroll()
83 } else {
84 this.lockBodyScroll()
85 }
86 }
87
88 private lockBodyScroll () {
89 document.getElementById('content').classList.add('lock-scroll')
90 }
91
92 private unlockBodyScroll () {
93 document.getElementById('content').classList.remove('lock-scroll')
94 }
95
96 private async updatePreviews () {
97 if (this.content === null || this.content === undefined) return
98
99 this.truncatedPreviewHTML = await this.markdownRender(truncate(this.content, { length: this.truncate }))
100 this.previewHTML = await this.markdownRender(this.content)
101 }
102
103 private async markdownRender (text: string) {
104 const html = this.markdownType === 'text' ?
105 await this.markdownService.textMarkdownToHTML(text) :
106 await this.markdownService.enhancedMarkdownToHTML(text)
107
108 return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html
109 }
110}
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 @@
1<div class="root flex-column">
2 <div class="d-flex">
3 <label class="form-group-checkbox">
4 <input
5 type="checkbox"
6 [(ngModel)]="checked"
7 (ngModelChange)="onModelChange()"
8 [id]="inputName"
9 [disabled]="disabled"
10 />
11 <span role="checkbox" [attr.aria-checked]="checked"></span>
12 <span *ngIf="labelText">{{ labelText }}</span>
13 <span
14 *ngIf="!labelText && labelInnerHTML"
15 [innerHTML]="labelInnerHTML"
16 ></span>
17
18 <span *ngIf="labelTemplate">
19 <ng-container *ngTemplateOutlet="labelTemplate"></ng-container>
20 </span>
21 </label>
22
23 <my-help
24 *ngIf="helpTemplate"
25 [tooltipPlacement]="helpPlacement"
26 helpType="custom"
27 >
28 <ng-template ptTemplate="customHtml">
29 <ng-template *ngTemplateOutlet="helpTemplate"></ng-template>
30 </ng-template>
31 </my-help>
32
33 <div *ngIf="recommended" class="recommended" i18n>Recommended</div>
34 </div>
35
36 <div class="ml-4 d-flex flex-column">
37 <small class="wrapper mt-2 text-muted">
38 <ng-content select="description"></ng-content>
39 </small>
40
41 <span class="wrapper mt-3">
42 <ng-content select="extra"></ng-content>
43 </span>
44 </div>
45</div>
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 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 display: flex;
6
7 .form-group-checkbox {
8 display: flex;
9 align-items: center;
10
11 .label-text {
12 font-weight: $font-regular;
13 margin: 0;
14 }
15
16 input {
17 @include peertube-checkbox(1px);
18 }
19 }
20
21 label {
22 margin-bottom: 0;
23 }
24
25 my-help {
26 position: relative;
27 top: 2px;
28 }
29
30 small {
31 font-size: 90%;
32 }
33
34 .wrapper:empty {
35 display: none;
36 }
37
38 .recommended {
39 margin-left: .5rem;
40 align-self: baseline;
41 display: inline-block;
42 padding: 4px 6px;
43 cursor: default;
44 border-radius: 3px;
45 font-size: 12px;
46 line-height: 12px;
47 font-weight: 500;
48 color: pvar(--inputPlaceholderColor);
49 background-color: rgba(217,225,232,.1);
50 border: 1px solid rgba(217,225,232,.5);
51 }
52} \ 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 @@
1import { AfterContentInit, ChangeDetectorRef, Component, ContentChildren, forwardRef, Input, QueryList, TemplateRef } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { PeerTubeTemplateDirective } from '@app/shared/shared-main'
4
5@Component({
6 selector: 'my-peertube-checkbox',
7 styleUrls: [ './peertube-checkbox.component.scss' ],
8 templateUrl: './peertube-checkbox.component.html',
9 providers: [
10 {
11 provide: NG_VALUE_ACCESSOR,
12 useExisting: forwardRef(() => PeertubeCheckboxComponent),
13 multi: true
14 }
15 ]
16})
17export class PeertubeCheckboxComponent implements ControlValueAccessor, AfterContentInit {
18 @Input() checked = false
19 @Input() inputName: string
20 @Input() labelText: string
21 @Input() labelInnerHTML: string
22 @Input() helpPlacement = 'top auto'
23 @Input() disabled = false
24 @Input() recommended = false
25
26 @ContentChildren(PeerTubeTemplateDirective) templates: QueryList<PeerTubeTemplateDirective<'label' | 'help'>>
27
28 // FIXME: https://github.com/angular/angular/issues/10816#issuecomment-307567836
29 @Input() onPushWorkaround = false
30
31 labelTemplate: TemplateRef<any>
32 helpTemplate: TemplateRef<any>
33
34 constructor (private cdr: ChangeDetectorRef) { }
35
36 ngAfterContentInit () {
37 {
38 const t = this.templates.find(t => t.name === 'label')
39 if (t) this.labelTemplate = t.template
40 }
41
42 {
43 const t = this.templates.find(t => t.name === 'help')
44 if (t) this.helpTemplate = t.template
45 }
46 }
47
48 propagateChange = (_: any) => { /* empty */ }
49
50 writeValue (checked: boolean) {
51 this.checked = checked
52
53 if (this.onPushWorkaround) {
54 this.cdr.markForCheck()
55 }
56 }
57
58 registerOnChange (fn: (_: any) => void) {
59 this.propagateChange = fn
60 }
61
62 registerOnTouched () {
63 // Unused
64 }
65
66 onModelChange () {
67 this.propagateChange(this.checked)
68 }
69
70 setDisabledState (isDisabled: boolean) {
71 this.disabled = isDisabled
72 }
73}
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 @@
1<div class="root">
2 <div class="preview-container">
3 <my-reactive-file
4 [inputName]="inputName" [inputLabel]="inputLabel" [extensions]="videoImageExtensions" [maxFileSize]="maxVideoImageSize" placement="right"
5 icon="edit" (fileChanged)="onFileChanged($event)" [ngbTooltip]="'(extensions: '+ videoImageExtensions +', '+ maxSizeText +': '+ maxVideoImageSizeInBytes +')'"
6 ></my-reactive-file>
7
8 <img *ngIf="imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" [src]="imageSrc" class="preview" />
9 <div *ngIf="!imageSrc" [ngStyle]="{ width: previewWidth, height: previewHeight }" class="preview no-image"></div>
10 </div>
11</div>
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 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 height: auto;
6 display: flex;
7 flex-direction: column;
8
9 .preview-container {
10 position: relative;
11
12 my-reactive-file {
13 position: absolute;
14 bottom: 10px;
15 left: 10px;
16 }
17
18 .preview {
19 object-fit: cover;
20 border-radius: 4px;
21 max-width: 100%;
22
23 &.no-image {
24 border: 2px solid grey;
25 background-color: pvar(--mainBackgroundColor);
26 }
27 }
28 }
29}
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 @@
1import { Component, forwardRef, Input, OnInit } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'
4import { ServerService } from '@app/core'
5import { ServerConfig } from '@shared/models'
6import { BytesPipe } from 'ngx-pipes'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8
9@Component({
10 selector: 'my-preview-upload',
11 styleUrls: [ './preview-upload.component.scss' ],
12 templateUrl: './preview-upload.component.html',
13 providers: [
14 {
15 provide: NG_VALUE_ACCESSOR,
16 useExisting: forwardRef(() => PreviewUploadComponent),
17 multi: true
18 }
19 ]
20})
21export class PreviewUploadComponent implements OnInit, ControlValueAccessor {
22 @Input() inputLabel: string
23 @Input() inputName: string
24 @Input() previewWidth: string
25 @Input() previewHeight: string
26
27 imageSrc: SafeResourceUrl
28 allowedExtensionsMessage = ''
29 maxSizeText: string
30
31 private serverConfig: ServerConfig
32 private bytesPipe: BytesPipe
33 private file: Blob
34
35 constructor (
36 private sanitizer: DomSanitizer,
37 private serverService: ServerService,
38 private i18n: I18n
39 ) {
40 this.bytesPipe = new BytesPipe()
41 this.maxSizeText = this.i18n('max size')
42 }
43
44 get videoImageExtensions () {
45 return this.serverConfig.video.image.extensions
46 }
47
48 get maxVideoImageSize () {
49 return this.serverConfig.video.image.size.max
50 }
51
52 get maxVideoImageSizeInBytes () {
53 return this.bytesPipe.transform(this.maxVideoImageSize)
54 }
55
56 ngOnInit () {
57 this.serverConfig = this.serverService.getTmpConfig()
58 this.serverService.getConfig()
59 .subscribe(config => this.serverConfig = config)
60
61 this.allowedExtensionsMessage = this.videoImageExtensions.join(', ')
62 }
63
64 onFileChanged (file: Blob) {
65 this.file = file
66
67 this.propagateChange(this.file)
68 this.updatePreview()
69 }
70
71 propagateChange = (_: any) => { /* empty */ }
72
73 writeValue (file: any) {
74 this.file = file
75 this.updatePreview()
76 }
77
78 registerOnChange (fn: (_: any) => void) {
79 this.propagateChange = fn
80 }
81
82 registerOnTouched () {
83 // Unused
84 }
85
86 private updatePreview () {
87 if (this.file) {
88 const url = URL.createObjectURL(this.file)
89 this.imageSrc = this.sanitizer.bypassSecurityTrustResourceUrl(url)
90 }
91 }
92}
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 @@
1<div class="root">
2 <div class="button-file" [ngClass]="{ 'with-icon': !!icon }">
3 <my-global-icon *ngIf="icon" [iconName]="icon"></my-global-icon>
4
5 <span>{{ inputLabel }}</span>
6
7 <input
8 type="file"
9 [name]="inputName" [id]="inputName" [accept]="extensions"
10 (change)="fileChange($event)" [(ngModel)]="fileInputValue"
11 />
12 </div>
13
14 <div class="filename" *ngIf="displayFilename === true && filename">{{ filename }}</div>
15</div>
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 @@
1@import '_variables';
2@import '_mixins';
3
4.root {
5 height: auto;
6 display: flex;
7 align-items: center;
8
9 .button-file {
10 @include peertube-button-file(auto);
11 @include grey-button;
12
13 &.with-icon {
14 @include button-with-icon;
15 }
16 }
17
18 .filename {
19 font-weight: $font-semibold;
20 margin-left: 5px;
21 }
22}
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 @@
1import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core'
4import { GlobalIconName } from '@app/shared/shared-icons'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6
7@Component({
8 selector: 'my-reactive-file',
9 styleUrls: [ './reactive-file.component.scss' ],
10 templateUrl: './reactive-file.component.html',
11 providers: [
12 {
13 provide: NG_VALUE_ACCESSOR,
14 useExisting: forwardRef(() => ReactiveFileComponent),
15 multi: true
16 }
17 ]
18})
19export class ReactiveFileComponent implements OnInit, ControlValueAccessor {
20 @Input() inputLabel: string
21 @Input() inputName: string
22 @Input() extensions: string[] = []
23 @Input() maxFileSize: number
24 @Input() displayFilename = false
25 @Input() icon: GlobalIconName
26
27 @Output() fileChanged = new EventEmitter<Blob>()
28
29 allowedExtensionsMessage = ''
30 fileInputValue: any
31
32 private file: File
33
34 constructor (
35 private notifier: Notifier,
36 private i18n: I18n
37 ) {}
38
39 get filename () {
40 if (!this.file) return ''
41
42 return this.file.name
43 }
44
45 ngOnInit () {
46 this.allowedExtensionsMessage = this.extensions.join(', ')
47 }
48
49 fileChange (event: any) {
50 if (event.target.files && event.target.files.length) {
51 const [ file ] = event.target.files
52
53 if (file.size > this.maxFileSize) {
54 this.notifier.error(this.i18n('This file is too large.'))
55 return
56 }
57
58 const extension = '.' + file.name.split('.').pop()
59 if (this.extensions.includes(extension) === false) {
60 const message = this.i18n(
61 'PeerTube cannot handle this kind of file. Accepted extensions are {{extensions}}.',
62 { extensions: this.allowedExtensionsMessage }
63 )
64 this.notifier.error(message)
65
66 return
67 }
68
69 this.file = file
70
71 this.propagateChange(this.file)
72 this.fileChanged.emit(this.file)
73 }
74 }
75
76 propagateChange = (_: any) => { /* empty */ }
77
78 writeValue (file: any) {
79 this.file = file
80
81 if (!this.file) this.fileInputValue = null
82 }
83
84 registerOnChange (fn: (_: any) => void) {
85 this.propagateChange = fn
86 }
87
88 registerOnTouched () {
89 // Unused
90 }
91}
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 @@
1
2import { NgModule } from '@angular/core'
3import { FormsModule, ReactiveFormsModule } from '@angular/forms'
4import { BatchDomainsValidatorsService } from '@app/shared/shared-forms/form-validators/batch-domains-validators.service'
5import { SharedGlobalIconModule } from '../shared-icons'
6import { SharedMainModule } from '../shared-main/shared-main.module'
7import {
8 CustomConfigValidatorsService,
9 FormValidatorService,
10 InstanceValidatorsService,
11 LoginValidatorsService,
12 ResetPasswordValidatorsService,
13 UserValidatorsService,
14 VideoAbuseValidatorsService,
15 VideoAcceptOwnershipValidatorsService,
16 VideoBlockValidatorsService,
17 VideoCaptionsValidatorsService,
18 VideoChangeOwnershipValidatorsService,
19 VideoChannelValidatorsService,
20 VideoCommentValidatorsService,
21 VideoPlaylistValidatorsService,
22 VideoValidatorsService
23} from './form-validators'
24import { InputReadonlyCopyComponent } from './input-readonly-copy.component'
25import { MarkdownTextareaComponent } from './markdown-textarea.component'
26import { PeertubeCheckboxComponent } from './peertube-checkbox.component'
27import { PreviewUploadComponent } from './preview-upload.component'
28import { ReactiveFileComponent } from './reactive-file.component'
29import { TextareaAutoResizeDirective } from './textarea-autoresize.directive'
30import { TimestampInputComponent } from './timestamp-input.component'
31
32@NgModule({
33 imports: [
34 FormsModule,
35 ReactiveFormsModule,
36
37 SharedMainModule,
38 SharedGlobalIconModule
39 ],
40
41 declarations: [
42 InputReadonlyCopyComponent,
43 MarkdownTextareaComponent,
44 PeertubeCheckboxComponent,
45 PreviewUploadComponent,
46 ReactiveFileComponent,
47 TextareaAutoResizeDirective,
48 TimestampInputComponent
49 ],
50
51 exports: [
52 FormsModule,
53 ReactiveFormsModule,
54
55 InputReadonlyCopyComponent,
56 MarkdownTextareaComponent,
57 PeertubeCheckboxComponent,
58 PreviewUploadComponent,
59 ReactiveFileComponent,
60 TextareaAutoResizeDirective,
61 TimestampInputComponent
62 ],
63
64 providers: [
65 CustomConfigValidatorsService,
66 FormValidatorService,
67 LoginValidatorsService,
68 InstanceValidatorsService,
69 LoginValidatorsService,
70 ResetPasswordValidatorsService,
71 UserValidatorsService,
72 VideoAbuseValidatorsService,
73 VideoAcceptOwnershipValidatorsService,
74 VideoBlockValidatorsService,
75 VideoCaptionsValidatorsService,
76 VideoChangeOwnershipValidatorsService,
77 VideoChannelValidatorsService,
78 VideoCommentValidatorsService,
79 VideoPlaylistValidatorsService,
80 VideoValidatorsService,
81 BatchDomainsValidatorsService
82 ]
83})
84export 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 @@
1// Thanks: https://github.com/evseevdev/ngx-textarea-autosize
2import { AfterViewInit, Directive, ElementRef, HostBinding, HostListener } from '@angular/core'
3
4@Directive({
5 selector: 'textarea[myAutoResize]'
6})
7export class TextareaAutoResizeDirective implements AfterViewInit {
8 @HostBinding('attr.rows') rows = '1'
9 @HostBinding('style.overflow') overflow = 'hidden'
10
11 constructor (private elem: ElementRef) { }
12
13 public ngAfterViewInit () {
14 this.resize()
15 }
16
17 @HostListener('input')
18 resize () {
19 const textarea = this.elem.nativeElement as HTMLTextAreaElement
20 // Reset textarea height to auto that correctly calculate the new height
21 textarea.style.height = 'auto'
22 // Set new height
23 textarea.style.height = `${textarea.scrollHeight}px`
24 }
25}
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 @@
1<p-inputMask
2 [disabled]="disabled" [(ngModel)]="timestampString" (onBlur)="onBlur()"
3 mask="9:99:99" slotChar="0" (ngModelChange)="onModelChange()"
4></p-inputMask>
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 @@
1@import 'variables';
2
3p-inputmask {
4 ::ng-deep input {
5 width: 80px;
6 font-size: 15px;
7
8 border: none;
9
10 &:focus-within,
11 &:focus {
12 box-shadow: #{$focus-box-shadow-form} pvar(--mainColorLightest);
13 }
14 }
15}
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 @@
1import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { secondsToTime, timeToInt } from '../../../assets/player/utils'
4
5@Component({
6 selector: 'my-timestamp-input',
7 styleUrls: [ './timestamp-input.component.scss' ],
8 templateUrl: './timestamp-input.component.html',
9 providers: [
10 {
11 provide: NG_VALUE_ACCESSOR,
12 useExisting: forwardRef(() => TimestampInputComponent),
13 multi: true
14 }
15 ]
16})
17export class TimestampInputComponent implements ControlValueAccessor, OnInit {
18 @Input() maxTimestamp: number
19 @Input() timestamp: number
20 @Input() disabled = false
21
22 timestampString: string
23
24 constructor (private changeDetector: ChangeDetectorRef) {}
25
26 ngOnInit () {
27 this.writeValue(this.timestamp || 0)
28 }
29
30 propagateChange = (_: any) => { /* empty */ }
31
32 writeValue (timestamp: number) {
33 this.timestamp = timestamp
34
35 this.timestampString = secondsToTime(this.timestamp, true, ':')
36 }
37
38 registerOnChange (fn: (_: any) => void) {
39 this.propagateChange = fn
40 }
41
42 registerOnTouched () {
43 // Unused
44 }
45
46 onModelChange () {
47 this.timestamp = timeToInt(this.timestampString)
48
49 this.propagateChange(this.timestamp)
50 }
51
52 onBlur () {
53 if (this.maxTimestamp && this.timestamp > this.maxTimestamp) {
54 this.writeValue(this.maxTimestamp)
55
56 this.changeDetector.detectChanges()
57
58 this.propagateChange(this.timestamp)
59 }
60 }
61}