diff options
author | Chocobozzz <chocobozzz@cpy.re> | 2021-05-27 15:59:55 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-27 15:59:55 +0200 |
commit | 2539932e16129992a2c0889b4ff527c265a8e2c7 (patch) | |
tree | fb5048e63e02a2485eb96d27455f43e4b22e8ae0 /client | |
parent | eb34ec30e0b57286fc6f85160490d2e973a3b0b1 (diff) | |
download | PeerTube-2539932e16129992a2c0889b4ff527c265a8e2c7.tar.gz PeerTube-2539932e16129992a2c0889b4ff527c265a8e2c7.tar.zst PeerTube-2539932e16129992a2c0889b4ff527c265a8e2c7.zip |
Instance homepage support (#4007)
* Prepare homepage parsers
* Add ability to update instance hompage
* Add ability to set homepage as landing page
* Add homepage preview in admin
* Dynamically update left menu for homepage
* Inject home content in homepage
* Add videos list and channel miniature custom markup
* Remove unused elements in markup service
Diffstat (limited to 'client')
50 files changed, 935 insertions, 93 deletions
diff --git a/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts b/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts index c45269be4..dd774a4ef 100644 --- a/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts +++ b/client/src/app/+about/about-peertube/about-peertube-contributors.component.ts | |||
@@ -14,6 +14,6 @@ export class AboutPeertubeContributorsComponent implements OnInit { | |||
14 | constructor (private markdownService: MarkdownService) { } | 14 | constructor (private markdownService: MarkdownService) { } |
15 | 15 | ||
16 | async ngOnInit () { | 16 | async ngOnInit () { |
17 | this.creditsHtml = await this.markdownService.completeMarkdownToHTML(this.markdown) | 17 | this.creditsHtml = await this.markdownService.unsafeMarkdownToHTML(this.markdown, true) |
18 | } | 18 | } |
19 | } | 19 | } |
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts index 45366f9ec..a7fe20b07 100644 --- a/client/src/app/+admin/admin.module.ts +++ b/client/src/app/+admin/admin.module.ts | |||
@@ -4,12 +4,13 @@ import { TableModule } from 'primeng/table' | |||
4 | import { NgModule } from '@angular/core' | 4 | import { NgModule } from '@angular/core' |
5 | import { SharedAbuseListModule } from '@app/shared/shared-abuse-list' | 5 | import { SharedAbuseListModule } from '@app/shared/shared-abuse-list' |
6 | import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit' | 6 | import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit' |
7 | import { SharedActorImageModule } from '@app/shared/shared-actor-image/shared-actor-image.module' | ||
8 | import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup' | ||
7 | import { SharedFormModule } from '@app/shared/shared-forms' | 9 | import { SharedFormModule } from '@app/shared/shared-forms' |
8 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' | 10 | import { SharedGlobalIconModule } from '@app/shared/shared-icons' |
9 | import { SharedMainModule } from '@app/shared/shared-main' | 11 | import { SharedMainModule } from '@app/shared/shared-main' |
10 | import { SharedModerationModule } from '@app/shared/shared-moderation' | 12 | import { SharedModerationModule } from '@app/shared/shared-moderation' |
11 | import { SharedVideoCommentModule } from '@app/shared/shared-video-comment' | 13 | import { SharedVideoCommentModule } from '@app/shared/shared-video-comment' |
12 | import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' | ||
13 | import { AdminRoutingModule } from './admin-routing.module' | 14 | import { AdminRoutingModule } from './admin-routing.module' |
14 | import { AdminComponent } from './admin.component' | 15 | import { AdminComponent } from './admin.component' |
15 | import { | 16 | import { |
@@ -18,6 +19,7 @@ import { | |||
18 | EditBasicConfigurationComponent, | 19 | EditBasicConfigurationComponent, |
19 | EditConfigurationService, | 20 | EditConfigurationService, |
20 | EditCustomConfigComponent, | 21 | EditCustomConfigComponent, |
22 | EditHomepageComponent, | ||
21 | EditInstanceInformationComponent, | 23 | EditInstanceInformationComponent, |
22 | EditLiveConfigurationComponent, | 24 | EditLiveConfigurationComponent, |
23 | EditVODTranscodingComponent | 25 | EditVODTranscodingComponent |
@@ -53,6 +55,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom | |||
53 | SharedVideoCommentModule, | 55 | SharedVideoCommentModule, |
54 | SharedActorImageModule, | 56 | SharedActorImageModule, |
55 | SharedActorImageEditModule, | 57 | SharedActorImageEditModule, |
58 | SharedCustomMarkupModule, | ||
56 | 59 | ||
57 | TableModule, | 60 | TableModule, |
58 | SelectButtonModule, | 61 | SelectButtonModule, |
@@ -100,7 +103,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom | |||
100 | EditVODTranscodingComponent, | 103 | EditVODTranscodingComponent, |
101 | EditLiveConfigurationComponent, | 104 | EditLiveConfigurationComponent, |
102 | EditAdvancedConfigurationComponent, | 105 | EditAdvancedConfigurationComponent, |
103 | EditInstanceInformationComponent | 106 | EditInstanceInformationComponent, |
107 | EditHomepageComponent | ||
104 | ], | 108 | ], |
105 | 109 | ||
106 | exports: [ | 110 | exports: [ |
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html index 84a793ae4..451e6a34a 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html | |||
@@ -26,22 +26,13 @@ | |||
26 | <div class="form-group" formGroupName="instance"> | 26 | <div class="form-group" formGroupName="instance"> |
27 | <label i18n for="instanceDefaultClientRoute">Landing page</label> | 27 | <label i18n for="instanceDefaultClientRoute">Landing page</label> |
28 | 28 | ||
29 | <div class="peertube-select-container"> | 29 | <my-select-custom-value |
30 | <select id="instanceDefaultClientRoute" formControlName="defaultClientRoute" class="form-control"> | 30 | id="instanceDefaultClientRoute" |
31 | <option i18n value="/videos/overview">Discover videos</option> | 31 | [items]="defaultLandingPageOptions" |
32 | 32 | formControlName="defaultClientRoute" | |
33 | <optgroup i18n-label label="Trending pages"> | 33 | inputType="text" |
34 | <option i18n value="/videos/trending">Default trending page</option> | 34 | [clearable]="false" |
35 | <option i18n value="/videos/trending?alg=best" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('best')">Best videos</option> | 35 | ></my-select-custom-value> |
36 | <option i18n value="/videos/trending?alg=hot" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('hot')">Hot videos</option> | ||
37 | <option i18n value="/videos/trending?alg=most-viewed" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-viewed')">Most viewed videos</option> | ||
38 | <option i18n value="/videos/trending?alg=most-liked" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-liked')">Most liked videos</option> | ||
39 | </optgroup> | ||
40 | |||
41 | <option i18n value="/videos/recently-added">Recently added videos</option> | ||
42 | <option i18n value="/videos/local">Local videos</option> | ||
43 | </select> | ||
44 | </div> | ||
45 | 36 | ||
46 | <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div> | 37 | <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div> |
47 | </div> | 38 | </div> |
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts index 34d05f9f3..d50148e7a 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts | |||
@@ -1,7 +1,9 @@ | |||
1 | 1 | ||
2 | import { pairwise } from 'rxjs/operators' | 2 | import { pairwise } from 'rxjs/operators' |
3 | import { Component, Input, OnInit } from '@angular/core' | 3 | import { SelectOptionsItem } from 'src/types/select-options-item.model' |
4 | import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core' | ||
4 | import { FormGroup } from '@angular/forms' | 5 | import { FormGroup } from '@angular/forms' |
6 | import { MenuService } from '@app/core' | ||
5 | import { ServerConfig } from '@shared/models' | 7 | import { ServerConfig } from '@shared/models' |
6 | import { ConfigService } from '../shared/config.service' | 8 | import { ConfigService } from '../shared/config.service' |
7 | 9 | ||
@@ -10,22 +12,31 @@ import { ConfigService } from '../shared/config.service' | |||
10 | templateUrl: './edit-basic-configuration.component.html', | 12 | templateUrl: './edit-basic-configuration.component.html', |
11 | styleUrls: [ './edit-custom-config.component.scss' ] | 13 | styleUrls: [ './edit-custom-config.component.scss' ] |
12 | }) | 14 | }) |
13 | export class EditBasicConfigurationComponent implements OnInit { | 15 | export class EditBasicConfigurationComponent implements OnInit, OnChanges { |
14 | @Input() form: FormGroup | 16 | @Input() form: FormGroup |
15 | @Input() formErrors: any | 17 | @Input() formErrors: any |
16 | 18 | ||
17 | @Input() serverConfig: ServerConfig | 19 | @Input() serverConfig: ServerConfig |
18 | 20 | ||
19 | signupAlertMessage: string | 21 | signupAlertMessage: string |
22 | defaultLandingPageOptions: SelectOptionsItem[] = [] | ||
20 | 23 | ||
21 | constructor ( | 24 | constructor ( |
22 | private configService: ConfigService | 25 | private configService: ConfigService, |
26 | private menuService: MenuService | ||
23 | ) { } | 27 | ) { } |
24 | 28 | ||
25 | ngOnInit () { | 29 | ngOnInit () { |
30 | this.buildLandingPageOptions() | ||
26 | this.checkSignupField() | 31 | this.checkSignupField() |
27 | } | 32 | } |
28 | 33 | ||
34 | ngOnChanges (changes: SimpleChanges) { | ||
35 | if (changes['serverConfig']) { | ||
36 | this.buildLandingPageOptions() | ||
37 | } | ||
38 | } | ||
39 | |||
29 | getVideoQuotaOptions () { | 40 | getVideoQuotaOptions () { |
30 | return this.configService.videoQuotaOptions | 41 | return this.configService.videoQuotaOptions |
31 | } | 42 | } |
@@ -70,6 +81,15 @@ export class EditBasicConfigurationComponent implements OnInit { | |||
70 | return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true | 81 | return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true |
71 | } | 82 | } |
72 | 83 | ||
84 | buildLandingPageOptions () { | ||
85 | this.defaultLandingPageOptions = this.menuService.buildCommonLinks(this.serverConfig) | ||
86 | .map(o => ({ | ||
87 | id: o.path, | ||
88 | label: o.label, | ||
89 | description: o.path | ||
90 | })) | ||
91 | } | ||
92 | |||
73 | private checkSignupField () { | 93 | private checkSignupField () { |
74 | const signupControl = this.form.get('signup.enabled') | 94 | const signupControl = this.form.get('signup.enabled') |
75 | 95 | ||
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html index b6365614d..3ceea02ca 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html | |||
@@ -3,8 +3,16 @@ | |||
3 | 3 | ||
4 | <div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs"> | 4 | <div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs"> |
5 | 5 | ||
6 | <ng-container ngbNavItem="instance-homepage"> | ||
7 | <a ngbNavLink i18n>Homepage</a> | ||
8 | |||
9 | <ng-template ngbNavContent> | ||
10 | <my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage> | ||
11 | </ng-template> | ||
12 | </ng-container> | ||
13 | |||
6 | <ng-container ngbNavItem="instance-information"> | 14 | <ng-container ngbNavItem="instance-information"> |
7 | <a ngbNavLink i18n>Instance information</a> | 15 | <a ngbNavLink i18n>Information</a> |
8 | 16 | ||
9 | <ng-template ngbNavContent> | 17 | <ng-template ngbNavContent> |
10 | <my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems"> | 18 | <my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems"> |
@@ -13,7 +21,7 @@ | |||
13 | </ng-container> | 21 | </ng-container> |
14 | 22 | ||
15 | <ng-container ngbNavItem="basic-configuration"> | 23 | <ng-container ngbNavItem="basic-configuration"> |
16 | <a ngbNavLink i18n>Basic configuration</a> | 24 | <a ngbNavLink i18n>Basic</a> |
17 | 25 | ||
18 | <ng-template ngbNavContent> | 26 | <ng-template ngbNavContent> |
19 | <my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig"> | 27 | <my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig"> |
@@ -40,7 +48,7 @@ | |||
40 | </ng-container> | 48 | </ng-container> |
41 | 49 | ||
42 | <ng-container ngbNavItem="advanced-configuration"> | 50 | <ng-container ngbNavItem="advanced-configuration"> |
43 | <a ngbNavLink i18n>Advanced configuration</a> | 51 | <a ngbNavLink i18n>Advanced</a> |
44 | 52 | ||
45 | <ng-template ngbNavContent> | 53 | <ng-template ngbNavContent> |
46 | <my-edit-advanced-configuration [form]="form" [formErrors]="formErrors"> | 54 | <my-edit-advanced-configuration [form]="form" [formErrors]="formErrors"> |
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts index 4b35d65fc..dc8334dd0 100644 --- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts +++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts | |||
@@ -1,4 +1,5 @@ | |||
1 | 1 | ||
2 | import omit from 'lodash-es/omit' | ||
2 | import { forkJoin } from 'rxjs' | 3 | import { forkJoin } from 'rxjs' |
3 | import { SelectOptionsItem } from 'src/types/select-options-item.model' | 4 | import { SelectOptionsItem } from 'src/types/select-options-item.model' |
4 | import { Component, OnInit } from '@angular/core' | 5 | import { Component, OnInit } from '@angular/core' |
@@ -24,9 +25,14 @@ import { | |||
24 | } from '@app/shared/form-validators/custom-config-validators' | 25 | } from '@app/shared/form-validators/custom-config-validators' |
25 | import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators' | 26 | import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators' |
26 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' | 27 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' |
27 | import { CustomConfig, ServerConfig } from '@shared/models' | 28 | import { CustomPageService } from '@app/shared/shared-main/custom-page' |
29 | import { CustomConfig, CustomPage, ServerConfig } from '@shared/models' | ||
28 | import { EditConfigurationService } from './edit-configuration.service' | 30 | import { EditConfigurationService } from './edit-configuration.service' |
29 | 31 | ||
32 | type ComponentCustomConfig = CustomConfig & { | ||
33 | instanceCustomHomepage: CustomPage | ||
34 | } | ||
35 | |||
30 | @Component({ | 36 | @Component({ |
31 | selector: 'my-edit-custom-config', | 37 | selector: 'my-edit-custom-config', |
32 | templateUrl: './edit-custom-config.component.html', | 38 | templateUrl: './edit-custom-config.component.html', |
@@ -35,9 +41,11 @@ import { EditConfigurationService } from './edit-configuration.service' | |||
35 | export class EditCustomConfigComponent extends FormReactive implements OnInit { | 41 | export class EditCustomConfigComponent extends FormReactive implements OnInit { |
36 | activeNav: string | 42 | activeNav: string |
37 | 43 | ||
38 | customConfig: CustomConfig | 44 | customConfig: ComponentCustomConfig |
39 | serverConfig: ServerConfig | 45 | serverConfig: ServerConfig |
40 | 46 | ||
47 | homepage: CustomPage | ||
48 | |||
41 | languageItems: SelectOptionsItem[] = [] | 49 | languageItems: SelectOptionsItem[] = [] |
42 | categoryItems: SelectOptionsItem[] = [] | 50 | categoryItems: SelectOptionsItem[] = [] |
43 | 51 | ||
@@ -47,6 +55,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
47 | protected formValidatorService: FormValidatorService, | 55 | protected formValidatorService: FormValidatorService, |
48 | private notifier: Notifier, | 56 | private notifier: Notifier, |
49 | private configService: ConfigService, | 57 | private configService: ConfigService, |
58 | private customPage: CustomPageService, | ||
50 | private serverService: ServerService, | 59 | private serverService: ServerService, |
51 | private editConfigurationService: EditConfigurationService | 60 | private editConfigurationService: EditConfigurationService |
52 | ) { | 61 | ) { |
@@ -56,11 +65,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
56 | ngOnInit () { | 65 | ngOnInit () { |
57 | this.serverConfig = this.serverService.getTmpConfig() | 66 | this.serverConfig = this.serverService.getTmpConfig() |
58 | this.serverService.getConfig() | 67 | this.serverService.getConfig() |
59 | .subscribe(config => { | 68 | .subscribe(config => this.serverConfig = config) |
60 | this.serverConfig = config | ||
61 | }) | ||
62 | 69 | ||
63 | const formGroupData: { [key in keyof CustomConfig ]: any } = { | 70 | const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = { |
64 | instance: { | 71 | instance: { |
65 | name: INSTANCE_NAME_VALIDATOR, | 72 | name: INSTANCE_NAME_VALIDATOR, |
66 | shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR, | 73 | shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR, |
@@ -215,6 +222,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
215 | disableLocalSearch: null, | 222 | disableLocalSearch: null, |
216 | isDefaultSearch: null | 223 | isDefaultSearch: null |
217 | } | 224 | } |
225 | }, | ||
226 | |||
227 | instanceCustomHomepage: { | ||
228 | content: null | ||
218 | } | 229 | } |
219 | } | 230 | } |
220 | 231 | ||
@@ -250,15 +261,23 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
250 | } | 261 | } |
251 | 262 | ||
252 | async formValidated () { | 263 | async formValidated () { |
253 | const value: CustomConfig = this.form.getRawValue() | 264 | const value: ComponentCustomConfig = this.form.getRawValue() |
254 | 265 | ||
255 | this.configService.updateCustomConfig(value) | 266 | forkJoin([ |
267 | this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')), | ||
268 | this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content) | ||
269 | ]) | ||
256 | .subscribe( | 270 | .subscribe( |
257 | res => { | 271 | ([ resConfig ]) => { |
258 | this.customConfig = res | 272 | const instanceCustomHomepage = { |
273 | content: value.instanceCustomHomepage.content | ||
274 | } | ||
275 | |||
276 | this.customConfig = { ...resConfig, instanceCustomHomepage } | ||
259 | 277 | ||
260 | // Reload general configuration | 278 | // Reload general configuration |
261 | this.serverService.resetConfig() | 279 | this.serverService.resetConfig() |
280 | .subscribe(config => this.serverConfig = config) | ||
262 | 281 | ||
263 | this.updateForm() | 282 | this.updateForm() |
264 | 283 | ||
@@ -317,9 +336,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit { | |||
317 | } | 336 | } |
318 | 337 | ||
319 | private loadConfigAndUpdateForm () { | 338 | private loadConfigAndUpdateForm () { |
320 | this.configService.getCustomConfig() | 339 | forkJoin([ |
321 | .subscribe(config => { | 340 | this.configService.getCustomConfig(), |
322 | this.customConfig = config | 341 | this.customPage.getInstanceHomepage() |
342 | ]) | ||
343 | .subscribe(([ config, homepage ]) => { | ||
344 | this.customConfig = { ...config, instanceCustomHomepage: homepage } | ||
323 | 345 | ||
324 | this.updateForm() | 346 | this.updateForm() |
325 | // Force form validation | 347 | // Force form validation |
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html new file mode 100644 index 000000000..c48fa5bf8 --- /dev/null +++ b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html | |||
@@ -0,0 +1,28 @@ | |||
1 | <ng-container [formGroup]="form"> | ||
2 | |||
3 | <ng-container formGroupName="instanceCustomHomepage"> | ||
4 | |||
5 | <div class="form-row mt-5"> <!-- homepage grid --> | ||
6 | <div class="form-group col-12 col-lg-4 col-xl-3"> | ||
7 | <div i18n class="inner-form-title">INSTANCE HOMEPAGE</div> | ||
8 | </div> | ||
9 | |||
10 | <div class="form-group form-group-right col-12 col-lg-8 col-xl-9"> | ||
11 | |||
12 | <div class="form-group"> | ||
13 | <label i18n for="instanceCustomHomepageContent">Homepage</label> | ||
14 | |||
15 | <my-markdown-textarea | ||
16 | name="instanceCustomHomepageContent" formControlName="content" textareaMaxWidth="90%" textareaHeight="300px" | ||
17 | [customMarkdownRenderer]="customMarkdownRenderer" | ||
18 | [classes]="{ 'input-error': formErrors['instanceCustomHomepage.content'] }" | ||
19 | ></my-markdown-textarea> | ||
20 | |||
21 | <div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error">{{ formErrors.instanceCustomHomepage.content }}</div> | ||
22 | </div> | ||
23 | </div> | ||
24 | </div> | ||
25 | |||
26 | </ng-container> | ||
27 | |||
28 | </ng-container> | ||
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts new file mode 100644 index 000000000..7decf8f75 --- /dev/null +++ b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { FormGroup } from '@angular/forms' | ||
3 | import { CustomMarkupService } from '@app/shared/shared-custom-markup' | ||
4 | |||
5 | @Component({ | ||
6 | selector: 'my-edit-homepage', | ||
7 | templateUrl: './edit-homepage.component.html', | ||
8 | styleUrls: [ './edit-custom-config.component.scss' ] | ||
9 | }) | ||
10 | export class EditHomepageComponent implements OnInit { | ||
11 | @Input() form: FormGroup | ||
12 | @Input() formErrors: any | ||
13 | |||
14 | customMarkdownRenderer: (text: string) => Promise<HTMLElement> | ||
15 | |||
16 | constructor (private customMarkup: CustomMarkupService) { | ||
17 | |||
18 | } | ||
19 | |||
20 | ngOnInit () { | ||
21 | this.customMarkdownRenderer = async (text: string) => { | ||
22 | return this.customMarkup.buildElement(text) | ||
23 | } | ||
24 | } | ||
25 | } | ||
diff --git a/client/src/app/+admin/config/edit-custom-config/index.ts b/client/src/app/+admin/config/edit-custom-config/index.ts index 95fcc8f52..4281ad09b 100644 --- a/client/src/app/+admin/config/edit-custom-config/index.ts +++ b/client/src/app/+admin/config/edit-custom-config/index.ts | |||
@@ -2,6 +2,7 @@ export * from './edit-advanced-configuration.component' | |||
2 | export * from './edit-basic-configuration.component' | 2 | export * from './edit-basic-configuration.component' |
3 | export * from './edit-configuration.service' | 3 | export * from './edit-configuration.service' |
4 | export * from './edit-custom-config.component' | 4 | export * from './edit-custom-config.component' |
5 | export * from './edit-homepage.component' | ||
5 | export * from './edit-instance-information.component' | 6 | export * from './edit-instance-information.component' |
6 | export * from './edit-live-configuration.component' | 7 | export * from './edit-live-configuration.component' |
7 | export * from './edit-vod-transcoding.component' | 8 | export * from './edit-vod-transcoding.component' |
diff --git a/client/src/app/+home/home-routing.module.ts b/client/src/app/+home/home-routing.module.ts new file mode 100644 index 000000000..1eaee4449 --- /dev/null +++ b/client/src/app/+home/home-routing.module.ts | |||
@@ -0,0 +1,18 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { MetaGuard } from '@ngx-meta/core' | ||
4 | import { HomeComponent } from './home.component' | ||
5 | |||
6 | const homeRoutes: Routes = [ | ||
7 | { | ||
8 | path: '', | ||
9 | component: HomeComponent, | ||
10 | canActivateChild: [ MetaGuard ] | ||
11 | } | ||
12 | ] | ||
13 | |||
14 | @NgModule({ | ||
15 | imports: [ RouterModule.forChild(homeRoutes) ], | ||
16 | exports: [ RouterModule ] | ||
17 | }) | ||
18 | export class HomeRoutingModule {} | ||
diff --git a/client/src/app/+home/home.component.html b/client/src/app/+home/home.component.html new file mode 100644 index 000000000..645b9dc69 --- /dev/null +++ b/client/src/app/+home/home.component.html | |||
@@ -0,0 +1,4 @@ | |||
1 | <div class="root margin-content"> | ||
2 | <div #contentWrapper></div> | ||
3 | </div> | ||
4 | |||
diff --git a/client/src/app/+home/home.component.scss b/client/src/app/+home/home.component.scss new file mode 100644 index 000000000..6c73e9248 --- /dev/null +++ b/client/src/app/+home/home.component.scss | |||
@@ -0,0 +1,3 @@ | |||
1 | .root { | ||
2 | padding-top: 20px; | ||
3 | } | ||
diff --git a/client/src/app/+home/home.component.ts b/client/src/app/+home/home.component.ts new file mode 100644 index 000000000..16d3a6df7 --- /dev/null +++ b/client/src/app/+home/home.component.ts | |||
@@ -0,0 +1,26 @@ | |||
1 | |||
2 | import { Component, ElementRef, OnInit, ViewChild } from '@angular/core' | ||
3 | import { CustomMarkupService } from '@app/shared/shared-custom-markup' | ||
4 | import { CustomPageService } from '@app/shared/shared-main/custom-page' | ||
5 | |||
6 | @Component({ | ||
7 | templateUrl: './home.component.html', | ||
8 | styleUrls: [ './home.component.scss' ] | ||
9 | }) | ||
10 | |||
11 | export class HomeComponent implements OnInit { | ||
12 | @ViewChild('contentWrapper') contentWrapper: ElementRef<HTMLInputElement> | ||
13 | |||
14 | constructor ( | ||
15 | private customMarkupService: CustomMarkupService, | ||
16 | private customPageService: CustomPageService | ||
17 | ) { } | ||
18 | |||
19 | async ngOnInit () { | ||
20 | this.customPageService.getInstanceHomepage() | ||
21 | .subscribe(async ({ content }) => { | ||
22 | const element = await this.customMarkupService.buildElement(content) | ||
23 | this.contentWrapper.nativeElement.appendChild(element) | ||
24 | }) | ||
25 | } | ||
26 | } | ||
diff --git a/client/src/app/+home/home.module.ts b/client/src/app/+home/home.module.ts new file mode 100644 index 000000000..102cdc296 --- /dev/null +++ b/client/src/app/+home/home.module.ts | |||
@@ -0,0 +1,25 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup' | ||
3 | import { SharedMainModule } from '@app/shared/shared-main' | ||
4 | import { HomeRoutingModule } from './home-routing.module' | ||
5 | import { HomeComponent } from './home.component' | ||
6 | |||
7 | @NgModule({ | ||
8 | imports: [ | ||
9 | HomeRoutingModule, | ||
10 | |||
11 | SharedMainModule, | ||
12 | SharedCustomMarkupModule | ||
13 | ], | ||
14 | |||
15 | declarations: [ | ||
16 | HomeComponent | ||
17 | ], | ||
18 | |||
19 | exports: [ | ||
20 | HomeComponent | ||
21 | ], | ||
22 | |||
23 | providers: [ ] | ||
24 | }) | ||
25 | export class HomeModule { } | ||
diff --git a/client/src/app/+home/index.ts b/client/src/app/+home/index.ts new file mode 100644 index 000000000..7c77cf9fd --- /dev/null +++ b/client/src/app/+home/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './home-routing.module' | ||
2 | export * from './home.component' | ||
3 | export * from './home.module' | ||
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts index fd379e80e..04f8f0d58 100644 --- a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts +++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts | |||
@@ -161,7 +161,7 @@ export class VideoCommentComponent implements OnInit, OnChanges { | |||
161 | // Before HTML rendering restore line feed for markdown list compatibility | 161 | // Before HTML rendering restore line feed for markdown list compatibility |
162 | const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n') | 162 | const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n') |
163 | const html = await this.markdownService.textMarkdownToHTML(commentText, true, true) | 163 | const html = await this.markdownService.textMarkdownToHTML(commentText, true, true) |
164 | this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) | 164 | this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html) |
165 | this.newParentComments = this.parentComments.concat([ this.comment ]) | 165 | this.newParentComments = this.parentComments.concat([ this.comment ]) |
166 | 166 | ||
167 | if (this.comment.account) { | 167 | if (this.comment.account) { |
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts index 116139d47..77405d149 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts | |||
@@ -509,7 +509,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { | |||
509 | 509 | ||
510 | private async setVideoDescriptionHTML () { | 510 | private async setVideoDescriptionHTML () { |
511 | const html = await this.markdownService.textMarkdownToHTML(this.video.description) | 511 | const html = await this.markdownService.textMarkdownToHTML(this.video.description) |
512 | this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html) | 512 | this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html) |
513 | } | 513 | } |
514 | 514 | ||
515 | private setVideoLikesBarTooltipText () { | 515 | private setVideoLikesBarTooltipText () { |
diff --git a/client/src/app/app-routing.module.ts b/client/src/app/app-routing.module.ts index 3ea5b7e5e..57e485e8e 100644 --- a/client/src/app/app-routing.module.ts +++ b/client/src/app/app-routing.module.ts | |||
@@ -14,6 +14,10 @@ const routes: Routes = [ | |||
14 | loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule) | 14 | loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule) |
15 | }, | 15 | }, |
16 | { | 16 | { |
17 | path: 'home', | ||
18 | loadChildren: () => import('./+home/home.module').then(m => m.HomeModule) | ||
19 | }, | ||
20 | { | ||
17 | path: 'my-account', | 21 | path: 'my-account', |
18 | loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule) | 22 | loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule) |
19 | }, | 23 | }, |
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index 239e275a4..863c3f3b5 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts | |||
@@ -231,7 +231,7 @@ export class AppComponent implements OnInit, AfterViewInit { | |||
231 | } | 231 | } |
232 | 232 | ||
233 | this.broadcastMessage = { | 233 | this.broadcastMessage = { |
234 | message: await this.markdownService.completeMarkdownToHTML(messageConfig.message), | 234 | message: await this.markdownService.unsafeMarkdownToHTML(messageConfig.message, true), |
235 | dismissable: messageConfig.dismissable, | 235 | dismissable: messageConfig.dismissable, |
236 | class: classes[messageConfig.level] | 236 | class: classes[messageConfig.level] |
237 | } | 237 | } |
diff --git a/client/src/app/core/menu/menu.service.ts b/client/src/app/core/menu/menu.service.ts index 502d3bb2f..77592cbb6 100644 --- a/client/src/app/core/menu/menu.service.ts +++ b/client/src/app/core/menu/menu.service.ts | |||
@@ -1,8 +1,19 @@ | |||
1 | import { fromEvent } from 'rxjs' | 1 | import { fromEvent } from 'rxjs' |
2 | import { debounceTime } from 'rxjs/operators' | 2 | import { debounceTime } from 'rxjs/operators' |
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { GlobalIconName } from '@app/shared/shared-icons' | ||
5 | import { sortObjectComparator } from '@shared/core-utils/miscs/miscs' | ||
6 | import { ServerConfig } from '@shared/models/server' | ||
4 | import { ScreenService } from '../wrappers' | 7 | import { ScreenService } from '../wrappers' |
5 | 8 | ||
9 | export type MenuLink = { | ||
10 | icon: GlobalIconName | ||
11 | label: string | ||
12 | menuLabel: string | ||
13 | path: string | ||
14 | priority: number | ||
15 | } | ||
16 | |||
6 | @Injectable() | 17 | @Injectable() |
7 | export class MenuService { | 18 | export class MenuService { |
8 | isMenuDisplayed = true | 19 | isMenuDisplayed = true |
@@ -48,6 +59,53 @@ export class MenuService { | |||
48 | this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser | 59 | this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser |
49 | } | 60 | } |
50 | 61 | ||
62 | buildCommonLinks (config: ServerConfig) { | ||
63 | let entries: MenuLink[] = [ | ||
64 | { | ||
65 | icon: 'globe' as 'globe', | ||
66 | label: $localize`Discover videos`, | ||
67 | menuLabel: $localize`Discover`, | ||
68 | path: '/videos/overview', | ||
69 | priority: 150 | ||
70 | }, | ||
71 | { | ||
72 | icon: 'trending' as 'trending', | ||
73 | label: $localize`Trending videos`, | ||
74 | menuLabel: $localize`Trending`, | ||
75 | path: '/videos/trending', | ||
76 | priority: 140 | ||
77 | }, | ||
78 | { | ||
79 | icon: 'recently-added' as 'recently-added', | ||
80 | label: $localize`Recently added videos`, | ||
81 | menuLabel: $localize`Recently added`, | ||
82 | path: '/videos/recently-added', | ||
83 | priority: 130 | ||
84 | }, | ||
85 | { | ||
86 | icon: 'octagon' as 'octagon', | ||
87 | label: $localize`Local videos`, | ||
88 | menuLabel: $localize`Local videos`, | ||
89 | path: '/videos/local', | ||
90 | priority: 120 | ||
91 | } | ||
92 | ] | ||
93 | |||
94 | if (config.homepage.enabled) { | ||
95 | entries.push({ | ||
96 | icon: 'home' as 'home', | ||
97 | label: $localize`Home`, | ||
98 | menuLabel: $localize`Home`, | ||
99 | path: '/home', | ||
100 | priority: 160 | ||
101 | }) | ||
102 | } | ||
103 | |||
104 | entries = entries.sort(sortObjectComparator('priority', 'desc')) | ||
105 | |||
106 | return entries | ||
107 | } | ||
108 | |||
51 | private handleWindowResize () { | 109 | private handleWindowResize () { |
52 | // On touch screens, do not handle window resize event since opened menu is handled with a content overlay | 110 | // On touch screens, do not handle window resize event since opened menu is handled with a content overlay |
53 | if (this.screenService.isInTouchScreen()) return | 111 | if (this.screenService.isInTouchScreen()) return |
diff --git a/client/src/app/core/renderer/html-renderer.service.ts b/client/src/app/core/renderer/html-renderer.service.ts index 3176cf6a4..418d8603e 100644 --- a/client/src/app/core/renderer/html-renderer.service.ts +++ b/client/src/app/core/renderer/html-renderer.service.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { Injectable } from '@angular/core' | 1 | import { Injectable } from '@angular/core' |
2 | import { LinkifierService } from './linkifier.service' | 2 | import { LinkifierService } from './linkifier.service' |
3 | import { SANITIZE_OPTIONS } from '@shared/core-utils/renderer/html' | 3 | import { getCustomMarkupSanitizeOptions, getSanitizeOptions } from '@shared/core-utils/renderer/html' |
4 | 4 | ||
5 | @Injectable() | 5 | @Injectable() |
6 | export class HtmlRendererService { | 6 | export class HtmlRendererService { |
@@ -20,7 +20,7 @@ export class HtmlRendererService { | |||
20 | }) | 20 | }) |
21 | } | 21 | } |
22 | 22 | ||
23 | async toSafeHtml (text: string) { | 23 | async toSafeHtml (text: string, additionalAllowedTags: string[] = []) { |
24 | const [ html ] = await Promise.all([ | 24 | const [ html ] = await Promise.all([ |
25 | // Convert possible markdown to html | 25 | // Convert possible markdown to html |
26 | this.linkifier.linkify(text), | 26 | this.linkifier.linkify(text), |
@@ -28,7 +28,11 @@ export class HtmlRendererService { | |||
28 | this.loadSanitizeHtml() | 28 | this.loadSanitizeHtml() |
29 | ]) | 29 | ]) |
30 | 30 | ||
31 | return this.sanitizeHtml(html, SANITIZE_OPTIONS) | 31 | const options = additionalAllowedTags.length !== 0 |
32 | ? getCustomMarkupSanitizeOptions(additionalAllowedTags) | ||
33 | : getSanitizeOptions() | ||
34 | |||
35 | return this.sanitizeHtml(html, options) | ||
32 | } | 36 | } |
33 | 37 | ||
34 | private async loadSanitizeHtml () { | 38 | private async loadSanitizeHtml () { |
diff --git a/client/src/app/core/renderer/markdown.service.ts b/client/src/app/core/renderer/markdown.service.ts index edddb0a66..ca1bf4eb9 100644 --- a/client/src/app/core/renderer/markdown.service.ts +++ b/client/src/app/core/renderer/markdown.service.ts | |||
@@ -17,12 +17,15 @@ type MarkdownParsers = { | |||
17 | enhancedMarkdownIt: MarkdownIt | 17 | enhancedMarkdownIt: MarkdownIt |
18 | enhancedWithHTMLMarkdownIt: MarkdownIt | 18 | enhancedWithHTMLMarkdownIt: MarkdownIt |
19 | 19 | ||
20 | completeMarkdownIt: MarkdownIt | 20 | unsafeMarkdownIt: MarkdownIt |
21 | |||
22 | customPageMarkdownIt: MarkdownIt | ||
21 | } | 23 | } |
22 | 24 | ||
23 | type MarkdownConfig = { | 25 | type MarkdownConfig = { |
24 | rules: string[] | 26 | rules: string[] |
25 | html: boolean | 27 | html: boolean |
28 | breaks: boolean | ||
26 | escape?: boolean | 29 | escape?: boolean |
27 | } | 30 | } |
28 | 31 | ||
@@ -35,18 +38,24 @@ export class MarkdownService { | |||
35 | private markdownParsers: MarkdownParsers = { | 38 | private markdownParsers: MarkdownParsers = { |
36 | textMarkdownIt: null, | 39 | textMarkdownIt: null, |
37 | textWithHTMLMarkdownIt: null, | 40 | textWithHTMLMarkdownIt: null, |
41 | |||
38 | enhancedMarkdownIt: null, | 42 | enhancedMarkdownIt: null, |
39 | enhancedWithHTMLMarkdownIt: null, | 43 | enhancedWithHTMLMarkdownIt: null, |
40 | completeMarkdownIt: null | 44 | |
45 | unsafeMarkdownIt: null, | ||
46 | |||
47 | customPageMarkdownIt: null | ||
41 | } | 48 | } |
42 | private parsersConfig: MarkdownParserConfigs = { | 49 | private parsersConfig: MarkdownParserConfigs = { |
43 | textMarkdownIt: { rules: TEXT_RULES, html: false }, | 50 | textMarkdownIt: { rules: TEXT_RULES, breaks: true, html: false }, |
44 | textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, html: true, escape: true }, | 51 | textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, breaks: true, html: true, escape: true }, |
45 | 52 | ||
46 | enhancedMarkdownIt: { rules: ENHANCED_RULES, html: false }, | 53 | enhancedMarkdownIt: { rules: ENHANCED_RULES, breaks: true, html: false }, |
47 | enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, html: true, escape: true }, | 54 | enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, breaks: true, html: true, escape: true }, |
48 | 55 | ||
49 | completeMarkdownIt: { rules: COMPLETE_RULES, html: true } | 56 | unsafeMarkdownIt: { rules: COMPLETE_RULES, breaks: true, html: true, escape: false }, |
57 | |||
58 | customPageMarkdownIt: { rules: COMPLETE_RULES, breaks: false, html: true, escape: true } | ||
50 | } | 59 | } |
51 | 60 | ||
52 | private emojiModule: any | 61 | private emojiModule: any |
@@ -54,22 +63,26 @@ export class MarkdownService { | |||
54 | constructor (private htmlRenderer: HtmlRendererService) {} | 63 | constructor (private htmlRenderer: HtmlRendererService) {} |
55 | 64 | ||
56 | textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) { | 65 | textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) { |
57 | if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown, withEmoji) | 66 | if (withHtml) return this.render({ name: 'textWithHTMLMarkdownIt', markdown, withEmoji }) |
58 | 67 | ||
59 | return this.render('textMarkdownIt', markdown, withEmoji) | 68 | return this.render({ name: 'textMarkdownIt', markdown, withEmoji }) |
60 | } | 69 | } |
61 | 70 | ||
62 | enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) { | 71 | enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) { |
63 | if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown, withEmoji) | 72 | if (withHtml) return this.render({ name: 'enhancedWithHTMLMarkdownIt', markdown, withEmoji }) |
73 | |||
74 | return this.render({ name: 'enhancedMarkdownIt', markdown, withEmoji }) | ||
75 | } | ||
64 | 76 | ||
65 | return this.render('enhancedMarkdownIt', markdown, withEmoji) | 77 | unsafeMarkdownToHTML (markdown: string, _trustedInput: true) { |
78 | return this.render({ name: 'unsafeMarkdownIt', markdown, withEmoji: true }) | ||
66 | } | 79 | } |
67 | 80 | ||
68 | completeMarkdownToHTML (markdown: string) { | 81 | customPageMarkdownToHTML (markdown: string, additionalAllowedTags: string[]) { |
69 | return this.render('completeMarkdownIt', markdown, true) | 82 | return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags }) |
70 | } | 83 | } |
71 | 84 | ||
72 | async processVideoTimestamps (html: string) { | 85 | processVideoTimestamps (html: string) { |
73 | return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) { | 86 | return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) { |
74 | const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0)) | 87 | const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0)) |
75 | const url = buildVideoLink({ startTime: t }) | 88 | const url = buildVideoLink({ startTime: t }) |
@@ -77,7 +90,13 @@ export class MarkdownService { | |||
77 | }) | 90 | }) |
78 | } | 91 | } |
79 | 92 | ||
80 | private async render (name: keyof MarkdownParsers, markdown: string, withEmoji = false) { | 93 | private async render (options: { |
94 | name: keyof MarkdownParsers | ||
95 | markdown: string | ||
96 | withEmoji: boolean | ||
97 | additionalAllowedTags?: string[] | ||
98 | }) { | ||
99 | const { name, markdown, withEmoji, additionalAllowedTags } = options | ||
81 | if (!markdown) return '' | 100 | if (!markdown) return '' |
82 | 101 | ||
83 | const config = this.parsersConfig[ name ] | 102 | const config = this.parsersConfig[ name ] |
@@ -96,7 +115,7 @@ export class MarkdownService { | |||
96 | let html = this.markdownParsers[ name ].render(markdown) | 115 | let html = this.markdownParsers[ name ].render(markdown) |
97 | html = this.avoidTruncatedTags(html) | 116 | html = this.avoidTruncatedTags(html) |
98 | 117 | ||
99 | if (config.escape) return this.htmlRenderer.toSafeHtml(html) | 118 | if (config.escape) return this.htmlRenderer.toSafeHtml(html, additionalAllowedTags) |
100 | 119 | ||
101 | return html | 120 | return html |
102 | } | 121 | } |
@@ -105,7 +124,7 @@ export class MarkdownService { | |||
105 | // FIXME: import('...') returns a struct module, containing a "default" field | 124 | // FIXME: import('...') returns a struct module, containing a "default" field |
106 | const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default | 125 | const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default |
107 | 126 | ||
108 | const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html }) | 127 | const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: config.breaks, html: config.html }) |
109 | 128 | ||
110 | for (const rule of config.rules) { | 129 | for (const rule of config.rules) { |
111 | markdownIt.enable(rule) | 130 | markdownIt.enable(rule) |
diff --git a/client/src/app/core/server/server.service.ts b/client/src/app/core/server/server.service.ts index e48786e18..5b1b7603f 100644 --- a/client/src/app/core/server/server.service.ts +++ b/client/src/app/core/server/server.service.ts | |||
@@ -173,6 +173,9 @@ export class ServerService { | |||
173 | disableLocalSearch: false, | 173 | disableLocalSearch: false, |
174 | isDefaultSearch: false | 174 | isDefaultSearch: false |
175 | } | 175 | } |
176 | }, | ||
177 | homepage: { | ||
178 | enabled: false | ||
176 | } | 179 | } |
177 | } | 180 | } |
178 | 181 | ||
@@ -198,9 +201,7 @@ export class ServerService { | |||
198 | this.configReset = true | 201 | this.configReset = true |
199 | 202 | ||
200 | // Notify config update | 203 | // Notify config update |
201 | this.getConfig().subscribe(() => { | 204 | return this.getConfig() |
202 | // empty, to fire a reset config event | ||
203 | }) | ||
204 | } | 205 | } |
205 | 206 | ||
206 | getConfig () { | 207 | getConfig () { |
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html index 2e07deca2..fcc0bc21a 100644 --- a/client/src/app/menu/menu.component.html +++ b/client/src/app/menu/menu.component.html | |||
@@ -123,24 +123,9 @@ | |||
123 | <div class="on-instance"> | 123 | <div class="on-instance"> |
124 | <div i18n class="block-title">ON {{instanceName}}</div> | 124 | <div i18n class="block-title">ON {{instanceName}}</div> |
125 | 125 | ||
126 | <a class="menu-link" routerLink="/videos/overview" routerLinkActive="active"> | 126 | <a class="menu-link" *ngFor="let commonLink of commonMenuLinks" [routerLink]="commonLink.path" routerLinkActive="active"> |
127 | <my-global-icon iconName="globe" aria-hidden="true"></my-global-icon> | 127 | <my-global-icon [iconName]="commonLink.icon" aria-hidden="true"></my-global-icon> |
128 | <ng-container i18n>Discover</ng-container> | 128 | <ng-container>{{ commonLink.menuLabel }}</ng-container> |
129 | </a> | ||
130 | |||
131 | <a class="menu-link" routerLink="/videos/trending" routerLinkActive="active"> | ||
132 | <my-global-icon iconName="trending" aria-hidden="true"></my-global-icon> | ||
133 | <ng-container i18n>Trending</ng-container> | ||
134 | </a> | ||
135 | |||
136 | <a class="menu-link" routerLink="/videos/recently-added" routerLinkActive="active"> | ||
137 | <my-global-icon iconName="recently-added" aria-hidden="true"></my-global-icon> | ||
138 | <ng-container i18n>Recently added</ng-container> | ||
139 | </a> | ||
140 | |||
141 | <a class="menu-link" routerLink="/videos/local" routerLinkActive="active"> | ||
142 | <my-global-icon iconName="home" aria-hidden="true"></my-global-icon> | ||
143 | <ng-container i18n>Local videos</ng-container> | ||
144 | </a> | 129 | </a> |
145 | </div> | 130 | </div> |
146 | </div> | 131 | </div> |
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts index 8fa1de326..2f7e0cf07 100644 --- a/client/src/app/menu/menu.component.ts +++ b/client/src/app/menu/menu.component.ts | |||
@@ -4,7 +4,17 @@ import { switchMap } from 'rxjs/operators' | |||
4 | import { ViewportScroller } from '@angular/common' | 4 | import { ViewportScroller } from '@angular/common' |
5 | import { Component, OnInit, ViewChild } from '@angular/core' | 5 | import { Component, OnInit, ViewChild } from '@angular/core' |
6 | import { Router } from '@angular/router' | 6 | import { Router } from '@angular/router' |
7 | import { AuthService, AuthStatus, AuthUser, MenuService, RedirectService, ScreenService, ServerService, UserService } from '@app/core' | 7 | import { |
8 | AuthService, | ||
9 | AuthStatus, | ||
10 | AuthUser, | ||
11 | MenuLink, | ||
12 | MenuService, | ||
13 | RedirectService, | ||
14 | ScreenService, | ||
15 | ServerService, | ||
16 | UserService | ||
17 | } from '@app/core' | ||
8 | import { scrollToTop } from '@app/helpers' | 18 | import { scrollToTop } from '@app/helpers' |
9 | import { LanguageChooserComponent } from '@app/menu/language-chooser.component' | 19 | import { LanguageChooserComponent } from '@app/menu/language-chooser.component' |
10 | import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component' | 20 | import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component' |
@@ -35,6 +45,8 @@ export class MenuComponent implements OnInit { | |||
35 | 45 | ||
36 | currentInterfaceLanguage: string | 46 | currentInterfaceLanguage: string |
37 | 47 | ||
48 | commonMenuLinks: MenuLink[] = [] | ||
49 | |||
38 | private languages: VideoConstant<string>[] = [] | 50 | private languages: VideoConstant<string>[] = [] |
39 | private serverConfig: ServerConfig | 51 | private serverConfig: ServerConfig |
40 | private routesPerRight: { [role in UserRight]?: string } = { | 52 | private routesPerRight: { [role in UserRight]?: string } = { |
@@ -80,7 +92,10 @@ export class MenuComponent implements OnInit { | |||
80 | ngOnInit () { | 92 | ngOnInit () { |
81 | this.serverConfig = this.serverService.getTmpConfig() | 93 | this.serverConfig = this.serverService.getTmpConfig() |
82 | this.serverService.getConfig() | 94 | this.serverService.getConfig() |
83 | .subscribe(config => this.serverConfig = config) | 95 | .subscribe(config => { |
96 | this.serverConfig = config | ||
97 | this.buildMenuLinks() | ||
98 | }) | ||
84 | 99 | ||
85 | this.isLoggedIn = this.authService.isLoggedIn() | 100 | this.isLoggedIn = this.authService.isLoggedIn() |
86 | if (this.isLoggedIn === true) { | 101 | if (this.isLoggedIn === true) { |
@@ -241,6 +256,10 @@ export class MenuComponent implements OnInit { | |||
241 | } | 256 | } |
242 | } | 257 | } |
243 | 258 | ||
259 | private buildMenuLinks () { | ||
260 | this.commonMenuLinks = this.menuService.buildCommonLinks(this.serverConfig) | ||
261 | } | ||
262 | |||
244 | private buildUserLanguages () { | 263 | private buildUserLanguages () { |
245 | if (!this.user) { | 264 | if (!this.user) { |
246 | this.videoLanguages = [] | 265 | this.videoLanguages = [] |
diff --git a/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html new file mode 100644 index 000000000..da81006b9 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html | |||
@@ -0,0 +1,8 @@ | |||
1 | <div *ngIf="channel" class="channel"> | ||
2 | <my-actor-avatar [channel]="channel" size="34"></my-actor-avatar> | ||
3 | |||
4 | <div class="display-name">{{ channel.displayName }}</div> | ||
5 | <div class="username">{{ channel.name }}</div> | ||
6 | |||
7 | <div class="description">{{ channel.description }}</div> | ||
8 | </div> | ||
diff --git a/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss new file mode 100644 index 000000000..85018afe2 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss | |||
@@ -0,0 +1,9 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | .channel { | ||
5 | border-radius: 15px; | ||
6 | padding: 10px; | ||
7 | width: min-content; | ||
8 | border: 1px solid pvar(--mainColor); | ||
9 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts new file mode 100644 index 000000000..97bb5567e --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts | |||
@@ -0,0 +1,26 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { VideoChannel, VideoChannelService } from '../shared-main' | ||
3 | |||
4 | /* | ||
5 | * Markup component that creates a channel miniature only | ||
6 | */ | ||
7 | |||
8 | @Component({ | ||
9 | selector: 'my-channel-miniature-markup', | ||
10 | templateUrl: 'channel-miniature-markup.component.html', | ||
11 | styleUrls: [ 'channel-miniature-markup.component.scss' ] | ||
12 | }) | ||
13 | export class ChannelMiniatureMarkupComponent implements OnInit { | ||
14 | @Input() name: string | ||
15 | |||
16 | channel: VideoChannel | ||
17 | |||
18 | constructor ( | ||
19 | private channelService: VideoChannelService | ||
20 | ) { } | ||
21 | |||
22 | ngOnInit () { | ||
23 | this.channelService.getVideoChannel(this.name) | ||
24 | .subscribe(channel => this.channel = channel) | ||
25 | } | ||
26 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/custom-markup.service.ts b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts new file mode 100644 index 000000000..ffaf15710 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/custom-markup.service.ts | |||
@@ -0,0 +1,136 @@ | |||
1 | import { ComponentRef, Injectable } from '@angular/core' | ||
2 | import { MarkdownService } from '@app/core' | ||
3 | import { | ||
4 | ChannelMiniatureMarkupData, | ||
5 | EmbedMarkupData, | ||
6 | PlaylistMiniatureMarkupData, | ||
7 | VideoMiniatureMarkupData, | ||
8 | VideosListMarkupData | ||
9 | } from '@shared/models' | ||
10 | import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component' | ||
11 | import { DynamicElementService } from './dynamic-element.service' | ||
12 | import { EmbedMarkupComponent } from './embed-markup.component' | ||
13 | import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component' | ||
14 | import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component' | ||
15 | import { VideosListMarkupComponent } from './videos-list-markup.component' | ||
16 | |||
17 | type BuilderFunction = (el: HTMLElement) => ComponentRef<any> | ||
18 | |||
19 | @Injectable() | ||
20 | export class CustomMarkupService { | ||
21 | private builders: { [ selector: string ]: BuilderFunction } = { | ||
22 | 'peertube-video-embed': el => this.embedBuilder(el, 'video'), | ||
23 | 'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'), | ||
24 | 'peertube-video-miniature': el => this.videoMiniatureBuilder(el), | ||
25 | 'peertube-playlist-miniature': el => this.playlistMiniatureBuilder(el), | ||
26 | 'peertube-channel-miniature': el => this.channelMiniatureBuilder(el), | ||
27 | 'peertube-videos-list': el => this.videosListBuilder(el) | ||
28 | } | ||
29 | |||
30 | constructor ( | ||
31 | private dynamicElementService: DynamicElementService, | ||
32 | private markdown: MarkdownService | ||
33 | ) { } | ||
34 | |||
35 | async buildElement (text: string) { | ||
36 | const html = await this.markdown.customPageMarkdownToHTML(text, this.getSupportedTags()) | ||
37 | |||
38 | const rootElement = document.createElement('div') | ||
39 | rootElement.innerHTML = html | ||
40 | |||
41 | for (const selector of this.getSupportedTags()) { | ||
42 | rootElement.querySelectorAll(selector) | ||
43 | .forEach((e: HTMLElement) => { | ||
44 | try { | ||
45 | const component = this.execBuilder(selector, e) | ||
46 | |||
47 | this.dynamicElementService.injectElement(e, component) | ||
48 | } catch (err) { | ||
49 | console.error('Cannot inject component %s.', selector, err) | ||
50 | } | ||
51 | }) | ||
52 | } | ||
53 | |||
54 | return rootElement | ||
55 | } | ||
56 | |||
57 | private getSupportedTags () { | ||
58 | return Object.keys(this.builders) | ||
59 | } | ||
60 | |||
61 | private execBuilder (selector: string, el: HTMLElement) { | ||
62 | return this.builders[selector](el) | ||
63 | } | ||
64 | |||
65 | private embedBuilder (el: HTMLElement, type: 'video' | 'playlist') { | ||
66 | const data = el.dataset as EmbedMarkupData | ||
67 | const component = this.dynamicElementService.createElement(EmbedMarkupComponent) | ||
68 | |||
69 | this.dynamicElementService.setModel(component, { uuid: data.uuid, type }) | ||
70 | |||
71 | return component | ||
72 | } | ||
73 | |||
74 | private videoMiniatureBuilder (el: HTMLElement) { | ||
75 | const data = el.dataset as VideoMiniatureMarkupData | ||
76 | const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent) | ||
77 | |||
78 | this.dynamicElementService.setModel(component, { uuid: data.uuid }) | ||
79 | |||
80 | return component | ||
81 | } | ||
82 | |||
83 | private playlistMiniatureBuilder (el: HTMLElement) { | ||
84 | const data = el.dataset as PlaylistMiniatureMarkupData | ||
85 | const component = this.dynamicElementService.createElement(PlaylistMiniatureMarkupComponent) | ||
86 | |||
87 | this.dynamicElementService.setModel(component, { uuid: data.uuid }) | ||
88 | |||
89 | return component | ||
90 | } | ||
91 | |||
92 | private channelMiniatureBuilder (el: HTMLElement) { | ||
93 | const data = el.dataset as ChannelMiniatureMarkupData | ||
94 | const component = this.dynamicElementService.createElement(ChannelMiniatureMarkupComponent) | ||
95 | |||
96 | this.dynamicElementService.setModel(component, { name: data.name }) | ||
97 | |||
98 | return component | ||
99 | } | ||
100 | |||
101 | private videosListBuilder (el: HTMLElement) { | ||
102 | const data = el.dataset as VideosListMarkupData | ||
103 | const component = this.dynamicElementService.createElement(VideosListMarkupComponent) | ||
104 | |||
105 | const model = { | ||
106 | title: data.title, | ||
107 | description: data.description, | ||
108 | sort: data.sort, | ||
109 | categoryOneOf: this.buildArrayNumber(data.categoryOneOf), | ||
110 | languageOneOf: this.buildArrayString(data.languageOneOf), | ||
111 | count: this.buildNumber(data.count) || 10 | ||
112 | } | ||
113 | |||
114 | this.dynamicElementService.setModel(component, model) | ||
115 | |||
116 | return component | ||
117 | } | ||
118 | |||
119 | private buildNumber (value: string) { | ||
120 | if (!value) return undefined | ||
121 | |||
122 | return parseInt(value, 10) | ||
123 | } | ||
124 | |||
125 | private buildArrayNumber (value: string) { | ||
126 | if (!value) return undefined | ||
127 | |||
128 | return value.split(',').map(v => parseInt(v, 10)) | ||
129 | } | ||
130 | |||
131 | private buildArrayString (value: string) { | ||
132 | if (!value) return undefined | ||
133 | |||
134 | return value.split(',') | ||
135 | } | ||
136 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/dynamic-element.service.ts b/client/src/app/shared/shared-custom-markup/dynamic-element.service.ts new file mode 100644 index 000000000..e967e30ac --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/dynamic-element.service.ts | |||
@@ -0,0 +1,57 @@ | |||
1 | import { | ||
2 | ApplicationRef, | ||
3 | ComponentFactoryResolver, | ||
4 | ComponentRef, | ||
5 | EmbeddedViewRef, | ||
6 | Injectable, | ||
7 | Injector, | ||
8 | OnChanges, | ||
9 | SimpleChange, | ||
10 | SimpleChanges, | ||
11 | Type | ||
12 | } from '@angular/core' | ||
13 | |||
14 | @Injectable() | ||
15 | export class DynamicElementService { | ||
16 | |||
17 | constructor ( | ||
18 | private injector: Injector, | ||
19 | private applicationRef: ApplicationRef, | ||
20 | private componentFactoryResolver: ComponentFactoryResolver | ||
21 | ) { } | ||
22 | |||
23 | createElement <T> (ofComponent: Type<T>) { | ||
24 | const div = document.createElement('div') | ||
25 | |||
26 | const component = this.componentFactoryResolver.resolveComponentFactory(ofComponent) | ||
27 | .create(this.injector, [], div) | ||
28 | |||
29 | return component | ||
30 | } | ||
31 | |||
32 | injectElement <T> (wrapper: HTMLElement, componentRef: ComponentRef<T>) { | ||
33 | const hostView = componentRef.hostView as EmbeddedViewRef<any> | ||
34 | |||
35 | this.applicationRef.attachView(hostView) | ||
36 | wrapper.appendChild(hostView.rootNodes[0]) | ||
37 | } | ||
38 | |||
39 | setModel <T> (componentRef: ComponentRef<T>, attributes: Partial<T>) { | ||
40 | const changes: SimpleChanges = {} | ||
41 | |||
42 | for (const key of Object.keys(attributes)) { | ||
43 | const previousValue = componentRef.instance[key] | ||
44 | const newValue = attributes[key] | ||
45 | |||
46 | componentRef.instance[key] = newValue | ||
47 | changes[key] = new SimpleChange(previousValue, newValue, previousValue === undefined) | ||
48 | } | ||
49 | |||
50 | const component = componentRef.instance | ||
51 | if (typeof (component as unknown as OnChanges).ngOnChanges === 'function') { | ||
52 | (component as unknown as OnChanges).ngOnChanges(changes) | ||
53 | } | ||
54 | |||
55 | componentRef.changeDetectorRef.detectChanges() | ||
56 | } | ||
57 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/embed-markup.component.ts b/client/src/app/shared/shared-custom-markup/embed-markup.component.ts new file mode 100644 index 000000000..a854d89f6 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/embed-markup.component.ts | |||
@@ -0,0 +1,22 @@ | |||
1 | import { buildPlaylistLink, buildVideoLink, buildVideoOrPlaylistEmbed } from 'src/assets/player/utils' | ||
2 | import { environment } from 'src/environments/environment' | ||
3 | import { Component, ElementRef, Input, OnInit } from '@angular/core' | ||
4 | |||
5 | @Component({ | ||
6 | selector: 'my-embed-markup', | ||
7 | template: '' | ||
8 | }) | ||
9 | export class EmbedMarkupComponent implements OnInit { | ||
10 | @Input() uuid: string | ||
11 | @Input() type: 'video' | 'playlist' = 'video' | ||
12 | |||
13 | constructor (private el: ElementRef) { } | ||
14 | |||
15 | ngOnInit () { | ||
16 | const link = this.type === 'video' | ||
17 | ? buildVideoLink({ baseUrl: `${environment.originServerUrl}/videos/embed/${this.uuid}` }) | ||
18 | : buildPlaylistLink({ baseUrl: `${environment.originServerUrl}/video-playlists/embed/${this.uuid}` }) | ||
19 | |||
20 | this.el.nativeElement.innerHTML = buildVideoOrPlaylistEmbed(link, this.uuid) | ||
21 | } | ||
22 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/index.ts b/client/src/app/shared/shared-custom-markup/index.ts new file mode 100644 index 000000000..14bde3ea9 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/index.ts | |||
@@ -0,0 +1,3 @@ | |||
1 | export * from './custom-markup.service' | ||
2 | export * from './dynamic-element.service' | ||
3 | export * from './shared-custom-markup.module' | ||
diff --git a/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html new file mode 100644 index 000000000..4e1d1a13f --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html | |||
@@ -0,0 +1,2 @@ | |||
1 | <my-video-playlist-miniature *ngIf="playlist" [playlist]="playlist"> | ||
2 | </my-video-playlist-miniature> | ||
diff --git a/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss new file mode 100644 index 000000000..281cef726 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss | |||
@@ -0,0 +1,7 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | my-video-playlist-miniature { | ||
5 | display: inline-block; | ||
6 | width: $video-thumbnail-width; | ||
7 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts new file mode 100644 index 000000000..7aee450f1 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts | |||
@@ -0,0 +1,38 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { MiniatureDisplayOptions } from '../shared-video-miniature' | ||
3 | import { VideoPlaylist, VideoPlaylistService } from '../shared-video-playlist' | ||
4 | |||
5 | /* | ||
6 | * Markup component that creates a playlist miniature only | ||
7 | */ | ||
8 | |||
9 | @Component({ | ||
10 | selector: 'my-playlist-miniature-markup', | ||
11 | templateUrl: 'playlist-miniature-markup.component.html', | ||
12 | styleUrls: [ 'playlist-miniature-markup.component.scss' ] | ||
13 | }) | ||
14 | export class PlaylistMiniatureMarkupComponent implements OnInit { | ||
15 | @Input() uuid: string | ||
16 | |||
17 | playlist: VideoPlaylist | ||
18 | |||
19 | displayOptions: MiniatureDisplayOptions = { | ||
20 | date: true, | ||
21 | views: true, | ||
22 | by: true, | ||
23 | avatar: false, | ||
24 | privacyLabel: false, | ||
25 | privacyText: false, | ||
26 | state: false, | ||
27 | blacklistInfo: false | ||
28 | } | ||
29 | |||
30 | constructor ( | ||
31 | private playlistService: VideoPlaylistService | ||
32 | ) { } | ||
33 | |||
34 | ngOnInit () { | ||
35 | this.playlistService.getVideoPlaylist(this.uuid) | ||
36 | .subscribe(playlist => this.playlist = playlist) | ||
37 | } | ||
38 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts b/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts new file mode 100644 index 000000000..4bbb71588 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts | |||
@@ -0,0 +1,49 @@ | |||
1 | |||
2 | import { CommonModule } from '@angular/common' | ||
3 | import { NgModule } from '@angular/core' | ||
4 | import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module' | ||
5 | import { SharedGlobalIconModule } from '../shared-icons' | ||
6 | import { SharedMainModule } from '../shared-main' | ||
7 | import { SharedVideoMiniatureModule } from '../shared-video-miniature' | ||
8 | import { SharedVideoPlaylistModule } from '../shared-video-playlist' | ||
9 | import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component' | ||
10 | import { CustomMarkupService } from './custom-markup.service' | ||
11 | import { DynamicElementService } from './dynamic-element.service' | ||
12 | import { EmbedMarkupComponent } from './embed-markup.component' | ||
13 | import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component' | ||
14 | import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component' | ||
15 | import { VideosListMarkupComponent } from './videos-list-markup.component' | ||
16 | |||
17 | @NgModule({ | ||
18 | imports: [ | ||
19 | CommonModule, | ||
20 | |||
21 | SharedMainModule, | ||
22 | SharedGlobalIconModule, | ||
23 | SharedVideoMiniatureModule, | ||
24 | SharedVideoPlaylistModule, | ||
25 | SharedActorImageModule | ||
26 | ], | ||
27 | |||
28 | declarations: [ | ||
29 | VideoMiniatureMarkupComponent, | ||
30 | PlaylistMiniatureMarkupComponent, | ||
31 | ChannelMiniatureMarkupComponent, | ||
32 | EmbedMarkupComponent, | ||
33 | VideosListMarkupComponent | ||
34 | ], | ||
35 | |||
36 | exports: [ | ||
37 | VideoMiniatureMarkupComponent, | ||
38 | PlaylistMiniatureMarkupComponent, | ||
39 | ChannelMiniatureMarkupComponent, | ||
40 | VideosListMarkupComponent, | ||
41 | EmbedMarkupComponent | ||
42 | ], | ||
43 | |||
44 | providers: [ | ||
45 | CustomMarkupService, | ||
46 | DynamicElementService | ||
47 | ] | ||
48 | }) | ||
49 | export class SharedCustomMarkupModule { } | ||
diff --git a/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html new file mode 100644 index 000000000..9b4930b6d --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html | |||
@@ -0,0 +1,6 @@ | |||
1 | <my-video-miniature | ||
2 | *ngIf="video" | ||
3 | [video]="video" [user]="getUser()" [displayAsRow]="false" | ||
4 | [displayVideoActions]="false" [displayOptions]="displayOptions" | ||
5 | > | ||
6 | </my-video-miniature> | ||
diff --git a/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss new file mode 100644 index 000000000..81e265f29 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss | |||
@@ -0,0 +1,7 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | my-video-miniature { | ||
5 | display: inline-block; | ||
6 | width: $video-thumbnail-width; | ||
7 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts new file mode 100644 index 000000000..79add0c3b --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts | |||
@@ -0,0 +1,44 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { AuthService } from '@app/core' | ||
3 | import { Video, VideoService } from '../shared-main' | ||
4 | import { MiniatureDisplayOptions } from '../shared-video-miniature' | ||
5 | |||
6 | /* | ||
7 | * Markup component that creates a video miniature only | ||
8 | */ | ||
9 | |||
10 | @Component({ | ||
11 | selector: 'my-video-miniature-markup', | ||
12 | templateUrl: 'video-miniature-markup.component.html', | ||
13 | styleUrls: [ 'video-miniature-markup.component.scss' ] | ||
14 | }) | ||
15 | export class VideoMiniatureMarkupComponent implements OnInit { | ||
16 | @Input() uuid: string | ||
17 | |||
18 | video: Video | ||
19 | |||
20 | displayOptions: MiniatureDisplayOptions = { | ||
21 | date: true, | ||
22 | views: true, | ||
23 | by: true, | ||
24 | avatar: false, | ||
25 | privacyLabel: false, | ||
26 | privacyText: false, | ||
27 | state: false, | ||
28 | blacklistInfo: false | ||
29 | } | ||
30 | |||
31 | constructor ( | ||
32 | private auth: AuthService, | ||
33 | private videoService: VideoService | ||
34 | ) { } | ||
35 | |||
36 | getUser () { | ||
37 | return this.auth.getUser() | ||
38 | } | ||
39 | |||
40 | ngOnInit () { | ||
41 | this.videoService.getVideo({ videoId: this.uuid }) | ||
42 | .subscribe(video => this.video = video) | ||
43 | } | ||
44 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/videos-list-markup.component.html b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.html new file mode 100644 index 000000000..501f35e04 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.html | |||
@@ -0,0 +1,13 @@ | |||
1 | <div class="root"> | ||
2 | <h4 *ngIf="title">{{ title }}</h4> | ||
3 | <div *ngIf="description" class="description">{{ description }}</div> | ||
4 | |||
5 | <div class="videos"> | ||
6 | <my-video-miniature | ||
7 | *ngFor="let video of videos" | ||
8 | [video]="video" [user]="getUser()" [displayAsRow]="false" | ||
9 | [displayVideoActions]="false" [displayOptions]="displayOptions" | ||
10 | > | ||
11 | </my-video-miniature> | ||
12 | </div> | ||
13 | </div> | ||
diff --git a/client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss new file mode 100644 index 000000000..dcd931090 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss | |||
@@ -0,0 +1,9 @@ | |||
1 | @import '_variables'; | ||
2 | @import '_mixins'; | ||
3 | |||
4 | my-video-miniature { | ||
5 | margin-right: 15px; | ||
6 | display: inline-block; | ||
7 | min-width: $video-thumbnail-width; | ||
8 | max-width: $video-thumbnail-width; | ||
9 | } | ||
diff --git a/client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts new file mode 100644 index 000000000..cc25d0a51 --- /dev/null +++ b/client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts | |||
@@ -0,0 +1,60 @@ | |||
1 | import { Component, Input, OnInit } from '@angular/core' | ||
2 | import { AuthService } from '@app/core' | ||
3 | import { VideoSortField } from '@shared/models' | ||
4 | import { Video, VideoService } from '../shared-main' | ||
5 | import { MiniatureDisplayOptions } from '../shared-video-miniature' | ||
6 | |||
7 | /* | ||
8 | * Markup component list videos depending on criterias | ||
9 | */ | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-videos-list-markup', | ||
13 | templateUrl: 'videos-list-markup.component.html', | ||
14 | styleUrls: [ 'videos-list-markup.component.scss' ] | ||
15 | }) | ||
16 | export class VideosListMarkupComponent implements OnInit { | ||
17 | @Input() title: string | ||
18 | @Input() description: string | ||
19 | @Input() sort = '-publishedAt' | ||
20 | @Input() categoryOneOf: number[] | ||
21 | @Input() languageOneOf: string[] | ||
22 | @Input() count = 10 | ||
23 | |||
24 | videos: Video[] | ||
25 | |||
26 | displayOptions: MiniatureDisplayOptions = { | ||
27 | date: true, | ||
28 | views: true, | ||
29 | by: true, | ||
30 | avatar: false, | ||
31 | privacyLabel: false, | ||
32 | privacyText: false, | ||
33 | state: false, | ||
34 | blacklistInfo: false | ||
35 | } | ||
36 | |||
37 | constructor ( | ||
38 | private auth: AuthService, | ||
39 | private videoService: VideoService | ||
40 | ) { } | ||
41 | |||
42 | getUser () { | ||
43 | return this.auth.getUser() | ||
44 | } | ||
45 | |||
46 | ngOnInit () { | ||
47 | const options = { | ||
48 | videoPagination: { | ||
49 | currentPage: 1, | ||
50 | itemsPerPage: this.count | ||
51 | }, | ||
52 | categoryOneOf: this.categoryOneOf, | ||
53 | languageOneOf: this.languageOneOf, | ||
54 | sort: this.sort as VideoSortField | ||
55 | } | ||
56 | |||
57 | this.videoService.getVideos(options) | ||
58 | .subscribe(({ data }) => this.videos = data) | ||
59 | } | ||
60 | } | ||
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html index 513b543cd..6e70e2f37 100644 --- a/client/src/app/shared/shared-forms/markdown-textarea.component.html +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html | |||
@@ -19,6 +19,7 @@ | |||
19 | <a ngbNavLink i18n>Complete preview</a> | 19 | <a ngbNavLink i18n>Complete preview</a> |
20 | 20 | ||
21 | <ng-template ngbNavContent> | 21 | <ng-template ngbNavContent> |
22 | <div #previewElement></div> | ||
22 | <div [innerHTML]="previewHTML"></div> | 23 | <div [innerHTML]="previewHTML"></div> |
23 | </ng-template> | 24 | </ng-template> |
24 | </ng-container> | 25 | </ng-container> |
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts index 9b3ab9cf3..a233a4205 100644 --- a/client/src/app/shared/shared-forms/markdown-textarea.component.ts +++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts | |||
@@ -1,9 +1,10 @@ | |||
1 | import { ViewportScroller } from '@angular/common' | ||
2 | import truncate from 'lodash-es/truncate' | 1 | import truncate from 'lodash-es/truncate' |
3 | import { Subject } from 'rxjs' | 2 | import { Subject } from 'rxjs' |
4 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | 3 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' |
4 | import { ViewportScroller } from '@angular/common' | ||
5 | import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core' | 5 | import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core' |
6 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' | 6 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' |
7 | import { SafeHtml } from '@angular/platform-browser' | ||
7 | import { MarkdownService, ScreenService } from '@app/core' | 8 | import { MarkdownService, ScreenService } from '@app/core' |
8 | 9 | ||
9 | @Component({ | 10 | @Component({ |
@@ -21,18 +22,27 @@ import { MarkdownService, ScreenService } from '@app/core' | |||
21 | 22 | ||
22 | export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { | 23 | export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { |
23 | @Input() content = '' | 24 | @Input() content = '' |
25 | |||
24 | @Input() classes: string[] | { [klass: string]: any[] | any } = [] | 26 | @Input() classes: string[] | { [klass: string]: any[] | any } = [] |
27 | |||
25 | @Input() textareaMaxWidth = '100%' | 28 | @Input() textareaMaxWidth = '100%' |
26 | @Input() textareaHeight = '150px' | 29 | @Input() textareaHeight = '150px' |
30 | |||
27 | @Input() truncate: number | 31 | @Input() truncate: number |
32 | |||
28 | @Input() markdownType: 'text' | 'enhanced' = 'text' | 33 | @Input() markdownType: 'text' | 'enhanced' = 'text' |
34 | @Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement> | ||
35 | |||
29 | @Input() markdownVideo = false | 36 | @Input() markdownVideo = false |
37 | |||
30 | @Input() name = 'description' | 38 | @Input() name = 'description' |
31 | 39 | ||
32 | @ViewChild('textarea') textareaElement: ElementRef | 40 | @ViewChild('textarea') textareaElement: ElementRef |
41 | @ViewChild('previewElement') previewElement: ElementRef | ||
42 | |||
43 | truncatedPreviewHTML: SafeHtml | string = '' | ||
44 | previewHTML: SafeHtml | string = '' | ||
33 | 45 | ||
34 | truncatedPreviewHTML = '' | ||
35 | previewHTML = '' | ||
36 | isMaximized = false | 46 | isMaximized = false |
37 | 47 | ||
38 | maximizeInText = $localize`Maximize editor` | 48 | maximizeInText = $localize`Maximize editor` |
@@ -115,10 +125,31 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { | |||
115 | } | 125 | } |
116 | 126 | ||
117 | private async markdownRender (text: string) { | 127 | private async markdownRender (text: string) { |
118 | const html = this.markdownType === 'text' ? | 128 | let html: string |
119 | await this.markdownService.textMarkdownToHTML(text) : | 129 | |
120 | await this.markdownService.enhancedMarkdownToHTML(text) | 130 | if (this.customMarkdownRenderer) { |
131 | const result = await this.customMarkdownRenderer(text) | ||
132 | |||
133 | if (result instanceof HTMLElement) { | ||
134 | html = '' | ||
135 | |||
136 | const wrapperElement = this.previewElement.nativeElement as HTMLElement | ||
137 | wrapperElement.innerHTML = '' | ||
138 | wrapperElement.appendChild(result) | ||
139 | return | ||
140 | } | ||
141 | |||
142 | html = result | ||
143 | } else if (this.markdownType === 'text') { | ||
144 | html = await this.markdownService.textMarkdownToHTML(text) | ||
145 | } else { | ||
146 | html = await this.markdownService.enhancedMarkdownToHTML(text) | ||
147 | } | ||
148 | |||
149 | if (this.markdownVideo) { | ||
150 | html = this.markdownService.processVideoTimestamps(html) | ||
151 | } | ||
121 | 152 | ||
122 | return this.markdownVideo ? this.markdownService.processVideoTimestamps(html) : html | 153 | return html |
123 | } | 154 | } |
124 | } | 155 | } |
diff --git a/client/src/app/shared/shared-icons/global-icon.component.ts b/client/src/app/shared/shared-icons/global-icon.component.ts index 3af517927..a4dd72db6 100644 --- a/client/src/app/shared/shared-icons/global-icon.component.ts +++ b/client/src/app/shared/shared-icons/global-icon.component.ts | |||
@@ -72,6 +72,7 @@ const icons = { | |||
72 | 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, | 72 | 'repeat': require('!!raw-loader?!../../../assets/images/feather/repeat.svg').default, |
73 | 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, | 73 | 'message-circle': require('!!raw-loader?!../../../assets/images/feather/message-circle.svg').default, |
74 | 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, | 74 | 'codesandbox': require('!!raw-loader?!../../../assets/images/feather/codesandbox.svg').default, |
75 | 'octagon': require('!!raw-loader?!../../../assets/images/feather/octagon.svg').default, | ||
75 | 'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default | 76 | 'award': require('!!raw-loader?!../../../assets/images/feather/award.svg').default |
76 | } | 77 | } |
77 | 78 | ||
diff --git a/client/src/app/shared/shared-main/custom-page/custom-page.service.ts b/client/src/app/shared/shared-main/custom-page/custom-page.service.ts new file mode 100644 index 000000000..e5c2b3cd4 --- /dev/null +++ b/client/src/app/shared/shared-main/custom-page/custom-page.service.ts | |||
@@ -0,0 +1,38 @@ | |||
1 | import { of } from 'rxjs' | ||
2 | import { catchError, map } from 'rxjs/operators' | ||
3 | import { HttpClient } from '@angular/common/http' | ||
4 | import { Injectable } from '@angular/core' | ||
5 | import { RestExtractor } from '@app/core' | ||
6 | import { CustomPage } from '@shared/models' | ||
7 | import { environment } from '../../../../environments/environment' | ||
8 | |||
9 | @Injectable() | ||
10 | export class CustomPageService { | ||
11 | static BASE_INSTANCE_HOMEPAGE_URL = environment.apiUrl + '/api/v1/custom-pages/homepage/instance' | ||
12 | |||
13 | constructor ( | ||
14 | private authHttp: HttpClient, | ||
15 | private restExtractor: RestExtractor | ||
16 | ) { } | ||
17 | |||
18 | getInstanceHomepage () { | ||
19 | return this.authHttp.get<CustomPage>(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL) | ||
20 | .pipe( | ||
21 | catchError(err => { | ||
22 | if (err.status === 404) { | ||
23 | return of({ content: '' }) | ||
24 | } | ||
25 | |||
26 | this.restExtractor.handleError(err) | ||
27 | }) | ||
28 | ) | ||
29 | } | ||
30 | |||
31 | updateInstanceHomepage (content: string) { | ||
32 | return this.authHttp.put(CustomPageService.BASE_INSTANCE_HOMEPAGE_URL, { content }) | ||
33 | .pipe( | ||
34 | map(this.restExtractor.extractDataBool), | ||
35 | catchError(err => this.restExtractor.handleError(err)) | ||
36 | ) | ||
37 | } | ||
38 | } | ||
diff --git a/client/src/app/shared/shared-main/custom-page/index.ts b/client/src/app/shared/shared-main/custom-page/index.ts new file mode 100644 index 000000000..7269ece95 --- /dev/null +++ b/client/src/app/shared/shared-main/custom-page/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './custom-page.service' | |||
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts index 772198cb2..f9b6085cf 100644 --- a/client/src/app/shared/shared-main/shared-main.module.ts +++ b/client/src/app/shared/shared-main/shared-main.module.ts | |||
@@ -29,6 +29,7 @@ import { | |||
29 | } from './angular' | 29 | } from './angular' |
30 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' | 30 | import { AUTH_INTERCEPTOR_PROVIDER } from './auth' |
31 | import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' | 31 | import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' |
32 | import { CustomPageService } from './custom-page' | ||
32 | import { DateToggleComponent } from './date' | 33 | import { DateToggleComponent } from './date' |
33 | import { FeedComponent } from './feeds' | 34 | import { FeedComponent } from './feeds' |
34 | import { LoaderComponent, SmallLoaderComponent } from './loaders' | 35 | import { LoaderComponent, SmallLoaderComponent } from './loaders' |
@@ -171,7 +172,9 @@ import { VideoChannelService } from './video-channel' | |||
171 | 172 | ||
172 | VideoCaptionService, | 173 | VideoCaptionService, |
173 | 174 | ||
174 | VideoChannelService | 175 | VideoChannelService, |
176 | |||
177 | CustomPageService | ||
175 | ] | 178 | ] |
176 | }) | 179 | }) |
177 | export class SharedMainModule { } | 180 | export class SharedMainModule { } |
diff --git a/client/src/assets/images/feather/octagon.svg b/client/src/assets/images/feather/octagon.svg new file mode 100644 index 000000000..1ed9bacbf --- /dev/null +++ b/client/src/assets/images/feather/octagon.svg | |||
@@ -0,0 +1,3 @@ | |||
1 | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-octagon"> | ||
2 | <polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon> | ||
3 | </svg> | ||
diff --git a/client/src/assets/player/utils.ts b/client/src/assets/player/utils.ts index d7451fa1d..1243526d2 100644 --- a/client/src/assets/player/utils.ts +++ b/client/src/assets/player/utils.ts | |||
@@ -95,7 +95,7 @@ function buildVideoLink (options: { | |||
95 | function buildPlaylistLink (options: { | 95 | function buildPlaylistLink (options: { |
96 | baseUrl?: string | 96 | baseUrl?: string |
97 | 97 | ||
98 | playlistPosition: number | 98 | playlistPosition?: number |
99 | }) { | 99 | }) { |
100 | const { baseUrl } = options | 100 | const { baseUrl } = options |
101 | 101 | ||