diff --git a/src/js/_components/_apollo.cache.js b/src/js/_components/_apollo.cache.js
new file mode 100644
index 0000000..6599de2
--- /dev/null
+++ b/src/js/_components/_apollo.cache.js
@@ -0,0 +1,19 @@
+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/_components/_element.jsx b/src/js/_components/_element.jsx
new file mode 100644
index 0000000..12172a8
--- /dev/null
+++ b/src/js/_components/_element.jsx
@@ -0,0 +1,208 @@
+/*
+ * Lightbox window
+ */
+import { Component } from 'react';
+import Events from '../_events';
+
+import { client } from './_apollo';
+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/_components/_main.css-screen-size.js b/src/js/_components/_main.css-screen-size.js
new file mode 100644
index 0000000..c1749e3
--- /dev/null
+++ b/src/js/_components/_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/_components/_main.links.js b/src/js/_components/_main.links.js
new file mode 100644
index 0000000..5414822
--- /dev/null
+++ b/src/js/_components/_main.links.js
@@ -0,0 +1,146 @@
+// browser tab visibility state detection
+
+import Events from '../_events';
+import Consts from '../_consts';
+import Page from './_page.jsx';
+
+const MainUILinks = ((W) => {
+ const NAME = '_main.links';
+ const D = document;
+ const BODY = D.body;
+
+ class MainUILinks {
+ 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 reset() {
+ // reset focus
+ D.activeElement.blur();
+
+ // remove active and loading classes
+ D.querySelectorAll('.graphql-page,.nav-item').forEach((el2) => {
+ el2.classList.remove('active');
+ el2.classList.remove('loading');
+ });
+ }
+
+ static popState(e) {
+ const ui = this;
+
+ if (!ui.GraphPage) {
+ console.log(
+ `${NAME}: [popstate] GraphPage is missing. Have to render it first`,
+ );
+
+ ui.GraphPage = ReactDOM.render(
+ ,
+ document.getElementById('MainContent'),
+ );
+ }
+
+ 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();
+ D.querySelectorAll(`[data-${ui.name}-id="${e.state.link}"]`).forEach(
+ (el) => {
+ el.classList.add('active');
+ },
+ );
+
+ ui.GraphPage.setState(state);
+ } 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);
+ }
+ }
+
+ // link specific event {this} = current link, not MainUILinks
+ static loadClick(e) {
+ console.groupCollapsed(`${NAME}: load on click`);
+ const ui = MainUILinks;
+ ui.reset();
+
+ e.preventDefault();
+
+ if (!ui.GraphPage) {
+ ui.GraphPage = ReactDOM.render(
+ ,
+ document.getElementById('MainContent'),
+ );
+ }
+
+ const el = e.currentTarget;
+ const link = el.getAttribute('href') || el.getAttribute('data-href');
+
+ ui.GraphPage.state.current = el;
+
+ el.classList.add('loading');
+ ui.GraphPage.load(link)
+ .then((response) => {
+ el.classList.remove('loading');
+ el.classList.add('active');
+
+ 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,
+ );
+ }
+
+ console.groupEnd(`${NAME}: load on click`);
+ })
+ .catch((e) => {
+ console.log(e);
+
+ el.classList.remove('loading');
+ el.classList.add('not-found');
+
+ console.groupEnd(`${NAME}: load on click`);
+ });
+ }
+ }
+
+ W.addEventListener(`${Events.LOADED}`, () => {
+ MainUILinks.init();
+ });
+
+ W.addEventListener(`${Events.AJAX}`, () => {
+ MainUILinks.loaded();
+ });
+})(window);
+
+export default MainUILinks;
diff --git a/src/js/_components/_main.online.js b/src/js/_components/_main.online.js
new file mode 100644
index 0000000..1fbdb31
--- /dev/null
+++ b/src/js/_components/_main.online.js
@@ -0,0 +1,101 @@
+// 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
+ 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}`, () => {
+ UPDATE_ONLINE_STATUS(true);
+ });
+})(window);
diff --git a/src/js/_components/_main.touch.js b/src/js/_components/_main.touch.js
new file mode 100644
index 0000000..9828a20
--- /dev/null
+++ b/src/js/_components/_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/_components/_main.visibility.js b/src/js/_components/_main.visibility.js
new file mode 100644
index 0000000..8459a35
--- /dev/null
+++ b/src/js/_components/_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);