@crombolo/backoffice-host 0.14.4 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/plugin/assistant/umbraco-package.json +1 -1
- package/plugin/branding/theme-dark.css +61 -0
- package/plugin/branding/theme-system.css +6 -0
- package/plugin/localizations/umbraco-package.json +1 -1
- package/plugin/seo/document-view.js +2 -1
- package/plugin/seo/umbraco-package.json +1 -1
- package/plugin/sign-in/umbraco-package.json +1 -1
- package/plugin/theme-switch.js +134 -0
- package/plugin/two-factor-setup.js +1 -0
- package/plugin/umbraco-package.json +30 -3
- package/plugin/welcome-dashboard.js +2 -2
- package/src/index.ts +1 -0
- package/src/shell.ts +6 -2
- package/src/theme-early.ts +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/backoffice-host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Serves the Umbraco backoffice SPA: shell HTML, import map and package manifests.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@crombolo/core": "0.
|
|
34
|
+
"@crombolo/core": "0.15.0",
|
|
35
35
|
"@crombolo/backoffice-dist": "18.2.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Crombolo's dark mode (story 049): the vendored `dark.css` for every token,
|
|
3
|
+
* warmed to the brand — cocoa surfaces, cream text, terracotta accents.
|
|
4
|
+
*
|
|
5
|
+
* The theme context appends this after `theme.css` when Dark is chosen, so it
|
|
6
|
+
* overrides the light palette. `/umbraco/backoffice/…` is served wherever the
|
|
7
|
+
* back office is mounted, which is why the import can name it outright.
|
|
8
|
+
* `tests/theme.test.ts` checks the contrast of every pair below.
|
|
9
|
+
*/
|
|
10
|
+
@import url("/umbraco/backoffice/css/dark.css");
|
|
11
|
+
|
|
12
|
+
:root {
|
|
13
|
+
--crombolo-orange: #da987c;
|
|
14
|
+
--crombolo-orange-light: #e2ae98;
|
|
15
|
+
--crombolo-ink: #f6ede4;
|
|
16
|
+
--crombolo-ink-dimmed: #c9b5a5;
|
|
17
|
+
--crombolo-night: #1f1813;
|
|
18
|
+
--crombolo-night-surface: #2a211b;
|
|
19
|
+
--crombolo-night-alt: #342922;
|
|
20
|
+
--crombolo-night-header: #2b1d15;
|
|
21
|
+
--crombolo-cocoa-deep: #25160e;
|
|
22
|
+
|
|
23
|
+
--uui-color-background: var(--crombolo-night);
|
|
24
|
+
--uui-color-surface: var(--crombolo-night-surface);
|
|
25
|
+
--uui-color-surface-alt: var(--crombolo-night-alt);
|
|
26
|
+
--uui-color-surface-emphasis: #30261f;
|
|
27
|
+
--uui-color-text: var(--crombolo-ink);
|
|
28
|
+
--uui-color-text-alt: var(--crombolo-ink-dimmed);
|
|
29
|
+
--uui-color-interactive: var(--crombolo-ink);
|
|
30
|
+
--uui-color-interactive-emphasis: var(--crombolo-orange-light);
|
|
31
|
+
|
|
32
|
+
/* The accent is light, so what sits on it is dark, as in the light mode. */
|
|
33
|
+
--uui-color-default: var(--crombolo-orange);
|
|
34
|
+
--uui-color-default-emphasis: var(--crombolo-orange-light);
|
|
35
|
+
--uui-color-default-standalone: var(--crombolo-orange);
|
|
36
|
+
--uui-color-default-contrast: var(--crombolo-cocoa-deep);
|
|
37
|
+
--uui-color-selected: var(--crombolo-orange);
|
|
38
|
+
--uui-color-selected-emphasis: var(--crombolo-orange-light);
|
|
39
|
+
--uui-color-selected-standalone: var(--crombolo-orange);
|
|
40
|
+
--uui-color-selected-contrast: var(--crombolo-cocoa-deep);
|
|
41
|
+
--uui-color-current: var(--crombolo-orange);
|
|
42
|
+
--uui-color-current-emphasis: var(--crombolo-orange-light);
|
|
43
|
+
--uui-color-current-contrast: var(--crombolo-cocoa-deep);
|
|
44
|
+
--uui-color-focus: var(--crombolo-orange);
|
|
45
|
+
|
|
46
|
+
/* A dark header with cream text; the active section's marker is the accent. */
|
|
47
|
+
--uui-color-header-surface: var(--crombolo-night-header);
|
|
48
|
+
--uui-color-header-contrast: rgb(246 237 228 / 80%);
|
|
49
|
+
--uui-color-header-contrast-emphasis: var(--crombolo-ink);
|
|
50
|
+
--crombolo-header-current: var(--crombolo-orange);
|
|
51
|
+
|
|
52
|
+
/* The SEO view's search-result preview, in Google's dark-mode link blue. */
|
|
53
|
+
--crombolo-snippet-title: #8ab4f8;
|
|
54
|
+
|
|
55
|
+
--uui-color-border: #4a3b31;
|
|
56
|
+
--uui-color-border-standalone: #8b7564;
|
|
57
|
+
--uui-color-border-emphasis: #a38c7a;
|
|
58
|
+
--uui-color-divider: #3a2e26;
|
|
59
|
+
--uui-color-divider-standalone: #4a3b31;
|
|
60
|
+
--uui-color-divider-emphasis: #5a4638;
|
|
61
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* "Match my system" (story 049): the dark mode while the computer asks for
|
|
3
|
+
* dark, and the light one otherwise — including a change made while the back
|
|
4
|
+
* office is open, which the media query follows by itself.
|
|
5
|
+
*/
|
|
6
|
+
@import url("./theme-dark.css") (prefers-color-scheme: dark);
|
|
@@ -122,7 +122,8 @@ export default class CromboloSeoDocumentViewElement extends UmbLitElement {
|
|
|
122
122
|
overflow-wrap: anywhere;
|
|
123
123
|
}
|
|
124
124
|
.snippet .title {
|
|
125
|
-
|
|
125
|
+
/* A search result's link blue: Google's own, light and dark. */
|
|
126
|
+
color: var(--crombolo-snippet-title, #1a0dab);
|
|
126
127
|
font-size: 20px;
|
|
127
128
|
margin: 4px 0;
|
|
128
129
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light, dark or the computer's choice, from the header (story 049).
|
|
3
|
+
*
|
|
4
|
+
* The choice is Umbraco's theme context's — it links the stylesheet and keeps
|
|
5
|
+
* the alias in `localStorage` for the shell's early script to apply before the
|
|
6
|
+
* next paint. This adds the menu, and keeps the choice for the person in user
|
|
7
|
+
* data, so it follows them to another browser.
|
|
8
|
+
*/
|
|
9
|
+
import { css, html } from '@umbraco-cms/backoffice/external/lit'
|
|
10
|
+
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'
|
|
11
|
+
import { UMB_THEME_CONTEXT } from '@umbraco-cms/backoffice/themes'
|
|
12
|
+
|
|
13
|
+
const USER_DATA = '/umbraco/management/api/v1/user-data'
|
|
14
|
+
const GROUP = 'crombolo'
|
|
15
|
+
const IDENTIFIER = 'theme'
|
|
16
|
+
const EARLY = 'crombolo-early-theme'
|
|
17
|
+
|
|
18
|
+
const CHOICES = [
|
|
19
|
+
{ alias: 'umb-light-theme', label: 'Light', icon: 'icon-sunny' },
|
|
20
|
+
{ alias: 'Crombolo.Theme.Dark', label: 'Dark', icon: 'icon-contrast' },
|
|
21
|
+
{ alias: 'Crombolo.Theme.System', label: 'Match my system', icon: 'icon-lightbulb' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export default class CromboloThemeSwitchElement extends UmbLitElement {
|
|
25
|
+
static properties = { _alias: { state: true } }
|
|
26
|
+
|
|
27
|
+
#context
|
|
28
|
+
#savedKey
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
super()
|
|
32
|
+
this._alias = 'umb-light-theme'
|
|
33
|
+
this.consumeContext(UMB_THEME_CONTEXT, (context) => {
|
|
34
|
+
this.#context = context
|
|
35
|
+
this.observe(context.theme, (alias) => {
|
|
36
|
+
this._alias = alias || 'umb-light-theme'
|
|
37
|
+
// The shell's early stylesheet stood in until the context took over;
|
|
38
|
+
// once the choice differs from it, it would only fight the new one.
|
|
39
|
+
const early = document.getElementById(EARLY)
|
|
40
|
+
if (early && early.dataset.theme !== this._alias) early.remove()
|
|
41
|
+
})
|
|
42
|
+
this.#restore()
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The person's saved choice, when this browser's differs from it. */
|
|
47
|
+
async #restore() {
|
|
48
|
+
try {
|
|
49
|
+
const query = new URLSearchParams({ groups: GROUP, identifiers: IDENTIFIER, take: '1' })
|
|
50
|
+
const response = await fetch(`${USER_DATA}?${query}`, { credentials: 'include' })
|
|
51
|
+
if (!response.ok) return
|
|
52
|
+
const found = (await response.json()).items?.[0]
|
|
53
|
+
if (!found) return
|
|
54
|
+
this.#savedKey = found.key
|
|
55
|
+
if (found.value && found.value !== this._alias) this.#context?.setThemeByAlias(found.value)
|
|
56
|
+
} catch {
|
|
57
|
+
// This browser's choice stands.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async #save(alias) {
|
|
62
|
+
const body = { group: GROUP, identifier: IDENTIFIER, value: alias }
|
|
63
|
+
try {
|
|
64
|
+
if (this.#savedKey) {
|
|
65
|
+
await fetch(USER_DATA, {
|
|
66
|
+
method: 'PUT',
|
|
67
|
+
credentials: 'include',
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ ...body, key: this.#savedKey }),
|
|
70
|
+
})
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const response = await fetch(USER_DATA, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
credentials: 'include',
|
|
76
|
+
headers: { 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
})
|
|
79
|
+
this.#savedKey = response.headers.get('umb-generated-resource') ?? undefined
|
|
80
|
+
} catch {
|
|
81
|
+
// Kept in this browser regardless; another will start from its own.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#choose(alias) {
|
|
86
|
+
this.#context?.setThemeByAlias(alias)
|
|
87
|
+
this.#save(alias)
|
|
88
|
+
this.renderRoot.querySelector('#crombolo-theme-menu')?.hidePopover?.()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
render() {
|
|
92
|
+
const current = CHOICES.find((choice) => choice.alias === this._alias) ?? CHOICES[0]
|
|
93
|
+
return html`
|
|
94
|
+
<uui-button
|
|
95
|
+
compact
|
|
96
|
+
look="primary"
|
|
97
|
+
label="Light or dark: ${current.label}"
|
|
98
|
+
popovertarget="crombolo-theme-menu">
|
|
99
|
+
<umb-icon name=${current.icon}></umb-icon>
|
|
100
|
+
</uui-button>
|
|
101
|
+
<uui-popover-container id="crombolo-theme-menu" placement="bottom-end">
|
|
102
|
+
<umb-popover-layout>
|
|
103
|
+
${CHOICES.map(
|
|
104
|
+
(choice) => html`<uui-menu-item
|
|
105
|
+
label=${choice.label}
|
|
106
|
+
?active=${choice.alias === this._alias}
|
|
107
|
+
@click-label=${() => this.#choose(choice.alias)}>
|
|
108
|
+
<umb-icon slot="icon" name=${choice.icon}></umb-icon>
|
|
109
|
+
</uui-menu-item>`,
|
|
110
|
+
)}
|
|
111
|
+
</umb-popover-layout>
|
|
112
|
+
</uui-popover-container>
|
|
113
|
+
`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
static styles = css`
|
|
117
|
+
uui-button {
|
|
118
|
+
font-size: 18px;
|
|
119
|
+
--uui-button-background-color: var(--umb-header-app-button-background-color, transparent);
|
|
120
|
+
--uui-button-background-color-hover: var(
|
|
121
|
+
--umb-header-app-button-background-color-hover,
|
|
122
|
+
var(--uui-color-emphasis)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
:host {
|
|
126
|
+
--uui-menu-item-flat-structure: 1;
|
|
127
|
+
}
|
|
128
|
+
umb-popover-layout {
|
|
129
|
+
min-width: 12rem;
|
|
130
|
+
}
|
|
131
|
+
`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
customElements.define('crombolo-theme-switch', CromboloThemeSwitchElement)
|
|
@@ -124,6 +124,7 @@ export default class CromboloTwoFactorSetupElement extends UmbLitElement {
|
|
|
124
124
|
width: 200px;
|
|
125
125
|
height: 200px;
|
|
126
126
|
margin: var(--uui-size-space-4) auto;
|
|
127
|
+
/* White in every mode: a scanner reads dark modules on a light ground. */
|
|
127
128
|
background: #fff;
|
|
128
129
|
}
|
|
129
130
|
.secret code,
|
|
@@ -2,9 +2,31 @@
|
|
|
2
2
|
"$schema": "../../backoffice-dist/dist/umbraco-package-schema.json",
|
|
3
3
|
"name": "Crombolo",
|
|
4
4
|
"id": "Crombolo.Core",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.15.0",
|
|
6
6
|
"allowPublicAccess": false,
|
|
7
7
|
"extensions": [
|
|
8
|
+
{
|
|
9
|
+
"type": "theme",
|
|
10
|
+
"alias": "Crombolo.Theme.Dark",
|
|
11
|
+
"name": "Dark",
|
|
12
|
+
"css": "/crombolo-plugin/branding/theme-dark.css",
|
|
13
|
+
"weight": 200,
|
|
14
|
+
"overwrites": "umb-dark-theme"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"type": "theme",
|
|
18
|
+
"alias": "Crombolo.Theme.System",
|
|
19
|
+
"name": "Match my system",
|
|
20
|
+
"css": "/crombolo-plugin/branding/theme-system.css",
|
|
21
|
+
"weight": 250
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"type": "headerApp",
|
|
25
|
+
"alias": "Crombolo.HeaderApp.Theme",
|
|
26
|
+
"name": "Light or dark",
|
|
27
|
+
"element": "/crombolo-plugin/theme-switch.js",
|
|
28
|
+
"weight": 850
|
|
29
|
+
},
|
|
8
30
|
{
|
|
9
31
|
"type": "entityUserPermission",
|
|
10
32
|
"alias": "Crombolo.UserPermission.Remote.Pull",
|
|
@@ -55,7 +77,10 @@
|
|
|
55
77
|
"name": "Crombolo Signed-in Devices",
|
|
56
78
|
"element": "/crombolo-plugin/signed-in-devices.js",
|
|
57
79
|
"weight": 100,
|
|
58
|
-
"meta": {
|
|
80
|
+
"meta": {
|
|
81
|
+
"label": "Signed-in devices",
|
|
82
|
+
"pathname": "devices"
|
|
83
|
+
}
|
|
59
84
|
},
|
|
60
85
|
{
|
|
61
86
|
"type": "mfaLoginProvider",
|
|
@@ -63,7 +88,9 @@
|
|
|
63
88
|
"name": "Crombolo Authenticator App",
|
|
64
89
|
"forProviderName": "UmbracoUserAppAuthenticator",
|
|
65
90
|
"element": "/crombolo-plugin/two-factor-setup.js",
|
|
66
|
-
"meta": {
|
|
91
|
+
"meta": {
|
|
92
|
+
"label": "Authenticator app"
|
|
93
|
+
}
|
|
67
94
|
},
|
|
68
95
|
{
|
|
69
96
|
"type": "sectionView",
|
|
@@ -31,8 +31,8 @@ const STEPS = [
|
|
|
31
31
|
{
|
|
32
32
|
title: 'Keep the schema in the repository',
|
|
33
33
|
body: html`Content types, data types and templates are also files:
|
|
34
|
-
<code>schema/*.toml</code>. <code>crombolo
|
|
35
|
-
the backoffice back out, and <code>crombolo
|
|
34
|
+
<code>schema/*.toml</code>. <code>crombolo types sync</code> writes changes made in
|
|
35
|
+
the backoffice back out, and <code>crombolo types check</code> tells you whether a
|
|
36
36
|
database matches. Schema travels through Git, not a database export.`,
|
|
37
37
|
},
|
|
38
38
|
]
|
package/src/index.ts
CHANGED
package/src/shell.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { MANAGEMENT_API_PATH } from '@crombolo/core'
|
|
10
10
|
import type { PackageManifestImportmap } from './manifests.ts'
|
|
11
11
|
import type { BackOfficePaths } from './paths.ts'
|
|
12
|
+
import { earlyThemeScript } from './theme-early.ts'
|
|
12
13
|
|
|
13
14
|
export interface ShellSettings {
|
|
14
15
|
/** GlobalSettings.DefaultUILanguage — the initial UI language. */
|
|
@@ -81,6 +82,7 @@ export function renderBackOfficeShell(
|
|
|
81
82
|
<link rel="stylesheet" href="${assets}/css/umb-css.css" />
|
|
82
83
|
<link rel="stylesheet" href="${assets}/css/light.css" />
|
|
83
84
|
<link rel="stylesheet" href="${paths.pluginPath}/branding/theme.css" />
|
|
85
|
+
<script>${earlyThemeScript(paths)}</script>
|
|
84
86
|
<script type="importmap">
|
|
85
87
|
${JSON.stringify(importmap, null, 2)}
|
|
86
88
|
</script>
|
|
@@ -117,7 +119,8 @@ export function renderLoginShell(
|
|
|
117
119
|
<title>${escapeHtml(settings.title)}</title>
|
|
118
120
|
<link rel="stylesheet" href="${assets}/css/light.css" />
|
|
119
121
|
<link rel="stylesheet" href="${paths.pluginPath}/branding/theme.css" />
|
|
120
|
-
<
|
|
122
|
+
<script>${earlyThemeScript(paths)}</script>
|
|
123
|
+
<style>body { margin: 0; padding: 0; background-color: var(--uui-color-background, #fff7ea); }</style>
|
|
121
124
|
<script type="importmap">
|
|
122
125
|
${JSON.stringify(importmap, null, 2)}
|
|
123
126
|
</script>
|
|
@@ -163,7 +166,8 @@ export function renderStandaloneShell(
|
|
|
163
166
|
<title>${escapeHtml(settings.title)}</title>
|
|
164
167
|
<link rel="stylesheet" href="${assets}/css/light.css" />
|
|
165
168
|
<link rel="stylesheet" href="${paths.pluginPath}/branding/theme.css" />
|
|
166
|
-
<
|
|
169
|
+
<script>${earlyThemeScript(paths)}</script>
|
|
170
|
+
<style>body { margin: 0; padding: 0; background-color: var(--uui-color-background, #fff7ea); }</style>
|
|
167
171
|
<script type="importmap">
|
|
168
172
|
${JSON.stringify(importmap, null, 2)}
|
|
169
173
|
</script>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The back office's light or dark mode, applied before the first paint
|
|
3
|
+
* (story 049). Umbraco's theme context keeps the choice in `localStorage` and
|
|
4
|
+
* applies it once the client has booted, so a reload painted light first; and
|
|
5
|
+
* the login shell boots no theme context at all. This inline script reads the
|
|
6
|
+
* same key and links the same stylesheet at once.
|
|
7
|
+
*/
|
|
8
|
+
import type { BackOfficePaths } from './paths.ts'
|
|
9
|
+
|
|
10
|
+
/** The key Umbraco's theme context keeps the choice under. */
|
|
11
|
+
export const THEME_STORAGE_KEY = 'umb-theme-alias'
|
|
12
|
+
|
|
13
|
+
export const THEME_DARK = 'Crombolo.Theme.Dark'
|
|
14
|
+
export const THEME_SYSTEM = 'Crombolo.Theme.System'
|
|
15
|
+
|
|
16
|
+
/** Umbraco's own dark, which crombolo's replaces: a browser that chose it gets the new one. */
|
|
17
|
+
const RENAMED: Record<string, string> = { 'umb-dark-theme': THEME_DARK }
|
|
18
|
+
|
|
19
|
+
export const EARLY_THEME_ID = 'crombolo-early-theme'
|
|
20
|
+
|
|
21
|
+
/** Each choice's stylesheet; light, or nothing chosen, needs none beyond the shell's own. */
|
|
22
|
+
export function themeStylesheets(paths: BackOfficePaths): Record<string, string> {
|
|
23
|
+
return {
|
|
24
|
+
[THEME_DARK]: `${paths.pluginPath}/branding/theme-dark.css`,
|
|
25
|
+
[THEME_SYSTEM]: `${paths.pluginPath}/branding/theme-system.css`,
|
|
26
|
+
'umb-high-contrast-theme': `${paths.assetsPath}/css/high-contrast.css`,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The inline script, as the shells embed it. It fails quietly: light is a fine fallback. */
|
|
31
|
+
export function earlyThemeScript(paths: BackOfficePaths): string {
|
|
32
|
+
const sheets = JSON.stringify(themeStylesheets(paths))
|
|
33
|
+
const renamed = JSON.stringify(RENAMED)
|
|
34
|
+
return `(function () {
|
|
35
|
+
try {
|
|
36
|
+
var key = ${JSON.stringify(THEME_STORAGE_KEY)};
|
|
37
|
+
var alias = localStorage.getItem(key);
|
|
38
|
+
var renamed = ${renamed};
|
|
39
|
+
if (alias && renamed[alias]) { alias = renamed[alias]; localStorage.setItem(key, alias); }
|
|
40
|
+
var href = alias ? ${sheets}[alias] : undefined;
|
|
41
|
+
if (!href) return;
|
|
42
|
+
var link = document.createElement('link');
|
|
43
|
+
link.id = ${JSON.stringify(EARLY_THEME_ID)};
|
|
44
|
+
link.rel = 'stylesheet';
|
|
45
|
+
link.href = href;
|
|
46
|
+
link.setAttribute('data-theme', alias);
|
|
47
|
+
document.head.appendChild(link);
|
|
48
|
+
} catch (error) {}
|
|
49
|
+
})();`
|
|
50
|
+
}
|