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: `
${config['icon']}
`, + 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 += + `
` + + `${item['accessibility_caption']}` + + '
'; + }, + ); + + 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; + } +}