mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 05:23:40 -05:00
Merge pull request #4941 from overleaf/jpa-as-homepage-prototype
[web] de-ng homepage prototype GitOrigin-RevId: 030a5bf0b4f05eac7d69fda928c906f3c9c962f0
This commit is contained in:
parent
cb9e4a41e0
commit
267b7fc17d
15 changed files with 601 additions and 19 deletions
128
services/web/app/views/layout-marketing.pug
Normal file
128
services/web/app/views/layout-marketing.pug
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
doctype html
|
||||||
|
html(
|
||||||
|
lang=(currentLngCode || 'en')
|
||||||
|
)
|
||||||
|
- metadata = metadata || {}
|
||||||
|
- entrypoint = 'marketing'
|
||||||
|
|
||||||
|
//- hook for overriding metadata/page
|
||||||
|
block vars
|
||||||
|
|
||||||
|
head
|
||||||
|
include ./_metadata.pug
|
||||||
|
|
||||||
|
if (typeof(gaExperiments) != "undefined")
|
||||||
|
|!{gaExperiments}
|
||||||
|
|
||||||
|
//- Stylesheet
|
||||||
|
link(rel='stylesheet', href=buildCssPath(getCssThemeModifier(userSettings, brandVariation)), id="main-stylesheet")
|
||||||
|
block css
|
||||||
|
each file in entrypointStyles(entrypoint)
|
||||||
|
link(rel='stylesheet', href=file)
|
||||||
|
|
||||||
|
block _headLinks
|
||||||
|
|
||||||
|
if settings.i18n.subdomainLang
|
||||||
|
each subdomainDetails in settings.i18n.subdomainLang
|
||||||
|
if !subdomainDetails.hide
|
||||||
|
link(rel="alternate", href=subdomainDetails.url+currentUrl, hreflang=subdomainDetails.lngCode)
|
||||||
|
|
||||||
|
//- Scripts
|
||||||
|
|
||||||
|
//- Google Analytics
|
||||||
|
if (typeof(gaToken) != "undefined")
|
||||||
|
script(type="text/javascript", nonce=scriptNonce).
|
||||||
|
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||||
|
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||||
|
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||||
|
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
|
||||||
|
script(type="text/javascript", nonce=scriptNonce).
|
||||||
|
ga('create', '#{gaToken}', '#{settings.cookieDomain.replace(/^\./, "")}');
|
||||||
|
ga('set', 'anonymizeIp', true);
|
||||||
|
ga('send', 'pageview');
|
||||||
|
|
||||||
|
try {
|
||||||
|
ga.isBlocked = localStorage.getItem('gaBlocked') === 'true'
|
||||||
|
if (!ga.isBlocked) {
|
||||||
|
window.addEventListener('load', function () {
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!ga.loaded) localStorage.setItem('gaBlocked', 'true')
|
||||||
|
}, 4000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if gaOptimize === true && typeof(gaOptimizeId) != "undefined"
|
||||||
|
//- Anti-flicker snippet
|
||||||
|
style(type='text/css') .async-hide { opacity: 0 !important}
|
||||||
|
script(type="text/javascript", nonce=scriptNonce).
|
||||||
|
if (!ga.isBlocked) {
|
||||||
|
ga('require', '#{gaOptimizeId}');
|
||||||
|
ga('send', 'event', 'pageview', document.title.substring(0, 499), window.location.href.substring(0, 499));
|
||||||
|
(function(a,s,y,n,c,h,i,d,e){s.className+=' '+y;h.start=1*new Date;
|
||||||
|
h.end=i=function(){s.className=s.className.replace(RegExp(' ?'+y),'')};
|
||||||
|
(a[n]=a[n]||[]).hide=h;setTimeout(function(){i();h.end=null},c);h.timeout=c;
|
||||||
|
})(window,document.documentElement,'async-hide','dataLayer',4000,
|
||||||
|
{'#{gaOptimizeId}':true});
|
||||||
|
}
|
||||||
|
|
||||||
|
else
|
||||||
|
script(type="text/javascript", nonce=scriptNonce).
|
||||||
|
window.ga = function() { console.log("would send to GA", arguments) };
|
||||||
|
|
||||||
|
block meta
|
||||||
|
meta(name="ol-csrfToken" content=csrfToken)
|
||||||
|
//- Configure dynamically loaded assets (via webpack) to be downloaded from CDN
|
||||||
|
//- See: https://webpack.js.org/guides/public-path/#on-the-fly
|
||||||
|
meta(name="ol-baseAssetPath" content=buildBaseAssetPath())
|
||||||
|
|
||||||
|
meta(name="ol-usersEmail" content=getUserEmail())
|
||||||
|
meta(name="ol-sharelatex" data-type="json" content={
|
||||||
|
siteUrl: settings.siteUrl,
|
||||||
|
wsUrl,
|
||||||
|
})
|
||||||
|
meta(name="ol-ab" data-type="json" content={})
|
||||||
|
meta(name="ol-user_id" content=getLoggedInUserId())
|
||||||
|
//- Internationalisation settings
|
||||||
|
meta(name="ol-i18n" data-type="json" content={
|
||||||
|
currentLangCode: currentLngCode
|
||||||
|
})
|
||||||
|
//- Expose some settings globally to the frontend
|
||||||
|
meta(name="ol-ExposedSettings" data-type="json" content=ExposedSettings)
|
||||||
|
|
||||||
|
if (typeof(settings.algolia) != "undefined")
|
||||||
|
meta(name="ol-algolia" data-type="json" content={
|
||||||
|
appId: settings.algolia.app_id,
|
||||||
|
apiKey: settings.algolia.read_only_api_key,
|
||||||
|
indexes: settings.algolia.indexes
|
||||||
|
})
|
||||||
|
|
||||||
|
if (typeof(settings.templates) != "undefined")
|
||||||
|
meta(name="ol-sharelatex.templates" data-type="json" content={
|
||||||
|
user_id : settings.templates.user_id,
|
||||||
|
cdnDomain : settings.templates.cdnDomain,
|
||||||
|
indexName : settings.templates.indexName
|
||||||
|
})
|
||||||
|
|
||||||
|
block head-scripts
|
||||||
|
|
||||||
|
|
||||||
|
body
|
||||||
|
if(settings.recaptcha && settings.recaptcha.siteKeyV3)
|
||||||
|
script(type="text/javascript", nonce=scriptNonce, src="https://www.recaptcha.net/recaptcha/api.js?render="+settings.recaptcha.siteKeyV3)
|
||||||
|
|
||||||
|
if (typeof(suppressSkipToContent) == "undefined")
|
||||||
|
a(class="skip-to-content" href="#main-content") #{translate('skip_to_content')}
|
||||||
|
|
||||||
|
if (typeof(suppressNavbar) == "undefined")
|
||||||
|
include layout/navbar-marketing
|
||||||
|
|
||||||
|
block content
|
||||||
|
|
||||||
|
if (typeof(suppressFooter) == "undefined")
|
||||||
|
include layout/footer-marketing
|
||||||
|
|
||||||
|
!= moduleIncludes("contactModal", locals)
|
||||||
|
|
||||||
|
block foot-scripts
|
||||||
|
each file in entrypointScripts(entrypoint)
|
||||||
|
script(type="text/javascript", nonce=scriptNonce, src=file)
|
41
services/web/app/views/layout/footer-marketing.pug
Normal file
41
services/web/app/views/layout/footer-marketing.pug
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
footer.site-footer
|
||||||
|
.site-footer-content.hidden-print
|
||||||
|
.row
|
||||||
|
ul.col-md-9
|
||||||
|
|
||||||
|
if Object.keys(settings.i18n.subdomainLang).length > 1
|
||||||
|
li.dropdown.dropup.subdued
|
||||||
|
a.dropdown-toggle(
|
||||||
|
data-toggle="dropdown",
|
||||||
|
aria-haspopup="true",
|
||||||
|
aria-expanded="false",
|
||||||
|
aria-label="Select " + translate('language'),
|
||||||
|
data-ol-lang-selector-tooltip,
|
||||||
|
title=translate('language')
|
||||||
|
)
|
||||||
|
figure(class="sprite-icon sprite-icon-lang sprite-icon-"+currentLngCode alt=translate(currentLngCode))
|
||||||
|
|
||||||
|
ul.dropdown-menu(role="menu")
|
||||||
|
li.dropdown-header #{translate("language")}
|
||||||
|
each subdomainDetails, subdomain in settings.i18n.subdomainLang
|
||||||
|
if !subdomainDetails.hide
|
||||||
|
li.lngOption
|
||||||
|
a.menu-indent(href=subdomainDetails.url+currentUrlWithQueryParams)
|
||||||
|
figure(class="sprite-icon sprite-icon-lang sprite-icon-"+subdomainDetails.lngCode alt=translate(subdomainDetails.lngCode))
|
||||||
|
| #{translate(subdomainDetails.lngCode)}
|
||||||
|
//- img(src="/img/flags/24/.png")
|
||||||
|
each item in nav.left_footer
|
||||||
|
li
|
||||||
|
if item.url
|
||||||
|
a(href=item.url, class=item.class) !{translate(item.text)}
|
||||||
|
else
|
||||||
|
| !{item.text}
|
||||||
|
|
||||||
|
ul.col-md-3.text-right
|
||||||
|
|
||||||
|
each item in nav.right_footer
|
||||||
|
li(ng-non-bindable)
|
||||||
|
if item.url
|
||||||
|
a(href=item.url, class=item.class, aria-label=item.label) !{item.text}
|
||||||
|
else
|
||||||
|
| !{item.text}
|
|
@ -4,7 +4,6 @@ footer.site-footer
|
||||||
.site-footer-content.hidden-print
|
.site-footer-content.hidden-print
|
||||||
.row
|
.row
|
||||||
ul.col-md-9
|
ul.col-md-9
|
||||||
|
|
||||||
if Object.keys(settings.i18n.subdomainLang).length > 1
|
if Object.keys(settings.i18n.subdomainLang).length > 1
|
||||||
li.dropdown.dropup.subdued(dropdown)
|
li.dropdown.dropup.subdued(dropdown)
|
||||||
a.dropdown-toggle(
|
a.dropdown-toggle(
|
||||||
|
@ -18,7 +17,7 @@ footer.site-footer
|
||||||
)
|
)
|
||||||
figure(class="sprite-icon sprite-icon-lang sprite-icon-"+currentLngCode alt=translate(currentLngCode))
|
figure(class="sprite-icon sprite-icon-lang sprite-icon-"+currentLngCode alt=translate(currentLngCode))
|
||||||
|
|
||||||
ul.dropdown-menu(role="menu")
|
ul.dropdown-menu
|
||||||
li.dropdown-header #{translate("language")}
|
li.dropdown-header #{translate("language")}
|
||||||
each subdomainDetails, subdomain in settings.i18n.subdomainLang
|
each subdomainDetails, subdomain in settings.i18n.subdomainLang
|
||||||
if !subdomainDetails.hide
|
if !subdomainDetails.hide
|
||||||
|
@ -26,7 +25,7 @@ footer.site-footer
|
||||||
a.menu-indent(href=subdomainDetails.url+currentUrlWithQueryParams)
|
a.menu-indent(href=subdomainDetails.url+currentUrlWithQueryParams)
|
||||||
figure(class="sprite-icon sprite-icon-lang sprite-icon-"+subdomainDetails.lngCode alt=translate(subdomainDetails.lngCode))
|
figure(class="sprite-icon sprite-icon-lang sprite-icon-"+subdomainDetails.lngCode alt=translate(subdomainDetails.lngCode))
|
||||||
| #{translate(subdomainDetails.lngCode)}
|
| #{translate(subdomainDetails.lngCode)}
|
||||||
//- img(src="/img/flags/24/.png")
|
|
||||||
each item in nav.left_footer
|
each item in nav.left_footer
|
||||||
li
|
li
|
||||||
if item.url
|
if item.url
|
||||||
|
@ -35,9 +34,8 @@ footer.site-footer
|
||||||
| !{item.text}
|
| !{item.text}
|
||||||
|
|
||||||
ul.col-md-3.text-right
|
ul.col-md-3.text-right
|
||||||
|
|
||||||
each item in nav.right_footer
|
each item in nav.right_footer
|
||||||
li(ng-non-bindable)
|
li
|
||||||
if item.url
|
if item.url
|
||||||
a(href=item.url, class=item.class, aria-label=item.label) !{item.text}
|
a(href=item.url, class=item.class, aria-label=item.label) !{item.text}
|
||||||
else
|
else
|
||||||
|
|
118
services/web/app/views/layout/navbar-marketing.pug
Normal file
118
services/web/app/views/layout/navbar-marketing.pug
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
nav.navbar.navbar-default.navbar-main
|
||||||
|
.container-fluid
|
||||||
|
.navbar-header
|
||||||
|
button.navbar-toggle.collapsed(
|
||||||
|
type="button",
|
||||||
|
data-toggle="collapse",
|
||||||
|
data-target="[data-ol-navbar-main-collapse]"
|
||||||
|
aria-label="Toggle " + translate('navigation')
|
||||||
|
)
|
||||||
|
i.fa.fa-bars(aria-hidden="true")
|
||||||
|
if settings.nav.custom_logo
|
||||||
|
a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand
|
||||||
|
else if (nav.title)
|
||||||
|
a(href='/', aria-label=settings.appName).navbar-title #{nav.title}
|
||||||
|
else
|
||||||
|
a(href='/', aria-label=settings.appName).navbar-brand
|
||||||
|
|
||||||
|
.navbar-collapse.collapse(data-ol-navbar-main-collapse)
|
||||||
|
ul.nav.navbar-nav.navbar-right
|
||||||
|
if (getSessionUser() && getSessionUser().isAdmin)
|
||||||
|
li.dropdown.subdued
|
||||||
|
a.dropdown-toggle(
|
||||||
|
href="#",
|
||||||
|
role="button",
|
||||||
|
aria-haspopup="true",
|
||||||
|
aria-expanded="false",
|
||||||
|
data-toggle="dropdown"
|
||||||
|
)
|
||||||
|
| Admin
|
||||||
|
span.caret
|
||||||
|
ul.dropdown-menu
|
||||||
|
li
|
||||||
|
a(href="/admin") Manage Site
|
||||||
|
li
|
||||||
|
a(href="/admin/user") Manage Users
|
||||||
|
|
||||||
|
|
||||||
|
// loop over header_extras
|
||||||
|
each item in nav.header_extras
|
||||||
|
-
|
||||||
|
if ((item.only_when_logged_in && getSessionUser())
|
||||||
|
|| (item.only_when_logged_out && (!getSessionUser()))
|
||||||
|
|| (!item.only_when_logged_out && !item.only_when_logged_in && !item.only_content_pages)
|
||||||
|
|| (item.only_content_pages && (typeof(suppressNavContentLinks) == "undefined" || !suppressNavContentLinks))
|
||||||
|
){
|
||||||
|
var showNavItem = true
|
||||||
|
} else {
|
||||||
|
var showNavItem = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if showNavItem
|
||||||
|
if item.dropdown
|
||||||
|
li.dropdown(class=item.class)
|
||||||
|
a.dropdown-toggle(
|
||||||
|
href="#",
|
||||||
|
role="button",
|
||||||
|
aria-haspopup="true",
|
||||||
|
aria-expanded="false",
|
||||||
|
data-toggle="dropdown"
|
||||||
|
)
|
||||||
|
| !{translate(item.text)}
|
||||||
|
span.caret
|
||||||
|
ul.dropdown-menu
|
||||||
|
each child in item.dropdown
|
||||||
|
if child.divider
|
||||||
|
li.divider
|
||||||
|
else
|
||||||
|
li
|
||||||
|
if child.url
|
||||||
|
a(href=child.url, class=child.class) !{translate(child.text)}
|
||||||
|
else
|
||||||
|
| !{translate(child.text)}
|
||||||
|
else
|
||||||
|
li(class=item.class)
|
||||||
|
if item.url
|
||||||
|
a(href=item.url, class=item.class) !{translate(item.text)}
|
||||||
|
else
|
||||||
|
| !{translate(item.text)}
|
||||||
|
|
||||||
|
// logged out
|
||||||
|
if !getSessionUser()
|
||||||
|
// register link
|
||||||
|
if hasFeature('registration-page')
|
||||||
|
li
|
||||||
|
a(href="/register") #{translate('register')}
|
||||||
|
|
||||||
|
// login link
|
||||||
|
li
|
||||||
|
a(href="/login") #{translate('log_in')}
|
||||||
|
|
||||||
|
// projects link and account menu
|
||||||
|
if getSessionUser()
|
||||||
|
li
|
||||||
|
a(href="/project") #{translate('Projects')}
|
||||||
|
li.dropdown
|
||||||
|
a.dropdown-toggle(
|
||||||
|
href="#",
|
||||||
|
role="button",
|
||||||
|
aria-haspopup="true",
|
||||||
|
aria-expanded="false",
|
||||||
|
data-toggle="dropdown"
|
||||||
|
)
|
||||||
|
| #{translate('Account')}
|
||||||
|
span.caret
|
||||||
|
ul.dropdown-menu
|
||||||
|
li
|
||||||
|
div.subdued #{getSessionUser().email}
|
||||||
|
li.divider.hidden-xs.hidden-sm
|
||||||
|
li
|
||||||
|
a(href="/user/settings") #{translate('Account Settings')}
|
||||||
|
if nav.showSubscriptionLink
|
||||||
|
li
|
||||||
|
a(href="/user/subscription") #{translate('subscription')}
|
||||||
|
li.divider.hidden-xs.hidden-sm
|
||||||
|
li
|
||||||
|
form(method="POST" action="/logout")
|
||||||
|
input(name='_csrf', type='hidden', value=csrfToken)
|
||||||
|
button.btn-link.text-left.dropdown-menu-button #{translate('log_out')}
|
23
services/web/frontend/js/features/form-helpers/captcha.js
Normal file
23
services/web/frontend/js/features/form-helpers/captcha.js
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
const grecaptcha = window.grecaptcha
|
||||||
|
|
||||||
|
let recaptchaId
|
||||||
|
const recaptchaCallbacks = []
|
||||||
|
|
||||||
|
export async function validateCaptchaV2() {
|
||||||
|
if (typeof grecaptcha === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (recaptchaId === undefined) {
|
||||||
|
const el = document.getElementById('recaptcha')
|
||||||
|
recaptchaId = grecaptcha.render(el, {
|
||||||
|
callback: token => {
|
||||||
|
recaptchaCallbacks.splice(0).forEach(cb => cb(token))
|
||||||
|
grecaptcha.reset(recaptchaId)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return await new Promise(resolve => {
|
||||||
|
recaptchaCallbacks.push(resolve)
|
||||||
|
grecaptcha.execute(recaptchaId)
|
||||||
|
})
|
||||||
|
}
|
125
services/web/frontend/js/features/form-helpers/hydrate-form.js
Normal file
125
services/web/frontend/js/features/form-helpers/hydrate-form.js
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { FetchError, postJSON } from '../../infrastructure/fetch-json'
|
||||||
|
import { validateCaptchaV2 } from './captcha'
|
||||||
|
|
||||||
|
// Form helper(s) to handle:
|
||||||
|
// - Attaching to the relevant form elements
|
||||||
|
// - Listening for submit event
|
||||||
|
// - Validating captcha
|
||||||
|
// - Sending fetch request
|
||||||
|
// - Redirect handling
|
||||||
|
// - Showing errors
|
||||||
|
// - Disabled state
|
||||||
|
|
||||||
|
function formSubmitHelper(formEl) {
|
||||||
|
formEl.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
formEl.dispatchEvent(new Event('inflight'))
|
||||||
|
|
||||||
|
// We currently only have capacity to show 1 error, so this is probably
|
||||||
|
// unnecessary but I've used a similar data structure in the past and it was
|
||||||
|
// nice to be able to handle multiple (e.g. validation) errors at once
|
||||||
|
const messageBag = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const captchaResponse = await validateCaptcha(formEl)
|
||||||
|
|
||||||
|
const data = await sendFormRequest(formEl, captchaResponse)
|
||||||
|
|
||||||
|
// Handle redirects. From poking around, this still appears to be the
|
||||||
|
// "correct" way of handling redirects with fetch
|
||||||
|
if (data.redir) {
|
||||||
|
window.location = data.redir
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a success message (e.g. used on 2FA page)
|
||||||
|
if (data.message) {
|
||||||
|
messageBag.push({
|
||||||
|
type: 'message',
|
||||||
|
text: data.message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
let text = error.message
|
||||||
|
if (error instanceof FetchError) {
|
||||||
|
text = error.getUserFacingMessage()
|
||||||
|
}
|
||||||
|
messageBag.push({
|
||||||
|
type: 'error',
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
// Possibly this could be wired up through events too?
|
||||||
|
showMessages(formEl, messageBag)
|
||||||
|
|
||||||
|
formEl.dispatchEvent(new Event('not-inflight'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateCaptcha(formEl) {
|
||||||
|
let captchaResponse
|
||||||
|
if (formEl.hasAttribute('captcha')) {
|
||||||
|
captchaResponse = await validateCaptchaV2()
|
||||||
|
}
|
||||||
|
return captchaResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFormRequest(formEl, captchaResponse) {
|
||||||
|
const formData = new FormData(formEl)
|
||||||
|
if (captchaResponse) {
|
||||||
|
formData.set('g-recaptcha-response', captchaResponse)
|
||||||
|
}
|
||||||
|
const body = Object.fromEntries(formData.entries())
|
||||||
|
const url = formEl.getAttribute('action')
|
||||||
|
return postJSON(url, { body })
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessages(formEl, messageBag) {
|
||||||
|
const messagesEl = formEl.querySelector('[data-ol-form-messages]')
|
||||||
|
if (!messagesEl) return
|
||||||
|
|
||||||
|
// Clear content
|
||||||
|
messagesEl.textContent = ''
|
||||||
|
|
||||||
|
// Render messages
|
||||||
|
messageBag.forEach(message => {
|
||||||
|
const messageEl = document.createElement('div')
|
||||||
|
messageEl.className = classNames('alert', {
|
||||||
|
'alert-danger': message.type === 'error',
|
||||||
|
'alert-success': message.type !== 'error',
|
||||||
|
})
|
||||||
|
messageEl.textContent = message.text
|
||||||
|
messagesEl.append(messageEl)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formInflightHelper(el) {
|
||||||
|
const disabledEl = el.querySelector('[data-ol-disabled-inflight]')
|
||||||
|
const showWhenNotInflightEl = el.querySelector('[data-ol-not-inflight-text]')
|
||||||
|
const showWhenInflightEl = el.querySelector('[data-ol-inflight-text]')
|
||||||
|
|
||||||
|
el.addEventListener('inflight', () => {
|
||||||
|
disabledEl.disabled = true
|
||||||
|
toggleDisplay(showWhenNotInflightEl, showWhenInflightEl)
|
||||||
|
})
|
||||||
|
|
||||||
|
el.addEventListener('not-inflight', () => {
|
||||||
|
disabledEl.disabled = false
|
||||||
|
toggleDisplay(showWhenInflightEl, showWhenNotInflightEl)
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleDisplay(hideEl, showEl) {
|
||||||
|
hideEl.setAttribute('hidden', '')
|
||||||
|
showEl.removeAttribute('hidden')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hydrateForm(el) {
|
||||||
|
formSubmitHelper(el)
|
||||||
|
formInflightHelper(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(`[data-ol-form]`).forEach(form => hydrateForm(form))
|
|
@ -0,0 +1,72 @@
|
||||||
|
export default function inputValidator(options) {
|
||||||
|
const { selector } = options
|
||||||
|
|
||||||
|
const inputEl = document.querySelector(selector)
|
||||||
|
|
||||||
|
inputEl.addEventListener('input', markDirty)
|
||||||
|
inputEl.addEventListener('change', markDirty)
|
||||||
|
inputEl.addEventListener('blur', insertInvalidMessage)
|
||||||
|
|
||||||
|
// Mark an input as "dirty": the user has typed something in at some point
|
||||||
|
function markDirty() {
|
||||||
|
// Note: this is used for the input styling as well as checks when inserting invalid
|
||||||
|
// message below
|
||||||
|
inputEl.dataset.olDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertInvalidMessage() {
|
||||||
|
if (!inputEl.validity.valid) {
|
||||||
|
// Already have a invalid message, don't insert another
|
||||||
|
if (inputEl._invalid_message_el) return
|
||||||
|
|
||||||
|
// Only show the message if the input is "dirty"
|
||||||
|
if (!inputEl.dataset.olDirty) return
|
||||||
|
|
||||||
|
const messageEl = createMessageEl({
|
||||||
|
message: getMessage(inputEl),
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
inputEl.insertAdjacentElement('afterend', messageEl)
|
||||||
|
|
||||||
|
// Add a reference so we can remove the element when the input becomes valid
|
||||||
|
inputEl._invalid_message_el = messageEl
|
||||||
|
} else {
|
||||||
|
if (!inputEl._invalid_message_el) return
|
||||||
|
|
||||||
|
// Remove the message element
|
||||||
|
inputEl._invalid_message_el.remove()
|
||||||
|
// Clean up the reference
|
||||||
|
delete inputEl._invalid_message_el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUp() {
|
||||||
|
inputEl.removeEventListener('input change', markDirty)
|
||||||
|
inputEl.removeEventListener('blue', insertInvalidMessage)
|
||||||
|
delete inputEl._invalid_message_el
|
||||||
|
delete inputEl.dataset.olDirty
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanUp
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessageEl({ message, messageClasses = [] }) {
|
||||||
|
const el = document.createElement('span')
|
||||||
|
// From what I understand, using textContent means that we're safe from XSS
|
||||||
|
el.textContent = message
|
||||||
|
el.classList.add(...messageClasses)
|
||||||
|
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessage(el) {
|
||||||
|
// Could be extended to all ValidityState properties: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
|
||||||
|
const { valueMissing, typeMismatch } = el.validity
|
||||||
|
if (valueMissing) {
|
||||||
|
return el.dataset.olInvalidValueMissing || 'Missing required value'
|
||||||
|
} else if (typeMismatch) {
|
||||||
|
return el.dataset.olInvalidTypeMismatch || 'Invalid type' // FIXME: Bad default
|
||||||
|
} else {
|
||||||
|
return 'Invalid'
|
||||||
|
}
|
||||||
|
}
|
|
@ -57,6 +57,8 @@ function getErrorMessageForStatusCode(statusCode) {
|
||||||
return 'Forbidden'
|
return 'Forbidden'
|
||||||
case 404:
|
case 404:
|
||||||
return 'Not Found'
|
return 'Not Found'
|
||||||
|
case 429:
|
||||||
|
return 'Too Many Requests'
|
||||||
case 500:
|
case 500:
|
||||||
return 'Internal Server Error'
|
return 'Internal Server Error'
|
||||||
case 502:
|
case 502:
|
||||||
|
@ -90,6 +92,27 @@ export class FetchError extends OError {
|
||||||
this.response = response
|
this.response = response
|
||||||
this.data = data
|
this.data = data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getUserFacingMessage() {
|
||||||
|
const statusCode = this.response?.status
|
||||||
|
const defaultMessage = getErrorMessageForStatusCode(statusCode)
|
||||||
|
const message = this.data?.message?.text || this.data?.message
|
||||||
|
if (message && message !== defaultMessage) return message
|
||||||
|
|
||||||
|
switch (statusCode) {
|
||||||
|
case 400:
|
||||||
|
return 'Invalid Request. Please correct the data and try again.'
|
||||||
|
case 403:
|
||||||
|
return 'Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.'
|
||||||
|
case 429:
|
||||||
|
return 'Too many attempts. Please wait for a while and try again.'
|
||||||
|
default:
|
||||||
|
return 'Something went wrong talking to the server :(. Please try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
6
services/web/frontend/js/marketing.js
Normal file
6
services/web/frontend/js/marketing.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import './utils/webpack-public-path'
|
||||||
|
import 'jquery'
|
||||||
|
import 'bootstrap'
|
||||||
|
import './features/form-helpers/hydrate-form'
|
||||||
|
|
||||||
|
$('[data-ol-lang-selector-tooltip]').tooltip({ trigger: 'hover' })
|
30
services/web/frontend/js/pages/marketing/homepage.js
Normal file
30
services/web/frontend/js/pages/marketing/homepage.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import '../../marketing'
|
||||||
|
|
||||||
|
function realTimeEditsDemo() {
|
||||||
|
const frames = [
|
||||||
|
{ before: '', time: 1000 },
|
||||||
|
{ before: 'i', time: 100 },
|
||||||
|
{ before: 'in', time: 200 },
|
||||||
|
{ before: 'in ', time: 300 },
|
||||||
|
{ before: 'in r', time: 100 },
|
||||||
|
{ before: 'in re', time: 200 },
|
||||||
|
{ before: 'in rea', time: 100 },
|
||||||
|
{ before: 'in real', time: 200 },
|
||||||
|
{ before: 'in real ', time: 400 },
|
||||||
|
{ before: 'in real t', time: 200 },
|
||||||
|
{ before: 'in real ti', time: 100 },
|
||||||
|
{ before: 'in real tim', time: 200 },
|
||||||
|
{ before: 'in real time', time: 2000 },
|
||||||
|
]
|
||||||
|
let index = 0
|
||||||
|
function nextFrame() {
|
||||||
|
const frame = frames[index]
|
||||||
|
index = (index + 1) % frames.length
|
||||||
|
|
||||||
|
$('.real-time-example').html(frame.before + "<div class='cursor'>|</div>")
|
||||||
|
setTimeout(nextFrame, frame.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextFrame()
|
||||||
|
}
|
||||||
|
realTimeEditsDemo()
|
|
@ -325,7 +325,8 @@ input[type='checkbox'],
|
||||||
color: @red;
|
color: @red;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control.ng-dirty.ng-invalid:not(:focus) {
|
.form-control.ng-dirty.ng-invalid:not(:focus),
|
||||||
|
.form-control[data-ol-dirty]:invalid:not(:focus) {
|
||||||
border-color: @state-danger-text;
|
border-color: @state-danger-text;
|
||||||
.box-shadow(
|
.box-shadow(
|
||||||
inset 0 1px 1px rgba(0, 0, 0, 0.075)
|
inset 0 1px 1px rgba(0, 0, 0, 0.075)
|
||||||
|
|
|
@ -72,7 +72,7 @@ audio:not([controls]) {
|
||||||
|
|
||||||
[hidden],
|
[hidden],
|
||||||
template {
|
template {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Links
|
// Links
|
||||||
|
|
29
services/web/package-lock.json
generated
29
services/web/package-lock.json
generated
|
@ -14006,6 +14006,11 @@
|
||||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA=="
|
||||||
|
},
|
||||||
"bowser": {
|
"bowser": {
|
||||||
"version": "2.11.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
|
||||||
|
@ -17017,7 +17022,7 @@
|
||||||
"d64": {
|
"d64": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d64/-/d64-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d64/-/d64-1.0.0.tgz",
|
||||||
"integrity": "sha1-QAKofoUMv8n52XBrYPymE6MzbpA="
|
"integrity": "sha512-5eNy3WZziVYnrogqgXhcdEmqcDB2IHurTqLcrgssJsfkMVCUoUaZpK6cJjxxvLV2dUm5SuJMNcYfVGoin9UIRw=="
|
||||||
},
|
},
|
||||||
"damerau-levenshtein": {
|
"damerau-levenshtein": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
|
@ -19726,7 +19731,7 @@
|
||||||
"expressionify": {
|
"expressionify": {
|
||||||
"version": "0.9.3",
|
"version": "0.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/expressionify/-/expressionify-0.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/expressionify/-/expressionify-0.9.3.tgz",
|
||||||
"integrity": "sha1-/iJnx+hpRXfxP02oML/DyNgXf5I="
|
"integrity": "sha512-ZhmYFs8RPiRcXrDUNgABPNjtScZvShmKAKeWA6VP4c07eNCfz7B6WsAnBH+XLiDUXj8mFoX1i25pwQvuNW5PYg=="
|
||||||
},
|
},
|
||||||
"ext": {
|
"ext": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
|
@ -20873,7 +20878,7 @@
|
||||||
"functional-red-black-tree": {
|
"functional-red-black-tree": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
|
||||||
"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
|
"integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"functions-have-names": {
|
"functions-have-names": {
|
||||||
|
@ -25195,7 +25200,7 @@
|
||||||
"lodash.camelcase": {
|
"lodash.camelcase": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="
|
||||||
},
|
},
|
||||||
"lodash.debounce": {
|
"lodash.debounce": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
|
@ -25871,7 +25876,7 @@
|
||||||
"microtime-nodejs": {
|
"microtime-nodejs": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/microtime-nodejs/-/microtime-nodejs-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/microtime-nodejs/-/microtime-nodejs-1.0.0.tgz",
|
||||||
"integrity": "sha1-iFlASvLipGKhXJzWvyxORo2r2+g="
|
"integrity": "sha512-SthP/4JW6HUIZfgM0nadNtwKm/WMH0+z1i4RsPDnud+UasjoABzSkCk3eMhIRzipgwPhkdAYpTI69X4II4j1pA=="
|
||||||
},
|
},
|
||||||
"miller-rabin": {
|
"miller-rabin": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
|
@ -26149,7 +26154,7 @@
|
||||||
"mkdirp": {
|
"mkdirp": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||||
"integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==",
|
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||||
"requires": {
|
"requires": {
|
||||||
"minimist": "0.0.8"
|
"minimist": "0.0.8"
|
||||||
},
|
},
|
||||||
|
@ -26775,7 +26780,7 @@
|
||||||
"mv": {
|
"mv": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
|
||||||
"integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=",
|
"integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"mkdirp": "~0.5.1",
|
"mkdirp": "~0.5.1",
|
||||||
|
@ -26786,7 +26791,7 @@
|
||||||
"glob": {
|
"glob": {
|
||||||
"version": "6.0.4",
|
"version": "6.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
|
||||||
"integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=",
|
"integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"inflight": "^1.0.4",
|
"inflight": "^1.0.4",
|
||||||
|
@ -26799,7 +26804,7 @@
|
||||||
"rimraf": {
|
"rimraf": {
|
||||||
"version": "2.4.5",
|
"version": "2.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
|
||||||
"integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=",
|
"integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"glob": "^6.0.1"
|
"glob": "^6.0.1"
|
||||||
|
@ -26861,13 +26866,13 @@
|
||||||
"natural-compare": {
|
"natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"ncp": {
|
"ncp": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
|
||||||
"integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=",
|
"integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"needle": {
|
"needle": {
|
||||||
|
@ -36106,7 +36111,7 @@
|
||||||
"timed-out": {
|
"timed-out": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz",
|
||||||
"integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8="
|
"integrity": "sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA=="
|
||||||
},
|
},
|
||||||
"timekeeper": {
|
"timekeeper": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
|
|
@ -73,6 +73,7 @@
|
||||||
"basic-auth-connect": "^1.0.0",
|
"basic-auth-connect": "^1.0.0",
|
||||||
"bcrypt": "^5.0.0",
|
"bcrypt": "^5.0.0",
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
|
"bootstrap": "^3.4.1",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"bufferedstream": "1.6.0",
|
"bufferedstream": "1.6.0",
|
||||||
"bull": "^3.18.0",
|
"bull": "^3.18.0",
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
|
const glob = require('glob')
|
||||||
const webpack = require('webpack')
|
const webpack = require('webpack')
|
||||||
const CopyPlugin = require('copy-webpack-plugin')
|
const CopyPlugin = require('copy-webpack-plugin')
|
||||||
const WebpackAssetsManifest = require('webpack-assets-manifest')
|
const WebpackAssetsManifest = require('webpack-assets-manifest')
|
||||||
|
@ -15,6 +16,7 @@ const entryPoints = {
|
||||||
main: './frontend/js/main.js',
|
main: './frontend/js/main.js',
|
||||||
ide: './frontend/js/ide.js',
|
ide: './frontend/js/ide.js',
|
||||||
'cdn-load-test': './frontend/js/cdn-load-test.js',
|
'cdn-load-test': './frontend/js/cdn-load-test.js',
|
||||||
|
marketing: './frontend/js/marketing.js',
|
||||||
style: './frontend/stylesheets/style.less',
|
style: './frontend/stylesheets/style.less',
|
||||||
'ieee-style': './frontend/stylesheets/ieee-style.less',
|
'ieee-style': './frontend/stylesheets/ieee-style.less',
|
||||||
'light-style': './frontend/stylesheets/light-style.less',
|
'light-style': './frontend/stylesheets/light-style.less',
|
||||||
|
@ -31,6 +33,15 @@ if (fs.existsSync(MODULES_PATH)) {
|
||||||
}, entryPoints)
|
}, entryPoints)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
glob.sync(path.join(__dirname, 'frontend/js/pages/**/*.js')).forEach(page => {
|
||||||
|
// in: /workspace/services/web/frontend/js/pages/marketing/homepage.js
|
||||||
|
// out: pages/marketing/homepage
|
||||||
|
const name = path
|
||||||
|
.relative(path.join(__dirname, 'frontend/js/'), page)
|
||||||
|
.replace(/.js$/, '')
|
||||||
|
entryPoints[name] = './' + path.relative(__dirname, page)
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// Defines the "entry point(s)" for the application - i.e. the file which
|
// Defines the "entry point(s)" for the application - i.e. the file which
|
||||||
// bootstraps the application
|
// bootstraps the application
|
||||||
|
|
Loading…
Reference in a new issue