aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
authorChocobozzz <chocobozzz@cpy.re>2021-05-27 15:59:55 +0200
committerGitHub <noreply@github.com>2021-05-27 15:59:55 +0200
commit2539932e16129992a2c0889b4ff527c265a8e2c7 (patch)
treefb5048e63e02a2485eb96d27455f43e4b22e8ae0 /client
parenteb34ec30e0b57286fc6f85160490d2e973a3b0b1 (diff)
downloadPeerTube-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')
-rw-r--r--client/src/app/+about/about-peertube/about-peertube-contributors.component.ts2
-rw-r--r--client/src/app/+admin/admin.module.ts8
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html23
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.ts26
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.html14
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts48
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html28
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-homepage.component.ts25
-rw-r--r--client/src/app/+admin/config/edit-custom-config/index.ts1
-rw-r--r--client/src/app/+home/home-routing.module.ts18
-rw-r--r--client/src/app/+home/home.component.html4
-rw-r--r--client/src/app/+home/home.component.scss3
-rw-r--r--client/src/app/+home/home.component.ts26
-rw-r--r--client/src/app/+home/home.module.ts25
-rw-r--r--client/src/app/+home/index.ts3
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.ts2
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts2
-rw-r--r--client/src/app/app-routing.module.ts4
-rw-r--r--client/src/app/app.component.ts2
-rw-r--r--client/src/app/core/menu/menu.service.ts58
-rw-r--r--client/src/app/core/renderer/html-renderer.service.ts10
-rw-r--r--client/src/app/core/renderer/markdown.service.ts53
-rw-r--r--client/src/app/core/server/server.service.ts7
-rw-r--r--client/src/app/menu/menu.component.html21
-rw-r--r--client/src/app/menu/menu.component.ts23
-rw-r--r--client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.html8
-rw-r--r--client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.scss9
-rw-r--r--client/src/app/shared/shared-custom-markup/channel-miniature-markup.component.ts26
-rw-r--r--client/src/app/shared/shared-custom-markup/custom-markup.service.ts136
-rw-r--r--client/src/app/shared/shared-custom-markup/dynamic-element.service.ts57
-rw-r--r--client/src/app/shared/shared-custom-markup/embed-markup.component.ts22
-rw-r--r--client/src/app/shared/shared-custom-markup/index.ts3
-rw-r--r--client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.html2
-rw-r--r--client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.scss7
-rw-r--r--client/src/app/shared/shared-custom-markup/playlist-miniature-markup.component.ts38
-rw-r--r--client/src/app/shared/shared-custom-markup/shared-custom-markup.module.ts49
-rw-r--r--client/src/app/shared/shared-custom-markup/video-miniature-markup.component.html6
-rw-r--r--client/src/app/shared/shared-custom-markup/video-miniature-markup.component.scss7
-rw-r--r--client/src/app/shared/shared-custom-markup/video-miniature-markup.component.ts44
-rw-r--r--client/src/app/shared/shared-custom-markup/videos-list-markup.component.html13
-rw-r--r--client/src/app/shared/shared-custom-markup/videos-list-markup.component.scss9
-rw-r--r--client/src/app/shared/shared-custom-markup/videos-list-markup.component.ts60
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.html1
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.ts45
-rw-r--r--client/src/app/shared/shared-icons/global-icon.component.ts1
-rw-r--r--client/src/app/shared/shared-main/custom-page/custom-page.service.ts38
-rw-r--r--client/src/app/shared/shared-main/custom-page/index.ts1
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts5
-rw-r--r--client/src/assets/images/feather/octagon.svg3
-rw-r--r--client/src/assets/player/utils.ts2
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'
4import { NgModule } from '@angular/core' 4import { NgModule } from '@angular/core'
5import { SharedAbuseListModule } from '@app/shared/shared-abuse-list' 5import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
6import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit' 6import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit'
7import { SharedActorImageModule } from '@app/shared/shared-actor-image/shared-actor-image.module'
8import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
7import { SharedFormModule } from '@app/shared/shared-forms' 9import { SharedFormModule } from '@app/shared/shared-forms'
8import { SharedGlobalIconModule } from '@app/shared/shared-icons' 10import { SharedGlobalIconModule } from '@app/shared/shared-icons'
9import { SharedMainModule } from '@app/shared/shared-main' 11import { SharedMainModule } from '@app/shared/shared-main'
10import { SharedModerationModule } from '@app/shared/shared-moderation' 12import { SharedModerationModule } from '@app/shared/shared-moderation'
11import { SharedVideoCommentModule } from '@app/shared/shared-video-comment' 13import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
12import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
13import { AdminRoutingModule } from './admin-routing.module' 14import { AdminRoutingModule } from './admin-routing.module'
14import { AdminComponent } from './admin.component' 15import { AdminComponent } from './admin.component'
15import { 16import {
@@ -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
2import { pairwise } from 'rxjs/operators' 2import { pairwise } from 'rxjs/operators'
3import { Component, Input, OnInit } from '@angular/core' 3import { SelectOptionsItem } from 'src/types/select-options-item.model'
4import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
4import { FormGroup } from '@angular/forms' 5import { FormGroup } from '@angular/forms'
6import { MenuService } from '@app/core'
5import { ServerConfig } from '@shared/models' 7import { ServerConfig } from '@shared/models'
6import { ConfigService } from '../shared/config.service' 8import { 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})
13export class EditBasicConfigurationComponent implements OnInit { 15export 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
2import omit from 'lodash-es/omit'
2import { forkJoin } from 'rxjs' 3import { forkJoin } from 'rxjs'
3import { SelectOptionsItem } from 'src/types/select-options-item.model' 4import { SelectOptionsItem } from 'src/types/select-options-item.model'
4import { Component, OnInit } from '@angular/core' 5import { 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'
25import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators' 26import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
26import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 27import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
27import { CustomConfig, ServerConfig } from '@shared/models' 28import { CustomPageService } from '@app/shared/shared-main/custom-page'
29import { CustomConfig, CustomPage, ServerConfig } from '@shared/models'
28import { EditConfigurationService } from './edit-configuration.service' 30import { EditConfigurationService } from './edit-configuration.service'
29 31
32type 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'
35export class EditCustomConfigComponent extends FormReactive implements OnInit { 41export 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 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { FormGroup } from '@angular/forms'
3import { 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})
10export 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'
2export * from './edit-basic-configuration.component' 2export * from './edit-basic-configuration.component'
3export * from './edit-configuration.service' 3export * from './edit-configuration.service'
4export * from './edit-custom-config.component' 4export * from './edit-custom-config.component'
5export * from './edit-homepage.component'
5export * from './edit-instance-information.component' 6export * from './edit-instance-information.component'
6export * from './edit-live-configuration.component' 7export * from './edit-live-configuration.component'
7export * from './edit-vod-transcoding.component' 8export * 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 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { MetaGuard } from '@ngx-meta/core'
4import { HomeComponent } from './home.component'
5
6const 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})
18export 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
2import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
3import { CustomMarkupService } from '@app/shared/shared-custom-markup'
4import { CustomPageService } from '@app/shared/shared-main/custom-page'
5
6@Component({
7 templateUrl: './home.component.html',
8 styleUrls: [ './home.component.scss' ]
9})
10
11export 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 @@
1import { NgModule } from '@angular/core'
2import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
3import { SharedMainModule } from '@app/shared/shared-main'
4import { HomeRoutingModule } from './home-routing.module'
5import { 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})
25export 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 @@
1export * from './home-routing.module'
2export * from './home.component'
3export * 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 @@
1import { fromEvent } from 'rxjs' 1import { fromEvent } from 'rxjs'
2import { debounceTime } from 'rxjs/operators' 2import { debounceTime } from 'rxjs/operators'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { GlobalIconName } from '@app/shared/shared-icons'
5import { sortObjectComparator } from '@shared/core-utils/miscs/miscs'
6import { ServerConfig } from '@shared/models/server'
4import { ScreenService } from '../wrappers' 7import { ScreenService } from '../wrappers'
5 8
9export type MenuLink = {
10 icon: GlobalIconName
11 label: string
12 menuLabel: string
13 path: string
14 priority: number
15}
16
6@Injectable() 17@Injectable()
7export class MenuService { 18export 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 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { LinkifierService } from './linkifier.service' 2import { LinkifierService } from './linkifier.service'
3import { SANITIZE_OPTIONS } from '@shared/core-utils/renderer/html' 3import { getCustomMarkupSanitizeOptions, getSanitizeOptions } from '@shared/core-utils/renderer/html'
4 4
5@Injectable() 5@Injectable()
6export class HtmlRendererService { 6export 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
23type MarkdownConfig = { 25type 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'
4import { ViewportScroller } from '@angular/common' 4import { ViewportScroller } from '@angular/common'
5import { Component, OnInit, ViewChild } from '@angular/core' 5import { Component, OnInit, ViewChild } from '@angular/core'
6import { Router } from '@angular/router' 6import { Router } from '@angular/router'
7import { AuthService, AuthStatus, AuthUser, MenuService, RedirectService, ScreenService, ServerService, UserService } from '@app/core' 7import {
8 AuthService,
9 AuthStatus,
10 AuthUser,
11 MenuLink,
12 MenuService,
13 RedirectService,
14 ScreenService,
15 ServerService,
16 UserService
17} from '@app/core'
8import { scrollToTop } from '@app/helpers' 18import { scrollToTop } from '@app/helpers'
9import { LanguageChooserComponent } from '@app/menu/language-chooser.component' 19import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
10import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component' 20import { 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 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { 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})
13export 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 @@
1import { ComponentRef, Injectable } from '@angular/core'
2import { MarkdownService } from '@app/core'
3import {
4 ChannelMiniatureMarkupData,
5 EmbedMarkupData,
6 PlaylistMiniatureMarkupData,
7 VideoMiniatureMarkupData,
8 VideosListMarkupData
9} from '@shared/models'
10import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component'
11import { DynamicElementService } from './dynamic-element.service'
12import { EmbedMarkupComponent } from './embed-markup.component'
13import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component'
14import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component'
15import { VideosListMarkupComponent } from './videos-list-markup.component'
16
17type BuilderFunction = (el: HTMLElement) => ComponentRef<any>
18
19@Injectable()
20export 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 @@
1import {
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()
15export 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 @@
1import { buildPlaylistLink, buildVideoLink, buildVideoOrPlaylistEmbed } from 'src/assets/player/utils'
2import { environment } from 'src/environments/environment'
3import { Component, ElementRef, Input, OnInit } from '@angular/core'
4
5@Component({
6 selector: 'my-embed-markup',
7 template: ''
8})
9export 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 @@
1export * from './custom-markup.service'
2export * from './dynamic-element.service'
3export * 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
4my-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 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { MiniatureDisplayOptions } from '../shared-video-miniature'
3import { 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})
14export 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
2import { CommonModule } from '@angular/common'
3import { NgModule } from '@angular/core'
4import { SharedActorImageModule } from '../shared-actor-image/shared-actor-image.module'
5import { SharedGlobalIconModule } from '../shared-icons'
6import { SharedMainModule } from '../shared-main'
7import { SharedVideoMiniatureModule } from '../shared-video-miniature'
8import { SharedVideoPlaylistModule } from '../shared-video-playlist'
9import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component'
10import { CustomMarkupService } from './custom-markup.service'
11import { DynamicElementService } from './dynamic-element.service'
12import { EmbedMarkupComponent } from './embed-markup.component'
13import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component'
14import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component'
15import { 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})
49export 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
4my-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 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { AuthService } from '@app/core'
3import { Video, VideoService } from '../shared-main'
4import { 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})
15export 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
4my-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 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { AuthService } from '@app/core'
3import { VideoSortField } from '@shared/models'
4import { Video, VideoService } from '../shared-main'
5import { 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})
16export 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 @@
1import { ViewportScroller } from '@angular/common'
2import truncate from 'lodash-es/truncate' 1import truncate from 'lodash-es/truncate'
3import { Subject } from 'rxjs' 2import { Subject } from 'rxjs'
4import { debounceTime, distinctUntilChanged } from 'rxjs/operators' 3import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
4import { ViewportScroller } from '@angular/common'
5import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core' 5import { Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'
6import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 6import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
7import { SafeHtml } from '@angular/platform-browser'
7import { MarkdownService, ScreenService } from '@app/core' 8import { MarkdownService, ScreenService } from '@app/core'
8 9
9@Component({ 10@Component({
@@ -21,18 +22,27 @@ import { MarkdownService, ScreenService } from '@app/core'
21 22
22export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit { 23export 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 @@
1import { of } from 'rxjs'
2import { catchError, map } from 'rxjs/operators'
3import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { RestExtractor } from '@app/core'
6import { CustomPage } from '@shared/models'
7import { environment } from '../../../../environments/environment'
8
9@Injectable()
10export 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'
30import { AUTH_INTERCEPTOR_PROVIDER } from './auth' 30import { AUTH_INTERCEPTOR_PROVIDER } from './auth'
31import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons' 31import { ActionDropdownComponent, ButtonComponent, DeleteButtonComponent, EditButtonComponent } from './buttons'
32import { CustomPageService } from './custom-page'
32import { DateToggleComponent } from './date' 33import { DateToggleComponent } from './date'
33import { FeedComponent } from './feeds' 34import { FeedComponent } from './feeds'
34import { LoaderComponent, SmallLoaderComponent } from './loaders' 35import { 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})
177export class SharedMainModule { } 180export 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: {
95function buildPlaylistLink (options: { 95function 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