From 3f3690b574adac9e9e79e892c5d15701b2f5fc5a Mon Sep 17 00:00:00 2001 From: Federico Bologni Date: Thu, 29 Jun 2023 20:28:12 +0200 Subject: [PATCH] Add router --- index.html | 51 +------ src/components/bottom-app-bar.js | 2 +- src/components/wd-loader.js | 223 +++++++++++++++++++++++++++++++ src/css/app.css | 42 ++++++ src/css/home-page.css | 76 +++++++++++ src/css/index.css | 117 +--------------- src/js/app.js | 35 +++++ src/js/home-page.js | 46 +++++++ src/js/index.js | 6 + src/js/router.js | 158 ++++++++++++++++++++++ src/pages/home-page.html | 52 +++++++ 11 files changed, 645 insertions(+), 163 deletions(-) create mode 100644 src/components/wd-loader.js create mode 100644 src/css/app.css create mode 100644 src/css/home-page.css create mode 100644 src/js/app.js create mode 100644 src/js/home-page.js create mode 100644 src/js/router.js create mode 100644 src/pages/home-page.html diff --git a/index.html b/index.html index 4075cc7..3921fca 100644 --- a/index.html +++ b/index.html @@ -14,54 +14,13 @@ -
-
Benvenuti a Pianello Val Tidone
-
+
-
-
-
-
- -
-
-
-
Percorsi
-
naturalistici
-
-
-
-
-
-
- -
-
-
-
Percorsi
-
storici
-
-
-
-
-
-
- -
-
-
-
Percorsi
-
tradizionalistici
-
-
-
-
-
-
+
- + + + diff --git a/src/components/bottom-app-bar.js b/src/components/bottom-app-bar.js index 221acaa..b1701eb 100644 --- a/src/components/bottom-app-bar.js +++ b/src/components/bottom-app-bar.js @@ -68,7 +68,7 @@ class BottomAppBar extends HTMLElement { * Set all shadow dom selectors. */ #setElements() { - this.selectors = {}; + this.elements = {}; } #addEventListeners() {} diff --git a/src/components/wd-loader.js b/src/components/wd-loader.js new file mode 100644 index 0000000..d801547 --- /dev/null +++ b/src/components/wd-loader.js @@ -0,0 +1,223 @@ +const template = ` +
+
+ + + +
+
+
+`; +const style = ` + * { + box-sizing: border-box; + } + + :host { + display: none; + } + + .background { + position: fixed; + top: 0px; + left: 0px; + width: 100%; + height: 100%; + opacity: 0; + background-color: rgba(0, 0, 0, 0.6); + z-index: 99999; + transition: opacity 0.333s cubic-bezier(0, 0, 0.21, 1); + } + + .loader { + left: 50%; + top: 50%; + position: fixed; + transform: translate(-50%, -50%); + z-index: -1; + } + + #spinner { + box-sizing: border-box; + stroke: var(--accent-color); + stroke-width: 3px; + transform-origin: 50%; + animation: line 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite, rotate 1.6s linear infinite; + } + + @keyframes rotate { + from { + -webkit-transform: rotate(0); + transform: rotate(0); + } + + to { + -webkit-transform: rotate(450deg); + transform: rotate(450deg); + } + } + + @keyframes line { + 0% { + stroke-dasharray: 2, 85.964; + -webkit-transform: rotate(0); + transform: rotate(0); + } + + 50% { + stroke-dasharray: 65.973, 21.9911; + stroke-dashoffset: 0; + } + + 100% { + stroke-dasharray: 2, 85.964; + stroke-dashoffset: -65.973; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); + } + } + + #message { + position: absolute; + height: 20%; + bottom: 0px; + width: 100%; + background-color: #212121; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + color: #eee; + place-items: center; + display: none; + padding: 6px; + } + + #message.show { + display: grid !important; + } + + :host(.contained) .background { + position: absolute; + width: 250px; + height: 250px; + border-radius: 8px; + } + + :host(.contained) .loader { + position: absolute; + } +`; + +/** + * Spinner-style loader ce. + */ +class WDLoader extends HTMLElement { + #elements = {}; + isVisible = false; + #counter = 0; + + /** + * @constructor + */ + constructor() { + super(); + + this.#createShadowDOM(); + this.#setElements(); + getComputedStyle(this.#elements.background).opacity; + } + + /** + * Set message. + * @param {String} value + */ + set message(value) { + if (value && value !== '') { + this.#elements.message.innerHTML = value; + this.#elements.message.classList.add('show'); + } else { + this.#elements.message.classList.remove('show'); + } + } + + /** + * @return {WDLoader} + */ + static get instance() { + let loader = document.querySelector('wd-loader'); + + if (loader) { + return loader; + } + + loader = new WDLoader(); + document.body.appendChild(loader); + return loader; + } + + /** + * Create shadow DOM. + */ + #createShadowDOM() { + this.attachShadow({mode: 'open'}); + this.shadowRoot.innerHTML = ` + + ${template} + `; + } + + /** + * Set all shadow dom selectors. + */ + #setElements() { + this.#elements = { + background: this.shadowRoot.querySelector('.background'), + message: this.shadowRoot.querySelector('#message'), + }; + } + + /** + * Show loader. + * @param {String} message + */ + show(message = '') { + this.#counter++; + this.style.display = 'block'; + this.isVisible = true; + this.#elements.background.style.opacity = 1; + this.message = message; + getComputedStyle(this.#elements.background).opacity; + } + + /** + * Hide loader. + */ + hide() { + this.#counter--; + + if (this.#counter > 0) { + return; + } + + const onLoaderTransitionEnd = (evt) => { + this.style.display = 'none'; + }; + + this.#elements.background.addEventListener( + 'transitionend', + onLoaderTransitionEnd.bind(this), + {once: true}); + + this.isVisible = false; + this.message = ''; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.#elements.background.style.opacity = 0; + }); + }); + } +} + +customElements.define('wd-loader', WDLoader); + +export default WDLoader; diff --git a/src/css/app.css b/src/css/app.css new file mode 100644 index 0000000..99af787 --- /dev/null +++ b/src/css/app.css @@ -0,0 +1,42 @@ + +:root { + --footer-height: calc(68px + 20px + 20px); + --accent-color: #213c8b; + --pianello-red: #de0e1b; + --pianello-yellow: #f6ae04; + --pianello-blue: #213c8b; + --card-background-color: #f9f4f1; +} + +* { + box-sizing: border-box; +} + +html { + width: 100%; + height: 100%; +} + +body { + width: 100%; + height: 100%; + margin: 0px; + background-color: #fff; + font-family: 'Roboto-Regular'; + display: grid; + place-items: center; + grid-template-rows: var(--footer-height) auto var(--footer-height); +} + +header, +main, +footer { + height: 100%; + width: 100%; + display: grid; + place-items: center; +} + +.bold { + font-weight: bold; +} diff --git a/src/css/home-page.css b/src/css/home-page.css new file mode 100644 index 0000000..369f7c5 --- /dev/null +++ b/src/css/home-page.css @@ -0,0 +1,76 @@ +/* @import './app.css'; */ + +#welcome-message { + font-size: 18px; +} + +#route-cards { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + align-items: center; +} + +.route-card { + flex: 1; + --route-card-radius: 45px; + border-radius: var(--route-card-radius); + box-shadow: 0 0 27px #ccc; + margin-top: 10px; + margin-bottom: 10px; + width: 90%; + display: flex; + background-color: var(--card-background-color); +} + +.route-card > * { + flex: 1; +} + +.route-card-left { + flex: 0 1 20%; + border-top-left-radius: var(--route-card-radius); + border-bottom-left-radius: var(--route-card-radius); + display: grid; + place-items: center; +} + +.route-card-left img { + display: block; + width: 80%; + height: auto; +} + +.route-card-right { + flex: 0 1 20%; + border-top-right-radius: var(--route-card-radius); + border-bottom-right-radius: var(--route-card-radius); + background-image: url('../../static/images/test-1.jpg'); + background-position: center; + background-size: cover; +} + +#route-cards .route-card:nth-child(1) .route-card-left { + background-color: var(--pianello-red); +} + +#route-cards .route-card:nth-child(2) .route-card-left { + background-color: var(--pianello-yellow); +} + +#route-cards .route-card:nth-child(3) .route-card-left { + background-color: var(--pianello-blue); +} + +.route-card .name { + display: grid; + height: 100%; + align-content: center; + padding: 20px; + font-size: 20px; +} + +.route-card .name > * { + flex: 1; +} diff --git a/src/css/index.css b/src/css/index.css index 20a882c..8138f6f 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -1,116 +1 @@ - -:root { - --footer-height: calc(68px + 20px + 20px); - --pianello-red: #de0e1b; - --pianello-yellow: #f6ae04; - --pianello-blue: #213c8b; - --card-background-color: #f9f4f1; -} - -* { - box-sizing: border-box; -} - -html { - width: 100%; - height: 100%; -} - -body { - width: 100%; - height: 100%; - margin: 0px; - background-color: #fff; - font-family: 'Roboto-Regular'; - display: grid; - place-items: center; - grid-template-rows: var(--footer-height) auto var(--footer-height); -} - -header, -main, -footer { - height: 100%; - width: 100%; - display: grid; - place-items: center; -} - -.bold { - font-weight: bold; -} - -#welcome-message { - font-size: 18px; -} - -#route-cards { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - align-items: center; -} - -.route-card { - flex: 1; - --route-card-radius: 45px; - border-radius: var(--route-card-radius); - box-shadow: 0 0 27px #ccc; - margin-top: 10px; - margin-bottom: 10px; - width: 90%; - display: flex; - background-color: var(--card-background-color); -} - -.route-card > * { - flex: 1; -} - -.route-card-left { - flex: 0 1 20%; - border-top-left-radius: var(--route-card-radius); - border-bottom-left-radius: var(--route-card-radius); - display: grid; - place-items: center; -} - -.route-card-left img { - display: block; - width: 80%; - height: auto; -} - -.route-card-right { - flex: 0 1 20%; - border-top-right-radius: var(--route-card-radius); - border-bottom-right-radius: var(--route-card-radius); - background-image: url('../../static/images/test-1.jpg'); - background-position: center; - background-size: cover; -} - -#route-cards .route-card:nth-child(1) .route-card-left { - background-color: var(--pianello-red); -} - -#route-cards .route-card:nth-child(2) .route-card-left { - background-color: var(--pianello-yellow); -} - -#route-cards .route-card:nth-child(3) .route-card-left { - background-color: var(--pianello-blue); -} - -.route-card .name { - display: grid; - height: 100%; - align-content: center; - padding: 20px; - font-size: 20px; -} - -.route-card .name > * { - flex: 1; -} +@import './app.css'; diff --git a/src/js/app.js b/src/js/app.js new file mode 100644 index 0000000..9b57d1c --- /dev/null +++ b/src/js/app.js @@ -0,0 +1,35 @@ +import Router from './router.js'; +import WDLoader from '../components/wd-loader.js'; +import robotoFontStyle from '../css/roboto.css?raw'; +import appStyle from '../css/app.css?raw'; +const appIconURL = new URL('../../static/images/home-icon.png', import.meta.url).href; + +const app = { + title: 'Pianello', + meta: { + charset: 'utf-8', + viewport: 'width=device-width', + }, + link: { + icon: appIconURL, + }, + page: null, + router: new Router(), + loader: WDLoader.instance, + init: () => { + document.head.innerHTML = ` + + + + `; + document.title = app.title; + document.adoptedStyleSheets = [robotoFontStyle, appStyle] + .map((rawSheet) => { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(rawSheet); + return sheet; + }); + }, +}; + +export default app; diff --git a/src/js/home-page.js b/src/js/home-page.js new file mode 100644 index 0000000..1d01f85 --- /dev/null +++ b/src/js/home-page.js @@ -0,0 +1,46 @@ +import app from './app.js'; +import pageStyle from '../css/home-page.css?raw'; +import BottomAppBar from '../components/bottom-app-bar.js'; + +class HomePage { + /** + * An object containing useful HTMLElement references. + * @type {Object} + */ + #elements = {}; + + constructor() { + this.#addPageStyle(); + this.#setElements(); + this.#addEventListeners(); + } + + /** + * @return {Object} + */ + get elements() { + return this.#elements; + } + + #addPageStyle() { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(pageStyle); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; + } + + /** + * Init `#elements` field that holds element references. + */ + #setElements() { + this.#elements = {}; + } + + /** + */ + #addEventListeners() {} +} + +export default HomePage; + +app.init(); +app.page = new HomePage(); diff --git a/src/js/index.js b/src/js/index.js index 18ad56c..72d6a75 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -1,7 +1,13 @@ +import app from './app.js'; +import homePageURL from '../pages/home-page.html?url'; + +app.init(); window.addEventListener('load', () => { if ('serviceWorker' in navigator) { // Service worker build output path. navigator.serviceWorker.register('/service-worker.js'); } + + app.router.navigate(homePageURL); }); diff --git a/src/js/router.js b/src/js/router.js new file mode 100644 index 0000000..6939d5e --- /dev/null +++ b/src/js/router.js @@ -0,0 +1,158 @@ +import app from './app.js'; + +/** + * Router + */ +class Router { + /** + * @constructor + */ + constructor() { + this.host = location.host; + this.previousURL = null; + + this.initListeners(); + } + + /** + * Add all event listeners handles. + */ + initListeners() { + document.addEventListener('click', this.onLinkClick.bind(this)); + window.addEventListener('popstate', this.onPopState.bind(this)); + } + + /** + * window popstate event handler. + * @param {Event} event + */ + onPopState(event) { + this.navigate(location.href, event.state.scrollTop, false); + } + + /** + * HTMLAnchorElement click event handler. + * @param {Event} event + */ + async onLinkClick(event) { + const anchor = event.target.closest('a'); + if (!anchor) { + return; + } + + if (anchor.dataset.hasOwnProperty('external')) { + return; + } + + const {href} = anchor; + const link = new URL(href); + + // If it’s an external link, just navigate. + if (link.host !== this.host) { + return; + } + + event.preventDefault(); + this.navigate(link.toString()) + .catch((error) => console.error(error)); + } + + /** + * Handle the navigation. + * @param {string} link + * @param {number} scrollTop + * @param {boolean} pushState + * @return {Promise} Fetch fragment status + */ + async navigate(link, scrollTop = 0, pushState = true) { + if (!link) { + return; + } + + // Save current route for history pop event. + this.previousURL = location.href; + // Manually handle the scroll restoration. + history.scrollRestoration = 'manual'; + + if (pushState) { + history.replaceState({ + scrollTop: document.scrollingElement.scrollTop, + }, ''); + history.pushState({scrollTop}, '', link); + } + + const linkUrl = new URL((link.startsWith('http')) ? + link : + `${location.origin}${link}`); + + // Fetch new page and switch. + const oldHeader = document.querySelector('header'); + const oldMain = document.querySelector('main'); + const oldFooter = document.querySelector('footer'); + const { + status, + header, + main, + footer, + script: newPageScript, + } = await this.fetchFragment(link); + + if (status === 200) { + // Replace elements. + oldHeader?.parentNode?.replaceChild(header, oldHeader); + oldMain?.parentNode?.replaceChild(main, oldMain); + oldFooter?.parentNode?.replaceChild(footer, oldFooter); + const newScriptFilename = newPageScript?.src.split('/').slice(-1)[0]; + app.loader.show(); + try { + await app.page?.clear?.(); + // String manipulation from static analysis. + const script = await import( + `../js/${newScriptFilename.replace('.js', '')}.js`); + // Enable current page re-navigation and avoid double page init. + // Check only on url.origin+url.pathname to allow + // re-navigation on same page with different url parameters. + const prevLinkUrl = new URL(this.previousURL); + if (!(app.page instanceof script.default) || + (linkUrl.origin + linkUrl.pathname === + prevLinkUrl.origin + prevLinkUrl.pathname)) { + // eslint-disable-next-line new-cap + app.page = new script.default(); + } + } catch (ex) { + console.error(ex); + } finally { + app.loader.hide(); + } + + document.scrollingElement.scrollTop = scrollTop; + } + + // Set to auto in case user hits page refresh. + history.scrollRestoration = 'auto'; + return status; + } + + /** + * Fetch page and get main element; + * @param {string} link + * @return {*} + */ + async fetchFragment(link) { + const url = (link.startsWith('http')) ? link : `${location.origin}${link}`; + link = (new URL(url)).pathname; + + const response = await fetch(link); + const template = document.createElement('template'); + template.innerHTML = await response.text(); + return { + status: response.status, + header: template.content.querySelector('header')?.cloneNode(true), + main: template.content.querySelector('main')?.cloneNode(true), + footer: template.content.querySelector('footer')?.cloneNode(true), + script: template.content.querySelector('#page-script')?.cloneNode(true), + }; + } +} + +export default Router; diff --git a/src/pages/home-page.html b/src/pages/home-page.html new file mode 100644 index 0000000..a70abcf --- /dev/null +++ b/src/pages/home-page.html @@ -0,0 +1,52 @@ + +
+
Benvenuti a Pianello Val Tidone
+
+ +
+
+
+
+ +
+
+
+
Percorsi
+
naturalistici
+
+
+
+
+
+
+ +
+
+
+
Percorsi
+
storici
+
+
+
+
+
+
+ +
+
+
+
Percorsi
+
tradizionalistici
+
+
+
+
+
+
+ +
+ +
+ + +