]>
Commit | Line | Data |
---|---|---|
1 | import { firstValueFrom } from 'rxjs' | |
2 | import { ComponentRef, Injectable } from '@angular/core' | |
3 | import { MarkdownService } from '@app/core' | |
4 | import { | |
5 | ButtonMarkupData, | |
6 | ChannelMiniatureMarkupData, | |
7 | ContainerMarkupData, | |
8 | EmbedMarkupData, | |
9 | PlaylistMiniatureMarkupData, | |
10 | VideoMiniatureMarkupData, | |
11 | VideosListMarkupData | |
12 | } from '@shared/models' | |
13 | import { DynamicElementService } from './dynamic-element.service' | |
14 | import { | |
15 | ButtonMarkupComponent, | |
16 | ChannelMiniatureMarkupComponent, | |
17 | EmbedMarkupComponent, | |
18 | PlaylistMiniatureMarkupComponent, | |
19 | VideoMiniatureMarkupComponent, | |
20 | VideosListMarkupComponent | |
21 | } from './peertube-custom-tags' | |
22 | import { CustomMarkupComponent } from './peertube-custom-tags/shared' | |
23 | import { logger } from '@root-helpers/logger' | |
24 | ||
25 | type AngularBuilderFunction = (el: HTMLElement) => ComponentRef<CustomMarkupComponent> | |
26 | type HTMLBuilderFunction = (el: HTMLElement) => HTMLElement | |
27 | ||
28 | @Injectable() | |
29 | export class CustomMarkupService { | |
30 | private angularBuilders: { [ selector: string ]: AngularBuilderFunction } = { | |
31 | 'peertube-button': el => this.buttonBuilder(el), | |
32 | 'peertube-video-embed': el => this.embedBuilder(el, 'video'), | |
33 | 'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'), | |
34 | 'peertube-video-miniature': el => this.videoMiniatureBuilder(el), | |
35 | 'peertube-playlist-miniature': el => this.playlistMiniatureBuilder(el), | |
36 | 'peertube-channel-miniature': el => this.channelMiniatureBuilder(el), | |
37 | 'peertube-videos-list': el => this.videosListBuilder(el) | |
38 | } | |
39 | ||
40 | private htmlBuilders: { [ selector: string ]: HTMLBuilderFunction } = { | |
41 | 'peertube-container': el => this.containerBuilder(el) | |
42 | } | |
43 | ||
44 | private customMarkdownRenderer: (text: string) => Promise<HTMLElement> | |
45 | ||
46 | constructor ( | |
47 | private dynamicElementService: DynamicElementService, | |
48 | private markdown: MarkdownService | |
49 | ) { | |
50 | this.customMarkdownRenderer = (text: string) => { | |
51 | return this.buildElement(text) | |
52 | .then(({ rootElement }) => rootElement) | |
53 | } | |
54 | } | |
55 | ||
56 | getCustomMarkdownRenderer () { | |
57 | return this.customMarkdownRenderer | |
58 | } | |
59 | ||
60 | async buildElement (text: string) { | |
61 | const html = await this.markdown.customPageMarkdownToHTML({ markdown: text, additionalAllowedTags: this.getSupportedTags() }) | |
62 | ||
63 | const rootElement = document.createElement('div') | |
64 | rootElement.innerHTML = html | |
65 | ||
66 | for (const selector of Object.keys(this.htmlBuilders)) { | |
67 | rootElement.querySelectorAll(selector) | |
68 | .forEach((e: HTMLElement) => { | |
69 | try { | |
70 | const element = this.execHTMLBuilder(selector, e) | |
71 | // Insert as first child | |
72 | e.insertBefore(element, e.firstChild) | |
73 | } catch (err) { | |
74 | logger.error(`Cannot inject component ${selector}`, err) | |
75 | } | |
76 | }) | |
77 | } | |
78 | ||
79 | const loadedPromises: Promise<boolean>[] = [] | |
80 | ||
81 | for (const selector of Object.keys(this.angularBuilders)) { | |
82 | rootElement.querySelectorAll(selector) | |
83 | .forEach((e: HTMLElement) => { | |
84 | try { | |
85 | const component = this.execAngularBuilder(selector, e) | |
86 | ||
87 | if (component.instance.loaded) { | |
88 | const p = firstValueFrom(component.instance.loaded) | |
89 | loadedPromises.push(p) | |
90 | } | |
91 | ||
92 | this.dynamicElementService.injectElement(e, component) | |
93 | } catch (err) { | |
94 | logger.error(`Cannot inject component ${selector}`, err) | |
95 | } | |
96 | }) | |
97 | } | |
98 | ||
99 | return { rootElement, componentsLoaded: Promise.all(loadedPromises) } | |
100 | } | |
101 | ||
102 | private getSupportedTags () { | |
103 | return Object.keys(this.angularBuilders) | |
104 | .concat(Object.keys(this.htmlBuilders)) | |
105 | } | |
106 | ||
107 | private execHTMLBuilder (selector: string, el: HTMLElement) { | |
108 | return this.htmlBuilders[selector](el) | |
109 | } | |
110 | ||
111 | private execAngularBuilder (selector: string, el: HTMLElement) { | |
112 | return this.angularBuilders[selector](el) | |
113 | } | |
114 | ||
115 | private embedBuilder (el: HTMLElement, type: 'video' | 'playlist') { | |
116 | const data = el.dataset as EmbedMarkupData | |
117 | const component = this.dynamicElementService.createElement(EmbedMarkupComponent) | |
118 | ||
119 | this.dynamicElementService.setModel(component, { uuid: data.uuid, type }) | |
120 | ||
121 | return component | |
122 | } | |
123 | ||
124 | private playlistMiniatureBuilder (el: HTMLElement) { | |
125 | const data = el.dataset as PlaylistMiniatureMarkupData | |
126 | const component = this.dynamicElementService.createElement(PlaylistMiniatureMarkupComponent) | |
127 | ||
128 | this.dynamicElementService.setModel(component, { uuid: data.uuid }) | |
129 | ||
130 | return component | |
131 | } | |
132 | ||
133 | private channelMiniatureBuilder (el: HTMLElement) { | |
134 | const data = el.dataset as ChannelMiniatureMarkupData | |
135 | const component = this.dynamicElementService.createElement(ChannelMiniatureMarkupComponent) | |
136 | ||
137 | const model = { | |
138 | name: data.name, | |
139 | displayLatestVideo: this.buildBoolean(data.displayLatestVideo) ?? true, | |
140 | displayDescription: this.buildBoolean(data.displayDescription) ?? true | |
141 | } | |
142 | ||
143 | this.dynamicElementService.setModel(component, model) | |
144 | ||
145 | return component | |
146 | } | |
147 | ||
148 | private buttonBuilder (el: HTMLElement) { | |
149 | const data = el.dataset as ButtonMarkupData | |
150 | const component = this.dynamicElementService.createElement(ButtonMarkupComponent) | |
151 | ||
152 | const model = { | |
153 | theme: data.theme ?? 'primary', | |
154 | href: data.href, | |
155 | label: data.label, | |
156 | blankTarget: this.buildBoolean(data.blankTarget) ?? false | |
157 | } | |
158 | this.dynamicElementService.setModel(component, model) | |
159 | ||
160 | return component | |
161 | } | |
162 | ||
163 | private videoMiniatureBuilder (el: HTMLElement) { | |
164 | const data = el.dataset as VideoMiniatureMarkupData | |
165 | const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent) | |
166 | ||
167 | const model = { | |
168 | uuid: data.uuid, | |
169 | onlyDisplayTitle: this.buildBoolean(data.onlyDisplayTitle) ?? false | |
170 | } | |
171 | ||
172 | this.dynamicElementService.setModel(component, model) | |
173 | ||
174 | return component | |
175 | } | |
176 | ||
177 | private videosListBuilder (el: HTMLElement) { | |
178 | const data = el.dataset as VideosListMarkupData | |
179 | const component = this.dynamicElementService.createElement(VideosListMarkupComponent) | |
180 | ||
181 | const model = { | |
182 | onlyDisplayTitle: this.buildBoolean(data.onlyDisplayTitle) ?? false, | |
183 | maxRows: this.buildNumber(data.maxRows) ?? -1, | |
184 | ||
185 | sort: data.sort || '-publishedAt', | |
186 | count: this.buildNumber(data.count) || 10, | |
187 | ||
188 | categoryOneOf: this.buildArrayNumber(data.categoryOneOf) ?? [], | |
189 | languageOneOf: this.buildArrayString(data.languageOneOf) ?? [], | |
190 | ||
191 | accountHandle: data.accountHandle || undefined, | |
192 | channelHandle: data.channelHandle || undefined, | |
193 | ||
194 | isLive: this.buildBoolean(data.isLive), | |
195 | ||
196 | isLocal: this.buildBoolean(data.onlyLocal) ? true : undefined | |
197 | } | |
198 | ||
199 | this.dynamicElementService.setModel(component, model) | |
200 | ||
201 | return component | |
202 | } | |
203 | ||
204 | private containerBuilder (el: HTMLElement) { | |
205 | const data = el.dataset as ContainerMarkupData | |
206 | ||
207 | // Move inner HTML in the new element we'll create | |
208 | const content = el.innerHTML | |
209 | el.innerHTML = '' | |
210 | ||
211 | const root = document.createElement('div') | |
212 | root.innerHTML = content | |
213 | ||
214 | const layoutClass = data.layout | |
215 | ? 'layout-' + data.layout | |
216 | : 'layout-column' | |
217 | ||
218 | root.classList.add('peertube-container', layoutClass) | |
219 | ||
220 | root.style.justifyContent = data.justifyContent || 'space-between' | |
221 | ||
222 | if (data.width) { | |
223 | root.setAttribute('width', data.width) | |
224 | } | |
225 | ||
226 | if (data.title || data.description) { | |
227 | const headerElement = document.createElement('div') | |
228 | headerElement.classList.add('header') | |
229 | ||
230 | if (data.title) { | |
231 | const titleElement = document.createElement('h4') | |
232 | titleElement.innerText = data.title | |
233 | headerElement.appendChild(titleElement) | |
234 | } | |
235 | ||
236 | if (data.description) { | |
237 | const descriptionElement = document.createElement('div') | |
238 | descriptionElement.innerText = data.description | |
239 | headerElement.append(descriptionElement) | |
240 | } | |
241 | ||
242 | root.insertBefore(headerElement, root.firstChild) | |
243 | } | |
244 | ||
245 | return root | |
246 | } | |
247 | ||
248 | private buildNumber (value: string) { | |
249 | if (!value) return undefined | |
250 | ||
251 | return parseInt(value, 10) | |
252 | } | |
253 | ||
254 | private buildBoolean (value: string) { | |
255 | if (value === 'true') return true | |
256 | if (value === 'false') return false | |
257 | ||
258 | return undefined | |
259 | } | |
260 | ||
261 | private buildArrayNumber (value: string) { | |
262 | if (!value) return undefined | |
263 | ||
264 | return value.split(',').map(v => parseInt(v, 10)) | |
265 | } | |
266 | ||
267 | private buildArrayString (value: string) { | |
268 | if (!value) return undefined | |
269 | ||
270 | return value.split(',') | |
271 | } | |
272 | } |