diff --git a/services/web/app/views/layout-marketing.pug b/services/web/app/views/layout-marketing.pug new file mode 100644 index 0000000000..34300b625d --- /dev/null +++ b/services/web/app/views/layout-marketing.pug @@ -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) diff --git a/services/web/app/views/layout/footer-marketing.pug b/services/web/app/views/layout/footer-marketing.pug new file mode 100644 index 0000000000..5d785c7680 --- /dev/null +++ b/services/web/app/views/layout/footer-marketing.pug @@ -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} diff --git a/services/web/app/views/layout/footer.pug b/services/web/app/views/layout/footer.pug index 518e3e1caf..79d9529a7f 100644 --- a/services/web/app/views/layout/footer.pug +++ b/services/web/app/views/layout/footer.pug @@ -4,7 +4,6 @@ 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(dropdown) a.dropdown-toggle( @@ -18,7 +17,7 @@ footer.site-footer ) 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")} each subdomainDetails, subdomain in settings.i18n.subdomainLang if !subdomainDetails.hide @@ -26,7 +25,7 @@ footer.site-footer 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 @@ -35,9 +34,8 @@ footer.site-footer | !{item.text} ul.col-md-3.text-right - each item in nav.right_footer - li(ng-non-bindable) + li if item.url a(href=item.url, class=item.class, aria-label=item.label) !{item.text} else diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug new file mode 100644 index 0000000000..cdeef726f9 --- /dev/null +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -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')} diff --git a/services/web/frontend/js/features/form-helpers/captcha.js b/services/web/frontend/js/features/form-helpers/captcha.js new file mode 100644 index 0000000000..ed7f362268 --- /dev/null +++ b/services/web/frontend/js/features/form-helpers/captcha.js @@ -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) + }) +} diff --git a/services/web/frontend/js/features/form-helpers/hydrate-form.js b/services/web/frontend/js/features/form-helpers/hydrate-form.js new file mode 100644 index 0000000000..f0174d26c2 --- /dev/null +++ b/services/web/frontend/js/features/form-helpers/hydrate-form.js @@ -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)) diff --git a/services/web/frontend/js/features/form-helpers/input-validator.js b/services/web/frontend/js/features/form-helpers/input-validator.js new file mode 100644 index 0000000000..1b119bce90 --- /dev/null +++ b/services/web/frontend/js/features/form-helpers/input-validator.js @@ -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' + } +} diff --git a/services/web/frontend/js/infrastructure/fetch-json.js b/services/web/frontend/js/infrastructure/fetch-json.js index cf297d8850..cb4527cd26 100644 --- a/services/web/frontend/js/infrastructure/fetch-json.js +++ b/services/web/frontend/js/infrastructure/fetch-json.js @@ -57,6 +57,8 @@ function getErrorMessageForStatusCode(statusCode) { return 'Forbidden' case 404: return 'Not Found' + case 429: + return 'Too Many Requests' case 500: return 'Internal Server Error' case 502: @@ -90,6 +92,27 @@ export class FetchError extends OError { this.response = response 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.' + } + } } /** diff --git a/services/web/frontend/js/marketing.js b/services/web/frontend/js/marketing.js new file mode 100644 index 0000000000..8ff6884920 --- /dev/null +++ b/services/web/frontend/js/marketing.js @@ -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' }) diff --git a/services/web/frontend/js/pages/marketing/homepage.js b/services/web/frontend/js/pages/marketing/homepage.js new file mode 100644 index 0000000000..7df570be7a --- /dev/null +++ b/services/web/frontend/js/pages/marketing/homepage.js @@ -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 + "
|
") + setTimeout(nextFrame, frame.time) + } + + nextFrame() +} +realTimeEditsDemo() diff --git a/services/web/frontend/stylesheets/components/forms.less b/services/web/frontend/stylesheets/components/forms.less index 7ce87d8b7f..07cf7767ef 100755 --- a/services/web/frontend/stylesheets/components/forms.less +++ b/services/web/frontend/stylesheets/components/forms.less @@ -325,7 +325,8 @@ input[type='checkbox'], 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; .box-shadow( inset 0 1px 1px rgba(0, 0, 0, 0.075) diff --git a/services/web/frontend/stylesheets/core/normalize.less b/services/web/frontend/stylesheets/core/normalize.less index b2489b658f..1fa99bd7b9 100755 --- a/services/web/frontend/stylesheets/core/normalize.less +++ b/services/web/frontend/stylesheets/core/normalize.less @@ -72,7 +72,7 @@ audio:not([controls]) { [hidden], template { - display: none; + display: none !important; } // Links diff --git a/services/web/package-lock.json b/services/web/package-lock.json index b025d8bbaf..2510374c4d 100644 --- a/services/web/package-lock.json +++ b/services/web/package-lock.json @@ -14006,6 +14006,11 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "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": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -17017,7 +17022,7 @@ "d64": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/d64/-/d64-1.0.0.tgz", - "integrity": "sha1-QAKofoUMv8n52XBrYPymE6MzbpA=" + "integrity": "sha512-5eNy3WZziVYnrogqgXhcdEmqcDB2IHurTqLcrgssJsfkMVCUoUaZpK6cJjxxvLV2dUm5SuJMNcYfVGoin9UIRw==" }, "damerau-levenshtein": { "version": "1.0.6", @@ -19726,7 +19731,7 @@ "expressionify": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/expressionify/-/expressionify-0.9.3.tgz", - "integrity": "sha1-/iJnx+hpRXfxP02oML/DyNgXf5I=" + "integrity": "sha512-ZhmYFs8RPiRcXrDUNgABPNjtScZvShmKAKeWA6VP4c07eNCfz7B6WsAnBH+XLiDUXj8mFoX1i25pwQvuNW5PYg==" }, "ext": { "version": "1.4.0", @@ -20873,7 +20878,7 @@ "functional-red-black-tree": { "version": "1.0.1", "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 }, "functions-have-names": { @@ -25195,7 +25200,7 @@ "lodash.camelcase": { "version": "4.3.0", "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": { "version": "4.0.8", @@ -25871,7 +25876,7 @@ "microtime-nodejs": { "version": "1.0.0", "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": { "version": "4.0.1", @@ -26149,7 +26154,7 @@ "mkdirp": { "version": "0.5.1", "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": { "minimist": "0.0.8" }, @@ -26775,7 +26780,7 @@ "mv": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", - "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=", + "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", "optional": true, "requires": { "mkdirp": "~0.5.1", @@ -26786,7 +26791,7 @@ "glob": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", - "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", "optional": true, "requires": { "inflight": "^1.0.4", @@ -26799,7 +26804,7 @@ "rimraf": { "version": "2.4.5", "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, "requires": { "glob": "^6.0.1" @@ -26861,13 +26866,13 @@ "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, "ncp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", - "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", "optional": true }, "needle": { @@ -36106,7 +36111,7 @@ "timed-out": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz", - "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=" + "integrity": "sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==" }, "timekeeper": { "version": "2.2.0", diff --git a/services/web/package.json b/services/web/package.json index c09f5caa8c..15e54815c8 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -73,6 +73,7 @@ "basic-auth-connect": "^1.0.0", "bcrypt": "^5.0.0", "body-parser": "^1.19.0", + "bootstrap": "^3.4.1", "bowser": "^2.11.0", "bufferedstream": "1.6.0", "bull": "^3.18.0", diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js index 8bff72e0ab..c337acb72d 100644 --- a/services/web/webpack.config.js +++ b/services/web/webpack.config.js @@ -1,5 +1,6 @@ const fs = require('fs') const path = require('path') +const glob = require('glob') const webpack = require('webpack') const CopyPlugin = require('copy-webpack-plugin') const WebpackAssetsManifest = require('webpack-assets-manifest') @@ -15,6 +16,7 @@ const entryPoints = { main: './frontend/js/main.js', ide: './frontend/js/ide.js', 'cdn-load-test': './frontend/js/cdn-load-test.js', + marketing: './frontend/js/marketing.js', style: './frontend/stylesheets/style.less', 'ieee-style': './frontend/stylesheets/ieee-style.less', 'light-style': './frontend/stylesheets/light-style.less', @@ -31,6 +33,15 @@ if (fs.existsSync(MODULES_PATH)) { }, 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 = { // Defines the "entry point(s)" for the application - i.e. the file which // bootstraps the application