diff --git a/src/js/ajax/apollo/cache.js b/src/js/ajax/apollo/cache.js
new file mode 100644
index 0000000..29b1068
--- /dev/null
+++ b/src/js/ajax/apollo/cache.js
@@ -0,0 +1,26 @@
+import {
+ InMemoryCache,
+} from '@apollo/client';
+
+//import { IonicStorageModule } from '@ionic/storage';
+//import { persistCache, IonicStorageWrapper } from 'apollo3-cache-persist';
+import {
+ persistCacheSync,
+ LocalStorageWrapper,
+} from 'apollo3-cache-persist';
+
+const cache = new InMemoryCache();
+
+// await before instantiating ApolloClient, else queries might run before the cache is persisted
+//await persistCache({
+persistCacheSync({
+ cache,
+ storage: new LocalStorageWrapper(window.localStorage),
+ key: 'web-persist',
+ maxSize: 1048576, // 1Mb
+ //new IonicStorageWrapper(IonicStorageModule),
+});
+
+export {
+ cache,
+};
diff --git a/src/js/ajax/apollo/init.js b/src/js/ajax/apollo/init.js
new file mode 100644
index 0000000..496e123
--- /dev/null
+++ b/src/js/ajax/apollo/init.js
@@ -0,0 +1,122 @@
+import Events from '../../_events';
+
+import {
+ cache,
+} from './cache';
+import {
+ from,
+ ApolloClient,
+ HttpLink,
+ ApolloLink,
+ concat,
+} from '@apollo/client';
+
+import {
+ onError,
+} from '@apollo/client/link/error';
+const NAME = 'appolo';
+
+const API_META = document.querySelector('meta[name="api_url"]');
+const API_URL = API_META ?
+ API_META.getAttribute('content') :
+ `${window.location.protocol }//${ window.location.host }/graphql`;
+
+const authMiddleware = new ApolloLink((operation, forward) => {
+ // add the authorization to the headers
+ operation.setContext({
+ headers: {
+ apikey: `${GRAPHQL_API_KEY}`,
+ },
+ });
+
+ return forward(operation);
+});
+
+console.info(`%cAPI: ${API_URL}`, 'color:green;font-size:10px');
+
+const link = from([
+ authMiddleware,
+ new ApolloLink((operation, forward) => {
+ operation.setContext({
+ start: new Date(),
+ });
+ return forward(operation);
+ }),
+ onError(({
+ operation,
+ response,
+ graphQLErrors,
+ networkError,
+ forward,
+ }) => {
+ if (operation.operationName === 'IgnoreErrorsQuery') {
+ console.error(`${NAME}: IgnoreErrorsQuery`);
+ response.errors = null;
+ return;
+ }
+
+ if (graphQLErrors) {
+ graphQLErrors.forEach(({
+ message,
+ locations,
+ path,
+ }) =>
+ console.error(
+ `${NAME}: [GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`,
+ ),
+ );
+ }
+
+ if (networkError) {
+ /*let msg = '';
+ switch (networkError.statusCode) {
+ case 404:
+ msg = 'Not Found.';
+ break;
+ case 500:
+ msg = 'Server issue, please try again latter.';
+ break;
+ default:
+ msg = 'Something went wrong.';
+ break;
+ }*/
+ console.error(`${NAME}: [Network error] ${networkError.statusCode}`);
+ }
+
+ console.error(`${NAME}: [APOLLO_ERROR]`);
+ window.dispatchEvent(new Event(Events.APOLLO_ERROR));
+ }),
+ new ApolloLink((operation, forward) => {
+ return forward(operation).map((data) => {
+ // data from a previous link
+ const time = new Date() - operation.getContext().start;
+ console.log(
+ `${NAME}: operation ${operation.operationName} took ${time} ms to complete`,
+ );
+
+ window.dispatchEvent(new Event(Events.ONLINE));
+ return data;
+ });
+ }),
+ new HttpLink({
+ uri: API_URL,
+
+ // Use explicit `window.fetch` so tha outgoing requests
+ // are captured and deferred until the Service Worker is ready.
+ fetch: (...args) => fetch(...args),
+ credentials: 'same-origin', //'include',
+ connectToDevTools: process.env.NODE_ENV === 'development' ? true : false,
+ }),
+]);
+
+// Isolate Apollo client so it could be reused
+// in both application runtime and tests.
+
+const client = new ApolloClient({
+ cache,
+ link,
+});
+
+export {
+ client,
+};
diff --git a/src/js/ajax/lazy-images.js b/src/js/ajax/lazy-images.js
new file mode 100644
index 0000000..1b3b153
--- /dev/null
+++ b/src/js/ajax/lazy-images.js
@@ -0,0 +1,79 @@
+// browser tab visibility state detection
+
+import Events from '../_events';
+import Consts from '../_consts';
+
+const axios = require('axios');
+
+export default ((W) => {
+ const NAME = 'main.lazy-images';
+ const D = document;
+ const BODY = D.body;
+
+ const API_STATIC = document.querySelector('meta[name="api_static_domain"]');
+ const API_STATIC_URL = API_STATIC ?
+ API_STATIC.getAttribute('content') :
+ `${window.location.protocol}//${window.location.host}`;
+
+ console.log(`${NAME} [static url]: ${API_STATIC_URL}`);
+
+ const loadLazyImages = () => {
+ console.log(`${NAME}: Load lazy images`);
+
+ D.querySelectorAll(`[data-lazy-src]`).forEach((el) => {
+ el.classList.remove('empty');
+ el.classList.add('loading');
+ el.classList.remove('loading__network-error');
+
+ const attr = el.getAttribute('data-lazy-src');
+ const imageUrl = attr.startsWith('http') ? attr : API_STATIC_URL + attr;
+
+ // offline response will be served by caching service worker
+ axios
+ .get(imageUrl, {
+ responseType: 'blob',
+ })
+ .then((response) => {
+ const reader = new FileReader(); // https://developer.mozilla.org/en-US/docs/Web/API/FileReader/FileReader
+ reader.readAsDataURL(response.data);
+ reader.onload = () => {
+ const imageDataUrl = reader.result;
+ el.setAttribute('src', imageDataUrl);
+ el.classList.remove('loading');
+ el.classList.add('loading__success');
+ };
+ })
+ .catch((e) => {
+ //el.setAttribute('src', imageUrl);
+
+ if (e.response) {
+ switch (e.response.status) {
+ case 404:
+ msg = 'Not Found.';
+ break;
+ case 500:
+ msg = 'Server issue, please try again latter.';
+ break;
+ default:
+ msg = 'Something went wrong.';
+ break;
+ }
+
+ console.error(`${NAME} [${imageUrl}]: ${msg}`);
+ } else if (e.request) {
+ msg = 'No response received';
+ console.error(`${NAME} [${imageUrl}]: ${msg}`);
+ } else {
+ console.error(`${NAME} [${imageUrl}]: ${e.message}`);
+ }
+
+ el.classList.remove('loading');
+ el.classList.add('loading__network-error');
+ el.classList.add('empty');
+ });
+ });
+ };
+
+ W.addEventListener(`${Events.LODEDANDREADY}`, loadLazyImages);
+ W.addEventListener(`${Events.AJAX}`, loadLazyImages);
+})(window);
diff --git a/src/js/ajax/links.js b/src/js/ajax/links.js
new file mode 100644
index 0000000..50e34c3
--- /dev/null
+++ b/src/js/ajax/links.js
@@ -0,0 +1,227 @@
+// browser tab visibility state detection
+
+import Events from '../_events';
+import Consts from '../_consts';
+import Page from './models/page.jsx';
+
+import {
+ getParents,
+} from '../main/funcs';
+
+import {
+ Collapse,
+} from 'bootstrap';
+
+import SpinnerUI from '../main/loading-spinner';
+
+const MainUILinks = ((W) => {
+ const NAME = 'main.links';
+ const D = document;
+ const BODY = D.body;
+
+ class MainUILinks {
+ window
+ static init() {
+ const ui = this;
+ ui.GraphPage = null;
+
+ console.log(`${NAME}: init`);
+
+ ui.loaded();
+
+ // history state switch
+ W.addEventListener('popstate', (e) => {
+ ui.popState(e);
+ });
+ }
+
+ static loaded() {
+ const ui = this;
+
+ D.querySelectorAll('.graphql-page').forEach((el, i) => {
+ const el_id = el.getAttribute('href');
+ el.setAttribute(`data-${ui.name}-id`, el_id);
+
+ el.addEventListener('click', ui.loadClick);
+ });
+ }
+
+ static setActiveLinks(link) {
+ const ui = this;
+ D.querySelectorAll(`[data-${ui.name}-id="${link}"]`).forEach(
+ (el) => {
+ el.classList.add('active');
+ },
+ );
+ }
+
+ static reset() {
+ // reset focus
+ D.activeElement.blur();
+
+ // remove active and loading classes
+ D.querySelectorAll('.graphql-page,.nav-item').forEach((el2) => {
+ el2.classList.remove('active', 'loading');
+ });
+ }
+
+ static popState(e) {
+ const ui = this;
+
+ SpinnerUI.show();
+
+ if (e.state && e.state.page) {
+ console.log(`${NAME}: [popstate] load`);
+ const state = JSON.parse(e.state.page);
+
+ state.current = null;
+ state.popstate = true;
+
+ ui.reset();
+ ui.setActiveLinks(e.state.link);
+
+ if (!ui.GraphPage) {
+ console.log(
+ `${NAME}: [popstate] GraphPage is missing. Have to render it first`,
+ );
+
+ ui.GraphPage = ReactDOM.render(
+ ,
+ document.getElementById('MainContent'),
+ );
+ }
+
+ ui.GraphPage.setState(state);
+ SpinnerUI.hide();
+
+ window.dispatchEvent(new Event(Events.AJAX));
+ } else if (e.state && e.state.landing) {
+ console.log(`${NAME}: [popstate] go to landing`);
+ W.location.href = e.state.landing;
+ } else {
+ console.warn(`${NAME}: [popstate] state is missing`);
+ console.log(e);
+ SpinnerUI.hide();
+ }
+ }
+
+ // link specific event {this} = current event, not MainUILinks
+ static loadClick(e) {
+ console.groupCollapsed(`${NAME}: load on click`);
+ e.preventDefault();
+
+ const ui = MainUILinks;
+ const el = e.currentTarget;
+
+ SpinnerUI.show();
+
+ ui.reset();
+ el.classList.add('loading');
+ el.classList.remove('response-404', 'response-500', 'response-523');
+ BODY.classList.add('ajax-loading');
+
+ // hide parent mobile nav
+ const navs = getParents(el, '.collapse');
+ if (navs.length) {
+ navs.forEach((nav) => {
+ const collapseInst = Collapse.getInstance(nav);
+ if (collapseInst) {
+ collapseInst.hide();
+ }
+ });
+ }
+
+ // hide parent dropdown
+ /*const dropdowns = getParents(el, '.dropdown-menu');
+ if (dropdowns.length) {
+ const DropdownInst = Dropdown.getInstance(dropdowns[0]);
+ DropdownInst.hide();
+ }*/
+
+ if (!ui.GraphPage) {
+ ui.GraphPage = ReactDOM.render(
+ ,
+ document.getElementById('MainContent'),
+ );
+ }
+
+ const link = el.getAttribute('href') || el.getAttribute('data-href');
+
+
+ ui.GraphPage.state.current = el;
+
+ ui.GraphPage.load(link)
+ .then((response) => {
+ BODY.classList.remove('ajax-loading');
+ el.classList.remove('loading');
+ el.classList.add('active');
+
+ D.loading_apollo_link = null;
+
+ if (ui.GraphPage.state.Link) {
+ window.history.pushState({
+ page: JSON.stringify(ui.GraphPage.state),
+ link: el.getAttribute(`data-${ui.name}-id`),
+ },
+ ui.GraphPage.state.Title,
+ ui.GraphPage.state.Link,
+ );
+
+ ui.setActiveLinks(ui.GraphPage.state.Link)
+ }
+
+ SpinnerUI.hide();
+
+ window.dispatchEvent(new Event(Events.AJAX));
+ console.groupEnd(`${NAME}: load on click`);
+ })
+ .catch((e) => {
+ console.error(`${NAME}: loading error`);
+ console.log(e);
+
+ /*BODY.classList.remove('ajax-loading');
+ el.classList.remove('loading');*/
+ el.classList.add('error', `response-${e.status}`);
+ /*switch (e.status) {
+ case 500:
+ break;
+ case 404:
+ el.classList.add('not-found');
+ break;
+ case 523:
+ el.classList.add('unreachable');
+ break;
+ }*/
+
+ //SpinnerUI.hide();
+
+ //window.dispatchEvent(new Event(Events.AJAX));
+ console.groupEnd(`${NAME}: load on click`);
+
+ console.log(`${NAME}: reloading page ${link}`);
+
+ // fallback loading
+ W.location.href = link;
+ });
+ }
+ }
+
+ W.addEventListener(`${Events.LOADED}`, () => {
+ MainUILinks.init();
+ });
+
+ W.addEventListener(`${Events.AJAX}`, () => {
+ MainUILinks.loaded();
+ });
+
+ // fallback
+ /*W.addEventListener(`${Events.APOLLO_ERROR}`, (e) => {
+ console.error(`${NAME}: [APOLLO_ERROR] loading failure, reloading the page`);
+ //W.dispatchEvent(new Event(Events.OFFLINE));
+ if (D.loading_apollo_link) {
+ W.location.href = D.loading_apollo_link;
+ }
+ });*/
+})(window);
+
+export default MainUILinks;
diff --git a/src/js/ajax/models/element.jsx b/src/js/ajax/models/element.jsx
new file mode 100644
index 0000000..638f006
--- /dev/null
+++ b/src/js/ajax/models/element.jsx
@@ -0,0 +1,229 @@
+/*
+ * Lightbox window
+ */
+import {
+ Component
+} from 'react';
+import Events from '../../events';
+
+import {
+ client
+} from '../apollo/init';
+import {
+ gql
+} from '@apollo/client';
+
+class Page extends Component {
+ state = {
+ type: [],
+ shown: false,
+ loading: false,
+ error: false,
+ current: null,
+ ID: null,
+ URLSegment: null,
+ ClassName: 'Page',
+ CSSClass: null,
+ Title: null,
+ Summary: null,
+ Link: null,
+ URL: null,
+ Elements: [],
+ page: null,
+ };
+
+ componentDidUpdate() {
+ const ui = this;
+
+ if (ui.state.Title) {
+ document.title = ui.state.Title;
+
+ if (ui.state.URL) {
+ window.history.pushState(
+ {
+ page: JSON.stringify(ui.state)
+ },
+ ui.state.Title,
+ ui.state.URL,
+ );
+ }
+ }
+
+ if (ui.state.Elements.length) {
+ window.dispatchEvent(new Event(Events.AJAX));
+ }
+ }
+
+ constructor(props) {
+ super(props);
+
+ const ui = this;
+ ui.name = ui.constructor.name;
+ console.log(`${ui.name}: init`);
+ }
+
+ reset = () => {
+ const ui = this;
+
+ ui.setState({
+ type: [],
+ shown: false,
+ loading: false,
+ error: false,
+ ID: null,
+ Title: null,
+ URL: null,
+ Elements: [],
+ });
+ };
+
+ load = (link) => {
+ const ui = this;
+ const query = gql(`
+ query Pages {
+ readPages(URLSegment: "home", limit: 1, offset: 0) {
+ edges {
+ node {
+ __typename
+ _id
+ ID
+ Title
+ ClassName
+ CSSClass
+ Summary
+ Link
+ URLSegment
+ Elements {
+ edges {
+ node {
+ __typename
+ _id
+ ID
+ Title
+ Render
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ totalCount
+ }
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ totalCount
+ }
+ }
+ }
+ `);
+
+ ui.reset();
+ ui.setState({
+ Title: 'Loading ...',
+ loading: true,
+ });
+ console.log(client.readQuery({
+ query
+ }));
+ client
+ .query({
+ query: query,
+ })
+ .then((resp) => {
+ const page = resp.data.readPages.edges[0].node;
+
+ // write to cache
+ client.writeQuery({
+ query,
+ data: {
+ resp
+ }
+ });
+ console.log(client.readQuery({
+ query
+ }));
+
+ ui.setState({
+ ID: page.ID,
+ Title: page.Title,
+ ClassName: page.ClassName,
+ URLSegment: page.URLSegment,
+ CSSClass: page.CSSClass,
+ Summary: page.Summary,
+ Link: page.Link,
+ Elements: page.Elements.edges,
+ URL: page.Link || link,
+ loading: false,
+ });
+ })
+ .catch((error) => {
+ console.error(error);
+
+ let msg = '';
+
+ if (error.response) {
+ switch (error.response.status) {
+ case 404:
+ msg = 'Not Found.';
+ break;
+ case 500:
+ msg = 'Server issue, please try again latter.';
+ break;
+ default:
+ msg = 'Something went wrong.';
+ break;
+ }
+ } else if (error.request) {
+ msg = 'No response received';
+ } else {
+ console.warn('Error', error.message);
+ }
+
+ ui.setState({
+ error: msg
+ });
+ });
+ };
+
+ getHtml = (html) => {
+ const decodeHtmlEntity = (input) => {
+ var doc = new DOMParser().parseFromString(input, 'text/html');
+ return doc.documentElement.textContent;
+ };
+
+ return {
+ __html: decodeHtmlEntity(html)
+ };
+ };
+
+ render() {
+ const ui = this;
+ const name = ui.name;
+ const className = `elemental-area page-${ui.state.CSSClass} url-${ui.state.URLSegment}`;
+
+ const ElementItem = (props) => (
+
+ );
+
+ let html = '';
+ if (ui.state.Elements.length) {
+ ui.state.Elements.map((el) => {
+ html += el.node.Render;
+ });
+ } else {
+ html += 'Loading ...
';
+ }
+
+ return (
+
+ );
+ }
+}
+
+export default Page;
diff --git a/src/js/ajax/models/page.jsx b/src/js/ajax/models/page.jsx
new file mode 100644
index 0000000..3030278
--- /dev/null
+++ b/src/js/ajax/models/page.jsx
@@ -0,0 +1,233 @@
+/*
+ * page #MainContent area
+ */
+import {
+ Component
+} from 'react';
+
+import {
+ useQuery,
+ gql
+} from '@apollo/client';
+import {
+ client
+} from '../apollo/init';
+import {
+ cache
+} from '../apollo/cache';
+
+const D = document;
+const BODY = document.body;
+
+class Page extends Component {
+ state = {
+ type: [],
+ shown: false,
+ Title: 'Loading ...',
+ loading: true,
+ error: false,
+ current: null,
+ ID: null,
+ URLSegment: null,
+ ClassName: 'Page',
+ CSSClass: null,
+ Summary: null,
+ Link: null,
+ URL: null,
+ HTML: null,
+ Elements: [],
+ page: null,
+ };
+
+ componentDidUpdate() {
+ const ui = this;
+
+ if (ui.state.Title) {
+ document.title = ui.state.Title;
+ }
+ }
+
+ constructor(props) {
+ super(props);
+
+ const ui = this;
+
+ ui.name = ui.constructor.name;
+ ui.empty_state = ui.state;
+
+ console.log(`${ui.name}: init`);
+ }
+
+ isOnline = () => {
+ return BODY.classList.contains('is-online');
+ };
+
+ load = (link) => {
+ const ui = this;
+
+ return new Promise((resolve, reject) => {
+ const query = gql(`query Pages {
+ readPages(Link: "${link}") {
+ edges {
+ node {
+ __typename
+ _id
+ ID
+ Title
+ ClassName
+ CSSClass
+ Summary
+ Link
+ URLSegment
+ HTML
+ }
+ }
+ }
+ }`);
+
+ if (!ui.isOnline()) {
+ const data = client.readQuery({
+ query
+ });
+
+ if (data && ui.processResponse(data)) {
+ console.log(`${ui.name}: Offline cached response`);
+ resolve(data);
+ } else {
+ console.log(`${ui.name}: No offline response`);
+
+ ui.setState(
+ Object.assign(ui.empty_state, {
+ Title: 'Offline',
+ CSSClass: 'graphql__status-523',
+ Summary: "You're Offline. The page is not available now.",
+ loading: false,
+ }),
+ );
+
+ reject({
+ status: 523
+ });
+ }
+ } else {
+ if (!ui.state.loading) {
+ ui.setState(ui.empty_state);
+ }
+
+ client
+ .query({
+ query: query,
+ fetchPolicy: ui.isOnline() ? 'no-cache' : 'cache-first',
+ })
+ .then((resp) => {
+ // write to cache
+ const data = resp.data;
+ client.writeQuery({
+ query,
+ data: data
+ });
+
+ if (ui.processResponse(data)) {
+ console.log(`${ui.name}: got the server response`);
+ resolve(data);
+ } else {
+ console.log(`${ui.name}: not found`);
+ reject({
+ status: 404
+ });
+ }
+ })
+ .catch((error) => {
+ reject({
+ status: 500,
+ error: error
+ });
+ });
+ }
+ });
+ };
+
+ processResponse = (data) => {
+ const ui = this;
+
+ if (!data.readPages.edges.length) {
+ console.log(`${ui.name}: not found`);
+
+ ui.setState(
+ Object.assign(ui.empty_state, {
+ Title: 'Not Found',
+ CSSClass: 'graphql__status-404',
+ Summary: 'The page is not found.',
+ loading: false,
+ }),
+ );
+
+ return false;
+ }
+
+ const page = data.readPages.edges[0].node;
+ ui.setState({
+ ID: page.ID,
+ Title: page.Title,
+ ClassName: page.ClassName,
+ URLSegment: page.URLSegment,
+ CSSClass: page.CSSClass,
+ Summary: page.Summary,
+ Link: page.Link,
+ HTML: page.HTML,
+ Elements: [], //page.Elements.edges,
+ loading: false,
+ });
+
+ return true;
+ };
+
+ getHtml = (html) => {
+ const decodeHtmlEntity = (input) => {
+ var doc = new DOMParser().parseFromString(input, 'text/html');
+ return doc.documentElement.textContent;
+ };
+
+ return {
+ __html: decodeHtmlEntity(html)
+ };
+ };
+
+ render() {
+ const ui = this;
+ const name = ui.name;
+ const className = `elemental-area graphql__page page-${ui.state.CSSClass} url-${ui.state.URLSegment}`;
+
+ const ElementItem = (props) => (
+
+ );
+
+ let html = '';
+ if (ui.state.HTML) {
+ console.log(`${ui.name}: HTML only`);
+ html = ui.state.HTML;
+ } else if (ui.state.Elements.length) {
+ console.log(`${ui.name}: render`);
+ ui.state.Elements.map((el) => {
+ html += el.node.Render;
+ });
+ } else if (ui.state.Summary && ui.state.Summary.length) {
+ console.log(`${ui.name}: summary only`);
+ html = `${ui.state.Summary}
`;
+ }
+
+ if (ui.state.loading) {
+ const spinner = D.getElementById('PageLoading');
+ html = `Loading ...
`;
+ }
+
+ return (
+
+ );
+ }
+}
+
+export default Page;
diff --git a/src/js/ajax/online.js b/src/js/ajax/online.js
new file mode 100644
index 0000000..e31f1d4
--- /dev/null
+++ b/src/js/ajax/online.js
@@ -0,0 +1,104 @@
+// ping online/offline state switch and detection
+
+import Events from '../_events';
+import Consts from '../_consts';
+
+const axios = require('axios');
+
+export default ((W) => {
+ const NAME = 'main.online';
+ const D = document;
+ const BODY = D.body;
+
+ let pingInterval;
+ const PING_META = D.querySelector('meta[name="ping"]');
+
+ let update_online_status_lock = false;
+ const UPDATE_ONLINE_STATUS = (online) => {
+ if (update_online_status_lock) {
+ return;
+ }
+
+ update_online_status_lock = true;
+ if (online) {
+ if (BODY.classList.contains('is-offline')) {
+ console.log(`${NAME}: back Online`);
+ W.dispatchEvent(new Event(Events.BACKONLINE));
+ } else {
+ console.log(`${NAME}: Online`);
+ W.dispatchEvent(new Event(Events.ONLINE));
+ }
+
+ BODY.classList.add('is-online');
+ BODY.classList.remove('is-offline');
+
+ if (PING_META && !pingInterval) {
+ console.log(`${NAME}: SESSION_PING is active`);
+ pingInterval = setInterval(SESSION_PING, 300000); // 5 min in ms
+ }
+ } else {
+ console.log(`${NAME}: Offline`);
+
+ BODY.classList.add('is-offline');
+ BODY.classList.remove('is-online');
+
+ clearInterval(pingInterval);
+ pingInterval = null;
+
+ W.dispatchEvent(new Event(Events.OFFLINE));
+ }
+
+ update_online_status_lock = false;
+ };
+
+ // session ping
+ let session_ping_lock = false;
+ const SESSION_PING = () => {
+ if (session_ping_lock || BODY.classList.contains('is-offline')) {
+ return;
+ }
+
+ const PING_URL = PING_META.getAttribute('content');
+
+ console.log(`${NAME}: session ping`);
+ session_ping_lock = true;
+
+ axios
+ .post(PING_URL, {})
+ .then((resp) => {
+ session_ping_lock = false;
+ UPDATE_ONLINE_STATUS(true);
+ })
+ .catch((error) => {
+ console.error(error);
+ console.warn(`${NAME}: SESSION_PING failed`);
+
+ session_ping_lock = false;
+ UPDATE_ONLINE_STATUS(false);
+ });
+ };
+
+ // current browser online state
+
+
+ const navigatorStateUpdate = () => {
+ if (typeof navigator.onLine !== 'undefined') {
+ if (!navigator.onLine) {
+ UPDATE_ONLINE_STATUS(false);
+ } else {
+ UPDATE_ONLINE_STATUS(true);
+ }
+ }
+ };
+
+ W.addEventListener(`${Events.OFFLINE}`, () => {
+ UPDATE_ONLINE_STATUS(false);
+ });
+
+ W.addEventListener(`${Events.ONLINE}`, () => {
+ UPDATE_ONLINE_STATUS(true);
+ });
+
+ W.addEventListener(`${Events.LOADED}`, navigatorStateUpdate);
+ W.addEventListener(`${Events.AJAX}`, navigatorStateUpdate);
+})(window);
diff --git a/src/js/drivers/_google.track.external.links.js b/src/js/drivers/_google.track.external.links.js
new file mode 100644
index 0000000..1ba472b
--- /dev/null
+++ b/src/js/drivers/_google.track.external.links.js
@@ -0,0 +1,61 @@
+function _gaLt(event) {
+ if (typeof ga !== 'function') {
+ return;
+ }
+
+ var el = event.srcElement || event.target;
+
+ /* Loop up the DOM tree through parent elements if clicked element is not a link (eg: an image inside a link) */
+ while (
+ el &&
+ (typeof el.tagName == 'undefined' ||
+ el.tagName.toLowerCase() != 'a' ||
+ !el.href)
+ ) {
+ el = el.parentNode;
+ }
+
+ if (el && el.href) {
+ /* link */
+ var link = el.href;
+ if (link.indexOf(location.host) == -1 && !link.match(/^javascript:/i)) {
+ /* external link */
+ /* HitCallback function to either open link in either same or new window */
+ var hitBack = function(link, target) {
+ target ? window.open(link, target) : (window.location.href = link);
+ };
+ /* Is target set and not _(self|parent|top)? */
+ var target =
+ el.target && !el.target.match(/^_(self|parent|top)$/i)
+ ? el.target
+ : false;
+ /* send event with callback */
+ ga(
+ 'send',
+ 'event',
+ 'Outgoing Links',
+ link,
+ document.location.pathname + document.location.search,
+ { hitCallback: hitBack(link, target) },
+ );
+
+ /* Prevent standard click */
+ event.preventDefault ? event.preventDefault() : (event.returnValue = !1);
+ }
+ }
+}
+
+/* Attach the event to all clicks in the document after page has loaded */
+var w = window;
+w.addEventListener
+ ? w.addEventListener(
+ 'load',
+ () => {
+ document.body.addEventListener('click', _gaLt, !1);
+ },
+ !1,
+ )
+ : w.attachEvent &&
+ w.attachEvent('onload', () => {
+ document.body.attachEvent('onclick', _gaLt);
+ });
diff --git a/src/js/drivers/_map.google.font-icons.js b/src/js/drivers/_map.google.font-icons.js
new file mode 100644
index 0000000..ccb5373
--- /dev/null
+++ b/src/js/drivers/_map.google.font-icons.js
@@ -0,0 +1,199 @@
+'use strict';
+
+const Obj = {
+ init: () => {
+ class GoogleMapsHtmlOverlay extends google.maps.OverlayView {
+ constructor(options) {
+ super();
+ const ui = this;
+
+ ui.setMap(options.map);
+ ui.position = options.position;
+ ui.html =
+ (options.html ?
+ options.html :
+ '
'
+ );
+ ui.divClass = options.divClass;
+ ui.align = options.align;
+ ui.isDebugMode = options.debug;
+ ui.onClick = options.onClick;
+ ui.onMouseOver = options.onMouseOver;
+
+ ui.isBoolean = (arg) => {
+ if (typeof arg === 'boolean') {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ ui.isNotUndefined = (arg) => {
+ if (typeof arg !== 'undefined') {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ ui.hasContent = (arg) => {
+ if (arg.length > 0) {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ ui.isString = (arg) => {
+ if (typeof arg === 'string') {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ ui.isFunction = (arg) => {
+ if (typeof arg === 'function') {
+ return true;
+ } else {
+ return false;
+ }
+ };
+ }
+ onAdd() {
+ const ui = this;
+
+ // Create div element.
+ ui.div = document.createElement('div');
+ ui.div.style.position = 'absolute';
+
+ // Validate and set custom div class
+ if (ui.isNotUndefined(ui.divClass) && ui.hasContent(ui.divClass))
+ ui.div.className = ui.divClass;
+
+ // Validate and set custom HTML
+ if (
+ ui.isNotUndefined(ui.html) &&
+ ui.hasContent(ui.html) &&
+ ui.isString(ui.html)
+ )
+ ui.div.innerHTML = ui.html;
+
+ // If debug mode is enabled custom content will be replaced with debug content
+ if (ui.isBoolean(ui.isDebugMode) && ui.isDebugMode) {
+ ui.div.className = 'debug-mode';
+ ui.div.innerHTML =
+ '' +
+ 'Debug mode
';
+ ui.div.setAttribute(
+ 'style',
+ 'position: absolute;' +
+ 'border: 5px dashed red;' +
+ 'height: 150px;' +
+ 'width: 150px;' +
+ 'display: flex;' +
+ 'justify-content: center;' +
+ 'align-items: center;'
+ );
+ }
+
+ // Add element to clickable layer
+ ui.getPanes().overlayMouseTarget.appendChild(ui.div);
+
+ // Add listeners to the element.
+ google.maps.event.addDomListener(ui.div, 'click', (event) => {
+ google.maps.event.trigger(ui, 'click');
+ if (ui.isFunction(ui.onClick)) ui.onClick();
+ event.stopPropagation();
+ });
+
+ google.maps.event.addDomListener(ui.div, 'mouseover', (event) => {
+ google.maps.event.trigger(ui, 'mouseover');
+ if (ui.isFunction(ui.onMouseOver)) ui.onMouseOver();
+ event.stopPropagation();
+ });
+ }
+
+ draw() {
+ const ui = this;
+
+ // Calculate position of div
+ var positionInPixels = ui.getProjection().fromLatLngToDivPixel(
+ new google.maps.LatLng(ui.position)
+ );
+
+ // Align HTML overlay relative to original position
+ var divOffset = {
+ y: undefined,
+ x: undefined,
+ };
+
+ switch (Array.isArray(ui.align) ? ui.align.join(' ') : '') {
+ case 'left top':
+ divOffset.y = ui.div.offsetHeight;
+ divOffset.x = ui.div.offsetWidth;
+ break;
+ case 'left center':
+ divOffset.y = ui.div.offsetHeight / 2;
+ divOffset.x = ui.div.offsetWidth;
+ break;
+ case 'left bottom':
+ divOffset.y = 0;
+ divOffset.x = ui.div.offsetWidth;
+ break;
+ case 'center top':
+ divOffset.y = ui.div.offsetHeight;
+ divOffset.x = ui.div.offsetWidth / 2;
+ break;
+ case 'center center':
+ divOffset.y = ui.div.offsetHeight / 2;
+ divOffset.x = ui.div.offsetWidth / 2;
+ break;
+ case 'center bottom':
+ divOffset.y = 0;
+ divOffset.x = ui.div.offsetWidth / 2;
+ break;
+ case 'right top':
+ divOffset.y = ui.div.offsetHeight;
+ divOffset.x = 0;
+ break;
+ case 'right center':
+ divOffset.y = ui.div.offsetHeight / 2;
+ divOffset.x = 0;
+ break;
+ case 'right bottom':
+ divOffset.y = 0;
+ divOffset.x = 0;
+ break;
+ default:
+ divOffset.y = ui.div.offsetHeight / 2;
+ divOffset.x = ui.div.offsetWidth / 2;
+ }
+
+ // Set position
+ ui.div.style.top = `${positionInPixels.y - divOffset.y }px`;
+ ui.div.style.left = `${positionInPixels.x - divOffset.x }px`;
+ }
+
+ getPosition() {
+ const ui = this;
+ return ui.position;
+ }
+
+ getDiv() {
+ const ui = this;
+ return ui.div;
+ }
+
+ setPosition(position, align) {
+ const ui = this;
+ ui.position = position;
+ ui.align = align;
+ ui.draw();
+ }
+ }
+ return GoogleMapsHtmlOverlay;
+ },
+}
+
+export default Obj;
diff --git a/src/js/drivers/_map.google.js b/src/js/drivers/_map.google.js
new file mode 100644
index 0000000..8985dd4
--- /dev/null
+++ b/src/js/drivers/_map.google.js
@@ -0,0 +1,283 @@
+'use strict';
+
+import MarkerClusterer from '@googlemaps/markerclustererplus';
+
+import Events from '../_events';
+import MarkerUI from './_map.google.marker';
+
+const GoogleMapsDriver = ((window) => {
+ class GoogleMapsDriver {
+ getName() {
+ return 'GoogleMapsDriver';
+ }
+
+ init(el, config = []) {
+ const ui = this;
+
+ ui.el = el;
+ ui.config = config;
+ ui.markers = [];
+
+ window[`init${ui.getName()}`] = () => {
+ ui.googleApiLoaded();
+ };
+
+ const script = document.createElement('script');
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${config['key']}&callback=init${ui.getName()}`;
+ script.async = true;
+ script.defer = true;
+ document.head.appendChild(script);
+ }
+
+ googleApiLoaded() {
+ const ui = this;
+
+ const el = ui.el;
+ const config = ui.config;
+ const mapDiv = el.querySelector('.mapAPI-map');
+ const zoom = config['mapZoom'] && config['mapZoom'] !== '0' ? config['mapZoom'] : 10;
+ const center = config['center'] && config['center'] !== ',' ?
+ {
+ lat: config['center'][1],
+ lng: config['center'][0],
+ } :
+ {
+ lat: 0,
+ lng: 0,
+ };
+ const style = config['style'] ? config['style'] : null;
+
+ console.log(`${ui.getName()}: API is loaded`);
+ // init fontawesome icons
+ ui.MarkerUI = MarkerUI.init();
+
+ ui.map = new google.maps.Map(mapDiv, {
+ zoom,
+ center,
+ fullscreenControl: true,
+ styles: style,
+ });
+
+ ui.default_zoom = zoom;
+
+ mapDiv.classList.add('mapboxgl-map');
+
+ ui.popup = new ui.MarkerUI({
+ map: ui.map,
+ align: ['center', 'top'],
+ divClass: 'mapboxgl-popup popup mapboxgl-popup-anchor-bottom d-none',
+ html: '',
+ });
+ ui.popup.setMap(ui.map);
+
+ ui.geocoder = new google.maps.Geocoder();
+
+ ui.cluster = new MarkerClusterer(ui.map, null, {
+ styles: [{
+ width: 30,
+ height: 30,
+ className: 'mapboxgl-cluster',
+ }],
+ });
+
+ el.dispatchEvent(new Event(Events.MAPAPILOADED));
+ }
+
+ addMarker(crds, config) {
+ const ui = this;
+
+ const pos = {
+ lat: crds[1],
+ lng: crds[0],
+ };
+
+ const marker = new ui.MarkerUI({
+ position: pos,
+ map: ui.map,
+ align: ['center', 'top'],
+ html: ``,
+ onClick: () => {
+ const el = document.querySelector(`#Marker${config['id']}`);
+ ui.showPopup(pos, config['content']);
+
+ el.dispatchEvent(new Event(Events.MAPMARKERCLICK));
+ },
+ });
+
+ ui.markers.push(marker);
+
+ ui.cluster.addMarker(marker);
+
+ return marker;
+ }
+
+ showPopup(pos, content) {
+ const ui = this;
+ const popup = ui.popup.getDiv();
+
+ if (ui.config['flyToMarker']) {
+ ui.map.setCenter(pos); // panTo
+ if (!ui.config['noZoom']) {
+ ui.map.setZoom(18);
+ }
+ }
+
+ // keep it hidden to render content
+ popup.style.opacity = '0';
+ popup.classList.remove('d-none');
+
+ popup.querySelector('.mapboxgl-popup-content .html').innerHTML = content;
+
+ popup.querySelector('.mapboxgl-popup-close-button').addEventListener('click', (e) => {
+ e.preventDefault();
+ ui.hidePopup();
+ });
+
+ // set position when content was rendered
+ ui.popup.setPosition(pos, ['center', 'top']);
+
+ // display popup
+ popup.style.opacity = '1';
+ popup.style['margin-top'] = '-1rem';
+ }
+
+ hidePopup() {
+ const ui = this;
+ const popup = ui.popup.getDiv();
+
+ popup.classList.add('d-none');
+ if (!ui.config['noRestoreBounds'] || ui.config['flyToBounds']) {
+ ui.restoreBounds();
+ }
+
+ ui.el.dispatchEvent(new Event(Events.MAPPOPUPCLOSE));
+ }
+
+ geocode(addr, callback) {
+ const ui = this;
+
+ ui.geocoder.geocode(
+ {
+ address: addr,
+ },
+ (results, status) => {
+ if (status === 'OK') {
+ //results[0].geometry.location;
+
+ if (typeof callback === 'function') {
+ callback(results);
+ }
+
+ return results;
+ } else {
+ console.error(
+ `${ui.getName()}: Geocode was not successful for the following reason: ${status}`,
+ );
+ }
+ },
+ );
+ }
+
+ reverseGeocode(latLng, callback) {
+ const ui = this;
+
+ ui.geocoder.geocode(
+ {
+ location: latlng,
+ },
+ (results, status) => {
+ if (status === 'OK') {
+ //results[0].formatted_address;
+
+ if (typeof callback === 'function') {
+ callback(results);
+ }
+
+ return results;
+ } else {
+ console.error(
+ `${ui.getName()}: Reverse Geocoding was not successful for the following reason: ${status}`,
+ );
+ }
+ },
+ );
+ }
+
+ addGeoJson(config) {
+ const ui = this;
+ const geojson = JSON.parse(config['geojson']);
+ const firstMarker = geojson.features[0].geometry.coordinates;
+ //Map.setCenter(firstMarker);
+ const bounds = new google.maps.LatLngBounds();
+
+ // add markers to map
+ geojson.features.forEach((marker) => {
+ const id = marker.id;
+ const crds = marker.geometry.coordinates;
+ const content = marker.properties.content;
+
+ ui.addMarker(crds, {
+ id,
+ content,
+ icon: marker.icon,
+ flyToMarker: config['flyToMarker'],
+ });
+
+ bounds.extend({
+ lat: crds[1],
+ lng: crds[0],
+ });
+ });
+
+ if (ui.markers.length > 1) {
+ ui.map.fitBounds(bounds, {
+ padding: 30,
+ }); //panToBounds
+ } else if (ui.markers[0]) {
+ ui.map.setCenter(ui.markers[0].getPosition());
+ }
+
+ ui.default_bounds = bounds;
+ ui.default_zoom = ui.map.getZoom();
+ }
+
+ getMap() {
+ const ui = this;
+ return ui.map;
+ }
+
+ getPopup() {
+ const ui = this;
+ return ui.popup;
+ }
+
+ restoreBounds() {
+ const ui = this;
+
+ if (ui.default_bounds && ui.markers.length > 1) {
+ ui.map.fitBounds(ui.default_bounds, {
+ padding: 30,
+ }); //panToBounds
+ } else {
+ if (ui.markers[0]) {
+ ui.map.setCenter(ui.markers[0].getPosition());
+ }
+
+ ui.restoreZoom();
+ }
+ }
+
+ restoreZoom() {
+ const ui = this;
+
+ ui.map.setZoom(ui.default_zoom);
+ }
+ }
+
+ return GoogleMapsDriver;
+})(window);
+
+export default GoogleMapsDriver;
diff --git a/src/js/drivers/_map.google.marker.js b/src/js/drivers/_map.google.marker.js
new file mode 100644
index 0000000..b4fe75a
--- /dev/null
+++ b/src/js/drivers/_map.google.marker.js
@@ -0,0 +1,222 @@
+'use strict';
+
+const Obj = {
+ init: () => {
+ class GoogleMapsHtmlOverlay extends google.maps.OverlayView {
+ constructor(options) {
+ super();
+ const ui = this;
+
+ ui.ownerMap = options.map;
+ //ui.setMap(options.map);
+ ui.position = options.position;
+ ui.html = options.html ?
+ options.html :
+ '
';
+ ui.divClass = options.divClass;
+ ui.align = options.align;
+ ui.isDebugMode = options.debug;
+ ui.onClick = options.onClick;
+ ui.onMouseOver = options.onMouseOver;
+
+ ui.isBoolean = (arg) => {
+ if (typeof arg === 'boolean') {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ ui.isNotUndefined = (arg) => {
+ if (typeof arg !== 'undefined') {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ ui.hasContent = (arg) => {
+ if (arg.length > 0) {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ ui.isString = (arg) => {
+ if (typeof arg === 'string') {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ ui.isFunction = (arg) => {
+ if (typeof arg === 'function') {
+ return true;
+ } else {
+ return false;
+ }
+ };
+ }
+ onAdd() {
+ const ui = this;
+
+ // Create div element.
+ ui.div = document.createElement('div');
+ ui.div.style.position = 'absolute';
+
+ // Validate and set custom div class
+ if (ui.isNotUndefined(ui.divClass) && ui.hasContent(ui.divClass))
+ ui.div.className = ui.divClass;
+
+ // Validate and set custom HTML
+ if (
+ ui.isNotUndefined(ui.html) &&
+ ui.hasContent(ui.html) &&
+ ui.isString(ui.html)
+ )
+ ui.div.innerHTML = ui.html;
+
+ // If debug mode is enabled custom content will be replaced with debug content
+ if (ui.isBoolean(ui.isDebugMode) && ui.isDebugMode) {
+ ui.div.className = 'debug-mode';
+ ui.div.innerHTML =
+ '' +
+ 'Debug mode
';
+ ui.div.setAttribute(
+ 'style',
+ 'position: absolute;' +
+ 'border: 5px dashed red;' +
+ 'height: 150px;' +
+ 'width: 150px;' +
+ 'display: flex;' +
+ 'justify-content: center;' +
+ 'align-items: center;',
+ );
+ }
+
+ // Add element to clickable layer
+ ui.getPanes().overlayMouseTarget.appendChild(ui.div);
+
+ // Add listeners to the element.
+ google.maps.event.addDomListener(ui.div, 'click', (event) => {
+ google.maps.event.trigger(ui, 'click');
+ if (ui.isFunction(ui.onClick)) ui.onClick();
+ event.stopPropagation();
+ });
+
+ google.maps.event.addDomListener(ui.div, 'mouseover', (event) => {
+ google.maps.event.trigger(ui, 'mouseover');
+ if (ui.isFunction(ui.onMouseOver)) ui.onMouseOver();
+ event.stopPropagation();
+ });
+ }
+
+ draw() {
+ const ui = this;
+
+ let div = document.querySelector('.popup');
+ if (!div.length) {
+ div = ui.div;
+ }
+
+ // Calculate position of div
+ const projection = ui.getProjection();
+
+ if (!projection) {
+ console.log('GoogleMapsHtmlOverlay: current map is missing');
+ return null;
+ }
+
+ const positionInPixels = projection.fromLatLngToDivPixel(ui.getPosition());
+
+ // Align HTML overlay relative to original position
+ const offset = {
+ y: undefined,
+ x: undefined,
+ };
+ const divWidth = div.offsetWidth;
+ const divHeight = div.offsetHeight;
+
+ switch (Array.isArray(ui.align) ? ui.align.join(' ') : '') {
+ case 'left top':
+ offset.y = divHeight;
+ offset.x = divWidth;
+ break;
+ case 'left center':
+ offset.y = divHeight / 2;
+ offset.x = divWidth;
+ break;
+ case 'left bottom':
+ offset.y = 0;
+ offset.x = divWidth;
+ break;
+ case 'center top':
+ offset.y = divHeight;
+ offset.x = divWidth / 2;
+ break;
+ case 'center center':
+ offset.y = divHeight / 2;
+ offset.x = divWidth / 2;
+ break;
+ case 'center bottom':
+ offset.y = 0;
+ offset.x = divWidth / 2;
+ break;
+ case 'right top':
+ offset.y = divHeight;
+ offset.x = 0;
+ break;
+ case 'right center':
+ offset.y = divHeight / 2;
+ offset.x = 0;
+ break;
+ case 'right bottom':
+ offset.y = 0;
+ offset.x = 0;
+ break;
+ default:
+ offset.y = divHeight / 2;
+ offset.x = divWidth / 2;
+ break;
+ }
+
+ // Set position
+ ui.div.style.top = `${positionInPixels.y - offset.y}px`;
+ ui.div.style.left = `${positionInPixels.x - offset.x}px`;
+ }
+
+ getPosition() {
+ const ui = this;
+ return new google.maps.LatLng(ui.position);
+ }
+
+ getDiv() {
+ const ui = this;
+ return ui.div;
+ }
+
+ setPosition(position, align) {
+ const ui = this;
+ ui.position = position;
+ ui.align = align;
+ ui.draw();
+ }
+
+ remove() {
+ const ui = this;
+ ui.setMap(null);
+ ui.div.remove();
+ }
+
+ // emulate google.maps.Marker functionality for compatibility (for example with @googlemaps/markerclustererplus)
+ getDraggable() {
+ return false;
+ }
+ }
+ return GoogleMapsHtmlOverlay;
+ },
+};
+
+export default Obj;
diff --git a/src/js/drivers/_map.mapbox.js b/src/js/drivers/_map.mapbox.js
new file mode 100644
index 0000000..b4a9fe5
--- /dev/null
+++ b/src/js/drivers/_map.mapbox.js
@@ -0,0 +1,187 @@
+'use strict';
+
+import $ from 'jquery';
+import mapBoxGL from 'mapbox-gl';
+
+import Events from '../../_events';
+
+const MapBoxDriver = (($) => {
+ class MapBoxDriver {
+ getName() {
+ return 'MapBoxDriver';
+ }
+
+ init($el, config = []) {
+ const ui = this;
+
+ mapBoxGL.accessToken = config['key'];
+
+ ui.map = new mapBoxGL.Map({
+ container: $el.find('.mapAPI-map')[0],
+ center: config['center'] ? config['center'] : [0, 0],
+ //hash: true,
+ style: config['style']
+ ? config['style']
+ : 'mapbox://styles/mapbox/streets-v9',
+ localIdeographFontFamily: config['font-family'],
+ zoom: config['mapZoom'] ? config['mapZoom'] : 10,
+ attributionControl: false,
+ antialias: true,
+ accessToken: config['key'],
+ })
+ .addControl(
+ new mapBoxGL.AttributionControl({
+ compact: true,
+ }),
+ )
+ .addControl(new mapBoxGL.NavigationControl(), 'top-right')
+ .addControl(
+ new mapBoxGL.GeolocateControl({
+ positionOptions: {
+ enableHighAccuracy: true,
+ },
+ trackUserLocation: true,
+ }),
+ 'bottom-right',
+ )
+ .addControl(
+ new mapBoxGL.ScaleControl({
+ maxWidth: 80,
+ unit: 'metric',
+ }),
+ 'top-left',
+ )
+ .addControl(new mapBoxGL.FullscreenControl());
+
+ ui.map.on('load', (e) => {
+ $el.trigger(Events.MAPAPILOADED);
+ });
+
+ ui.popup = new mapBoxGL.Popup({
+ closeOnClick: false,
+ className: 'popup',
+ });
+ }
+
+ addMarker(crds, config) {
+ const ui = this;
+
+ // create a DOM el for the marker
+ const $el = $(
+ `${config['icon']}
`,
+ );
+
+ $el.on('click', (e) => {
+ ui.popup
+ .setLngLat(crds)
+ .setHTML(config['content'])
+ .addTo(ui.map);
+
+ if (config['flyToMarker']) {
+ ui.map.flyTo({
+ center: crds,
+ zoom: 17,
+ });
+ }
+
+ $(e.currentTarget).trigger(Events.MAPMARKERCLICK);
+ });
+
+ // add marker to map
+ const marker = new mapBoxGL.Marker($el[0]).setLngLat(crds).addTo(ui.map);
+
+ return marker;
+ }
+
+ addGeoJson(config) {
+ const ui = this;
+ // Insert the layer beneath any symbol layer.
+ /*if (config['3d']) {
+ const layers = Map.getStyle().layers;
+ let labelLayerId;
+ for (let i = 0; i < layers.length; i++) {
+ if (layers[i].type === 'symbol' && layers[i].layout['text-field']) {
+ labelLayerId = layers[i].id;
+ break;
+ }
+ }
+
+ Map.addLayer({
+ 'id': '3d-buildings',
+ 'source': 'composite',
+ 'source-layer': 'building',
+ 'filter': ['==', 'extrude', 'true'],
+ 'type': 'fill-extrusion',flyToBounds
+ 'minzoom': 15,
+ 'paint': {
+ 'fill-extrusion-color': '#aaa',
+
+ // use an 'interpolate' expression to add a smooth transition effect to the
+ // buildings as the user zooms in
+ 'fill-extrusion-height': [
+ "interpolate", ["linear"],
+ ["zoom"],
+ 15, 0,
+ 15.05, ["get", "height"],
+ ],
+ 'fill-extrusion-base': [
+ "interpolate", ["linear"],
+ ["zoom"],
+ 15, 0,
+ 15.05, ["get", "min_height"],
+ ],
+ 'fill-extrusion-opacity': .6,
+ },
+ }, labelLayerId);
+ }*/
+
+ const firstMarker = config['geojson'].features[0].geometry.coordinates;
+ //Map.setCenter(firstMarker);
+ const bounds = new mapBoxGL.LngLatBounds(firstMarker, firstMarker);
+
+ // add markers to map
+ config['geojson'].features.forEach((marker) => {
+ const id = marker.id;
+ const crds = marker.geometry.coordinates;
+ const content = marker.properties.content;
+
+ ui.addMarker(crds, {
+ id,
+ content,
+ icon: marker.icon,
+ flyToMarker: config['flyToMarker'],
+ });
+
+ bounds.extend(crds);
+ });
+
+ ui.map.fitBounds(bounds, {
+ padding: 30,
+ });
+
+ ui.popup.on('close', (e) => {
+ if (config['flyToBounds']) {
+ ui.map.fitBounds(bounds, {
+ padding: 30,
+ });
+ }
+
+ $(e.currentTarget).trigger(Events.MAPPOPUPCLOSE);
+ });
+ }
+
+ getMap() {
+ const ui = this;
+ return ui.map;
+ }
+
+ getPopup() {
+ const ui = this;
+ return ui.popup;
+ }
+ }
+
+ return MapBoxDriver;
+})($);
+
+export default MapBoxDriver;
diff --git a/src/js/layout/index.js b/src/js/layout/index.js
new file mode 100644
index 0000000..5009814
--- /dev/null
+++ b/src/js/layout/index.js
@@ -0,0 +1,50 @@
+import Events from '../_events';
+
+const LayoutUI = ((W) => {
+ const NAME = '_layout';
+ const D = document;
+ const BODY = D.body;
+
+ const init_fonts = () => {
+ console.log(`${NAME}: init_fonts`);
+
+ const css = D.createElement('link');
+ css.rel = 'stylesheet';
+ css.type = 'text/css';
+ css.media = 'all';
+ css.href = 'https://fonts.googleapis.com/css?family=Roboto:ital,wght@0,400;0,700;1,400&display=swap';
+ D.getElementsByTagName('head')[0].appendChild(css);
+ };
+
+ const init_analytics = () => {
+ console.log(`${NAME}: init_analytics`);
+ /*google analytics */
+ /*(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',
+ );
+ ga('create', 'UA-********-*', 'auto');
+ ga('send', 'pageview');*/
+ }
+
+ W.addEventListener(`${Events.LOADED}`, () => {
+ init_fonts();
+ //init_analytics();
+ });
+})(window);
+export default LayoutUI;
diff --git a/src/js/libs/log.js b/src/js/libs/log.js
new file mode 100644
index 0000000..5b93f5b
--- /dev/null
+++ b/src/js/libs/log.js
@@ -0,0 +1,9 @@
+var debug = process.env.NODE_ENV === 'development' ? true : false;
+
+const log = (msg) => {
+ if (debug) {
+ console.log(msg);
+ }
+};
+
+module.exports = log;
diff --git a/src/js/main/css-screen-size.js b/src/js/main/css-screen-size.js
new file mode 100644
index 0000000..c1749e3
--- /dev/null
+++ b/src/js/main/css-screen-size.js
@@ -0,0 +1,54 @@
+// browser tab visibility state detection
+
+import Events from '../_events';
+import Consts from '../_consts';
+
+export default ((W) => {
+ const NAME = '_main.css-screen-size';
+ const D = document;
+ const BODY = D.body;
+
+ const detectCSSScreenSize = () => {
+ const el = D.createElement('div');
+ el.className = 'env-test';
+ BODY.appendChild(el);
+
+ const envs = [...Consts.ENVS].reverse();
+ let curEnv = envs.shift();
+ BODY.classList.remove(...envs);
+
+ for (let i = 0; i < envs.length; ++i) {
+ const env = envs[i];
+ el.classList.add(`d-${env}-none`);
+
+ if (W.getComputedStyle(el).display === 'none') {
+ curEnv = env;
+ BODY.classList.add(`${curEnv}`);
+ break;
+ }
+ }
+
+ let landscape = true;
+ if (W.innerWidth > W.innerHeight) {
+ BODY.classList.add('landscape');
+ BODY.classList.remove('portrait');
+ } else {
+ landscape = false;
+
+ BODY.classList.add('portrait');
+ BODY.classList.remove('landscape');
+ }
+
+ console.log(
+ `${NAME}: screen size detected ${curEnv} | landscape ${landscape}`,
+ );
+
+ BODY.removeChild(el);
+
+ return curEnv;
+ };
+
+ W.addEventListener(`${Events.LOADED}`, detectCSSScreenSize);
+
+ W.addEventListener(`${Events.RESIZE}`, detectCSSScreenSize);
+})(window);
diff --git a/src/js/main/funcs.js b/src/js/main/funcs.js
new file mode 100644
index 0000000..d2e82a9
--- /dev/null
+++ b/src/js/main/funcs.js
@@ -0,0 +1,35 @@
+const funcs = {};
+
+/*!
+ * Get all of an element's parent elements up the DOM tree
+ * (c) 2019 Chris Ferdinandi, MIT License, https://gomakethings.com
+ * @param {Node} elem The element
+ * @param {String} selector Selector to match against [optional]
+ * @return {Array} The parent elements
+ */
+
+funcs.getParents = (elem, selector) => {
+ // Setup parents array
+ const parents = [];
+ let el = elem;
+ // Get matching parent elements
+ while (el && el !== document) {
+ // If using a selector, add matching parents to array
+ // Otherwise, add all parents
+ if (selector) {
+ if (el.matches(selector)) {
+ parents.push(el);
+ }
+ } else {
+ parents.push(el);
+ }
+
+ // Jump to the next parent node
+ el = el.parentNode;
+ }
+
+ return parents;
+};
+
+module.exports = funcs;
+module.exports.default = funcs;
diff --git a/src/js/main/index.js b/src/js/main/index.js
new file mode 100644
index 0000000..5cb5da2
--- /dev/null
+++ b/src/js/main/index.js
@@ -0,0 +1,7 @@
+import Events from '../_events';
+import Consts from '../_consts';
+
+import './visibility';
+import './touch';
+import './css-screen-size';
+import './main';
diff --git a/src/js/main/loading-spinner.js b/src/js/main/loading-spinner.js
new file mode 100644
index 0000000..3c7cd20
--- /dev/null
+++ b/src/js/main/loading-spinner.js
@@ -0,0 +1,21 @@
+// browser tab visibility state detection
+
+import Events from '../_events';
+
+const NAME = '_main.loading-spinner';
+const D = document;
+const BODY = D.body;
+const SPINNER = D.getElementById('PageLoading');
+
+class SpinnerUI {
+ static show() {
+ console.log(`${NAME}: show`);
+ SPINNER.classList.remove('d-none');
+ }
+ static hide() {
+ console.log(`${NAME}: hide`);
+ SPINNER.classList.add('d-none');
+ }
+}
+
+export default SpinnerUI;
diff --git a/src/js/main/main.js b/src/js/main/main.js
new file mode 100644
index 0000000..9f7672a
--- /dev/null
+++ b/src/js/main/main.js
@@ -0,0 +1,85 @@
+import Events from '../_events';
+import Consts from '../_consts';
+import SpinnerUI from './loading-spinner';
+
+const MainUI = ((W) => {
+ const NAME = '_main';
+ const D = document;
+ const BODY = D.body;
+
+ console.info(
+ `%cUI Kit ${UINAME} ${UIVERSION}`,
+ 'color:yellow;font-size:14px',
+ );
+ console.info(
+ `%c${UIMetaNAME} ${UIMetaVersion}`,
+ 'color:yellow;font-size:12px',
+ );
+ console.info(
+ `%chttps://github.com/a2nt/webpack-bootstrap-ui-kit by ${UIAUTHOR}`,
+ 'color:yellow;font-size:10px',
+ );
+
+ console.info(`%cENV: ${process.env.NODE_ENV}`, 'color:green;font-size:10px');
+ console.groupCollapsed('Events');
+ Object.keys(Events).forEach((k) => {
+ console.info(`${k}: ${Events[k]}`);
+ });
+ console.groupEnd('Events');
+
+ console.groupCollapsed('Consts');
+ Object.keys(Consts).forEach((k) => {
+ console.info(`${k}: ${Consts[k]}`);
+ });
+ console.groupEnd('Events');
+
+ console.groupCollapsed('Init');
+ console.time('init');
+
+ class MainUI {
+ // first time the website initialization
+ static init() {
+ const ui = this;
+
+ // store landing page state
+ W.history.replaceState(
+ {
+ landing: W.location.href,
+ },
+ D.title,
+ W.location.href,
+ );
+ //
+
+ ui.loaded();
+ }
+
+ // init AJAX components
+ static loaded() {
+ const ui = this;
+ console.log(`${NAME}: loaded`);
+ }
+ }
+
+ W.addEventListener(`${Events.LOADED}`, () => {
+ MainUI.init();
+
+ BODY.classList.add('loaded');
+ SpinnerUI.hide();
+
+ console.groupEnd('init');
+ console.timeEnd('init');
+
+ W.dispatchEvent(new Event(Events.LODEDANDREADY));
+ });
+
+ W.addEventListener(`${Events.AJAX}`, () => {
+ MainUI.loaded();
+ });
+
+ W.MainUI = MainUI;
+
+ return MainUI;
+})(window);
+
+export default MainUI;
diff --git a/src/js/main/touch.js b/src/js/main/touch.js
new file mode 100644
index 0000000..b28f4c1
--- /dev/null
+++ b/src/js/main/touch.js
@@ -0,0 +1,70 @@
+// touch/mouse detection
+
+import Events from '../_events';
+import Consts from '../_consts';
+
+export default ((W) => {
+ const NAME = '_main.touch';
+ const D = document;
+ const BODY = D.body;
+
+ let prev_touch_event_name;
+ let touch_timeout;
+ const SET_TOUCH_SCREEN = (bool, event_name) => {
+ if (touch_timeout || event_name === prev_touch_event_name) {
+ return;
+ }
+
+ if (bool) {
+ console.log(`${NAME}: Touch screen enabled`);
+
+ BODY.classList.add('is-touch');
+ BODY.classList.remove('is-mouse');
+
+ W.dispatchEvent(new Event(Events.TOUCHENABLE));
+ } else {
+ console.log(`${NAME}: Touch screen disabled`);
+
+ BODY.classList.add('is-mouse');
+ BODY.classList.remove('is-touch');
+
+ W.dispatchEvent(new Event(Events.TOUCHDISABLED));
+ }
+
+ prev_touch_event_name = event_name;
+ // prevent firing touch and mouse events together
+ if (!touch_timeout) {
+ touch_timeout = setTimeout(() => {
+ clearTimeout(touch_timeout);
+ touch_timeout = null;
+ }, 500);
+ }
+ };
+
+ SET_TOUCH_SCREEN(
+ 'ontouchstart' in W ||
+ navigator.MaxTouchPoints > 0 ||
+ navigator.msMaxTouchPoints > 0 ||
+ W.matchMedia('(hover: none)').matches,
+ 'init',
+ );
+
+ D.addEventListener('touchend', (e) => {
+ let touch = false;
+ if (e.type !== 'click') {
+ touch = true;
+ }
+
+ SET_TOUCH_SCREEN(touch, 'click-touchend');
+ });
+
+ // disable touch on mouse events
+ D.addEventListener('click', (e) => {
+ let touch = false;
+ if (e.type !== 'click') {
+ touch = true;
+ }
+
+ SET_TOUCH_SCREEN(touch, 'click-touchend');
+ });
+})(window);
diff --git a/src/js/main/visibility.js b/src/js/main/visibility.js
new file mode 100644
index 0000000..8459a35
--- /dev/null
+++ b/src/js/main/visibility.js
@@ -0,0 +1,34 @@
+// browser tab visibility state detection
+
+import Events from '../_events';
+import Consts from '../_consts';
+
+export default ((W) => {
+ const NAME = '_main.visibility';
+ const D = document;
+ const BODY = D.body;
+
+ // update visibility state
+ // get browser window visibility preferences
+ // Opera 12.10, Firefox >=18, Chrome >=31, IE11
+ const HiddenName = 'hidden';
+ const VisibilityChangeEvent = 'visibilitychange';
+
+ D.addEventListener(VisibilityChangeEvent, () => {
+ if (D.visibilityState === HiddenName) {
+ console.log(`${NAME}: Tab: hidden`);
+
+ BODY.classList.add('is-hidden');
+ BODY.classList.remove('is-focused');
+
+ W.dispatchEvent(new Event(Events.TABHIDDEN));
+ } else {
+ console.log(`${NAME}: Tab: focused`);
+
+ BODY.classList.add('is-focused');
+ BODY.classList.remove('is-hidden');
+
+ W.dispatchEvent(new Event(Events.TABFOCUSED));
+ }
+ });
+})(window);
diff --git a/src/js/store/configureStore.js b/src/js/store/configureStore.js
new file mode 100644
index 0000000..dff7087
--- /dev/null
+++ b/src/js/store/configureStore.js
@@ -0,0 +1,8 @@
+import {
+ createStore,
+} from 'redux'
+import reducers from '../reducers'
+
+export default function configureStore() {
+ return createStore(reducers)
+}
diff --git a/src/js/store/connect.js b/src/js/store/connect.js
new file mode 100644
index 0000000..dff7087
--- /dev/null
+++ b/src/js/store/connect.js
@@ -0,0 +1,8 @@
+import {
+ createStore,
+} from 'redux'
+import reducers from '../reducers'
+
+export default function configureStore() {
+ return createStore(reducers)
+}
diff --git a/src/js/ui/carousel.js b/src/js/ui/carousel.js
new file mode 100644
index 0000000..cd93d43
--- /dev/null
+++ b/src/js/ui/carousel.js
@@ -0,0 +1,86 @@
+import Events from '../_events';
+import Carousel from 'bootstrap/js/src/carousel';
+
+const CarouselUI = ((window) => {
+ const NAME = 'js-carousel';
+
+ const init = () => {
+ console.log(`${NAME}: init`);
+
+ document.querySelectorAll(`.${NAME}`).forEach((el, i) => {
+ const carousel = new Carousel(el);
+ // create next/prev arrows
+ if (el.dataset.bsArrows) {
+ const next = document.createElement('button');
+ next.classList.add('carousel-control-next');
+ next.setAttribute('type', 'button');
+ next.setAttribute('aria-label', 'Next Slide');
+ next.setAttribute('data-bs-target', el.getAttribute('id'));
+ next.setAttribute('data-bs-slide', 'next');
+ next.addEventListener('click', (e) => {
+ carousel.next();
+ });
+ next.innerHTML = 'Next';
+ el.appendChild(next);
+
+ const prev = document.createElement('button');
+ prev.setAttribute('type', 'button');
+ prev.setAttribute('aria-label', 'Previous Slide');
+ prev.classList.add('carousel-control-prev');
+ prev.setAttribute('data-bs-target', el.getAttribute('id'));
+ prev.setAttribute('data-bs-slide', 'prev');
+ prev.addEventListener('click', (e) => {
+ carousel.prev();
+ });
+ prev.innerHTML = 'Previous';
+ el.appendChild(prev);
+ }
+
+ if (el.dataset.bsIndicators) {
+ const indicators = document.createElement('div');
+ indicators.classList.add('carousel-indicators');
+ const items = el.querySelectorAll('.carousel-item');
+ let i = 0;
+ while (i < items.length) {
+ const ind = document.createElement('button');
+ ind.setAttribute('type', 'button');
+ ind.setAttribute('aria-label', `Slide to #${ i + 1}`);
+ if (i == 0) {
+ ind.classList.add('active');
+ }
+ ind.setAttribute('data-bs-target', el.getAttribute('id'));
+ ind.setAttribute('data-bs-slide-to', i);
+
+ ind.addEventListener('click', (e) => {
+ const target = e.target;
+ carousel.to(target.getAttribute('data-bs-slide-to'));
+ indicators.querySelectorAll('.active').forEach((ind2) => {
+ ind2.classList.remove('active');
+ });
+ target.classList.add('active');
+ });
+
+ indicators.appendChild(ind);
+ i++;
+ }
+
+ el.appendChild(indicators);
+ el.addEventListener('slide.bs.carousel', (e) => {
+ el.querySelectorAll('.carousel-indicators .active').forEach((ind2) => {
+ ind2.classList.remove('active');
+ });
+ el.querySelectorAll(`.carousel-indicators [data-bs-slide-to="${ e.to }"]`).forEach((ind2) => {
+ ind2.classList.add('active');
+ });
+ });
+
+ }
+ el.classList.add(`${NAME}-active`);
+ });
+ };
+
+ window.addEventListener(`${Events.LODEDANDREADY}`, init);
+ window.addEventListener(`${Events.AJAX}`, init);
+})(window);
+
+export default CarouselUI;
diff --git a/src/js/ui/instagram.feed.js b/src/js/ui/instagram.feed.js
new file mode 100644
index 0000000..5e03df2
--- /dev/null
+++ b/src/js/ui/instagram.feed.js
@@ -0,0 +1,95 @@
+// api-less instagram feed
+
+// Visitor network maybe temporary banned by Instagram because of too many requests from external websites
+// so it isn't very stable implementation. You should have something for the fall-back.
+
+import Events from '../_events';
+import Consts from '../_consts';
+import InstagramFeed from '@jsanahuja/instagramfeed/src/InstagramFeed';
+
+export default ((window) => {
+ const NAME = 'js-instagramfeed';
+ const BODY = document.body;
+
+ const ig_media_preview = (base64data) => {
+ const jpegtpl =
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEABsaGikdKUEmJkFCLy8vQkc/Pj4/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0cBHSkpNCY0PygoP0c/NT9HR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR//AABEIABQAKgMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AA==',
+ t = atob(base64data),
+ p = t.slice(3).split(''),
+ o = t
+ .substring(0, 3)
+ .split('')
+ .map((e) => {
+ return e.charCodeAt(0);
+ }),
+ c = atob(jpegtpl).split('');
+ c[162] = String.fromCharCode(o[1]);
+ c[160] = String.fromCharCode(o[2]);
+ return base64data ?
+ `data:image/jpeg;base64,${btoa(c.concat(p).join(''))}` :
+ null;
+ };
+
+ const loadFeed = () => {
+ console.log(`${NAME}: loading`);
+
+ document.querySelectorAll(`.${NAME}`).forEach((el, i) => {
+ const ID = `InstagramFeed${i}`;
+ const dataset = el.dataset;
+
+ el.classList.add(`${NAME}-loading`);
+ el.classList.remove(`${NAME}-loaded`, `${NAME}-error`);
+
+ new InstagramFeed({
+ username: dataset['username'],
+ tag: dataset['tag'] || null,
+ display_profile: dataset['display-profile'],
+ display_biography: dataset['display-biography'],
+ display_gallery: dataset['display-gallery'],
+ display_captions: dataset['display-captions'],
+ cache_time: dataset['cache_time'] || 360,
+ items: dataset['items'] || 12,
+ styling: false,
+ lazy_load: true,
+ callback: (data) => {
+ console.log(`${NAME}: data response received`);
+
+ const list = document.createElement('div');
+ list.classList.add(`${NAME}-list`, 'row');
+ el.appendChild(list);
+
+ data['edge_owner_to_timeline_media']['edges'].forEach(
+ (el, i) => {
+ const item = el['node'];
+ const preview = ig_media_preview(item['media_preview']);
+
+ list.innerHTML +=
+ `` +
+ `
` +
+ '
';
+ },
+ );
+
+ el.classList.remove(`${NAME}-loading`);
+ el.classList.add(`${NAME}-loaded`);
+
+ window.dispatchEvent(new Event('MetaWindowindow.initLinks'));
+ window.dispatchEvent(new Event(`${NAME}.loaded`));
+ },
+ on_error: (e) => {
+ console.error(`${NAME}: ${e}`);
+
+ el.classList.remove(`${NAME}-loading`);
+ el.classList.add(`${NAME}-error`);
+
+ window.dispatchEvent(new Event(`${NAME}.error`));
+ },
+ });
+ });
+ };
+
+ window.addEventListener(`${Events.LODEDANDREADY}`, loadFeed);
+ window.addEventListener(`${Events.AJAX}`, loadFeed);
+})(window);
diff --git a/src/js/ui/map.api.js b/src/js/ui/map.api.js
new file mode 100644
index 0000000..7973351
--- /dev/null
+++ b/src/js/ui/map.api.js
@@ -0,0 +1,122 @@
+'use strict';
+
+import Events from '../_events';
+import Consts from 'js/_consts';
+
+import '../../scss/ui/map.api.scss';
+
+const MapAPI = ((window) => {
+ // Constants
+ const NAME = 'js-mapapi';
+ const MAP_DRIVER = Consts['MAP_DRIVER'];
+
+ class MapAPI {
+ // Constructor
+ constructor(el) {
+ const ui = this;
+ const Drv = new MAP_DRIVER();
+ const BODY = document.querySelector('body');
+ const config = el.dataset;
+ config['center'] = [
+ config['lng'] ? config['lng'] : BODY.dataset['default-lng'],
+ config['lat'] ? config['lat'] : BODY.dataset['default-lat'],
+ ];
+
+ /*config['style'] = config['style'] ?
+ jQuery.parseJSON(config['style']) :
+ null;
+
+ config['font-family'] = $BODY.css('font-family');*/
+
+ if (!config['icon']) {
+ config['icon'] = '';
+ }
+
+ console.log(`${NAME}: init ${Drv.getName()}...`);
+ ui.drv = Drv;
+ ui.el = el;
+ ui.config = config;
+
+ Drv.init(el, config);
+
+ el.addEventListener(Events.MAPAPILOADED, () => {
+ ui.addMarkers()
+ });
+ }
+
+ // Public methods
+ getMap() {
+ return ui.map;
+ }
+
+ dispose() {
+ const ui = this;
+
+ ui.el = null;
+ ui.el.classList.remove(`${NAME}-active`);
+ }
+
+ addMarkers() {
+ console.log(`${NAME}: addMarkers`);
+ const ui = this;
+ const el = ui.el;
+ const Drv = ui.drv;
+ const config = ui.config;
+
+ ui.map = Drv.getMap();
+
+ if (config['geojson']) {
+ console.log(`${NAME}: setting up geocode data`);
+ Drv.addGeoJson(config);
+ } else if (config['address']) {
+ console.log(config['address']);
+ console.log(`${NAME}: setting up address marker`);
+ Drv.geocode(config['address'], (results) => {
+ console.log(results);
+
+ const lat = results[0].geometry.location.lat();
+ const lng = results[0].geometry.location.lng();
+
+ console.log(
+ `${NAME}: setting up single lat/lng marker lat: ${lat} lng: ${lng}`,
+ );
+
+ Drv.addMarker([lng, lat], config);
+ ui.map.setCenter({
+ lat,
+ lng,
+ });
+ });
+ } else if (config['lat'] && config['lng']) {
+ const lat = config['lat'];
+ const lng = config['lng'];
+
+ console.log(
+ `${NAME}: setting up single lat/lng marker lat: ${lat} lng: ${lng}`,
+ );
+
+ Drv.addMarker([lng, lat], config);
+ }
+
+ el.classList.add(`${NAME}-active`);
+
+ el.dispatchEvent(new Event(Events.MAPLOADED));
+ console.log(`${NAME}: Map is loaded`);
+ }
+ }
+
+ const init = () => {
+ console.log(`${NAME}: init`);
+ document.querySelectorAll(`.${NAME}`).forEach((el, i) => {
+ const map = new MapAPI(el);
+ });
+ }
+
+ // auto-apply
+ window.addEventListener(`${Events.LODEDANDREADY}`, init);
+ window.addEventListener(`${Events.AJAX}`, init);
+
+ return MapAPI;
+})(window);
+
+export default MapAPI;
diff --git a/src/scss/elements/accordion.scss b/src/scss/elements/accordion.scss
new file mode 100644
index 0000000..c9c8b39
--- /dev/null
+++ b/src/scss/elements/accordion.scss
@@ -0,0 +1,3 @@
+.site__elements__accordion {
+ >.element-container>.accordion {}
+}
diff --git a/src/scss/elements/grid.list.scss b/src/scss/elements/grid.list.scss
new file mode 100644
index 0000000..5e11323
--- /dev/null
+++ b/src/scss/elements/grid.list.scss
@@ -0,0 +1,17 @@
+// remove paddings for elemental list cuz inner elements will have paddings
+.dnadesign__elementallist__model__elementlist {
+ margin: 0;
+ padding-bottom: 0;
+
+ .element__content {
+ --bs-gutter-x: 2rem;
+ --bs-gutter-y: .5rem;
+ }
+
+ /*.element {
+ padding-top: $element-reduced-spacer-y;
+ padding-bottom: $element-reduced-spacer-y;
+ margin-top: $element-reduced-d-spacer-y;
+ margin-bottom: $element-reduced-d-spacer-y;
+ }*/
+}
diff --git a/src/scss/elements/grid.scss b/src/scss/elements/grid.scss
new file mode 100644
index 0000000..a9378d5
--- /dev/null
+++ b/src/scss/elements/grid.scss
@@ -0,0 +1,45 @@
+.elemental-area {
+ display: flex;
+ flex-direction: column;
+ --bs-gutter-x: .75rem;
+ --bs-gutter-y: .5rem;
+
+ >.element {
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+}
+
+.element {
+ position: relative;
+
+ margin-top: $element-spacer-y;
+ margin-bottom: $element-spacer-y;
+ padding-top: $element-spacer-y;
+ padding-bottom: $element-spacer-y;
+
+ // Sidebar-like elements
+ &.secondary {
+ padding-top: ($element-reduced-spacer-y);
+ padding-bottom: ($element-reduced-spacer-y);
+ }
+}
+
+// sub-elements
+.element {
+ .elemental-area {
+ .element {
+
+ .container,
+ .container-fluid {
+ padding: 0;
+ width: auto;
+ }
+ }
+ }
+}
diff --git a/src/scss/elements/index.scss b/src/scss/elements/index.scss
new file mode 100644
index 0000000..ea650d8
--- /dev/null
+++ b/src/scss/elements/index.scss
@@ -0,0 +1,7 @@
+@import 'grid';
+@import 'grid.list';
+@import 'page';
+@import 'slider';
+@import 'sidebar';
+@import 'accordion';
+@import 'other';
diff --git a/src/scss/elements/other.scss b/src/scss/elements/other.scss
new file mode 100644
index 0000000..14aa860
--- /dev/null
+++ b/src/scss/elements/other.scss
@@ -0,0 +1,6 @@
+/*
+ * Basic styles for silverstripe-elemental
+ */
+.blog-post-info {
+ position: relative;
+}
diff --git a/src/scss/elements/page.scss b/src/scss/elements/page.scss
new file mode 100644
index 0000000..669a77a
--- /dev/null
+++ b/src/scss/elements/page.scss
@@ -0,0 +1,38 @@
+// hide default page title cuz elemental object will be used to display titles
+.element__breadcrumbs {
+ //margin-bottom: calc(-2 * #{inspect($element-spacer-y)});
+
+ .container {}
+
+ .breadcrumb-link {
+ text-decoration: none;
+
+ &:hover,
+ &:focus,
+ &:active,
+ &.active {
+ color: $sidebar-nav-link-hover-color;
+ }
+ }
+
+ .active {
+ .breadcrumb-link {
+ color: $sidebar-nav-link-hover-color;
+ }
+ }
+}
+
+.page-header-element {
+ --bs-gutter-y: .75rem;
+ display: none;
+ //margin-bottom: calc(-1 * #{inspect($element-spacer-y)});
+
+ .page-header {
+ line-height: 1em;
+ margin-bottom: 0;
+ }
+}
+
+.page-header-element:not(.d-block)+.element {
+ margin-top: 0;
+}
diff --git a/src/scss/elements/sidebar.scss b/src/scss/elements/sidebar.scss
new file mode 100644
index 0000000..1aa9cd6
--- /dev/null
+++ b/src/scss/elements/sidebar.scss
@@ -0,0 +1,57 @@
+// remove containers for child elements
+.sidebar__col {
+ position: relative;
+ margin-top: $element-reduced-spacer-y;
+ margin-bottom: $element-reduced-spacer-y;
+}
+
+.content-holder__sidebar {
+ .row {
+
+ .container,
+ .container-fluid {
+ padding: 0;
+ width: auto;
+ }
+ }
+}
+
+.page-content-sidebar {
+
+ // Sidebar elements
+ .element {
+ padding-top: ($element-reduced-spacer-y);
+ padding-bottom: ($element-reduced-spacer-y);
+
+ &:first-child {
+ padding-top: 0;
+ }
+
+ &:last-child {
+ padding-bottom: 0;
+ }
+ }
+}
+
+.element__widget {}
+
+.widget__Site_Widgets_SubmenuWidget {
+ .nav-link {
+ width: 100%;
+
+ &:hover,
+ &:focus,
+ &:active,
+ &.active {
+ font-weight: bold;
+ color: $sidebar-nav-link-hover-color;
+ }
+ }
+
+ .active {
+ .nav-link {
+ font-weight: bold;
+ color: $sidebar-nav-link-hover-color;
+ }
+ }
+}
diff --git a/src/scss/elements/slider.scss b/src/scss/elements/slider.scss
new file mode 100644
index 0000000..84cd518
--- /dev/null
+++ b/src/scss/elements/slider.scss
@@ -0,0 +1,33 @@
+.dynamic__elements__image__elements__elementimage,
+.site__elements__sliderelement {
+ .element-container {
+ max-width: none;
+ padding-left: 0;
+ padding-right: 0;
+ }
+
+ .element__image {
+ min-width: 100%;
+ }
+
+ .carousel-slide {
+ background: $sliderelement-carousel-slide-bg;
+ max-height: $sliderelement-carousel-slide-max-y;
+ align-items: center;
+
+ .video {
+ position: relative;
+ height: 100%;
+ @include responsive-ratio($sliderelement-carousel-slide-ratio-x, $sliderelement-carousel-slide-ratio-y, true);
+
+ iframe {
+ position: absolute;
+ top: 0;
+ height: 100% !important;
+ width: 100vw !important;
+ max-width: none;
+ height: unquote(($sliderelement-carousel-slide-ratio-y / $sliderelement-carousel-slide-ratio-x) * 100 + 'vw') !important;
+ }
+ }
+ }
+}
diff --git a/src/scss/layout/forms/basics.scss b/src/scss/layout/forms/basics.scss
new file mode 100644
index 0000000..7c3b803
--- /dev/null
+++ b/src/scss/layout/forms/basics.scss
@@ -0,0 +1,30 @@
+.field {
+ flex-direction: row;
+
+ &__label {
+ padding-right: $form-spacer-x;
+ display: inline-flex;
+ align-items: center;
+
+ &+.field__content {
+ padding-left: $form-spacer-x;
+ }
+ }
+
+ .field__content {
+ flex: 1 1 auto;
+ }
+
+ &.CompositeField {
+ flex-direction: column;
+ }
+}
+
+.field.password {
+ .show-password {
+ position: absolute;
+ top: 0.5em;
+ right: 0.5em;
+ color: $input-color;
+ }
+}
diff --git a/src/scss/layout/forms/index.scss b/src/scss/layout/forms/index.scss
new file mode 100644
index 0000000..04082fc
--- /dev/null
+++ b/src/scss/layout/forms/index.scss
@@ -0,0 +1 @@
+@import './basics';
diff --git a/src/scss/layout/index.scss b/src/scss/layout/index.scss
new file mode 100644
index 0000000..c3064f9
--- /dev/null
+++ b/src/scss/layout/index.scss
@@ -0,0 +1,5 @@
+@import '../_variables';
+@import '../_animations';
+
+@import './main';
+@import './forms';
diff --git a/src/scss/layout/main/alerts.scss b/src/scss/layout/main/alerts.scss
new file mode 100644
index 0000000..8341110
--- /dev/null
+++ b/src/scss/layout/main/alerts.scss
@@ -0,0 +1,32 @@
+@import '../../_variables';
+
+#SiteWideAlerts {
+ position: fixed;
+ bottom: 0;
+ right: 0;
+ z-index: 99999;
+
+ .btn-close {
+ background: none;
+ }
+
+ .alert {
+ margin-bottom: 0;
+ }
+}
+
+.alert-offline {
+ display: none;
+}
+
+.is-online {
+ .alert-offline {
+ display: none;
+ }
+}
+
+.is-offline {
+ .alert-offline {
+ display: flex;
+ }
+}
diff --git a/src/scss/layout/main/base.scss b/src/scss/layout/main/base.scss
new file mode 100644
index 0000000..24ec8e1
--- /dev/null
+++ b/src/scss/layout/main/base.scss
@@ -0,0 +1,179 @@
+/*
+ * some basic styles
+ */
+
+@import '../../_variables';
+@import '../../_animations';
+
+html,
+body {
+ min-height: 100%;
+ min-height: 100vh;
+}
+
+// sticky footer
+body {
+ display: flex;
+ flex-direction: column;
+ --body-gutter-x: #{inspect($body-gutter-x)};
+ --body-gutter-y: #{inspect($body-gutter-y)};
+ --body-double-gutter-x: #{inspect($body-double-gutter-x)};
+ --body-double-gutter-y: #{inspect($body-double-gutter-y)};
+ --body-gutter-reduced-x: #{inspect($body-gutter-reduced-x)};
+ --body-gutter-reduced-y: #{inspect($body-gutter-reduced-y)};
+ --body-gutter-reduced-d-x: #{inspect($body-gutter-reduced-d-x)};
+ --body-gutter-reduced-d-y: #{inspect($body-gutter-reduced-d-y)};
+
+ .wrapper {
+ flex: 1 0 auto;
+ margin-bottom: $element-spacer-y;
+ }
+
+ .footer {
+ flex-shrink: 0;
+ margin-top: $element-spacer-y;
+ }
+}
+
+@media (min-width: $extra-large-screen) {
+
+ html,
+ body {
+ font-size: .9vw !important;
+ }
+
+ .container {
+ max-width: 80vw;
+ }
+}
+
+// don't let images be wider than the parent layer
+div,
+a,
+span,
+button,
+i {
+ background-repeat: no-repeat;
+ background-size: contain;
+}
+
+iframe,
+img {
+ max-width: 100%;
+}
+
+ul,
+table,
+p {
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.a {
+ cursor: pointer;
+ color: $link-color;
+ text-decoration: $link-decoration;
+
+ &:hover,
+ &:focus {
+ text-decoration: $link-hover-decoration;
+ color: $link-hover-color;
+ }
+}
+
+// exclude bootstrap-table
+[data-toggle='table'] {
+
+ &:hover,
+ &.active,
+ &:focus {
+ opacity: 1;
+ }
+}
+
+[data-toggle='collapse'] {
+ &[aria-expanded='true'] {
+ .accordion-icon {
+ &:before {
+ content: '\f068';
+ }
+ }
+ }
+}
+
+// transactions
+.transition,
+a,
+a *,
+.a,
+.a *,
+button,
+input,
+optgroup,
+select,
+textarea,
+.btn,
+.btn *,
+.dropdown,
+.row,
+.alert,
+.alert *,
+.message,
+[data-toggle],
+[data-toggle] * {
+ transition: all 0.4s ease;
+}
+
+.a,
+a,
+[data-toggle],
+button,
+.btn {
+
+ &:hover,
+ &.active,
+ &[aria-expanded='true'] {
+
+ >.fa,
+ >.far,
+ >.fas,
+ >.fab,
+ &.fa,
+ &.far,
+ &.fas,
+ &.fab {
+ transform: scale(1.5);
+ }
+ }
+
+ &:hover,
+ &[aria-expanded='true'] {
+ opacity: 0.8;
+ }
+
+ &.disabled {
+ opacity: 0.5;
+ cursor: default;
+
+ &:hover,
+ &.active,
+ &[aria-expanded='true'] {
+
+ >.fa,
+ >.far,
+ >.fas,
+ >.fab,
+ &.fa,
+ &.far,
+ &.fas,
+ &.fab {
+ transform: rotate(0deg);
+ }
+ }
+ }
+}
diff --git a/src/scss/layout/main/index.scss b/src/scss/layout/main/index.scss
new file mode 100644
index 0000000..976b77c
--- /dev/null
+++ b/src/scss/layout/main/index.scss
@@ -0,0 +1,9 @@
+@import '../../_variables';
+@import '../../_animations';
+
+@import './base';
+@import './main';
+@import './alerts';
+
+// states
+@import './states';
diff --git a/src/scss/layout/main/main.scss b/src/scss/layout/main/main.scss
new file mode 100644
index 0000000..e98ea22
--- /dev/null
+++ b/src/scss/layout/main/main.scss
@@ -0,0 +1,176 @@
+/*
+ * some basic styles
+ */
+
+.meta-MetaWindow {
+ z-index: 1031;
+
+ .meta-nav {
+ text-decoration: none;
+ }
+}
+
+.pulse {
+ animation: pulse 0.8s linear infinite;
+}
+
+// navs
+.navbar-toggler {
+ transition: transform ease 0.4s;
+}
+
+.navbar-toggler-icon {
+ width: auto;
+ height: auto;
+}
+
+.nav-item,
+.nav-link {
+ display: flex;
+}
+
+button.nav-link {
+ border: 0;
+ outline: 0;
+ text-transform: inherit;
+ letter-spacing: inherit;
+}
+
+.navbar-toggler {
+ &[aria-expanded='true'] {
+ transform: rotate(90deg);
+ }
+}
+
+.dropdown-toggle {
+ position: relative;
+ padding-right: 1.5em;
+
+ &:after {
+ position: absolute;
+ right: 0.5em;
+ bottom: 1em;
+ }
+}
+
+.navbar-nav .dropdown-toggle.nav-link {
+ padding-right: 1.5em;
+}
+
+.dropdown.show .dropdown-toggle::after,
+.dropdown-toggle.active-dropdown::after,
+.dropdown-toggle.active::after {
+ transform: rotate(-90deg);
+}
+
+.dropdown-menu {
+ padding: 0;
+ border-radius: 0;
+ will-change: max-height, display;
+ overflow: hidden;
+ transition: none;
+
+ &.show {
+ animation: expand 2s;
+ animation-fill-mode: both;
+ overflow: visible;
+ }
+
+ .dropdown-list {
+ @extend .list-unstyled;
+ }
+
+ .dropdown-menu {
+ top: 0;
+ left: 100%;
+ }
+}
+
+.dropdown-item {
+ white-space: normal;
+}
+
+.field {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap;
+ margin: $form-spacer-y 0;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.btn-toolbar {
+ margin-top: $form-spacer-y;
+}
+
+// rewrite btn opacity on hover
+.btn {
+
+ &:hover,
+ &.active,
+ &:focus {
+ opacity: 1;
+ }
+}
+
+// SS-messages
+
+.alert+.alert {
+ border-top: 0;
+}
+
+/*.message {
+ @extend .alert;
+
+ @extend .alert-info;
+
+ display: block;
+ margin: 0.5rem 0;
+}
+
+.message.validation,
+.message.required,
+.message.error {
+ @extend .alert;
+
+ @extend .alert-danger;
+}
+
+.message.required,
+.message.error {
+ @extend .alert;
+
+ @extend .alert-danger;
+}*/
+
+.list-group-item.active {
+
+ a,
+ .a {
+ color: $list-group-active-color;
+ }
+}
+
+[aria-expanded='true'] {
+ .fa-bars {
+ &:before {
+ content: '\f00d';
+ }
+ }
+}
+
+.jsSidebarUI {
+ position: relative;
+ min-height: 100%;
+}
+
+.jsSidebarUI__inner {
+ position: relative;
+ will-change: position, top;
+}
diff --git a/src/scss/layout/main/states/index.scss b/src/scss/layout/main/states/index.scss
new file mode 100644
index 0000000..282b338
--- /dev/null
+++ b/src/scss/layout/main/states/index.scss
@@ -0,0 +1,5 @@
+@import '../../../_variables';
+@import '../../../_animations';
+
+@import './mobile';
+@import './network';
diff --git a/src/scss/layout/main/states/mobile.scss b/src/scss/layout/main/states/mobile.scss
new file mode 100644
index 0000000..5d257fd
--- /dev/null
+++ b/src/scss/layout/main/states/mobile.scss
@@ -0,0 +1,43 @@
+/*
+ * Mobile/Desktop states
+ */
+
+// display dropdown on hover + focus
+@media (min-width: $full-body-min-width) {
+ .dropdown-hover {
+
+ &:hover,
+ &:focus {
+ .dropdown-menu {
+ display: block;
+ }
+ }
+ }
+}
+
+// custom toggler for mobile view
+.dropdown {
+ >.dropdown-toggle-sm {
+ @media (min-width: $full-body-min-width) {
+ display: none;
+ }
+ }
+
+ >.dropdown-toggle-fl {
+ display: none;
+
+ @media (min-width: $full-body-min-width) {
+ display: inherit;
+ }
+ }
+
+ @media not all and (hover: none) {
+ >.dropdown-toggle-touch {
+ display: inherit;
+ }
+
+ >.dropdown-toggle-notouch {
+ display: none;
+ }
+ }
+}
diff --git a/src/scss/layout/main/states/network.scss b/src/scss/layout/main/states/network.scss
new file mode 100644
index 0000000..ad1529a
--- /dev/null
+++ b/src/scss/layout/main/states/network.scss
@@ -0,0 +1,39 @@
+/*
+ * Network States
+ */
+
+.loading {
+ animation: fade 0.5s linear infinite;
+}
+
+.graphql-page {
+ &.response-404 {
+ filter: grayscale(1);
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+}
+
+.is-offline {
+ iframe {
+ display: none;
+ }
+
+ .graphql-page {
+ &.response-523 {
+ filter: grayscale(1);
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+}
+
+body.ajax-loading {
+ overflow: hidden;
+ height: 100vh;
+
+ #Header {
+ position: relative;
+ z-index: 2001;
+ }
+}
diff --git a/src/scss/layout/test.scss b/src/scss/layout/test.scss
new file mode 100644
index 0000000..81571d9
--- /dev/null
+++ b/src/scss/layout/test.scss
@@ -0,0 +1,177 @@
+.sidebar__col {
+ position: relative;
+ margin-top: $element-reduced-spacer-y;
+ margin-bottom: $element-reduced-spacer-y;
+}
+.content-holder__sidebar {
+ > .container {
+ padding: 0;
+ }
+}
+
+#SiteWideMessage {
+ text-align: center;
+ .alert {
+ margin-bottom: 0;
+ .btn-close {
+ margin-top: -0.5rem;
+ float: right;
+ }
+ }
+}
+
+#Header {
+ background-color: $header-bg;
+ color: $header-color;
+
+ a {
+ color: $header-link;
+ }
+
+ .nav-container {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+ position: static;
+ }
+
+ .logo {
+ filter: invert(100%);
+ }
+
+ .tagline {
+ display: inline-block;
+ font-size: 1.2rem;
+ margin-left: 2em;
+ }
+}
+
+#Navigation {
+ font-size: 1.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.25rem;
+ width: 100%;
+ background: $header-bg;
+
+ .navbar-toggler {
+ color: $main-nav-link-color;
+ font-size: $main-nav-toggler-size;
+ }
+
+ .nav-item,
+ .nav-link {
+ flex-direction: column;
+ /*@media (min-width: $full-body-min-width) {
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ }*/
+ }
+
+ .nav-link {
+ color: $main-nav-link-color;
+ background: $main-nav-link-bg;
+
+ &:focus,
+ &:hover,
+ &.active {
+ background: $main-nav-link-hover-bg;
+ color: $main-nav-link-hover-color;
+ }
+ }
+
+ .active {
+ > .nav-link {
+ background: $main-nav-link-hover-bg;
+ color: $main-nav-link-hover-color;
+ }
+ }
+
+ .nav-item .nav-dropdown {
+ .fa-chevron-right
+ //&:after
+ {
+ display: none;
+ }
+ }
+
+ .dropdown-menu {
+ border-color: $main-nav-dropdown-bg;
+ background: $main-nav-dropdown-bg;
+ margin-top: 0;
+ border-top: 0;
+ min-width: 100%;
+ .nav-item-link {
+ color: $main-nav-dropdown-color;
+ }
+ }
+
+ .dropdown-item {
+ &.active,
+ &:active,
+ &:focus,
+ &:hover {
+ background: $main-nav-dropdown-hover-bg;
+ .nav-item-link {
+ color: $main-nav-dropdown-hover-color;
+ }
+ }
+ .nav-item-link {
+ width: 100%;
+ justify-content: flex-start;
+ align-items: flex-start;
+ }
+ }
+
+ @media (min-width: $full-body-min-width) {
+ .navbar-nav > .nav-item {
+ padding-right: 2rem;
+ padding-left: 2rem;
+ }
+ .dropdown-item .nav-item-link {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+ }
+}
+
+/*#MainContent {
+ padding-top: 2 * $element-reduced-spacer-y;
+ padding-bottom: 2 * $element-reduced-spacer-y;
+}*/
+
+#PageBreadcumbs {
+ position: relative;
+ z-index: 2;
+}
+
+#Footer {
+ display: flex;
+ flex-direction: column;
+ background-color: $footer-bg;
+ color: $footer-color;
+
+ > .wrapper {
+ padding-top: $element-reduced-spacer-y;
+ padding-bottom: $element-reduced-spacer-y;
+ }
+
+ a,
+ .a {
+ color: $footer-link;
+ }
+
+ .footer {
+ padding-top: $element-reduced-spacer-y;
+ padding-bottom: $element-reduced-spacer-y;
+ background-color: $footer-footer-bg;
+
+ .copyright {
+ padding-right: 0.5rem;
+ }
+
+ li {
+ padding: 0 0.5rem;
+ }
+ }
+}
diff --git a/src/scss/libs/bootstrap-table.scss b/src/scss/libs/bootstrap-table.scss
new file mode 100644
index 0000000..2b118fe
--- /dev/null
+++ b/src/scss/libs/bootstrap-table.scss
@@ -0,0 +1,39 @@
+@import "~bootstrap-table/src/bootstrap-table.scss";
+
+.bootstrap-table {
+ .fixed-table-container {
+ .table {
+ thead th {
+ .both, .asc, .desc {
+ background-image: none;
+
+ &:after {
+ margin-left: .5em;
+ content: '';
+ font-family: "Font Awesome 5 Free";
+ font-weight: 900;
+ }
+ }
+
+ .asc:after {
+ content: "\f0de";
+ }
+
+ .desc:after {
+ content: "\f0dd";
+ }
+
+ .both:after {
+ content: "\f0dc";
+ }
+
+ .th-inner.sortable {
+ &:hover,
+ &:focus {
+ opacity: .8;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/scss/libs/bootstrap.scss b/src/scss/libs/bootstrap.scss
new file mode 100644
index 0000000..bc652ac
--- /dev/null
+++ b/src/scss/libs/bootstrap.scss
@@ -0,0 +1,51 @@
+// Bootstrap
+// Configuration
+@import '~bootstrap/scss/functions';
+@import '~bootstrap/scss/variables';
+@import '~bootstrap/scss/mixins';
+@import '~bootstrap/scss/utilities';
+
+// Layout & components
+@import '~bootstrap/scss/root';
+@import '~bootstrap/scss/reboot';
+@import '~bootstrap/scss/type';
+@import '~bootstrap/scss/containers';
+@import '~bootstrap/scss/grid';
+@import '~bootstrap/scss/tables';
+@import '~bootstrap/scss/forms';
+@import '~bootstrap/scss/buttons';
+@import '~bootstrap/scss/transitions';
+
+// Optional
+//@import '~bootstrap/scss/images';
+@import '~bootstrap/scss/dropdown';
+@import '~bootstrap/scss/nav';
+@import '~bootstrap/scss/navbar';
+@import '~bootstrap/scss/breadcrumb';
+@import '~bootstrap/scss/pagination';
+@import '~bootstrap/scss/alert';
+@import '~bootstrap/scss/close';
+
+/*@import '~bootstrap/scss/button-group';
+@import '~bootstrap/scss/card';
+@import '~bootstrap/scss/accordion';
+@import '~bootstrap/scss/badge';
+@import '~bootstrap/scss/progress';
+@import '~bootstrap/scss/list-group';
+@import '~bootstrap/scss/toasts';
+@import '~bootstrap/scss/modal';
+@import '~bootstrap/scss/tooltip';
+@import '~bootstrap/scss/popover';
+@import '~bootstrap/scss/spinners';*/
+
+// Helpers
+@import '~bootstrap/scss/helpers';
+
+// Utilities
+@import '~bootstrap/scss/utilities/api';
+
+@import '../ui/carousel';
+
+.navbar {
+ justify-content: flex-end;
+}
diff --git a/src/scss/libs/fontawesome.scss b/src/scss/libs/fontawesome.scss
new file mode 100644
index 0000000..b5c3c8d
--- /dev/null
+++ b/src/scss/libs/fontawesome.scss
@@ -0,0 +1,3 @@
+$fa-font-path: "~font-awesome/fonts";
+
+@import "~font-awesome/scss/font-awesome";
diff --git a/src/scss/libs/silverstripe.scss b/src/scss/libs/silverstripe.scss
new file mode 100644
index 0000000..e8627c2
--- /dev/null
+++ b/src/scss/libs/silverstripe.scss
@@ -0,0 +1,57 @@
+.message {
+ @extend .alert !optional;
+
+ &.warning {
+ @extend .alert-warning !optional;
+ }
+
+ &.error {
+ @extend .alert-danger !optional;
+ }
+}
+
+.embed-responsive-4by3,
+.embed-responsive-16by9 {
+ position: relative;
+ padding-top: 56.25%;
+
+ iframe {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ }
+}
+
+.embed-responsive-4by3 {
+ padding-top: 75%;
+}
+
+#ForgotPassword {
+ margin: 1rem 0;
+ width: 100%;
+}
+
+#BetterNavigator {
+ display: none;
+ top: 50% !important;
+ margin-top: -41px;
+
+ &.open {
+ top: 0 !important;
+ margin-top: 0;
+ }
+
+ a,
+ button,
+ div,
+ i,
+ span {
+ background-size: auto;
+ }
+
+ @media (min-width: map-get($grid-breakpoints, 'md')) {
+ display: block;
+ }
+}
diff --git a/src/scss/libs/silverstripe.shop.scss b/src/scss/libs/silverstripe.shop.scss
new file mode 100644
index 0000000..ada7ca5
--- /dev/null
+++ b/src/scss/libs/silverstripe.shop.scss
@@ -0,0 +1,8 @@
+.cart-footer {
+ margin-top: $grid-gutter-height / 2;
+}
+
+.address-panel,
+.account-nav {
+ margin-bottom: $grid-gutter-height / 2;
+}
diff --git a/src/scss/ui/carousel.scss b/src/scss/ui/carousel.scss
new file mode 100644
index 0000000..e4f9527
--- /dev/null
+++ b/src/scss/ui/carousel.scss
@@ -0,0 +1,93 @@
+@import '~bootstrap/scss/carousel';
+
+/*
+ * Bootstrap carousel improvement
+ */
+
+/*.carousel-item {
+ &.active {
+ display: flex !important;
+ justify-content: center;
+ align-items: flex-start;
+ }
+}*/
+
+$carousel-title-color: $white !default;
+$carousel-slide-min-height: 4rem !default;
+$carousel-text-shadow: 1px 1px $black !default;
+$carousel-controls-font-size: 3rem;
+$carousel-controls-zindex: 11 !default;
+$carousel-controls-shadow: 1px 1px $black !default;
+$carousel-controls-hover-bg: transparentize($black, 0.4) !default;
+$carousel-slide-img-loading-max-height: 25vh !default;
+
+.carousel-slide {
+ min-height: $carousel-slide-min-height;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ flex-direction: column;
+
+ >.container {
+ position: relative;
+ }
+
+ .video {
+ width: 100%;
+
+ iframe {
+ width: 100% !important;
+ height: auto !important;
+ }
+ }
+
+ .img {
+ display: block;
+ width: 100%;
+ }
+
+ img.loading {
+ max-height: $carousel-slide-img-loading-max-height;
+ }
+}
+
+.carousel-control-prev,
+.carousel-control-next {
+ z-index: $carousel-controls-zindex;
+ font-size: $carousel-controls-font-size;
+ text-shadow: $carousel-controls-shadow;
+
+ &:hover,
+ &:focus {
+ background: $carousel-controls-hover-bg;
+ }
+}
+
+.carousel-indicators li {
+ box-shadow: none;
+}
+
+.carousel-title {
+ color: $carousel-title-color;
+}
+
+.carousel-title,
+.carousel-content {
+ text-shadow: $carousel-text-shadow;
+}
+
+.carousel-caption {
+ right: 0;
+ left: auto;
+ width: 50%;
+ bottom: 0;
+}
+
+.slide-link__media {
+ position: absolute;
+ opacity: 0;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+}
diff --git a/src/scss/ui/flyout.scss b/src/scss/ui/flyout.scss
new file mode 100644
index 0000000..639e216
--- /dev/null
+++ b/src/scss/ui/flyout.scss
@@ -0,0 +1,34 @@
+$flyout-height-padding: 1rem;
+$flyout-width-padding: 2rem;
+$flyout-padding: $flyout-height-padding $flyout-width-padding;
+$flyout-bg: #000 !default;
+$flyout-color: #fff !default;
+$flyout-title-color: #fff !default;
+$flyout-transition: right 2s;
+
+.flyout-FlyoutUI {
+ position: absolute;
+ z-index: 99;
+ transform: translateY(-50%);
+ transition: $flyout-transition;
+ right: -100%;
+ top: 50%;
+ background: $flyout-bg;
+ color: $flyout-color;
+ padding: $flyout-padding;
+
+ &__active {
+ display: block;
+ right: 0;
+ }
+
+ &__title {
+ color: $flyout-title-color;
+ }
+
+ &__close {
+ position: absolute;
+ top: $flyout-height-padding;
+ right: $flyout-width-padding;
+ }
+}
diff --git a/src/scss/ui/form.stepped.scss b/src/scss/ui/form.stepped.scss
new file mode 100644
index 0000000..aa6a372
--- /dev/null
+++ b/src/scss/ui/form.stepped.scss
@@ -0,0 +1,9 @@
+.form-stepped {
+ .step {
+ display: none !important;
+
+ &.active {
+ display: flex !important;
+ }
+ }
+}
diff --git a/src/scss/ui/lightbox.scss b/src/scss/ui/lightbox.scss
new file mode 100644
index 0000000..a5efdad
--- /dev/null
+++ b/src/scss/ui/lightbox.scss
@@ -0,0 +1,14 @@
+@import '../_variables';
+
+/*$lightbox-breakpoint: map-get($grid-breakpoints, 'sm') !default;
+$lightbox-link-hover-color: $link-hover-color !default;
+
+@import '~@a2nt/meta-lightbox/src/scss/app';
+
+.lightbox-overlay-custom {
+ @extend .meta-lightbox-overlay;
+ @extend .meta-lightbox-theme-default;
+ @extend .meta-lightbox-effect-fade;
+ // meta-lightbox-open
+}
+*/
diff --git a/src/scss/ui/mailchimp.scss b/src/scss/ui/mailchimp.scss
new file mode 100644
index 0000000..a444c1c
--- /dev/null
+++ b/src/scss/ui/mailchimp.scss
@@ -0,0 +1,53 @@
+#mc_embed_signup,
+.mc_embed_signup {
+ padding: 2rem;
+ .mc-field-group {
+ @extend .form-group;
+ }
+ input[type='text'],
+ input[type='email'] {
+ @extend .form-control;
+ }
+ input[type='submit'] {
+ @extend .btn;
+ @extend .btn-primary;
+ margin: 0 auto;
+ width: 50%;
+ display: block;
+ }
+ .clear {
+ float: none;
+ clear: both;
+ }
+
+ .input-group {
+ @extend .form-check;
+ ul,
+ li {
+ list-style: none;
+ }
+ input[type='checkbox'] {
+ @extend .form-check-input;
+ }
+
+ label {
+ @extend .form-check-label;
+ }
+ }
+
+ .mce_inline_error,
+ #mce-success-response,
+ #mce-error-response {
+ margin-top: 1rem;
+ @extend .alert;
+ }
+
+ #mce-success-response {
+ @extend .alert-success;
+ }
+
+ .mce_inline_error,
+ #mce-error-response {
+ @extend .alert-danger;
+ }
+}
diff --git a/src/scss/ui/map.api.scss b/src/scss/ui/map.api.scss
new file mode 100644
index 0000000..72bacf7
--- /dev/null
+++ b/src/scss/ui/map.api.scss
@@ -0,0 +1,134 @@
+@import '../_variables';
+@import '../_animations';
+
+//@import "~mapbox-gl/src/css/mapbox-gl.css";
+$map-height: 30rem !default;
+
+$map-marker-color: $primary !default;
+$map-marker-size: 30px !default;
+
+$map-popup-font-size: 0.8rem !default;
+$map-popup-width: 16rem !default;
+$map-popup-height: 7rem !default;
+$map-popup-bg: $white !default;
+$map-popup-color: $body-color !default;
+
+.mapAPI-map {
+ height: $map-height;
+ //margin-bottom: $grid-gutter-element-height;
+}
+
+.mapboxgl {
+ &-popup {
+ width: $map-popup-width;
+ height: $map-popup-height;
+ font-size: $map-popup-font-size;
+ line-height: 1.2em;
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: flex;
+ pointer-events: none;
+ z-index: 4;
+ }
+
+ &-popup-anchor-bottom,
+ &-popup-anchor-bottom-left,
+ &-popup-anchor-bottom-right {
+ flex-direction: column-reverse;
+ }
+
+ &-popup-content {
+ min-width: $map-popup-width;
+ background: $map-popup-bg;
+ color: $map-popup-color;
+ position: relative;
+ pointer-events: auto;
+ padding: 0.8rem;
+ border-radius: 0.25rem;
+ min-height: 5rem;
+ box-shadow: 0 0.1rem 0.8rem 0 rgba(0, 0, 0, 0.4);
+ }
+
+ &-popup-close-button {
+ position: absolute;
+ right: 0;
+ top: 0;
+ font-size: 2rem;
+ padding: 0.5rem;
+ border-top-right-radius: 0.25rem;
+ z-index: 2;
+
+ &:hover,
+ &:focus {
+ background: $primary;
+ color: $white;
+ }
+ }
+
+ &-popup-tip {
+ width: 0;
+ height: 0;
+ border: 0.8rem solid transparent;
+ z-index: 1;
+ }
+
+ &-popup-anchor-bottom &-popup-tip {
+ border-top-color: $map-popup-bg;
+ align-self: center;
+ border-bottom: none;
+ }
+
+ &-marker {
+ width: $map-marker-size;
+ height: $map-marker-size;
+ font-size: $map-marker-size;
+ line-height: 1em;
+ color: $map-marker-color;
+ cursor: pointer;
+ text-align: center;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+
+ .marker-icon,
+ .fas,
+ .fab,
+ .far {
+ animation: pulse 0.8s linear infinite;
+ }
+ }
+
+ &-cluster {
+ background: $info;
+ color: color-yiq($info);
+ border-radius: 100%;
+ font-weight: bold;
+ font-size: 1.2rem;
+ display: flex;
+ align-items: center;
+ animation: pulse 0.8s linear infinite;
+
+ &::before,
+ &::after {
+ content: "";
+ display: block;
+ position: absolute;
+ width: 140%;
+ height: 140%;
+
+ transform: translate(-50%, -50%);
+ top: 50%;
+ left: 50%;
+ background: $info;
+ opacity: 0.2;
+ border-radius: 100%;
+ z-index: -1;
+ }
+
+ &::after {
+ width: 180%;
+ height: 180%;
+ }
+ }
+}
diff --git a/src/scss/ui/multislider.scss b/src/scss/ui/multislider.scss
new file mode 100644
index 0000000..493cc41
--- /dev/null
+++ b/src/scss/ui/multislider.scss
@@ -0,0 +1,55 @@
+@import '../_variables';
+$grid-gutter-element-height: 2rem !default;
+
+.jsMultiSlider {
+ position: relative;
+ display: flex;
+ margin-bottom: $grid-gutter-element-height/2;
+
+ align-items: center;
+ justify-content: center;
+ min-width: 100%;
+
+ &-active {
+ margin-bottom: 0;
+ }
+
+ .slide {
+ position: relative;
+ padding: 0 0.5rem;
+ }
+}
+
+.jsMultiSlider-container {
+ position: relative;
+ margin-bottom: $grid-gutter-element-height/2;
+
+ .slider-actions {
+ font-size: 2rem;
+ .act {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+ &-slider-prev {
+ }
+ &-slider-next {
+ left: auto;
+ right: 0;
+ }
+ }
+ }
+}
+
+.jsMultiSlider-slides-container {
+ overflow: hidden;
+ margin: 0 2rem;
+
+ > .slider-nav {
+ position: relative;
+ }
+}