diff options
author | Chocobozzz <me@florianbigard.com> | 2020-06-23 14:10:17 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-06-23 16:00:49 +0200 |
commit | 67ed6552b831df66713bac9e672738796128d33f (patch) | |
tree | 59c97d41e0b49d75a90aa3de987968ab9b1ff447 /client/src/app/shared/shared-forms | |
parent | 0c4bacbff53bc732f5a2677d62a6ead7752e2405 (diff) | |
download | PeerTube-67ed6552b831df66713bac9e672738796128d33f.tar.gz PeerTube-67ed6552b831df66713bac9e672738796128d33f.tar.zst PeerTube-67ed6552b831df66713bac9e672738796128d33f.zip |
Reorganize client shared modules
Diffstat (limited to 'client/src/app/shared/shared-forms')
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 @@ | |||
1 | import { FormGroup } from '@angular/forms' | ||
2 | import { BuildFormArgument, BuildFormDefaultValues, FormValidatorService } from './form-validators' | ||
3 | |||
4 | export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors } | ||
5 | export type FormReactiveValidationMessages = { | ||
6 | [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages | ||
7 | } | ||
8 | |||
9 | export 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 @@ | |||
1 | import { Injectable } from '@angular/core' | ||
2 | import { ValidatorFn, Validators } from '@angular/forms' | ||
3 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | import { validateHost } from './host' | ||
6 | |||
7 | @Injectable() | ||
8 | export 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 @@ | |||
1 | import { Validators } from '@angular/forms' | ||
2 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
3 | import { BuildFormValidator } from './form-validator.service' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' | ||
2 | import { Injectable } from '@angular/core' | ||
3 | import { FormReactiveErrors, FormReactiveValidationMessages } from '../form-reactive' | ||
4 | |||
5 | export type BuildFormValidator = { | ||
6 | VALIDATORS: ValidatorFn[], | ||
7 | MESSAGES: { [ name: string ]: string } | ||
8 | } | ||
9 | export type BuildFormArgument = { | ||
10 | [ id: string ]: BuildFormValidator | BuildFormArgument | ||
11 | } | ||
12 | export type BuildFormDefaultValues = { | ||
13 | [ name: string ]: string | string[] | BuildFormDefaultValues | ||
14 | } | ||
15 | |||
16 | @Injectable() | ||
17 | export 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 @@ | |||
1 | export 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 @@ | |||
1 | export * from './batch-domains-validators.service' | ||
2 | export * from './custom-config-validators.service' | ||
3 | export * from './form-validator.service' | ||
4 | export * from './host' | ||
5 | export * from './instance-validators.service' | ||
6 | export * from './login-validators.service' | ||
7 | export * from './reset-password-validators.service' | ||
8 | export * from './user-validators.service' | ||
9 | export * from './video-abuse-validators.service' | ||
10 | export * from './video-accept-ownership-validators.service' | ||
11 | export * from './video-block-validators.service' | ||
12 | export * from './video-captions-validators.service' | ||
13 | export * from './video-change-ownership-validators.service' | ||
14 | export * from './video-channel-validators.service' | ||
15 | export * from './video-comment-validators.service' | ||
16 | export * from './video-playlist-validators.service' | ||
17 | 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { BuildFormValidator } from './form-validator.service' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { BuildFormValidator } from './form-validator.service' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { AbstractControl, ValidationErrors, Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { AbstractControl, FormControl, Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | import { VideoPlaylistPrivacy } from '@shared/models' | ||
6 | |||
7 | @Injectable() | ||
8 | export 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 @@ | |||
1 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
2 | import { Validators } from '@angular/forms' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { BuildFormValidator } from './form-validator.service' | ||
5 | |||
6 | @Injectable() | ||
7 | export 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 @@ | |||
1 | export * from './form-validators' | ||
2 | export * from './form-reactive' | ||
3 | export * from './input-readonly-copy.component' | ||
4 | export * from './markdown-textarea.component' | ||
5 | export * from './peertube-checkbox.component' | ||
6 | export * from './preview-upload.component' | ||
7 | export * from './reactive-file.component' | ||
8 | export * from './textarea-autoresize.directive' | ||
9 | export * from './timestamp-input.component' | ||
10 | 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 @@ | |||
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 @@ | |||
1 | input.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 @@ | |||
1 | import { Component, Input } from '@angular/core' | ||
2 | import { Notifier } from '@app/core' | ||
3 | import { 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 | }) | ||
10 | export 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 @@ | |||
1 | import truncate from 'lodash-es/truncate' | ||
2 | import { Subject } from 'rxjs' | ||
3 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | ||
4 | import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core' | ||
5 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
6 | import { 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 | |||
21 | export 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 @@ | |||
1 | import { AfterContentInit, ChangeDetectorRef, Component, ContentChildren, forwardRef, Input, QueryList, TemplateRef } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { 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 | }) | ||
17 | export 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 @@ | |||
1 | import { Component, forwardRef, Input, OnInit } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser' | ||
4 | import { ServerService } from '@app/core' | ||
5 | import { ServerConfig } from '@shared/models' | ||
6 | import { BytesPipe } from 'ngx-pipes' | ||
7 | import { 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 | }) | ||
21 | export 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 @@ | |||
1 | import { Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { Notifier } from '@app/core' | ||
4 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
5 | import { 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 | }) | ||
19 | export 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 | |||
2 | import { NgModule } from '@angular/core' | ||
3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||
4 | import { BatchDomainsValidatorsService } from '@app/shared/shared-forms/form-validators/batch-domains-validators.service' | ||
5 | import { SharedGlobalIconModule } from '../shared-icons' | ||
6 | import { SharedMainModule } from '../shared-main/shared-main.module' | ||
7 | import { | ||
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' | ||
24 | import { InputReadonlyCopyComponent } from './input-readonly-copy.component' | ||
25 | import { MarkdownTextareaComponent } from './markdown-textarea.component' | ||
26 | import { PeertubeCheckboxComponent } from './peertube-checkbox.component' | ||
27 | import { PreviewUploadComponent } from './preview-upload.component' | ||
28 | import { ReactiveFileComponent } from './reactive-file.component' | ||
29 | import { TextareaAutoResizeDirective } from './textarea-autoresize.directive' | ||
30 | import { 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 | }) | ||
84 | 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 @@ | |||
1 | // Thanks: https://github.com/evseevdev/ngx-textarea-autosize | ||
2 | import { AfterViewInit, Directive, ElementRef, HostBinding, HostListener } from '@angular/core' | ||
3 | |||
4 | @Directive({ | ||
5 | selector: 'textarea[myAutoResize]' | ||
6 | }) | ||
7 | export 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 | |||
3 | p-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 @@ | |||
1 | import { ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core' | ||
2 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | ||
3 | import { 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 | }) | ||
17 | export 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 | } | ||