Add router
All checks were successful
ci/woodpecker/push/build Pipeline was successful

This commit is contained in:
Federico Bologni 2023-06-29 20:28:12 +02:00
parent 8a0aa862c7
commit 3f3690b574
11 changed files with 645 additions and 163 deletions

View file

@ -14,54 +14,13 @@
<body> <body>
<header> <header></header>
<div id="welcome-message">Benvenuti a <span class="bold">Pianello Val Tidone</span></div>
</header>
<main> <main></main>
<div id="route-cards">
<div class="route-card">
<div class="route-card-left">
<img src="static/images/app-bar-logo.png">
</div>
<div class="route-card-center">
<div class="name">
<div>Percorsi</div>
<div class="bold">naturalistici</div>
</div>
</div>
<div class="route-card-right"></div>
</div>
<div class="route-card">
<div class="route-card-left">
<img src="static/images/app-bar-logo.png">
</div>
<div class="route-card-center">
<div class="name">
<div>Percorsi</div>
<div class="bold">storici</div>
</div>
</div>
<div class="route-card-right"></div>
</div>
<div class="route-card">
<div class="route-card-left">
<img src="static/images/app-bar-logo.png">
</div>
<div class="route-card-center">
<div class="name">
<div>Percorsi</div>
<div class="bold">tradizionalistici</div>
</div>
</div>
<div class="route-card-right"></div>
</div>
</div>
</main>
<footer> <footer></footer>
<bottom-app-bar></bottom-app-bar>
</footer> <!-- <wd-loader></wd-loader> -->
<script src="/src/components/bottom-app-bar.js" type="module"></script> <script src="/src/components/bottom-app-bar.js" type="module"></script>
<script src="/src/js/index.js" type="module"></script> <script src="/src/js/index.js" type="module"></script>

View file

@ -68,7 +68,7 @@ class BottomAppBar extends HTMLElement {
* Set all shadow dom selectors. * Set all shadow dom selectors.
*/ */
#setElements() { #setElements() {
this.selectors = {}; this.elements = {};
} }
#addEventListeners() {} #addEventListeners() {}

223
src/components/wd-loader.js Normal file
View file

@ -0,0 +1,223 @@
const template = `
<div class="background">
<div class="loader">
<svg viewBox="0 0 32 32" width="32" height="32">
<circle id="spinner" cx="16" cy="16" r="14" fill="none"></circle>
</svg>
</div>
<div id="message"></div>
</div>
`;
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 = `
<style>${style}</style>
${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;

42
src/css/app.css Normal file
View file

@ -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;
}

76
src/css/home-page.css Normal file
View file

@ -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;
}

View file

@ -1,116 +1 @@
@import './app.css';
: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;
}

35
src/js/app.js Normal file
View file

@ -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 = `
<meta charset="${app.meta.charset}">
<link rel="icon" href="${app.link.icon}">
<meta name="viewport" content="${app.meta.viewport}">
`;
document.title = app.title;
document.adoptedStyleSheets = [robotoFontStyle, appStyle]
.map((rawSheet) => {
const sheet = new CSSStyleSheet();
sheet.replaceSync(rawSheet);
return sheet;
});
},
};
export default app;

46
src/js/home-page.js Normal file
View file

@ -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();

View file

@ -1,7 +1,13 @@
import app from './app.js';
import homePageURL from '../pages/home-page.html?url';
app.init();
window.addEventListener('load', () => { window.addEventListener('load', () => {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
// Service worker build output path. // Service worker build output path.
navigator.serviceWorker.register('/service-worker.js'); navigator.serviceWorker.register('/service-worker.js');
} }
app.router.navigate(homePageURL);
}); });

158
src/js/router.js Normal file
View file

@ -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 its 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<number>} 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;

52
src/pages/home-page.html Normal file
View file

@ -0,0 +1,52 @@
<body>
<header>
<div id="welcome-message">Benvenuti a <span class="bold">Pianello Val Tidone</span></div>
</header>
<main>
<div id="route-cards">
<div class="route-card">
<div class="route-card-left">
<img src="../../static/images/app-bar-logo.png">
</div>
<div class="route-card-center">
<div class="name">
<div>Percorsi</div>
<div class="bold">naturalistici</div>
</div>
</div>
<div class="route-card-right"></div>
</div>
<div class="route-card">
<div class="route-card-left">
<img src="../../static/images/app-bar-logo.png">
</div>
<div class="route-card-center">
<div class="name">
<div>Percorsi</div>
<div class="bold">storici</div>
</div>
</div>
<div class="route-card-right"></div>
</div>
<div class="route-card">
<div class="route-card-left">
<img src="../../static/images/app-bar-logo.png">
</div>
<div class="route-card-center">
<div class="name">
<div>Percorsi</div>
<div class="bold">tradizionalistici</div>
</div>
</div>
<div class="route-card-right"></div>
</div>
</div>
</main>
<footer>
<bottom-app-bar></bottom-app-bar>
</footer>
<script id="page-script" src="../js/home-page.js" type="module"></script>
</body>