Compare commits
3 commits
5280d47c1d
...
3f3690b574
Author | SHA1 | Date | |
---|---|---|---|
3f3690b574 | |||
8a0aa862c7 | |||
0fc8c96045 |
14 changed files with 657 additions and 27 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
service-worker.build.js
|
||||
.env
|
||||
.env
|
||||
static/images/mockups
|
10
index.html
10
index.html
|
@ -8,15 +8,19 @@
|
|||
<link rel="manifest" href="/manifest.json">
|
||||
<title>Pianello</title>
|
||||
|
||||
<link rel="stylesheet" href="/src/css/roboto.css">
|
||||
<link rel="stylesheet" href="/src/css/index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header></header>
|
||||
|
||||
<footer>
|
||||
<bottom-app-bar></bottom-app-bar>
|
||||
</footer>
|
||||
<main></main>
|
||||
|
||||
<footer></footer>
|
||||
|
||||
<!-- <wd-loader></wd-loader> -->
|
||||
|
||||
<script src="/src/components/bottom-app-bar.js" type="module"></script>
|
||||
<script src="/src/js/index.js" type="module"></script>
|
||||
|
|
|
@ -16,10 +16,11 @@ const template = `
|
|||
const style = `
|
||||
:host {
|
||||
display: flex;
|
||||
height: 42px;
|
||||
width: 80%;
|
||||
height: 68px;
|
||||
width: 90%;
|
||||
border-radius: 90px;
|
||||
box-shadow: 0 0 50px #ccc;
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
:host > div {
|
||||
|
@ -67,7 +68,7 @@ class BottomAppBar extends HTMLElement {
|
|||
* Set all shadow dom selectors.
|
||||
*/
|
||||
#setElements() {
|
||||
this.selectors = {};
|
||||
this.elements = {};
|
||||
}
|
||||
|
||||
#addEventListeners() {}
|
||||
|
|
223
src/components/wd-loader.js
Normal file
223
src/components/wd-loader.js
Normal 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
42
src/css/app.css
Normal 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
76
src/css/home-page.css
Normal 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;
|
||||
}
|
|
@ -1,20 +1 @@
|
|||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: calc(42px + 20px + 20px);
|
||||
}
|
||||
@import './app.css';
|
||||
|
|
5
src/css/roboto.css
Normal file
5
src/css/roboto.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
|
||||
@font-face {
|
||||
font-family: 'Roboto-Regular';
|
||||
src: url('../../static/fonts/roboto/Roboto-Regular.ttf') format('TrueType');
|
||||
}
|
35
src/js/app.js
Normal file
35
src/js/app.js
Normal 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
46
src/js/home-page.js
Normal 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();
|
|
@ -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);
|
||||
});
|
||||
|
|
158
src/js/router.js
Normal file
158
src/js/router.js
Normal 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 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<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
52
src/pages/home-page.html
Normal 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>
|
BIN
static/images/test-1.jpg
Normal file
BIN
static/images/test-1.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 466 KiB |
Loading…
Reference in a new issue