diff --git a/index.html b/index.html
index 4075cc7..3921fca 100644
--- a/index.html
+++ b/index.html
@@ -14,54 +14,13 @@
-
+
-
-
-
-
-
-
-
-
-
Percorsi
-
naturalistici
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
Percorsi
+
naturalistici
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Percorsi
+
tradizionalistici
+
+
+
+
+
+
+
+
+
+
+