├── .npmrc ├── .gitattributes ├── src ├── sass │ ├── vendor │ │ ├── _all.scss │ │ └── reseter.scss │ ├── util │ │ ├── _all.scss │ │ ├── reset-btn.scss │ │ ├── font.scss │ │ ├── ellipsis.scss │ │ └── media-query.scss │ ├── config │ │ ├── radius.scss │ │ ├── z-index.scss │ │ ├── _all.scss │ │ ├── breakpoint.scss │ │ ├── font.scss │ │ ├── border.scss │ │ ├── shadow.scss │ │ ├── root-variable.scss │ │ └── color.scss │ ├── component │ │ ├── footer.scss │ │ ├── label.scss │ │ ├── form.scss │ │ ├── box.scss │ │ ├── loader.scss │ │ ├── table.scss │ │ ├── grid.scss │ │ ├── button.scss │ │ ├── section.scss │ │ ├── hero.scss │ │ ├── toast.scss │ │ ├── header.scss │ │ └── helper.scss │ └── main.scss ├── img │ └── no-image.jpg ├── public │ ├── favicon.ico │ ├── favicon.png │ ├── robots.txt │ ├── sitemap.xml │ ├── favicon.svg │ ├── data-theme.js │ └── .htaccess ├── font │ ├── inter-100.woff2 │ ├── inter-200.woff2 │ ├── inter-300.woff2 │ ├── inter-400.woff2 │ ├── inter-500.woff2 │ ├── inter-600.woff2 │ ├── inter-700.woff2 │ ├── inter-800.woff2 │ ├── inter-900.woff2 │ ├── inter-100-italic.woff2 │ ├── inter-200-italic.woff2 │ ├── inter-300-italic.woff2 │ ├── inter-400-italic.woff2 │ ├── inter-500-italic.woff2 │ ├── inter-600-italic.woff2 │ ├── inter-700-italic.woff2 │ ├── inter-800-italic.woff2 │ └── inter-900-italic.woff2 ├── js │ ├── component │ │ ├── watermark.js │ │ ├── protect-image.js │ │ ├── placeholder-image.js │ │ ├── format-tel-link.js │ │ ├── external-link-norefer.js │ │ ├── responsive-table.js │ │ ├── header.js │ │ ├── data-copy.js │ │ ├── data-toast.js │ │ ├── smooth-scroll.js │ │ ├── logger.js │ │ ├── section-navigation.js │ │ └── keyboard-focus-fix.js │ ├── util │ │ ├── sleep.js │ │ ├── debounce.js │ │ ├── smooth-scroll.js │ │ ├── random.js │ │ ├── is-object.js │ │ ├── vibrate.js │ │ ├── encoding.js │ │ ├── clipboard.js │ │ ├── fade.js │ │ ├── route.js │ │ ├── storage.js │ │ ├── toast.js │ │ ├── cookie.js │ │ ├── cyr-to-lat.js │ │ ├── geolocation.js │ │ └── request.js │ ├── api │ │ ├── index.js │ │ ├── _dummy.js │ │ └── getSearchResultByText.js │ └── main.js ├── component │ ├── footer.twig │ ├── loader.twig │ ├── hero.twig │ └── header.twig ├── data │ ├── header.json │ └── features.json ├── config.js ├── view │ ├── 404.twig │ ├── index.twig │ └── guide.twig └── layout │ └── main.twig ├── .env ├── docs ├── favicon.ico ├── favicon.png ├── robots.txt ├── img │ └── no-image.jpg ├── font │ ├── inter-100.woff2 │ ├── inter-200.woff2 │ ├── inter-300.woff2 │ ├── inter-400.woff2 │ ├── inter-500.woff2 │ ├── inter-600.woff2 │ ├── inter-700.woff2 │ ├── inter-800.woff2 │ ├── inter-900.woff2 │ ├── inter-100-italic.woff2 │ ├── inter-200-italic.woff2 │ ├── inter-300-italic.woff2 │ ├── inter-400-italic.woff2 │ ├── inter-500-italic.woff2 │ ├── inter-600-italic.woff2 │ ├── inter-700-italic.woff2 │ ├── inter-800-italic.woff2 │ └── inter-900-italic.woff2 ├── sitemap.xml ├── favicon.svg ├── data-theme.js ├── .htaccess ├── 404.html ├── index.html ├── js │ └── main.js ├── guide.html └── components.html ├── .dockerignore ├── .vscode ├── extensions.json └── settings.json ├── .editorconfig ├── jsconfig.json ├── gulp ├── del.js ├── font.js ├── htmlmin.js ├── public-files.js ├── version-number.js ├── server.js ├── html-transform-base.js ├── js.js ├── img.js ├── app.js ├── sass.js ├── path.js └── twig.js ├── nginx.conf ├── Dockerfile ├── .gitignore ├── backend.js ├── .stylelintrc ├── LICENSE ├── eslint.config.js ├── gulpfile.js ├── README.md └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /src/sass/vendor/_all.scss: -------------------------------------------------------------------------------- 1 | @import "./reseter"; 2 | -------------------------------------------------------------------------------- /src/sass/vendor/reseter.scss: -------------------------------------------------------------------------------- 1 | @import "reseter.css/css/reseter.min"; 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # console.log(APP_ENV_TEST) 2 | APP_ENV_TEST=test 3 | APP_API_KEY= 4 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/favicon.png -------------------------------------------------------------------------------- /docs/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /404 3 | 4 | Sitemap: https://yoursite.com/sitemap.xml 5 | -------------------------------------------------------------------------------- /src/img/no-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/img/no-image.jpg -------------------------------------------------------------------------------- /docs/img/no-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/img/no-image.jpg -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/public/favicon.ico -------------------------------------------------------------------------------- /src/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/public/favicon.png -------------------------------------------------------------------------------- /docs/font/inter-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-100.woff2 -------------------------------------------------------------------------------- /docs/font/inter-200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-200.woff2 -------------------------------------------------------------------------------- /docs/font/inter-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-300.woff2 -------------------------------------------------------------------------------- /docs/font/inter-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-400.woff2 -------------------------------------------------------------------------------- /docs/font/inter-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-500.woff2 -------------------------------------------------------------------------------- /docs/font/inter-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-600.woff2 -------------------------------------------------------------------------------- /docs/font/inter-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-700.woff2 -------------------------------------------------------------------------------- /docs/font/inter-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-800.woff2 -------------------------------------------------------------------------------- /docs/font/inter-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-900.woff2 -------------------------------------------------------------------------------- /src/font/inter-100.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-100.woff2 -------------------------------------------------------------------------------- /src/font/inter-200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-200.woff2 -------------------------------------------------------------------------------- /src/font/inter-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-300.woff2 -------------------------------------------------------------------------------- /src/font/inter-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-400.woff2 -------------------------------------------------------------------------------- /src/font/inter-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-500.woff2 -------------------------------------------------------------------------------- /src/font/inter-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-600.woff2 -------------------------------------------------------------------------------- /src/font/inter-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-700.woff2 -------------------------------------------------------------------------------- /src/font/inter-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-800.woff2 -------------------------------------------------------------------------------- /src/font/inter-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-900.woff2 -------------------------------------------------------------------------------- /src/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /404 3 | 4 | Sitemap: https://yoursite.com/sitemap.xml 5 | -------------------------------------------------------------------------------- /docs/font/inter-100-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-100-italic.woff2 -------------------------------------------------------------------------------- /docs/font/inter-200-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-200-italic.woff2 -------------------------------------------------------------------------------- /docs/font/inter-300-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-300-italic.woff2 -------------------------------------------------------------------------------- /docs/font/inter-400-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-400-italic.woff2 -------------------------------------------------------------------------------- /docs/font/inter-500-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-500-italic.woff2 -------------------------------------------------------------------------------- /docs/font/inter-600-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-600-italic.woff2 -------------------------------------------------------------------------------- /docs/font/inter-700-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-700-italic.woff2 -------------------------------------------------------------------------------- /docs/font/inter-800-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-800-italic.woff2 -------------------------------------------------------------------------------- /docs/font/inter-900-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/docs/font/inter-900-italic.woff2 -------------------------------------------------------------------------------- /src/font/inter-100-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-100-italic.woff2 -------------------------------------------------------------------------------- /src/font/inter-200-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-200-italic.woff2 -------------------------------------------------------------------------------- /src/font/inter-300-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-300-italic.woff2 -------------------------------------------------------------------------------- /src/font/inter-400-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-400-italic.woff2 -------------------------------------------------------------------------------- /src/font/inter-500-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-500-italic.woff2 -------------------------------------------------------------------------------- /src/font/inter-600-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-600-italic.woff2 -------------------------------------------------------------------------------- /src/font/inter-700-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-700-italic.woff2 -------------------------------------------------------------------------------- /src/font/inter-800-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-800-italic.woff2 -------------------------------------------------------------------------------- /src/font/inter-900-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zakandaiev/frontend-starter/HEAD/src/font/inter-900-italic.woff2 -------------------------------------------------------------------------------- /src/sass/util/_all.scss: -------------------------------------------------------------------------------- 1 | @import "./font"; 2 | @import "./ellipsis"; 3 | @import "./media-query"; 4 | @import "./reset-btn"; 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # IGNORE ALL 2 | ** 3 | 4 | # EXCEPT 5 | !gulp/ 6 | !src/ 7 | !.env 8 | !gulpfile.* 9 | !package.json 10 | -------------------------------------------------------------------------------- /src/js/component/watermark.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | console.log('%cMade by Zakandaiev', 'background:#e44d26;color:#fff;padding:10px;font-weight:bold;'); 3 | -------------------------------------------------------------------------------- /src/sass/config/radius.scss: -------------------------------------------------------------------------------- 1 | $radius-list: ( 2 | xxs: 2px, 3 | xs: 4px, 4 | sm: 8px, 5 | md: 12px, 6 | lg: 16px, 7 | xl: 20px, 8 | xxl: 24px, 9 | ); 10 | -------------------------------------------------------------------------------- /src/js/util/sleep.js: -------------------------------------------------------------------------------- 1 | function sleep(ms) { 2 | // eslint-disable-next-line 3 | return new Promise((resolve) => setTimeout(resolve, ms)); 4 | } 5 | 6 | export default sleep; 7 | -------------------------------------------------------------------------------- /src/sass/util/reset-btn.scss: -------------------------------------------------------------------------------- 1 | @mixin reset-btn { 2 | all: unset; 3 | 4 | cursor: auto; 5 | 6 | box-sizing: border-box; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | -------------------------------------------------------------------------------- /src/js/api/index.js: -------------------------------------------------------------------------------- 1 | import dummy from './_dummy'; 2 | import getSearchResultByText from './getSearchResultByText'; 3 | 4 | export { 5 | dummy, 6 | getSearchResultByText, 7 | }; 8 | -------------------------------------------------------------------------------- /src/sass/config/z-index.scss: -------------------------------------------------------------------------------- 1 | $z-index-list: ( 2 | top: 1000, 3 | tooltip: 300, 4 | modal: 200, 5 | header: 100, 6 | default: 10, 7 | below: -10, 8 | bottom: -1000, 9 | ); 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "EditorConfig.EditorConfig", 5 | "stylelint.vscode-stylelint", 6 | "mblode.twig-language" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/component/footer.twig: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | end_of_line = lf 8 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "@/*": [ 6 | "./src/*" 7 | ] 8 | } 9 | }, 10 | "exclude": [ 11 | "node_modules", 12 | "dist" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/data/header.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Home", 4 | "link": "/" 5 | }, 6 | { 7 | "name": "Guide", 8 | "link": "/guide" 9 | }, 10 | { 11 | "name": "Components", 12 | "link": "/components" 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /src/js/component/protect-image.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | document.addEventListener('contextmenu', (event) => { 3 | if (event.target.nodeName === 'IMG') { 4 | event.preventDefault(); 5 | } 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/sass/config/_all.scss: -------------------------------------------------------------------------------- 1 | @import "./border"; 2 | @import "./breakpoint"; 3 | @import "./color"; 4 | @import "./font"; 5 | @import "./radius"; 6 | @import "./shadow"; 7 | @import "./z-index"; 8 | 9 | // AT THE END 10 | @import "./root-variable"; 11 | -------------------------------------------------------------------------------- /gulp/del.js: -------------------------------------------------------------------------------- 1 | import { deleteAsync } from 'del'; 2 | import { path } from './path.js'; 3 | 4 | const delConfig = { 5 | force: true, 6 | }; 7 | 8 | function del() { 9 | return deleteAsync(path.del, delConfig); 10 | } 11 | 12 | export default del; 13 | -------------------------------------------------------------------------------- /src/component/loader.twig: -------------------------------------------------------------------------------- 1 | {% set classname = size is not defined or size is null ? '' : ' loader_' ~ size %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/sass/component/footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | display: block; 3 | margin: 3rem 0 0; 4 | border-top: var(--border-md-medium); 5 | text-align: center; 6 | } 7 | 8 | :root[data-theme="dark"] { 9 | .footer { 10 | border-color: #000; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/js/component/placeholder-image.js: -------------------------------------------------------------------------------- 1 | window.onload = () => { 2 | document.querySelectorAll('img').forEach((image) => { 3 | if (image.complete && typeof image.naturalWidth === 'number' && image.naturalWidth <= 0) { 4 | image.src = './img/no-image.jpg'; 5 | } 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/sass/config/breakpoint.scss: -------------------------------------------------------------------------------- 1 | $breakpoint: ( 2 | xs: 0, 3 | sm: 576px, 4 | md: 768px, 5 | lg: 992px, 6 | xl: 1200px, 7 | xxl: 1400px 8 | ); 9 | 10 | $breakpoint-reverse: ( 11 | xs: 575px, 12 | sm: 767px, 13 | md: 991px, 14 | lg: 1199px, 15 | xl: 1399px 16 | ); 17 | -------------------------------------------------------------------------------- /gulp/font.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import newer from 'gulp-newer'; 3 | import { path } from './path.js'; 4 | 5 | function font() { 6 | return gulp.src(path.font.src, { encoding: false }) 7 | .pipe(newer(path.font.dist)) 8 | .pipe(gulp.dest(path.font.dist)); 9 | } 10 | 11 | export default font; 12 | -------------------------------------------------------------------------------- /src/js/component/format-tel-link.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | document.querySelectorAll('a').forEach((anchor) => { 3 | if (anchor.hasAttribute('href') && anchor.href.startsWith('tel:')) { 4 | anchor.href = `tel:${anchor.href.replaceAll(/[^\d+]/g, '')}`; 5 | } 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/sass/util/font.scss: -------------------------------------------------------------------------------- 1 | @mixin font($name, $style, $weight, $url, $format: "woff2") { 2 | @font-face { 3 | font-family: $name; 4 | font-weight: $weight; 5 | font-style: $style; 6 | 7 | font-display: swap; 8 | font-stretch: 100%; 9 | src: local(""), url($url) format($format); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/js/component/external-link-norefer.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | document.querySelectorAll('a').forEach((anchor) => { 3 | if (anchor.hasAttribute('target') && anchor.getAttribute('target') === '_blank') { 4 | anchor.setAttribute('rel', 'noopener noreferrer nofollow'); 5 | } 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /gulp/htmlmin.js: -------------------------------------------------------------------------------- 1 | import htmlminInstance from 'gulp-htmlmin'; 2 | 3 | const htmlminConfig = { 4 | collapseWhitespace: true, 5 | includeAutoGeneratedTags: false, 6 | minifyCSS: true, 7 | minifyJS: true, 8 | removeComments: true, 9 | }; 10 | 11 | const htmlmin = () => htmlminInstance(htmlminConfig); 12 | 13 | export default htmlmin; 14 | -------------------------------------------------------------------------------- /gulp/public-files.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import newer from 'gulp-newer'; 3 | import { path } from './path.js'; 4 | 5 | function publicFiles() { 6 | return gulp.src(path.public.src, { dot: true, encoding: false }) 7 | .pipe(newer(path.public.dist)) 8 | .pipe(gulp.dest(path.public.dist)); 9 | } 10 | 11 | export default publicFiles; 12 | -------------------------------------------------------------------------------- /gulp/version-number.js: -------------------------------------------------------------------------------- 1 | import versionNumberInstance from 'gulp-version-number'; 2 | import { packageData } from './app.js'; 3 | 4 | const versionNumberConfig = { 5 | value: packageData.version || '%MDS%', 6 | append: { 7 | key: 'v', 8 | to: 'all', 9 | }, 10 | }; 11 | 12 | const versionNumber = () => versionNumberInstance(versionNumberConfig); 13 | 14 | export default versionNumber; 15 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const Config = { 2 | app: { 3 | /* eslint-disable no-undef */ 4 | name: APP_NAME, 5 | version: APP_VERSION, 6 | mode: APP_MODE, 7 | }, 8 | api: { 9 | delayMs: 500, 10 | timeoutMs: 30000, 11 | 12 | backend: 'http://localhost:4173', 13 | key: APP_API_KEY, 14 | }, 15 | search: { 16 | debounceMs: 1000, 17 | }, 18 | }; 19 | 20 | export default Config; 21 | -------------------------------------------------------------------------------- /src/js/util/debounce.js: -------------------------------------------------------------------------------- 1 | let timeout; 2 | 3 | async function debounce(callback, delay = 500, ...args) { 4 | return new Promise((resolve) => { 5 | if (timeout) { 6 | clearTimeout(timeout); 7 | } 8 | 9 | timeout = setTimeout(async () => { 10 | const result = await callback(...args); 11 | 12 | resolve(result); 13 | }, delay); 14 | }); 15 | } 16 | 17 | export default debounce; 18 | -------------------------------------------------------------------------------- /docs/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://yoursite.com 5 | 2024-02-17T22:22:22+02:00 6 | 1.00 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/js/util/smooth-scroll.js: -------------------------------------------------------------------------------- 1 | function smoothScroll(element = null, offsetTop = 0, behavior = 'smooth') { 2 | if (element) { 3 | const elementPosition = element.getBoundingClientRect().top; 4 | const offsetPosition = elementPosition + window.scrollY - offsetTop; 5 | 6 | window.scrollTo({ top: offsetPosition, behavior }); 7 | } else { 8 | window.scrollTo({ top: 0, behavior }); 9 | } 10 | } 11 | 12 | export default smoothScroll; 13 | -------------------------------------------------------------------------------- /src/public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://yoursite.com 5 | 2024-02-17T22:22:22+02:00 6 | 1.00 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/sass/util/ellipsis.scss: -------------------------------------------------------------------------------- 1 | @mixin ellipsis($lines: 1) { 2 | overflow: hidden; 3 | text-overflow: ellipsis; 4 | white-space: nowrap; 5 | 6 | @if $lines > 1 { 7 | @supports (-webkit-line-clamp: $lines) { 8 | overflow: hidden; 9 | display: -webkit-box; 10 | -webkit-box-orient: vertical; 11 | -webkit-line-clamp: $lines; 12 | 13 | text-overflow: ellipsis; 14 | white-space: initial; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/sass/config/font.scss: -------------------------------------------------------------------------------- 1 | $font-weight-list: ( 2 | // thin: 100, 3 | // extralight: 200, 4 | // light: 300, 5 | regular: 400, 6 | medium: 500, 7 | // semibold: 600, 8 | bold: 700, 9 | // extrabold: 800, 10 | // "black": 900, 11 | ); 12 | 13 | @each $key, $val in $font-weight-list { 14 | @include font("Inter", normal, $val, "../font/inter-#{$val}.woff2"); 15 | @include font("Inter", italic, $val, "../font/inter-#{$val}-italic.woff2"); 16 | } 17 | -------------------------------------------------------------------------------- /src/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/js/component/responsive-table.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | document.querySelectorAll('table').forEach((table) => { 3 | if (!table.parentElement.classList.contains('table-responsive') && !table.hasAttribute('data-noresponsive')) { 4 | const wrapper = document.createElement('div'); 5 | 6 | wrapper.classList.add('table-responsive'); 7 | 8 | table.before(wrapper); 9 | 10 | wrapper.appendChild(table); 11 | } 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 3000; 3 | server_name _; 4 | 5 | include /etc/nginx/mime.types; 6 | charset utf-8; 7 | 8 | root /usr/share/nginx/html; 9 | index index.html; 10 | 11 | location / { 12 | autoindex off; 13 | expires off; 14 | 15 | add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0" always; 16 | add_header Pragma "no-cache" always; 17 | add_header Expires 0 always; 18 | 19 | try_files $uri $uri/ /index.html; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine3.20 AS build 2 | 3 | WORKDIR /app 4 | 5 | ARG APP_MODE 6 | ENV APP_MODE=$APP_MODE 7 | 8 | COPY . /app 9 | 10 | RUN apk update && apk add --no-cache bash curl mc netcat-openbsd && npm install --ignore-scripts && npm run build && rm -rf /etc/apk/cache 11 | 12 | # CMD ["npm", "run", "preview"] 13 | 14 | FROM nginx:stable-alpine 15 | 16 | COPY nginx.conf /etc/nginx/conf.d/default.conf 17 | COPY --from=build /app/dist /usr/share/nginx/html 18 | 19 | EXPOSE 3000 20 | 21 | CMD ["nginx", "-g", "daemon off;"] 22 | -------------------------------------------------------------------------------- /src/js/component/header.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | setHeader(); 3 | 4 | window.addEventListener('scroll', () => setHeader()); 5 | }); 6 | 7 | function setHeader() { 8 | const header = document.getElementById('header'); 9 | 10 | if (!header) { 11 | return false; 12 | } 13 | 14 | const scrolledPxs = document.documentElement.scrollTop; 15 | 16 | if (scrolledPxs > 0) { 17 | header.classList.add('is-scrolled'); 18 | } else { 19 | header.classList.remove('is-scrolled'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/js/util/random.js: -------------------------------------------------------------------------------- 1 | function randomInt(mi, ma) { 2 | const min = parseInt(mi, 10); 3 | const max = parseInt(ma, 10); 4 | 5 | return Math.floor(Math.random() * (max - min + 1) + min); 6 | } 7 | 8 | function randomFloat(mi, ma) { 9 | const min = parseFloat(mi); 10 | const max = parseFloat(ma); 11 | 12 | return Math.random() * (max - min) + min; 13 | } 14 | 15 | function randomString() { 16 | return Math.random().toString(32).replace('0.', ''); 17 | } 18 | 19 | export { 20 | randomFloat, 21 | randomInt, 22 | randomString, 23 | }; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | cache 13 | dist 14 | dist-ssr 15 | coverage 16 | *.local 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | !.vscode/settings.json 22 | .idea 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | 29 | *.tsbuildinfo 30 | +.* 31 | 32 | # Cypress 33 | /cypress/videos/ 34 | /cypress/screenshots/ 35 | 36 | # Vitest 37 | __screenshots__/ 38 | -------------------------------------------------------------------------------- /src/js/api/_dummy.js: -------------------------------------------------------------------------------- 1 | import Config from '@/config'; 2 | import { request } from '@/js/util/request'; 3 | 4 | async function dummy(body = {}, opt = {}) { 5 | const url = `${Config.api.backend}/dummy`; 6 | const options = { 7 | method: 'POST', 8 | body: { 9 | ...body, 10 | }, 11 | }; 12 | 13 | const result = await request(url, options); 14 | if (result.status !== 'success') { 15 | return false; 16 | } 17 | 18 | return opt.returnResponse === true 19 | ? result 20 | : result.data; 21 | } 22 | 23 | export default dummy; 24 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | import '@/js/component/watermark'; 2 | import '@/js/component/logger'; 3 | 4 | import '@/js/component/data-copy'; 5 | import '@/js/component/data-toast'; 6 | import '@/js/component/external-link-norefer'; 7 | import '@/js/component/format-tel-link'; 8 | import '@/js/component/keyboard-focus-fix'; 9 | import '@/js/component/placeholder-image'; 10 | import '@/js/component/protect-image'; 11 | import '@/js/component/responsive-table'; 12 | import '@/js/component/smooth-scroll'; 13 | 14 | import '@/js/component/header'; 15 | import '@/js/component/section-navigation'; 16 | -------------------------------------------------------------------------------- /src/js/component/data-copy.js: -------------------------------------------------------------------------------- 1 | import copyToClipboard from '@/js/util/clipboard'; 2 | 3 | document.addEventListener('DOMContentLoaded', () => { 4 | document.addEventListener('click', (event) => { 5 | const clipboardNode = event.target.closest('[data-copy]'); 6 | 7 | if (!clipboardNode) { 8 | return false; 9 | } 10 | 11 | const text = clipboardNode.getAttribute('data-copy').length > 0 ? clipboardNode.getAttribute('data-copy') : clipboardNode.textContent; 12 | 13 | if (!text) { 14 | return false; 15 | } 16 | 17 | copyToClipboard(text); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/js/api/getSearchResultByText.js: -------------------------------------------------------------------------------- 1 | import Config from '@/config'; 2 | import { request } from '@/js/util/request'; 3 | 4 | async function getSearchResultByText(body = {}, opt = {}) { 5 | const url = `${Config.api.backend}/getSearchResultByText`; 6 | const options = { 7 | method: 'POST', 8 | body: { 9 | ...body, 10 | }, 11 | }; 12 | 13 | const result = await request(url, options); 14 | if (result.status !== 'success') { 15 | return false; 16 | } 17 | 18 | return opt.returnResponse === true 19 | ? result 20 | : result.data; 21 | } 22 | 23 | export default getSearchResultByText; 24 | -------------------------------------------------------------------------------- /src/component/hero.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ APP_NAME_FORMATTED }}

4 | 5 |

Modern Frontend Tooling

6 | 7 |

{{ APP_DESCRIPTION }}

8 | 9 |
10 | Get Started 11 | View on GitHub 12 |
13 |
14 | 15 |
16 | HTML5 Logo 17 |
18 |
19 | -------------------------------------------------------------------------------- /src/js/component/data-toast.js: -------------------------------------------------------------------------------- 1 | import toast from '@/js/util/toast'; 2 | 3 | document.addEventListener('DOMContentLoaded', () => { 4 | document.addEventListener('click', (event) => { 5 | const toastNode = event.target.closest('[data-toast]'); 6 | 7 | if (!toastNode) { 8 | return false; 9 | } 10 | 11 | const text = toastNode.getAttribute('data-toast').length > 0 ? toastNode.getAttribute('data-toast') : toastNode.textContent; 12 | const type = toastNode.getAttribute('data-toast-type'); 13 | const duration = toastNode.getAttribute('data-toast-duration'); 14 | 15 | toast(text, type, duration); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/sass/util/media-query.scss: -------------------------------------------------------------------------------- 1 | @mixin query($bp, $computer_first: false) { 2 | @if $computer_first == true { 3 | @if map-has-key($breakpoint-reverse, $bp) { 4 | $bp-value: map-get($breakpoint-reverse, $bp); 5 | 6 | @media (max-width: $bp-value) { 7 | @content; 8 | } 9 | } @else { 10 | @warn 'Invalid breakpoint: #{$bp}'; 11 | } 12 | } @else { 13 | @if map-has-key($breakpoint, $bp) { 14 | $bp-value: map-get($breakpoint, $bp); 15 | 16 | @media (min-width: $bp-value) { 17 | @content; 18 | } 19 | } @else { 20 | @warn 'Invalid breakpoint: #{$bp}'; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/js/util/is-object.js: -------------------------------------------------------------------------------- 1 | function isArray(a) { 2 | return (!!a) && (a.constructor === Array); 3 | } 4 | 5 | function isObject(o) { 6 | return (!!o) && (o.constructor === Object); 7 | } 8 | 9 | function isStringValidJSON(s) { 10 | if ( 11 | /^[\],:{}\s]*$/.test( 12 | // eslint-disable-next-line 13 | s.replace(/\\["\\\/bfnrtu]/g, '@') 14 | // eslint-disable-next-line 15 | .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']') 16 | .replace(/(?:^|:|,)(?:\s*\[)+/g, ''), 17 | ) 18 | ) { 19 | return true; 20 | } 21 | 22 | return false; 23 | } 24 | 25 | export { 26 | isArray, 27 | isObject, 28 | isStringValidJSON, 29 | }; 30 | -------------------------------------------------------------------------------- /backend.js: -------------------------------------------------------------------------------- 1 | import Fastify from 'fastify'; 2 | 3 | const fastify = Fastify({ 4 | logger: false, 5 | }); 6 | 7 | fastify.addHook('onRequest', async (request, reply) => { 8 | reply.header('Access-Control-Allow-Origin', '*'); 9 | reply.header('Access-Control-Allow-Methods', '*'); 10 | 11 | reply.type('application/json'); 12 | 13 | reply.code(200); 14 | }); 15 | 16 | fastify.get('/', async (request) => { 17 | // eslint-disable-next-line 18 | console.log(request.params); 19 | 20 | return { 21 | status: 'success', 22 | payload: 'backend test', 23 | }; 24 | }); 25 | 26 | try { 27 | await fastify.listen({ port: 4173 }); 28 | } catch (err) { 29 | fastify.log.error(err); 30 | process.exit(1); 31 | } 32 | -------------------------------------------------------------------------------- /src/view/404.twig: -------------------------------------------------------------------------------- 1 | {% set title = '404' %} 2 | 3 | {% extends '@layout/main.twig' %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |

404

10 | 11 |

Page not found

12 | 13 |

The page you are looking for might have been removed

14 | 15 |
16 | Return to Homepage 17 |
18 |
19 | 20 |
21 | HTML5 Logo 22 |
23 |
24 |
25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /src/view/index.twig: -------------------------------------------------------------------------------- 1 | {% set active_nav = '/' %} 2 | 3 | {% extends '@layout/main.twig' %} 4 | 5 | {% block content %} 6 |
7 | 8 | {% include '@component/hero.twig' %} 9 | 10 |
11 |
12 | {% for feature in features %} 13 |
14 |
15 |
{{ feature.icon }}
16 | 17 |

{{ feature.title }}

18 | 19 |
{{ feature.text }}
20 |
21 |
22 | {% endfor %} 23 |
24 |
25 | 26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /src/js/util/vibrate.js: -------------------------------------------------------------------------------- 1 | function getVibrate(type) { 2 | switch (type) { 3 | case 'success': { 4 | return [40]; 5 | } 6 | case 'warning': { 7 | return [40, 20, 40]; 8 | } 9 | case 'error': { 10 | return [20, 20, 20, 20, 20]; 11 | } 12 | default: { 13 | return [20]; 14 | } 15 | } 16 | } 17 | 18 | function vibrate(type = null) { 19 | const isSupports = window.navigator && window.navigator.vibrate ? true : false; 20 | 21 | if (!isSupports) { 22 | return false; 23 | } 24 | 25 | const signal = typeof type === 'string' ? getVibrate(type) : type; 26 | 27 | window.navigator.vibrate(signal); 28 | 29 | return true; 30 | } 31 | 32 | export { 33 | getVibrate, 34 | vibrate, 35 | }; 36 | -------------------------------------------------------------------------------- /src/js/util/encoding.js: -------------------------------------------------------------------------------- 1 | import { isArray, isObject, isStringValidJSON } from '@/js/util/is-object'; 2 | 3 | function encode(data) { 4 | if (isArray(data) || isObject(data)) { 5 | data = JSON.stringify(data); 6 | } 7 | 8 | return window.btoa(encodeURIComponent(data)); 9 | } 10 | 11 | function decode(data) { 12 | data = decodeURIComponent(window.atob(data)); 13 | 14 | if (data.charAt(0) === '[' || data.charAt(0) === '{') { 15 | if (isStringValidJSON(data)) { 16 | data = JSON.parse(data); 17 | } else if (data.charAt(0) === '[') { 18 | data = []; 19 | } else if (data.charAt(0) === '{') { 20 | data = {}; 21 | } 22 | } 23 | 24 | return data; 25 | } 26 | 27 | export { 28 | decode, 29 | encode, 30 | }; 31 | -------------------------------------------------------------------------------- /src/sass/config/border.scss: -------------------------------------------------------------------------------- 1 | $border-width-list: ( 2 | md: 1px, 3 | lg: 2px, 4 | xl: 3px, 5 | xxl: 4px, 6 | ); 7 | 8 | $border-color-list: ( 9 | extralight: rgb(0 0 0 / 3%), 10 | light: rgb(0 0 0 / 5%), 11 | regular: rgb(0 0 0 / 8%), 12 | medium: rgb(0 0 0 / 10%), 13 | semibold: rgb(0 0 0 / 15%), 14 | bold: rgb(0 0 0 / 20%), 15 | extrabold: rgb(0 0 0 / 25%), 16 | "black": rgb(0 0 0 / 30%), 17 | ); 18 | 19 | $border-color-inverse-list: ( 20 | extralight: rgb(255 255 255 / 3%), 21 | light: rgb(255 255 255 / 5%), 22 | regular: rgb(255 255 255 / 8%), 23 | medium: rgb(255 255 255 / 10%), 24 | semibold: rgb(255 255 255 / 15%), 25 | bold: rgb(255 255 255 / 20%), 26 | extrabold: rgb(255 255 255 / 25%), 27 | "black": rgb(255 255 255 / 30%), 28 | ); 29 | -------------------------------------------------------------------------------- /gulp/server.js: -------------------------------------------------------------------------------- 1 | import browserSync from 'browser-sync'; 2 | import { isDev, isProd, processArg } from './app.js'; 3 | import { pathDist } from './path.js'; 4 | 5 | const server = browserSync.create(); 6 | 7 | const serverConfig = { 8 | // proxy: 'starter.loc', 9 | // or 10 | server: { 11 | baseDir: pathDist, 12 | serveStaticOptions: { 13 | extensions: ['html'], 14 | }, 15 | }, 16 | port: processArg.port || isProd ? 3000 : 5173, 17 | tunnel: processArg.host ? true : false, 18 | open: isDev, 19 | notify: false, 20 | }; 21 | 22 | function reload(done) { 23 | server.reload(); 24 | done(); 25 | } 26 | 27 | function serve(done) { 28 | server.init(serverConfig); 29 | done(); 30 | } 31 | 32 | export { 33 | reload, 34 | serve, 35 | }; 36 | 37 | export default server; 38 | -------------------------------------------------------------------------------- /src/sass/config/shadow.scss: -------------------------------------------------------------------------------- 1 | $shadow-width-list: ( 2 | md: 0px 1px 12px 0px, 3 | lg: 0px 2px 16px 0px, 4 | xl: 0px 2px 20px 0px, 5 | xxl: 0px 4px 24px 0px, 6 | ); 7 | 8 | $shadow-color-list: ( 9 | extralight: rgb(0 0 0 / 3%), 10 | light: rgb(0 0 0 / 4%), 11 | regular: rgb(0 0 0 / 5%), 12 | medium: rgb(0 0 0 / 10%), 13 | semibold: rgb(0 0 0 / 15%), 14 | bold: rgb(0 0 0 / 20%), 15 | extrabold: rgb(0 0 0 / 25%), 16 | "black": rgb(0 0 0 / 30%), 17 | ); 18 | 19 | $shadow-color-inverse-list: ( 20 | extralight: rgb(255 255 255 / 3%), 21 | light: rgb(255 255 255 / 4%), 22 | regular: rgb(255 255 255 / 5%), 23 | medium: rgb(255 255 255 / 10%), 24 | semibold: rgb(255 255 255 / 15%), 25 | bold: rgb(255 255 255 / 20%), 26 | extrabold: rgb(255 255 255 / 25%), 27 | "black": rgb(255 255 255 / 30%), 28 | ); 29 | -------------------------------------------------------------------------------- /src/sass/component/label.scss: -------------------------------------------------------------------------------- 1 | .label { 2 | @include ellipsis; 3 | 4 | display: inline-flex; 5 | align-items: center; 6 | justify-content: center; 7 | 8 | max-width: 100%; 9 | padding: 0.4rem 1.2rem; 10 | border: var(--border-md-medium); 11 | border-radius: var(--radius-xs); 12 | 13 | font-size: 1rem; 14 | font-weight: var(--font-bold); 15 | line-height: 1; 16 | color: var(--color-heading); 17 | text-align: center; 18 | vertical-align: middle; 19 | 20 | background: var(--color-body); 21 | 22 | // .label_cancel 23 | &_cancel { 24 | border-color: var(--color-border-regular); 25 | background: var(--color-border-regular); 26 | } 27 | 28 | @each $color in $accent-color-list { 29 | &_#{$color} { 30 | border-color: var(--color-#{$color}); 31 | color: var(--color-#{$color}-text); 32 | background: var(--color-#{$color}); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/data/features.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "icon": "⚙️", 4 | "title": "Gulp environment", 5 | "text": "Modern & Automated development environment with it's all benefits" 6 | }, 7 | { 8 | "icon": "🏗️", 9 | "title": "Twig template engine", 10 | "text": "Flexible, fast, and secure template engine integrated" 11 | }, 12 | { 13 | "icon": "📁", 14 | "title": "Structured", 15 | "text": "Well thought-out and convenient project structure" 16 | }, 17 | { 18 | "icon": "🛠️", 19 | "title": "Rich features", 20 | "text": "Ready-to-use utils, styled components, helpers etc." 21 | }, 22 | { 23 | "icon": "⚡", 24 | "title": "Optimized content", 25 | "text": "Convenient, modern and SEO friendly templates" 26 | }, 27 | { 28 | "icon": "🤖", 29 | "title": "Аutomation features", 30 | "text": "Hot reload, SASS preprocessor, assets auto minifier etc." 31 | } 32 | ] 33 | -------------------------------------------------------------------------------- /src/sass/component/form.scss: -------------------------------------------------------------------------------- 1 | form { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | label { 7 | @include reset-btn; 8 | 9 | display: block; 10 | 11 | width: 100%; 12 | margin: 0; 13 | padding: 0; 14 | 15 | font-size: 1.4rem; 16 | font-weight: var(--font-regular); 17 | line-height: 1.2; 18 | 19 | &[for] { 20 | cursor: pointer; 21 | } 22 | } 23 | 24 | input, textarea, select { 25 | @include reset-btn; 26 | 27 | display: block; 28 | 29 | width: 100%; 30 | padding: calc(1.4rem - var(--border-width-md)); 31 | border: var(--border-md-medium); 32 | border-radius: var(--radius-md); 33 | 34 | font-size: 1.6rem; 35 | font-weight: var(--font-medium); 36 | line-height: 1.25; 37 | color: var(--color-text); 38 | 39 | background: var(--color-body); 40 | } 41 | 42 | textarea { 43 | // resize: vertical; 44 | overflow: hidden auto; 45 | min-height: 3.4rem; 46 | max-height: 30rem; 47 | } 48 | -------------------------------------------------------------------------------- /src/sass/component/box.scss: -------------------------------------------------------------------------------- 1 | .box { 2 | display: flex; 3 | flex-direction: column; 4 | 5 | padding: 2rem; 6 | border-radius: var(--radius-md); 7 | 8 | background: var(--color-box); 9 | // box-shadow: var(--shadow-md-medium); 10 | 11 | // .box__icon 12 | &__icon { 13 | display: inline-flex; 14 | align-items: center; 15 | justify-content: center; 16 | 17 | width: 4.8rem; 18 | height: 4.8rem; 19 | margin: 0 0 1.4rem; 20 | border-radius: var(--radius-sm); 21 | 22 | font-size: 2.4rem; 23 | color: var(--color-primary); 24 | text-align: center; 25 | 26 | background: var(--color-border-regular); 27 | } 28 | 29 | // .box__title 30 | &__title { 31 | display: block; 32 | font-weight: var(--font-medium); 33 | color: var(--color-heading); 34 | } 35 | 36 | // .box__text 37 | &__text { 38 | display: block; 39 | margin: 0.8rem 0 0; 40 | font-size: 1.4rem; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/js/component/smooth-scroll.js: -------------------------------------------------------------------------------- 1 | import smoothScroll from '@/js/util/smooth-scroll'; 2 | 3 | document.addEventListener('DOMContentLoaded', () => { 4 | const headerHeight = document.getElementById('header')?.offsetHeight || 0; 5 | 6 | document.addEventListener('click', (event) => { 7 | const anchor = event.target.closest('a'); 8 | 9 | if (!anchor) { 10 | return false; 11 | } 12 | 13 | const anchorHref = anchor.getAttribute('href'); 14 | 15 | if (anchorHref === '#') { 16 | event.preventDefault(); 17 | 18 | smoothScroll(); 19 | } else if (anchorHref.charAt(0) === '#' || (anchorHref.charAt(0) === '/' && anchorHref.charAt(1) === '#')) { 20 | if (!anchor.hash) { 21 | return false; 22 | } 23 | 24 | const target = document.querySelector(anchor.hash); 25 | if (target) { 26 | event.preventDefault(); 27 | 28 | smoothScroll(target, headerHeight + 32); 29 | } 30 | } 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/js/util/clipboard.js: -------------------------------------------------------------------------------- 1 | async function copyToClipboard(textToCopy) { 2 | let result = false; 3 | 4 | if (navigator.clipboard && window.isSecureContext) { 5 | try { 6 | await navigator.clipboard.writeText(textToCopy); 7 | 8 | result = true; 9 | } catch { 10 | // do nothing 11 | } 12 | } else { 13 | const textArea = document.createElement('textarea'); 14 | textArea.value = textToCopy; 15 | 16 | textArea.style.position = 'fixed'; 17 | textArea.style.zIndex = '-1000000'; 18 | textArea.style.top = '100%'; 19 | textArea.style.left = '100%'; 20 | textArea.style.opacity = '0'; 21 | textArea.style.visibility = 'hidden'; 22 | 23 | document.body.append(textArea); 24 | 25 | textArea.select(); 26 | 27 | try { 28 | document.execCommand('copy'); 29 | 30 | result = true; 31 | } catch { 32 | // do nothing 33 | } finally { 34 | textArea.remove(); 35 | } 36 | } 37 | 38 | return result; 39 | } 40 | 41 | export default copyToClipboard; 42 | -------------------------------------------------------------------------------- /src/js/util/fade.js: -------------------------------------------------------------------------------- 1 | function fadeIn(element, callback = null, timing = 20) { 2 | if (!element) { 3 | return false; 4 | } 5 | 6 | let opacity = 0; 7 | 8 | const timer = setInterval(() => { 9 | if (opacity >= 1) { 10 | clearInterval(timer); 11 | 12 | if (callback instanceof Function) { 13 | callback(element); 14 | } 15 | } 16 | 17 | element.style.opacity = opacity.toFixed(1); 18 | 19 | opacity += 0.1; 20 | }, timing); 21 | } 22 | 23 | function fadeOut(element, callback = null, timing = 20) { 24 | if (!element) { 25 | return false; 26 | } 27 | 28 | let opacity = 1; 29 | 30 | const timer = setInterval(() => { 31 | if (opacity <= 0) { 32 | clearInterval(timer); 33 | 34 | if (callback instanceof Function) { 35 | callback(element); 36 | } else { 37 | element.remove(); 38 | } 39 | } 40 | 41 | element.style.opacity = opacity.toFixed(1); 42 | 43 | opacity -= 0.1; 44 | }, timing); 45 | } 46 | 47 | export { 48 | fadeIn, 49 | fadeOut, 50 | }; 51 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-clean-order", 4 | "stylelint-config-standard-scss" 5 | ], 6 | "plugins": [ 7 | "stylelint-scss" 8 | ], 9 | "ignoreFiles": [ 10 | "**/*.min.css" 11 | ], 12 | "rules": { 13 | "at-rule-empty-line-before": null, 14 | "custom-property-empty-line-before": null, 15 | "declaration-empty-line-before": null, 16 | "no-descending-specificity": null, 17 | "no-invalid-double-slash-comments": null, 18 | "no-invalid-position-at-import-rule": null, 19 | "selector-class-pattern": null, 20 | "selector-pseudo-class-no-unknown": null, 21 | "scss/at-else-empty-line-before": null, 22 | "scss/at-extend-no-missing-placeholder": null, 23 | "scss/at-if-closing-brace-newline-after": null, 24 | "scss/at-mixin-parentheses-space-before": null, 25 | "scss/dollar-variable-empty-line-before": null, 26 | "scss/double-slash-comment-empty-line-before": null, 27 | "scss/load-no-partial-leading-underscore": null, 28 | "scss/no-global-function-names": null 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Oleksandr Zakandaiev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/js/util/route.js: -------------------------------------------------------------------------------- 1 | const route = { 2 | get base() { 3 | return `${this.protocol}://${this.host}`; 4 | }, 5 | 6 | get hash() { 7 | return window.location.hash.replace('#', ''); 8 | }, 9 | 10 | get host() { 11 | return window.location.host; 12 | }, 13 | 14 | get hostname() { 15 | return window.location.hostname; 16 | }, 17 | 18 | get origin() { 19 | return window.location.origin; 20 | }, 21 | 22 | get port() { 23 | return window.location.port; 24 | }, 25 | 26 | get protocol() { 27 | return window.location.protocol.replace(':', ''); 28 | }, 29 | 30 | get query() { 31 | return Object.fromEntries(new URLSearchParams(window.location.search)); 32 | }, 33 | 34 | get queryString() { 35 | return window.location.search.replace('?', ''); 36 | }, 37 | 38 | get uri() { 39 | return window.location.pathname; 40 | }, 41 | 42 | get uriFull() { 43 | const { search } = window.location; 44 | return search 45 | ? `${this.uri}${search}` 46 | : this.uri; 47 | }, 48 | 49 | get url() { 50 | return `${this.base}${this.uri}`; 51 | }, 52 | 53 | get urlFull() { 54 | return window.location.href; 55 | }, 56 | }; 57 | 58 | export default route; 59 | -------------------------------------------------------------------------------- /src/js/util/storage.js: -------------------------------------------------------------------------------- 1 | import { isArray, isObject, isStringValidJSON } from '@/js/util/is-object'; 2 | 3 | function setStorage(key, data, type = 'session') { 4 | if (isArray(data) || isObject(data)) { 5 | data = JSON.stringify(data); 6 | } 7 | 8 | if (type === 'session') { 9 | sessionStorage.setItem(key, data); 10 | } else { 11 | localStorage.setItem(key, data); 12 | } 13 | 14 | return true; 15 | } 16 | 17 | function getStorage(key, type = 'session') { 18 | let data = (type === 'session') ? sessionStorage.getItem(key) : localStorage.getItem(key); 19 | 20 | if (data && (data.charAt(0) === '[' || data.charAt(0) === '{')) { 21 | if (isStringValidJSON(data)) { 22 | data = JSON.parse(data); 23 | } else if (data.charAt(0) === '[') { 24 | data = []; 25 | flushStorage(key); 26 | } else if (data.charAt(0) === '{') { 27 | data = {}; 28 | flushStorage(key); 29 | } 30 | } 31 | 32 | return data; 33 | } 34 | 35 | function flushStorage(key, type = 'session') { 36 | if (type === 'session') { 37 | sessionStorage.removeItem(key); 38 | } else { 39 | localStorage.removeItem(key); 40 | } 41 | 42 | return true; 43 | } 44 | 45 | export { 46 | flushStorage, 47 | getStorage, 48 | setStorage, 49 | }; 50 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import js from '@eslint/js'; 3 | import globals from 'globals'; 4 | import { absPath } from './gulp/path.js'; 5 | 6 | const compat = new FlatCompat({ 7 | baseDirectory: absPath.root, 8 | recommendedConfig: js.configs.recommended, 9 | allConfig: js.configs.all, 10 | }); 11 | 12 | export default [ 13 | { 14 | ignores: [ 15 | 'build', 16 | 'coverage', 17 | 'docs', 18 | 'dist', 19 | 'dist-ssr', 20 | 'node_modules', 21 | ], 22 | }, 23 | ...compat.extends('airbnb-base'), 24 | { 25 | languageOptions: { 26 | globals: { 27 | ...globals.browser, 28 | ...globals.node, 29 | }, 30 | 31 | ecmaVersion: 'latest', 32 | sourceType: 'module', 33 | }, 34 | 35 | settings: { 36 | 'import/resolver': { 37 | alias: { 38 | map: [['@', './src']], 39 | }, 40 | }, 41 | }, 42 | 43 | files: ['**/*.{js,jsx,cjs,mjs}'], 44 | 45 | rules: { 46 | 'consistent-return': 0, 47 | 'import/extensions': 0, 48 | 'import/no-extraneous-dependencies': 0, 49 | 'max-len': 0, 50 | 'no-param-reassign': 0, 51 | 'no-unneeded-ternary': 0, 52 | 'no-use-before-define': 0, 53 | }, 54 | }, 55 | ]; 56 | -------------------------------------------------------------------------------- /src/component/header.twig: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /gulp/html-transform-base.js: -------------------------------------------------------------------------------- 1 | import { Transform } from 'stream'; 2 | import { processArg } from './app.js'; 3 | 4 | function htmlTransformBase() { 5 | return new Transform({ 6 | objectMode: true, 7 | 8 | transform(file, enc, callback) { 9 | if (file.isNull() || !file.isBuffer()) { 10 | return callback(null, file); 11 | } 12 | 13 | const { base } = processArg; 14 | 15 | if (!base || base === '/' || base.startsWith('.')) { 16 | return callback(null, file); 17 | } 18 | 19 | const baseFormatted = `/${base.trim().replace(/^\/|\/$/g, '')}`; 20 | 21 | const html = file.contents.toString(enc); 22 | 23 | const modifiedHtml = html.replace(/(href|src)=["']([^"']+)["']/gi, (match, attr, url) => { 24 | if (!url || !url.length || url.startsWith('./') || url.startsWith(baseFormatted) || url.startsWith('http') || url.startsWith('www')) { 25 | return match; 26 | } 27 | 28 | const urlFormatted = url === '/' ? '' : `${url.trim().replace(/^\/|\/$/g, '')}`; 29 | 30 | return `${attr}="${baseFormatted}${urlFormatted.length ? `/${urlFormatted}` : ''}"`; 31 | }); 32 | 33 | file.contents = Buffer.from(modifiedHtml); 34 | 35 | callback(null, file); 36 | }, 37 | }); 38 | } 39 | 40 | export default htmlTransformBase; 41 | -------------------------------------------------------------------------------- /src/sass/component/loader.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | overflow: hidden; 3 | display: inline-flex; 4 | width: 4rem; 5 | height: 4rem; 6 | 7 | svg { 8 | display: block; 9 | width: 100%; 10 | height: 100%; 11 | } 12 | 13 | circle { 14 | transform-origin: 50%; 15 | stroke: var(--color-primary); 16 | stroke-width: 0.3rem; 17 | animation: line 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite, rotate 1.6s linear infinite; 18 | } 19 | 20 | // .loader_xs 21 | &_xs { 22 | width: 2rem; 23 | height: 2rem; 24 | } 25 | 26 | // .loader_sm 27 | &_sm { 28 | width: 3rem; 29 | height: 3rem; 30 | } 31 | 32 | // .loader_lg 33 | &_lg { 34 | width: 5rem; 35 | height: 5rem; 36 | } 37 | 38 | // .loader_xl 39 | &_xl { 40 | width: 6rem; 41 | height: 6rem; 42 | } 43 | } 44 | 45 | @keyframes rotate { 46 | from { 47 | transform: rotate(0); 48 | } 49 | 50 | to { 51 | transform: rotate(450deg); 52 | } 53 | } 54 | 55 | @keyframes line { 56 | 0% { 57 | transform: rotate(0); 58 | stroke-dasharray: 2, 85.964; 59 | } 60 | 61 | 50% { 62 | stroke-dasharray: 65.973, 21.9911; 63 | stroke-dashoffset: 0; 64 | } 65 | 66 | 100% { 67 | transform: rotate(90deg); 68 | stroke-dasharray: 2, 85.964; 69 | stroke-dashoffset: -65.973; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // EDITOR 3 | "editor.tabSize": 2, 4 | "editor.formatOnSave": true, 5 | "editor.insertSpaces": true, 6 | "files.eol": "\n", 7 | "files.insertFinalNewline": true, 8 | "files.trimFinalNewlines": true, 9 | "files.trimTrailingWhitespace": true, 10 | "emmet.triggerExpansionOnTab": true, 11 | "emmet.includeLanguages": { 12 | "twig": "html" 13 | }, 14 | // HTML 15 | "html.format.enable": true, 16 | "html.autoClosingTags": false, 17 | "html.format.wrapLineLength": 0, 18 | "[html]": { 19 | "editor.defaultFormatter": "vscode.html-language-features" 20 | }, 21 | "twig-language.indentStyle": "space", 22 | "[twig]": { 23 | "editor.defaultFormatter": "mblode.twig-language" 24 | }, 25 | // JS 26 | "eslint.enable": true, 27 | "eslint.format.enable": true, 28 | "editor.codeActionsOnSave": { 29 | "source.fixAll": "explicit", 30 | "source.fixAll.eslint": "explicit", 31 | "source.fixAll.stylelint": "explicit", 32 | "source.organizeImports": "always" 33 | }, 34 | "[javascript]": { 35 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 36 | }, 37 | // CSS 38 | "css.format.spaceAroundSelectorSeparator": true, 39 | "scss.format.spaceAroundSelectorSeparator": true, 40 | "stylelint.validate": [ 41 | "css", 42 | "sass", 43 | "scss" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /gulp/js.js: -------------------------------------------------------------------------------- 1 | import alias from '@rollup/plugin-alias'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 4 | import replace from '@rollup/plugin-replace'; 5 | import terser from '@rollup/plugin-terser'; 6 | import { rollup } from 'rollup'; 7 | import multiInput from 'rollup-plugin-multi-input'; 8 | import { isDev, isProd, replaceData } from './app.js'; 9 | import { absPath, path } from './path.js'; 10 | 11 | const terserConfig = { 12 | mangle: true, 13 | keep_classnames: true, 14 | keep_fnames: false, 15 | ie8: false, 16 | }; 17 | 18 | async function js(done) { 19 | const plugins = [ 20 | replace(replaceData), 21 | nodeResolve(), 22 | alias({ 23 | entries: [ 24 | { find: '@', replacement: absPath.src }, 25 | ], 26 | }), 27 | commonjs({ 28 | requireReturnsDefault: true, 29 | sourceMap: isDev, 30 | }), 31 | multiInput(), 32 | ]; 33 | 34 | if (isProd) { 35 | plugins.push( 36 | terser(terserConfig), 37 | ); 38 | } 39 | 40 | const bundle = await rollup({ 41 | input: path.js.src, 42 | plugins, 43 | }); 44 | 45 | const result = await bundle.write({ 46 | dir: absPath.dist, 47 | sourcemap: isDev, 48 | }); 49 | 50 | done(); 51 | 52 | return result; 53 | } 54 | 55 | export default js; 56 | -------------------------------------------------------------------------------- /gulp/img.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import gulpif from 'gulp-if'; 3 | import imagemin, { 4 | gifsicle, 5 | mozjpeg, 6 | optipng, 7 | svgo, 8 | } from 'gulp-imagemin'; 9 | import newer from 'gulp-newer'; 10 | import { isProd } from './app.js'; 11 | import { path } from './path.js'; 12 | 13 | const imageminConfig = { 14 | gifsicle: { 15 | optimizationLevel: 1, 16 | interlaced: false, 17 | }, 18 | optipng: { 19 | optimizationLevel: 5, 20 | }, 21 | mozjpeg: { 22 | quality: 75, progressive: true, 23 | }, 24 | pngquant: { 25 | quality: [0.7, 0.9], 26 | speed: 7, 27 | }, 28 | svgo: { 29 | plugins: [ 30 | { 31 | name: 'removeViewBox', 32 | active: false, 33 | }, 34 | { 35 | name: 'convertShapeToPath', 36 | active: false, 37 | }, 38 | { 39 | name: 'convertEllipseToCircle', 40 | active: false, 41 | }, 42 | ], 43 | }, 44 | }; 45 | 46 | function img() { 47 | return gulp.src(path.img.src, { encoding: false }) 48 | .pipe(newer(path.img.dist)) 49 | .pipe( 50 | gulpif( 51 | isProd, 52 | imagemin([ 53 | gifsicle(imageminConfig.gifsicle), 54 | mozjpeg(imageminConfig.mozjpeg), 55 | optipng(imageminConfig.optipng), 56 | svgo(imageminConfig.svgo), 57 | ]), 58 | ), 59 | ) 60 | .pipe(gulp.dest(path.img.dist)); 61 | } 62 | 63 | export default img; 64 | -------------------------------------------------------------------------------- /gulp/app.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import minimist from 'minimist'; 3 | import fs from 'node:fs'; 4 | import { argv, env } from 'node:process'; 5 | 6 | dotenv.config({ path: ['.env', '.env.local'], override: true }); 7 | 8 | const processArg = minimist(argv.slice(2)); 9 | 10 | const isProd = processArg.prod; 11 | const isDev = !isProd; 12 | 13 | const packageData = JSON.parse(fs.readFileSync('./package.json')); 14 | 15 | const appData = { 16 | APP_IS_DEV: isDev, 17 | APP_IS_PROD: isProd, 18 | APP_MODE: isProd ? 'prod' : 'dev', 19 | 20 | APP_NAME: packageData.name, 21 | APP_NAME_FORMATTED: packageData.name.replace(/[^a-z]+/gi, ' ').replace(/(^\w|\s\w)/g, (m) => m.toUpperCase()), 22 | 23 | APP_VERSION: packageData.version, 24 | APP_AUTHOR: packageData.author, 25 | APP_AUTHOR_URL: packageData.authorUrl, 26 | APP_REPOSITORY: packageData.repository?.url, 27 | 28 | APP_DESCRIPTION: packageData.description, 29 | APP_KEYWORDS: packageData.keywords, 30 | }; 31 | 32 | const envData = {}; 33 | Object.keys(env).forEach((key) => { 34 | if (key.startsWith('APP_')) { 35 | envData[key] = env[key]; 36 | } 37 | }); 38 | 39 | const replaceData = { 40 | ...Object.fromEntries(Object.entries(appData).map(([k, v]) => [k, JSON.stringify(v)])), 41 | ...Object.fromEntries(Object.entries(envData).map(([k, v]) => [k, JSON.stringify(v)])), 42 | }; 43 | 44 | export { 45 | appData, 46 | envData, isDev, 47 | isProd, 48 | packageData, processArg, replaceData, 49 | }; 50 | -------------------------------------------------------------------------------- /gulp/sass.js: -------------------------------------------------------------------------------- 1 | import autoprefixer from 'autoprefixer'; 2 | import cssnano from 'cssnano'; 3 | import gulp from 'gulp'; 4 | import gulpif from 'gulp-if'; 5 | import postCss from 'gulp-postcss'; 6 | import gulpSass from 'gulp-sass'; 7 | import combineMediaQuery from 'postcss-combine-media-query'; 8 | import dartSass from 'sass'; 9 | import { isDev, isProd } from './app.js'; 10 | import { path } from './path.js'; 11 | import server from './server.js'; 12 | 13 | const sassPlugin = gulpSass(dartSass); 14 | 15 | const sassConfig = { 16 | api: 'modern-compiler', 17 | loadPaths: ['node_modules'], 18 | silenceDeprecations: ['mixed-decls', 'color-functions', 'global-builtin', 'import'], 19 | }; 20 | 21 | const autoprefixerConfig = { 22 | cascade: !isProd, 23 | grid: false, 24 | }; 25 | 26 | const cssnanoConfig = { 27 | preset: [ 28 | 'default', 29 | { 30 | discardComments: { 31 | removeAll: true, 32 | }, 33 | }, 34 | ], 35 | }; 36 | 37 | function sass() { 38 | return gulp.src(path.sass.src, { encoding: false, sourcemaps: isDev }) 39 | .pipe(sassPlugin.sync(sassConfig).on('error', sassPlugin.logError)) 40 | .pipe( 41 | gulpif( 42 | isProd, 43 | postCss([ 44 | combineMediaQuery(), 45 | autoprefixer(autoprefixerConfig), 46 | cssnano(cssnanoConfig), 47 | ]), 48 | ), 49 | ) 50 | .pipe(gulp.dest(path.sass.dist, { sourcemaps: isDev })) 51 | .pipe(server.stream()); 52 | } 53 | 54 | export default sass; 55 | -------------------------------------------------------------------------------- /src/js/util/toast.js: -------------------------------------------------------------------------------- 1 | function toast(text, type = 'default', duration = null) { 2 | if (typeof text !== 'string' || !text?.length) { 3 | return false; 4 | } 5 | 6 | let container = document.querySelector('.toasts'); 7 | if (!container) { 8 | container = document.createElement('div'); 9 | container.classList.add('toasts'); 10 | document.body.appendChild(container); 11 | } 12 | 13 | const toastNode = document.createElement('div'); 14 | toastNode.classList.add('toasts__item'); 15 | if (type) { 16 | toastNode.classList.add(type); 17 | } 18 | 19 | const toastIcon = document.createElement('i'); 20 | toastIcon.classList.add('toasts__icon'); 21 | 22 | const toastText = document.createElement('span'); 23 | toastText.classList.add('toasts__text'); 24 | toastText.textContent = text; 25 | 26 | toastNode.appendChild(toastIcon); 27 | toastNode.appendChild(toastText); 28 | 29 | container.appendChild(toastNode); 30 | 31 | toastNode.addEventListener('click', () => toastRemove(container, toastNode)); 32 | 33 | setTimeout(() => toastRemove(container, toastNode), duration || 5000); 34 | 35 | return true; 36 | } 37 | 38 | function toastRemove(toastContainer, toastNode) { 39 | toastNode.classList.add('disappear'); 40 | 41 | toastNode.addEventListener('animationend', () => { 42 | toastNode.remove(); 43 | 44 | if (toastContainer && toastContainer.childElementCount <= 0) { 45 | toastContainer.remove(); 46 | } 47 | }); 48 | } 49 | 50 | export default toast; 51 | -------------------------------------------------------------------------------- /src/js/util/cookie.js: -------------------------------------------------------------------------------- 1 | import { isArray, isObject, isStringValidJSON } from '@/js/util/is-object'; 2 | 3 | function setCookie(key, data, days = 3, path = '/') { 4 | if (isArray(data) || isObject(data)) { 5 | data = JSON.stringify(data); 6 | } 7 | 8 | const dateNow = new Date(); 9 | dateNow.setTime(dateNow.getTime() + (days * 24 * 60 * 60 * 1000)); 10 | 11 | document.cookie = `${key}=${data}; expires=${dateNow.toUTCString()}; path=${path}`; 12 | } 13 | 14 | function getCookie(key) { 15 | const cookieName = `${key}=`; 16 | const cookieArray = document.cookie.split(';'); 17 | 18 | for (let i = 0; i < cookieArray.length; i += 1) { 19 | let c = cookieArray[i]; 20 | while (c.charAt(0) === ' ') c = c.substring(1, c.length); 21 | if (c.indexOf(cookieName) === 0) return formatCookie(key, c.substring(cookieName.length, c.length)); 22 | } 23 | 24 | return null; 25 | } 26 | 27 | function formatCookie(key, data) { 28 | if (data && (data.charAt(0) === '[' || data.charAt(0) === '{')) { 29 | if (isStringValidJSON(data)) { 30 | data = JSON.parse(data); 31 | } else if (data.charAt(0) === '[') { 32 | data = []; 33 | flushCookie(key); 34 | } else if (data.charAt(0) === '{') { 35 | data = {}; 36 | flushCookie(key); 37 | } 38 | } 39 | 40 | return data; 41 | } 42 | 43 | function flushCookie(key) { 44 | document.cookie = `${key}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`; 45 | } 46 | 47 | export { 48 | flushCookie, 49 | getCookie, 50 | setCookie, 51 | }; 52 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import { isProd } from './gulp/app.js'; 3 | import del from './gulp/del.js'; 4 | import font from './gulp/font.js'; 5 | import img from './gulp/img.js'; 6 | import js from './gulp/js.js'; 7 | import { path } from './gulp/path.js'; 8 | import publicFiles from './gulp/public-files.js'; 9 | import sass from './gulp/sass.js'; 10 | import { reload, serve } from './gulp/server.js'; 11 | import twig from './gulp/twig.js'; 12 | 13 | function watch() { 14 | gulp.watch(path.font.watch, gulp.series(font, reload)); 15 | gulp.watch(path.img.watch, gulp.series(img, reload)); 16 | gulp.watch(path.js.watch, gulp.series(js, reload)); 17 | gulp.watch(path.public.watch, gulp.series(publicFiles, reload)); 18 | gulp.watch(path.sass.watch, sass); 19 | gulp.watch(path.twig.watch, gulp.series(twig, reload)); 20 | } 21 | 22 | function compileFiles() { 23 | return gulp.series( 24 | del, 25 | gulp.parallel(font, img, js, publicFiles, sass, twig), 26 | ); 27 | } 28 | 29 | function startServer() { 30 | return gulp.parallel(serve, watch); 31 | } 32 | 33 | function startDevServer() { 34 | return gulp.series( 35 | compileFiles(), 36 | startServer(), 37 | ); 38 | } 39 | 40 | const dev = startDevServer(); 41 | const prod = compileFiles(); 42 | const build = compileFiles(); 43 | const preview = startServer(); 44 | 45 | export { 46 | build, 47 | del, 48 | dev, 49 | font, 50 | img, 51 | js, 52 | preview, 53 | prod, 54 | publicFiles, 55 | sass, 56 | serve, 57 | twig, 58 | }; 59 | 60 | export default isProd ? prod : dev; 61 | -------------------------------------------------------------------------------- /src/sass/component/table.scss: -------------------------------------------------------------------------------- 1 | .table-responsive { 2 | overflow-x: auto; 3 | display: block; 4 | } 5 | 6 | table, .table { 7 | border-spacing: 0; 8 | border-collapse: collapse; 9 | 10 | width: 100%; 11 | margin: 0; 12 | 13 | text-align: left; 14 | 15 | th { 16 | font-weight: var(--font-bold); 17 | color: var(--color-heading); 18 | } 19 | 20 | th, td { 21 | padding: 1.6rem; 22 | border-bottom: var(--border-md-medium); 23 | vertical-align: middle; 24 | } 25 | } 26 | 27 | .table { 28 | // .table_fixed 29 | &_fixed { 30 | table-layout: fixed; 31 | } 32 | 33 | // .table_align-top 34 | &_align-top { 35 | th, td { 36 | vertical-align: top; 37 | } 38 | } 39 | 40 | // .table_align-middle 41 | &_align-middle { 42 | th, td { 43 | vertical-align: middle; 44 | } 45 | } 46 | 47 | // .table_align-bottom 48 | &_align-bottom { 49 | th, td { 50 | vertical-align: bottom; 51 | } 52 | } 53 | 54 | // .table_striped 55 | &_striped > tbody > tr:nth-child(odd) { 56 | & > th, & > td { 57 | background: var(--color-box); 58 | } 59 | } 60 | 61 | // .table_borderless 62 | &_borderless { 63 | th, td { 64 | border: none; 65 | 66 | &:first-child { 67 | padding-left: 0; 68 | } 69 | 70 | &:last-child { 71 | padding-right: 0; 72 | } 73 | } 74 | 75 | th { 76 | color: var(--color-text); 77 | } 78 | } 79 | 80 | // .table_sm 81 | &_sm { 82 | th, td { 83 | padding-top: 1.2rem; 84 | padding-bottom: 1.2rem; 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/sass/component/grid.scss: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | flex-wrap: wrap; 4 | 5 | // .row.fill 6 | &.fill > .col { 7 | flex: 1 0 auto; 8 | 9 | // .row.fill > .col-auto 10 | &-auto { 11 | flex: initial; 12 | } 13 | } 14 | } 15 | 16 | .col, 17 | [class^="col-"] { 18 | display: block; 19 | flex: 0 0 auto; 20 | 21 | // .col.fill 22 | &.fill { 23 | flex: 1 0 auto; 24 | } 25 | } 26 | 27 | @each $bp, $val in $breakpoint { 28 | // @media (min-width: $val) { 29 | @include query($bp) { 30 | // COLUMN COUNT - general class for .row 31 | @for $i from 1 through 12 { 32 | .row.cols-#{$bp}-#{$i} > .col { 33 | width: calc(100% / $i); 34 | } 35 | } 36 | 37 | // COLUMN COUNT - single class for .col 38 | @for $i from 1 through 12 { 39 | .col-#{$bp}-#{$i} { 40 | width: calc(100% / 12) * $i; 41 | } 42 | } 43 | 44 | // OFFSET - single class for .col 45 | @for $i from 1 through 12 { 46 | .offset-#{$bp}-#{$i} { 47 | margin-left: calc(100% / 12) * $i; 48 | } 49 | } 50 | 51 | // ORDER - single class for .col 52 | @for $i from 1 through 12 { 53 | .order-#{$bp}-#{$i} { 54 | order: $i; 55 | } 56 | } 57 | 58 | // GAP - XY 59 | .row.gap-#{$bp} { 60 | margin: calc(-1 * var(--gap) / 2); 61 | } 62 | 63 | .row.gap-#{$bp} > .col, 64 | .row.gap-#{$bp} > [class^="col-"] { 65 | padding: calc(var(--gap) / 2); 66 | } 67 | 68 | // GAP - X 69 | .row.gap-#{$bp}-x { 70 | margin: 0 calc(-1 * var(--gap) / 2); 71 | } 72 | 73 | .row.gap-#{$bp}-x > .col, 74 | .row.gap-#{$bp}-x > [class^="col-"] { 75 | padding: 0 calc(var(--gap) / 2); 76 | } 77 | 78 | // GAP - Y 79 | .row.gap-#{$bp}-y { 80 | margin: calc(-1 * var(--gap) / 2) 0; 81 | } 82 | 83 | .row.gap-#{$bp}-y > .col, 84 | .row.gap-#{$bp}-y > [class^="col-"] { 85 | padding: calc(var(--gap) / 2) 0; 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/sass/component/button.scss: -------------------------------------------------------------------------------- 1 | .btn { 2 | @include reset-btn; 3 | cursor: pointer; 4 | 5 | position: relative; 6 | 7 | display: inline-flex; 8 | gap: 0.8rem; 9 | align-items: center; 10 | justify-content: center; 11 | 12 | padding: 1.2rem 1.6rem; 13 | border: var(--border-md-medium); 14 | border-radius: var(--radius-md); 15 | 16 | font-size: 1.4rem; 17 | font-weight: var(--font-medium); 18 | line-height: 1; 19 | color: var(--color-heading); 20 | text-align: center; 21 | text-overflow: ellipsis; 22 | white-space: nowrap; 23 | 24 | background: var(--color-body); 25 | 26 | transition: var(--transition); 27 | 28 | &:hover, &:focus, &:active { 29 | color: var(--color-heading); 30 | background: var(--color-box); 31 | } 32 | 33 | & > svg, & > img { 34 | display: inline-block; 35 | width: 3rem; 36 | height: 3rem; 37 | } 38 | 39 | & > small { 40 | padding: 0.15em 0 0 0.25em; 41 | font-size: 2rem; 42 | font-weight: var(--font-regular); 43 | } 44 | 45 | // .btn_cancel 46 | &_cancel { 47 | border-color: var(--color-border-regular); 48 | color: var(--color-heading); 49 | background: var(--color-border-regular); 50 | 51 | &:hover, &:focus, &:active { 52 | border-color: var(--color-border-medium); 53 | color: var(--color-heading); 54 | background: var(--color-border-medium); 55 | } 56 | } 57 | 58 | @each $color in $accent-color-list { 59 | &_#{$color} { 60 | border-color: var(--color-#{$color}); 61 | color: var(--color-#{$color}-text); 62 | background: var(--color-#{$color}); 63 | 64 | &:hover, &:focus, &:active { 65 | border-color: var(--color-#{$color}-hover); 66 | color: var(--color-#{$color}-text); 67 | background: var(--color-#{$color}-hover); 68 | } 69 | } 70 | } 71 | 72 | // .btn_disabled 73 | &_disabled, 74 | &:disabled { 75 | cursor: not-allowed; 76 | opacity: 0.7; 77 | } 78 | 79 | // .btn_fit 80 | &_fit { 81 | width: 100%; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/js/component/logger.js: -------------------------------------------------------------------------------- 1 | import Config from '@/config'; 2 | import { request } from '@/js/util/request'; 3 | import route from '@/js/util/route'; 4 | 5 | async function logError(error, customBody = {}) { 6 | if (Config.app.mode !== 'prod') { 7 | return false; 8 | } 9 | 10 | if (error && !error?.stack?.includes(window.location.hostname)) { 11 | return false; 12 | } 13 | 14 | const url = `${Config.api.backend}/logError`; 15 | const options = { 16 | method: 'POST', 17 | body: { 18 | app: Config.app, 19 | client: getClientInfo(), 20 | error, 21 | url: route.urlFull, 22 | ...customBody, 23 | }, 24 | }; 25 | const data = await request(url, options); 26 | 27 | return data; 28 | } 29 | 30 | function getClientInfo() { 31 | return { 32 | userAgent: window.navigator.userAgent, 33 | language: window.navigator.language, 34 | hardwareConcurrency: window.navigator.hardwareConcurrency, 35 | deviceMemory: window.navigator.deviceMemory, 36 | webdriver: window.navigator.webdriver, 37 | maxTouchPoints: window.navigator.maxTouchPoints, 38 | onLine: window.navigator.onLine, 39 | screen: { 40 | availWidth: window.screen.availWidth, 41 | availHeight: window.screen.availHeight, 42 | width: window.screen.width, 43 | height: window.screen.height, 44 | dpi: window.devicePixelRatio, 45 | orientation: window.screen?.orientation?.type, 46 | }, 47 | window: { 48 | innerWidth: window.innerWidth, 49 | innerHeight: window.innerHeight, 50 | }, 51 | deviceType: (() => { 52 | const { userAgent } = window.navigator; 53 | if (/ip(hone|od)|android.+mobile|blackberry|iemobile/i.test(userAgent)) return 'mobile'; 54 | if (/(tablet|ipad|playbook|silk)|(android(?!.*mobile))/i.test(userAgent)) return 'tablet'; 55 | return 'desktop'; 56 | })(), 57 | }; 58 | } 59 | 60 | window.onerror = async (message, source, line, col, error) => logError({ 61 | message, 62 | source, 63 | line, 64 | col, 65 | stack: error?.stack || null, 66 | }); 67 | -------------------------------------------------------------------------------- /gulp/path.js: -------------------------------------------------------------------------------- 1 | import nodePath from 'node:path'; 2 | import { cwd } from 'node:process'; 3 | import { processArg } from './app.js'; 4 | 5 | const pathDist = processArg.dist || './dist'; 6 | const pathSrc = './src'; 7 | 8 | const absPath = { 9 | root: nodePath.resolve(cwd()), 10 | node: nodePath.resolve(cwd(), 'node_modules'), 11 | dist: nodePath.resolve(cwd(), pathDist), 12 | src: nodePath.resolve(cwd(), pathSrc), 13 | component: nodePath.resolve(cwd(), `${pathSrc}/component`), 14 | data: nodePath.resolve(cwd(), `${pathSrc}/data`), 15 | font: nodePath.resolve(cwd(), `${pathSrc}/font`), 16 | img: nodePath.resolve(cwd(), `${pathSrc}/img`), 17 | js: nodePath.resolve(cwd(), `${pathSrc}/js`), 18 | layout: nodePath.resolve(cwd(), `${pathSrc}/layout`), 19 | public: nodePath.resolve(cwd(), `${pathSrc}/public`), 20 | sass: nodePath.resolve(cwd(), `${pathSrc}/sass`), 21 | view: nodePath.resolve(cwd(), `${pathSrc}/view`), 22 | }; 23 | 24 | const path = { 25 | dist: pathDist, 26 | src: pathSrc, 27 | 28 | del: pathDist, 29 | 30 | font: { 31 | src: `${pathSrc}/font/**/*.{woff2,woff,svg,ttf,eot}`, 32 | watch: `${pathSrc}/font/**/*.{woff2,woff,svg,ttf,eot}`, 33 | dist: `${pathDist}/font`, 34 | }, 35 | 36 | img: { 37 | src: `${pathSrc}/img/**/*.*`, 38 | watch: `${pathSrc}/img/**/*.*`, 39 | dist: `${pathDist}/img`, 40 | }, 41 | 42 | js: { 43 | src: `${pathSrc}/js/*.js`, 44 | watch: `${pathSrc}/js/**/*.js`, 45 | dist: `${pathDist}/js`, 46 | }, 47 | 48 | public: { 49 | src: `${pathSrc}/public/**/*.*`, 50 | watch: `${pathSrc}/public/**/*.*`, 51 | dist: pathDist, 52 | }, 53 | 54 | sass: { 55 | src: `${pathSrc}/sass/*.{sass,scss}`, 56 | watch: `${pathSrc}/sass/**/*.{sass,scss}`, 57 | dist: `${pathDist}/css`, 58 | }, 59 | 60 | twig: { 61 | src: `${pathSrc}/view/*.twig`, 62 | watch: [ 63 | `${pathSrc}/component/**/*.twig`, 64 | `${pathSrc}/layout/**/*.twig`, 65 | `${pathSrc}/view/**/*.twig`, 66 | ], 67 | dist: pathDist, 68 | }, 69 | }; 70 | 71 | export { 72 | absPath, 73 | path, pathDist, 74 | pathSrc, 75 | }; 76 | -------------------------------------------------------------------------------- /src/js/util/cyr-to-lat.js: -------------------------------------------------------------------------------- 1 | function cyrToLat(text) { 2 | const gost = { 3 | а: 'a', 4 | А: 'A', 5 | б: 'b', 6 | Б: 'B', 7 | в: 'v', 8 | В: 'V', 9 | г: 'g', 10 | Г: 'G', 11 | д: 'd', 12 | Д: 'D', 13 | е: 'e', 14 | Е: 'E', 15 | ё: 'e', 16 | Ё: 'E', 17 | ж: 'zh', 18 | Ж: 'Zh', 19 | з: 'z', 20 | З: 'Z', 21 | и: 'i', 22 | И: 'I', 23 | й: 'y', 24 | Й: 'Y', 25 | к: 'k', 26 | К: 'K', 27 | л: 'l', 28 | Л: 'L', 29 | м: 'm', 30 | М: 'M', 31 | н: 'n', 32 | Н: 'N', 33 | о: 'o', 34 | О: 'O', 35 | п: 'p', 36 | П: 'P', 37 | р: 'r', 38 | Р: 'R', 39 | с: 's', 40 | С: 'S', 41 | т: 't', 42 | Т: 'T', 43 | у: 'u', 44 | У: 'U', 45 | ф: 'f', 46 | Ф: 'F', 47 | х: 'kh', 48 | Х: 'Kh', 49 | ц: 'tz', 50 | Ц: 'Tz', 51 | ч: 'ch', 52 | Ч: 'Ch', 53 | ш: 'sh', 54 | Ш: 'Sh', 55 | щ: 'sch', 56 | Щ: 'Sch', 57 | ы: 'y', 58 | Ы: 'Y', 59 | э: 'e', 60 | Э: 'E', 61 | ю: 'iu', 62 | Ю: 'Iu', 63 | я: 'ia', 64 | Я: 'Ia', 65 | ь: '', 66 | Ь: '', 67 | ъ: '', 68 | Ъ: '', 69 | ї: 'yi', 70 | Ї: 'Yi', 71 | і: 'i', 72 | І: 'I', 73 | ґ: 'g', 74 | Ґ: 'G', 75 | є: 'e', 76 | Є: 'E', 77 | }; 78 | 79 | return text.split('').map((char) => gost[char] || char).join(''); 80 | } 81 | 82 | function getSlug(text, delimiter = '-') { 83 | const replaceInvalidChars = new RegExp(`[^A-Za-z0-9${delimiter}]+`, 'g'); 84 | const replaceDelimiterRepeats = new RegExp(`[${delimiter}]+`, 'g'); 85 | const replaceNoDelimiter = new RegExp(`^${delimiter}`); 86 | const replaceDelimiter = new RegExp(`${delimiter}$`); 87 | 88 | return cyrToLat(text) 89 | .replaceAll(replaceInvalidChars, delimiter) 90 | .replaceAll(replaceDelimiterRepeats, delimiter) 91 | .replace(replaceNoDelimiter, '') 92 | .replace(replaceDelimiter, '') 93 | .toLowerCase(); 94 | } 95 | 96 | export { 97 | cyrToLat, 98 | getSlug, 99 | }; 100 | -------------------------------------------------------------------------------- /gulp/twig.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import gulpif from 'gulp-if'; 3 | import twigInstance from 'gulp-twig'; 4 | import fs from 'node:fs'; 5 | import nodePath from 'node:path'; 6 | import { appData, envData, isProd } from './app.js'; 7 | import htmlTransformBase from './html-transform-base.js'; 8 | import htmlmin from './htmlmin.js'; 9 | import { absPath, path, pathSrc } from './path.js'; 10 | import versionNumber from './version-number.js'; 11 | 12 | const tablerIconsAbsPath = nodePath.join(absPath.node, '@tabler', 'icons', 'icons'); 13 | 14 | const twigConfig = { 15 | base: pathSrc, 16 | data: getTwigGlobals(), 17 | namespaces: { 18 | node: absPath.node, 19 | component: absPath.component, 20 | layout: absPath.layout, 21 | view: absPath.view, 22 | ti: nodePath.join(tablerIconsAbsPath, 'outline'), 23 | 'ti-filled': nodePath.join(tablerIconsAbsPath, 'filled'), 24 | }, 25 | }; 26 | 27 | function twig() { 28 | return gulp.src(path.twig.src, { encoding: false }) 29 | .pipe(twigInstance(twigConfig)) 30 | .pipe( 31 | gulpif( 32 | isProd, 33 | versionNumber(), 34 | ), 35 | ) 36 | .pipe( 37 | gulpif( 38 | isProd, 39 | htmlmin(), 40 | ), 41 | ) 42 | .pipe( 43 | gulpif( 44 | isProd, 45 | htmlTransformBase(), 46 | ), 47 | ) 48 | .pipe(gulp.dest(path.twig.dist)); 49 | } 50 | 51 | function getTwigGlobals() { 52 | const data = { 53 | ...appData, 54 | ...envData, 55 | }; 56 | 57 | const dataFolder = absPath.data; 58 | const dataFiles = fs.readdirSync(dataFolder).filter((file) => file.endsWith('.json')) || []; 59 | 60 | dataFiles.forEach((file) => { 61 | const filePath = nodePath.join(dataFolder, file); 62 | const fileContent = fs.readFileSync(filePath, 'utf8') || '{}'; 63 | const fileData = JSON.parse(fileContent); 64 | const fileName = file.replace('.json', '').replace(/[\s-]+/g, '_').replace(/[^a-z_]+/g, '').replace(/(_)./g, (s) => s.slice(-1).toUpperCase()); 65 | 66 | data[fileName] = fileData; 67 | }); 68 | 69 | return data; 70 | } 71 | 72 | export default twig; 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | HTML5 Logo 2 | 3 | # frontend-starter 4 | 5 | FrontEnd Starter is a boilerplate kit for easy building modern static web-sites using Gulp 6 | 7 | ## Homepage 8 | [https://zakandaiev.github.io/frontend-starter](https://zakandaiev.github.io/frontend-starter) 9 | 10 | ## Features 11 | * Modern environment for development 12 | * Twig template engine 13 | * Well thought-out and convenient project structure 14 | * HTML5 and CSS3 ready 15 | * SEO friendly 16 | * SASS/SCSS preprocessor 17 | * Autoprefixer 18 | * Live-server with hot-reload 19 | * HTML, CSS, JS, images auto minifier 20 | * Ready-to-use Javascript utils, HTML styled components, CSS helpers, SASS utils etc. 21 | * reseter.css 22 | * .htaccess, robots.txt, sitemap.xml, favicon 23 | * 404 page 24 | * And many more... 25 | 26 | ## How to use 27 | 28 | ### Install 29 | 30 | ``` bash 31 | # Clone the repository 32 | git clone https://github.com/zakandaiev/frontend-starter.git 33 | 34 | # Go to the folder 35 | cd frontend-starter 36 | 37 | # Install 38 | npm i 39 | # or 40 | npm install 41 | 42 | # Remove link to the original repository 43 | # - if you use Windows system 44 | Remove-Item .git -Recurse -Force 45 | # - or if you use Unix system 46 | rm -rf .git 47 | ``` 48 | 49 | ### Develop 50 | 51 | ``` bash 52 | # Start development mode with live-server 53 | npm run dev 54 | # or with options 55 | npm run dev --port 3000 56 | ``` 57 | 58 | ### Build 59 | 60 | ``` bash 61 | # Build static files for production 62 | npm run build 63 | # or 64 | npm run prod 65 | # or with options 66 | npm run build --base=/subdomain --dist=./dest 67 | 68 | # Start server for build preview 69 | npm run preview 70 | # or with options 71 | npm run preview --port 3001 72 | ``` 73 | 74 | ### Lint 75 | 76 | ``` bash 77 | # ESLint 78 | npm run lint:js 79 | # or 80 | npm run lint:js:fix 81 | 82 | # StyleLint 83 | npm run lint:css 84 | # or 85 | npm run lint:css:fix 86 | ``` 87 | 88 | ### Backend emulation 89 | 90 | ``` bash 91 | # Fastify listen backend.js 92 | npm run backend 93 | ``` 94 | -------------------------------------------------------------------------------- /src/js/component/section-navigation.js: -------------------------------------------------------------------------------- 1 | import { getSlug } from '@/js/util/cyr-to-lat'; 2 | import { randomString } from '@/js/util/random'; 3 | 4 | document.addEventListener('DOMContentLoaded', () => { 5 | const navigation = document.querySelector('.section__navigation'); 6 | const navigationTitles = document.querySelectorAll('.section__title'); 7 | 8 | if (!navigation || !navigationTitles.length) { 9 | return false; 10 | } 11 | 12 | navigationTitles.forEach((title) => { 13 | const link = document.createElement('a'); 14 | const linkId = `${getSlug(title.textContent)}-${randomString()}`; 15 | 16 | title.id = linkId; 17 | 18 | link.href = `#${linkId}`; 19 | link.innerHTML = `${title.textContent}`; 20 | link.classList.add('section__navigation-item'); 21 | 22 | navigation.appendChild(link); 23 | }); 24 | 25 | setSticky(navigation); 26 | sclollSpy(); 27 | }); 28 | 29 | function setSticky(navigation) { 30 | const parentNode = navigation.closest('.position-sticky'); 31 | 32 | if (!parentNode) { 33 | return false; 34 | } 35 | 36 | const headerHeight = document.getElementById('header')?.offsetHeight || 0; 37 | 38 | if (window.innerWidth >= 768) { 39 | parentNode.style.top = `calc(2em + ${headerHeight}px)`; 40 | } else { 41 | parentNode.style.top = `${headerHeight}px`; 42 | } 43 | } 44 | 45 | function sclollSpy() { 46 | const sections = document.querySelectorAll('.section'); 47 | 48 | const observer = new IntersectionObserver( 49 | (entries) => { 50 | entries.forEach((entry) => { 51 | if (entry.isIntersecting) { 52 | const activeSectionTitle = entry.target.querySelector('.section__title'); 53 | 54 | if (!activeSectionTitle) { 55 | return false; 56 | } 57 | 58 | document.querySelectorAll('.section__navigation-item').forEach((item) => { 59 | if (item.hash === `#${activeSectionTitle.id}`) { 60 | item.classList.add('active'); 61 | } else { 62 | item.classList.remove('active'); 63 | } 64 | }); 65 | } 66 | }); 67 | }, 68 | { 69 | root: document, 70 | rootMargin: '-10% 0px -90% 0px', 71 | }, 72 | ); 73 | 74 | sections.forEach((section) => { 75 | observer.observe(section); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /src/sass/config/root-variable.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-family-heading: "Inter", sans-serif; 3 | --font-family-text: "Inter", sans-serif; 4 | 5 | --gap: 1.6rem; 6 | 7 | // COLOR 8 | @each $key, $val in $color-list { 9 | --color-#{$key}: #{$val}; 10 | } 11 | 12 | // FONT 13 | @each $key, $val in $font-weight-list { 14 | #{'--font-' + $key}: #{$val}; 15 | } 16 | 17 | // RADIUS 18 | @each $key, $val in $radius-list { 19 | --radius-#{$key}: #{$val}; 20 | } 21 | 22 | // Z-INDEX 23 | @each $key, $val in $z-index-list { 24 | --zi-#{$key}: #{$val}; 25 | } 26 | 27 | // BORDER - COLOR 28 | @each $name, $color in $border-color-list { 29 | --color-border-#{$name}: #{$color}; 30 | } 31 | 32 | // BORDER - COLOR INVERSE 33 | @each $name, $color in $border-color-inverse-list { 34 | --color-border-inverse-#{$name}: #{$color}; 35 | } 36 | 37 | // BORDER - WIDTH 38 | @each $size, $value in $border-width-list { 39 | --border-width-#{$size}: #{$value}; 40 | } 41 | 42 | // BORDER - KIT 43 | @each $size, $width in $border-width-list { 44 | @each $name, $color in $border-color-list { 45 | --border-#{$size}-#{$name}: var(--border-width-#{$size}) solid var(--color-border-#{$name}); 46 | } 47 | } 48 | 49 | // SHADOW - COLOR 50 | @each $name, $color in $shadow-color-list { 51 | --color-shadow-#{$name}: #{$color}; 52 | } 53 | 54 | // SHADOW - COLOR INVERSE 55 | @each $name, $color in $shadow-color-inverse-list { 56 | --color-shadow-inverse-#{$name}: #{$color}; 57 | } 58 | 59 | // SHADOW - WIDTH 60 | @each $size, $value in $shadow-width-list { 61 | --shadow-width-#{$size}: #{$value}; 62 | } 63 | 64 | // SHADOW - KIT 65 | @each $size, $width in $shadow-width-list { 66 | @each $name, $color in $shadow-color-list { 67 | --shadow-#{$size}-#{$name}: var(--shadow-width-#{$size}) var(--color-shadow-#{$name}); 68 | } 69 | } 70 | 71 | // TRANSITION 72 | --transition-time: 0.3s; 73 | --transition-timing: cubic-bezier(0.25, 0.8, 0.5, 1); 74 | --transition: all var(--transition-time) var(--transition-timing); 75 | 76 | color-scheme: light; 77 | } 78 | 79 | :root[data-theme="dark"] { 80 | // COLOR 81 | @each $key, $val in $color-inverse-list { 82 | --color-#{$key}: #{$val}; 83 | } 84 | 85 | // BORDER - COLOR 86 | @each $name, $color in $border-color-inverse-list { 87 | --color-border-#{$name}: #{$color}; 88 | } 89 | 90 | // SHADOW - COLOR 91 | // @each $name, $color in $shadow-color-inverse-list { 92 | // --color-shadow-#{$name}: #{$color}; 93 | // } 94 | 95 | color-scheme: dark; 96 | } 97 | -------------------------------------------------------------------------------- /src/sass/component/section.scss: -------------------------------------------------------------------------------- 1 | .section { 2 | display: block; 3 | 4 | // .section__title 5 | &__title { 6 | display: flex; 7 | gap: 1.2rem; 8 | align-items: flex-start; 9 | justify-content: space-between; 10 | 11 | font-size: 2.4rem; 12 | font-weight: var(--font-medium); 13 | color: var(--color-heading); 14 | 15 | & + .section__body { 16 | margin: 3.2rem 0 0; 17 | } 18 | } 19 | 20 | // .section__body 21 | &__body { 22 | display: block; 23 | width: 100%; 24 | 25 | & > *:not(:first-child) { 26 | margin: 1.6rem 0 0; 27 | } 28 | 29 | a { 30 | text-decoration: underline; 31 | 32 | &:hover { 33 | text-decoration: none; 34 | } 35 | } 36 | } 37 | 38 | // .section__navigation 39 | &__navigation { 40 | display: flex; 41 | flex-direction: column; 42 | 43 | // .section__navigation-item 44 | &-item { 45 | position: relative; 46 | 47 | display: block; 48 | 49 | padding: 0.6rem 0 0.6rem 1.6rem; 50 | border-left: var(--border-md-medium); 51 | 52 | font-size: 1.4rem; 53 | font-weight: var(--font-regular); 54 | color: var(--color-text); 55 | 56 | transition: var(--transition); 57 | 58 | &:hover, &:focus, &:active { 59 | color: var(--color-heading); 60 | } 61 | 62 | & > * { 63 | @include ellipsis; 64 | } 65 | 66 | &.active { 67 | color: var(--color-heading); 68 | 69 | &::before { 70 | content: ""; 71 | 72 | position: absolute; 73 | top: 50%; 74 | left: 0; 75 | transform: translate(-50%, -50%); 76 | 77 | width: calc(var(--border-width-md) * 2); 78 | height: 2.4rem; 79 | 80 | background: var(--color-primary); 81 | } 82 | } 83 | } 84 | } 85 | 86 | // .section_offset 87 | &_offset { 88 | padding: 2.4rem 0; 89 | } 90 | 91 | // .section_offset-top 92 | &_offset-top { 93 | padding-top: 2.4rem; 94 | } 95 | 96 | // .section_offset-bottom 97 | &_offset-bottom { 98 | padding-bottom: 2.4rem; 99 | } 100 | 101 | // .section_grow 102 | &_grow { 103 | flex-grow: 1; 104 | } 105 | } 106 | 107 | @include query(md) { 108 | .section { 109 | // .section_offset 110 | &_offset { 111 | padding: 3.2rem 0; 112 | } 113 | 114 | // .section_offset-top 115 | &_offset-top { 116 | padding-top: 3.2rem; 117 | } 118 | 119 | // .section_offset-bottom 120 | &_offset-bottom { 121 | padding-bottom: 3.2rem; 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/layout/main.twig: -------------------------------------------------------------------------------- 1 | {% set title = title is not defined or title is null ? APP_NAME_FORMATTED : title ~ ' - ' ~ APP_NAME_FORMATTED %} 2 | {% set locale = meta.locale|default('en-US') %} 3 | {% set description = meta.description|default(APP_DESCRIPTION) %} 4 | {% set keywords = meta.keywords|default(APP_KEYWORDS) %} 5 | {% set image = meta.image %} 6 | 7 | 8 | 9 | 10 | 11 | {% block head %} 12 | {{ title }} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | {% endblock %} 54 | 55 | 56 | {% block head_additional %}{% endblock %} 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {% block header %} 65 | {% include '@component/header.twig' %} 66 | {% endblock %} 67 | 68 | 69 | {% block main %} 70 |
71 | {% block content %}{% endblock %} 72 |
73 | {% endblock %} 74 | 75 | 76 | {% block footer %} 77 | {% include '@component/footer.twig' %} 78 | {% endblock %} 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /docs/data-theme.js: -------------------------------------------------------------------------------- 1 | const DATA_THEME = { 2 | body_attribute_key: 'data-theme', 3 | storage_key: 'data-theme', 4 | value_default: 'light', 5 | value_dark: 'dark', 6 | 7 | getCurrentTheme: () => localStorage.getItem(DATA_THEME.storage_key) || document.documentElement.getAttribute(DATA_THEME.body_attribute_key), 8 | 9 | setTheme: (theme = null, storage = true) => { 10 | if (theme !== DATA_THEME.value_default && theme !== DATA_THEME.value_dark) { 11 | theme = DATA_THEME.value_default; 12 | } 13 | 14 | document.documentElement.setAttribute(DATA_THEME.body_attribute_key, theme); 15 | 16 | if (storage) { 17 | localStorage.setItem(DATA_THEME.storage_key, theme); 18 | } 19 | 20 | DATA_THEME.dataSrcDark(); 21 | 22 | return true; 23 | }, 24 | 25 | toggleTheme: () => { 26 | let theme = DATA_THEME.getCurrentTheme(); 27 | 28 | if (theme === DATA_THEME.value_default) { 29 | theme = DATA_THEME.value_dark; 30 | } else { 31 | theme = DATA_THEME.value_default; 32 | } 33 | 34 | DATA_THEME.setTheme(theme); 35 | 36 | return true; 37 | }, 38 | 39 | dataSrcDark: () => { 40 | const currentTheme = DATA_THEME.getCurrentTheme(); 41 | 42 | document.querySelectorAll('[data-src-dark]').forEach((item) => { 43 | if (!item.srcLight) { 44 | item.srcLight = item.src; 45 | } 46 | 47 | const { srcLight } = item; 48 | const srcDark = item.srcDark || item.getAttribute('data-src-dark') || srcLight; 49 | 50 | item.src = DATA_THEME.value_dark === currentTheme ? srcDark : srcLight; 51 | }); 52 | 53 | return true; 54 | }, 55 | }; 56 | 57 | const initialTheme = DATA_THEME.getCurrentTheme(); 58 | if (initialTheme) { 59 | DATA_THEME.setTheme(initialTheme); 60 | } else { 61 | let theme = DATA_THEME.value_default; 62 | 63 | if (window.matchMedia) { 64 | theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? DATA_THEME.value_dark : DATA_THEME.value_default; 65 | 66 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => { 67 | const t = event.matches ? DATA_THEME.value_dark : DATA_THEME.value_default; 68 | DATA_THEME.setTheme(t, false); 69 | }); 70 | } 71 | 72 | DATA_THEME.setTheme(theme, false); 73 | } 74 | 75 | document.addEventListener('click', (event) => { 76 | const themeSwitcher = event.target.closest('[data-theme-set]'); 77 | const themeToggler = event.target.closest('[data-theme-toggle]'); 78 | 79 | if (!themeSwitcher && !themeToggler) { 80 | return false; 81 | } 82 | 83 | event.preventDefault(); 84 | 85 | if (themeSwitcher) { 86 | const theme = themeSwitcher.getAttribute('data-theme-set'); 87 | 88 | DATA_THEME.setTheme(theme); 89 | } else if (themeToggler) { 90 | DATA_THEME.toggleTheme(); 91 | } 92 | }); 93 | 94 | document.addEventListener('DOMContentLoaded', () => { 95 | DATA_THEME.dataSrcDark(); 96 | }); 97 | -------------------------------------------------------------------------------- /src/public/data-theme.js: -------------------------------------------------------------------------------- 1 | const DATA_THEME = { 2 | body_attribute_key: 'data-theme', 3 | storage_key: 'data-theme', 4 | value_default: 'light', 5 | value_dark: 'dark', 6 | 7 | getCurrentTheme: () => localStorage.getItem(DATA_THEME.storage_key) || document.documentElement.getAttribute(DATA_THEME.body_attribute_key), 8 | 9 | setTheme: (theme = null, storage = true) => { 10 | if (theme !== DATA_THEME.value_default && theme !== DATA_THEME.value_dark) { 11 | theme = DATA_THEME.value_default; 12 | } 13 | 14 | document.documentElement.setAttribute(DATA_THEME.body_attribute_key, theme); 15 | 16 | if (storage) { 17 | localStorage.setItem(DATA_THEME.storage_key, theme); 18 | } 19 | 20 | DATA_THEME.dataSrcDark(); 21 | 22 | return true; 23 | }, 24 | 25 | toggleTheme: () => { 26 | let theme = DATA_THEME.getCurrentTheme(); 27 | 28 | if (theme === DATA_THEME.value_default) { 29 | theme = DATA_THEME.value_dark; 30 | } else { 31 | theme = DATA_THEME.value_default; 32 | } 33 | 34 | DATA_THEME.setTheme(theme); 35 | 36 | return true; 37 | }, 38 | 39 | dataSrcDark: () => { 40 | const currentTheme = DATA_THEME.getCurrentTheme(); 41 | 42 | document.querySelectorAll('[data-src-dark]').forEach((item) => { 43 | if (!item.srcLight) { 44 | item.srcLight = item.src; 45 | } 46 | 47 | const { srcLight } = item; 48 | const srcDark = item.srcDark || item.getAttribute('data-src-dark') || srcLight; 49 | 50 | item.src = DATA_THEME.value_dark === currentTheme ? srcDark : srcLight; 51 | }); 52 | 53 | return true; 54 | }, 55 | }; 56 | 57 | const initialTheme = DATA_THEME.getCurrentTheme(); 58 | if (initialTheme) { 59 | DATA_THEME.setTheme(initialTheme); 60 | } else { 61 | let theme = DATA_THEME.value_default; 62 | 63 | if (window.matchMedia) { 64 | theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? DATA_THEME.value_dark : DATA_THEME.value_default; 65 | 66 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => { 67 | const t = event.matches ? DATA_THEME.value_dark : DATA_THEME.value_default; 68 | DATA_THEME.setTheme(t, false); 69 | }); 70 | } 71 | 72 | DATA_THEME.setTheme(theme, false); 73 | } 74 | 75 | document.addEventListener('click', (event) => { 76 | const themeSwitcher = event.target.closest('[data-theme-set]'); 77 | const themeToggler = event.target.closest('[data-theme-toggle]'); 78 | 79 | if (!themeSwitcher && !themeToggler) { 80 | return false; 81 | } 82 | 83 | event.preventDefault(); 84 | 85 | if (themeSwitcher) { 86 | const theme = themeSwitcher.getAttribute('data-theme-set'); 87 | 88 | DATA_THEME.setTheme(theme); 89 | } else if (themeToggler) { 90 | DATA_THEME.toggleTheme(); 91 | } 92 | }); 93 | 94 | document.addEventListener('DOMContentLoaded', () => { 95 | DATA_THEME.dataSrcDark(); 96 | }); 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend-starter", 3 | "version": "2.3.2", 4 | "private": false, 5 | "author": "Oleksandr Zakandaiev", 6 | "authorUrl": "https://github.com/zakandaiev", 7 | "description": "FrontEnd Starter is a boilerplate kit for easy building modern static web-sites using Gulp", 8 | "keywords": [ 9 | "gulp", 10 | "builder", 11 | "frontend", 12 | "starter", 13 | "boilerplate", 14 | "kit", 15 | "twig", 16 | "html", 17 | "css", 18 | "sass", 19 | "scss", 20 | "javascript", 21 | "js", 22 | "es6", 23 | "optimized", 24 | "autoprefixer", 25 | "rollup", 26 | "browsersync", 27 | "404", 28 | "grid", 29 | "htaccess", 30 | "robots.txt", 31 | "sitemap.xml", 32 | "favicon" 33 | ], 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/zakandaiev/frontend-starter" 37 | }, 38 | "type": "module", 39 | "engines": { 40 | "node": ">=22.12.0" 41 | }, 42 | "browserslist": [ 43 | "last 15 versions", 44 | "not dead" 45 | ], 46 | "scripts": { 47 | "dev": "gulp", 48 | "prod": "gulp --prod", 49 | "build": "gulp --prod", 50 | "preview": "gulp preview --prod", 51 | "docs": "gulp --prod --base=/frontend-starter --dist=./docs", 52 | "lint:js": "eslint ./", 53 | "lint:js:fix": "eslint ./ --fix", 54 | "lint:css": "stylelint ./src/sass/**/*.{sass,scss}", 55 | "lint:css:fix": "stylelint ./src/sass/**/*.{sass,scss} --fix", 56 | "backend": "nodemon backend" 57 | }, 58 | "devDependencies": { 59 | "@eslint/eslintrc": "^3.3.3", 60 | "@eslint/js": "^9.39.2", 61 | "@rollup/plugin-alias": "^5.1.1", 62 | "@rollup/plugin-commonjs": "^29.0.0", 63 | "@rollup/plugin-node-resolve": "^16.0.3", 64 | "@rollup/plugin-replace": "^6.0.3", 65 | "@rollup/plugin-terser": "^0.4.4", 66 | "autoprefixer": "^10.4.23", 67 | "browser-sync": "^3.0.4", 68 | "cssnano": "^7.1.2", 69 | "del": "^8.0.1", 70 | "dotenv": "^17.2.3", 71 | "eslint": "^9.39.2", 72 | "eslint-config-airbnb-base": "^15.0.0", 73 | "eslint-import-resolver-alias": "^1.1.2", 74 | "eslint-plugin-import": "^2.32.0", 75 | "fastify": "^5.6.2", 76 | "globals": "^16.5.0", 77 | "gulp": "^5.0.1", 78 | "gulp-htmlmin": "^5.0.1", 79 | "gulp-if": "^3.0.0", 80 | "gulp-imagemin": "^9.2.0", 81 | "gulp-newer": "^1.4.0", 82 | "gulp-postcss": "^10.0.0", 83 | "gulp-sass": "^6.0.1", 84 | "gulp-twig": "^1.2.0", 85 | "gulp-version-number": "^0.2.4", 86 | "localtunnel": "^2.0.2", 87 | "minimist": "^1.2.8", 88 | "nodemon": "^3.1.11", 89 | "postcss-combine-media-query": "^2.1.0", 90 | "rollup": "^4.53.5", 91 | "rollup-plugin-multi-input": "^1.6.0", 92 | "sass": "^1.97.0", 93 | "stylelint": "^16.26.1", 94 | "stylelint-config-clean-order": "^8.0.0", 95 | "stylelint-config-standard-scss": "^16.0.0", 96 | "stylelint-scss": "^6.13.0" 97 | }, 98 | "dependencies": { 99 | "@tabler/icons": "^3.36.0", 100 | "reseter.css": "^2.0.0" 101 | }, 102 | "overrides": { 103 | "eslint": "^9.39.2" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/js/util/geolocation.js: -------------------------------------------------------------------------------- 1 | async function getUserPosition(opt = {}) { 2 | const position = { 3 | error: true, 4 | errorMessage: null, 5 | lat: null, 6 | lng: null, 7 | }; 8 | 9 | if (!navigator || !navigator.geolocation) { 10 | return position; 11 | } 12 | 13 | try { 14 | const { coords } = await new Promise((resolve, reject) => { 15 | navigator.geolocation.getCurrentPosition( 16 | resolve, 17 | reject, 18 | { 19 | enableHighAccuracy: true, 20 | maximumAge: 0, 21 | timeout: 5000, 22 | ...opt, 23 | }, 24 | ); 25 | }); 26 | 27 | if (coords.latitude && coords.longitude) { 28 | position.error = false; 29 | position.lat = coords.latitude; 30 | position.lng = coords.longitude; 31 | } 32 | } catch (error) { 33 | position.error = true; 34 | position.errorMessage = error; 35 | } 36 | 37 | return position; 38 | } 39 | 40 | function getDistanceBetweenCoords(lat1, lon1, lat2, lon2, unit = 'mi') { 41 | if ((lat1 === lat2) && (lon1 === lon2)) { 42 | return 0; 43 | } 44 | 45 | const radlat1 = (Math.PI * lat1) / 180; 46 | const radlat2 = (Math.PI * lat2) / 180; 47 | const theta = lon1 - lon2; 48 | const radtheta = (Math.PI * theta) / 180; 49 | 50 | let dist = Math.sin(radlat1) * Math.sin(radlat2) + Math.cos(radlat1) * Math.cos(radlat2) * Math.cos(radtheta); 51 | 52 | if (dist > 1) { 53 | dist = 1; 54 | } 55 | 56 | dist = Math.acos(dist); 57 | dist = (dist * 180) / Math.PI; 58 | dist = (dist * 60) * 1.1515; 59 | 60 | if (unit === 'km') { 61 | dist *= 1.609344; 62 | } else if (unit === 'm') { 63 | dist *= 1609.344; 64 | } 65 | 66 | return dist; 67 | } 68 | 69 | function watchUserPosition(callback, opt = {}) { 70 | if (!navigator.geolocation || typeof callback !== 'function') { 71 | return false; 72 | } 73 | 74 | const updateIntervalMs = opt.updateIntervalMs || 0; 75 | let lastUpdateTimestamp = 0; 76 | 77 | const watchId = navigator.geolocation.watchPosition( 78 | (pos) => { 79 | const nowTimestamp = Date.now(); 80 | if (updateIntervalMs > 0 && nowTimestamp - lastUpdateTimestamp < updateIntervalMs) { 81 | return false; 82 | } 83 | 84 | lastUpdateTimestamp = nowTimestamp; 85 | 86 | const { coords } = pos; 87 | if (coords.latitude && coords.longitude) { 88 | callback({ 89 | error: false, 90 | errorMessage: null, 91 | lat: coords.latitude, 92 | lng: coords.longitude, 93 | }); 94 | } 95 | }, 96 | (err) => { 97 | callback({ 98 | error: true, 99 | errorMessage: err, 100 | lat: null, 101 | lng: null, 102 | }); 103 | }, 104 | { 105 | enableHighAccuracy: true, 106 | maximumAge: 0, 107 | timeout: 5000, 108 | ...opt, 109 | }, 110 | ); 111 | 112 | return watchId; 113 | } 114 | 115 | function stopWatchUserPosition(watchId) { 116 | if (watchId && navigator.geolocation) { 117 | return navigator.geolocation.clearWatch(watchId); 118 | } 119 | 120 | return false; 121 | } 122 | 123 | export { 124 | getDistanceBetweenCoords, 125 | getUserPosition, 126 | stopWatchUserPosition, 127 | watchUserPosition, 128 | }; 129 | 130 | export default getUserPosition; 131 | -------------------------------------------------------------------------------- /src/js/util/request.js: -------------------------------------------------------------------------------- 1 | import Config from '@/config'; 2 | import sleep from '@/js/util/sleep'; 3 | 4 | function getApiTimeout(timeout) { 5 | return timeout || Config.api.timeoutMs || 15000; 6 | } 7 | 8 | function getApiDelay(delay) { 9 | return delay || Config.api.delayMs || 1000; 10 | } 11 | 12 | async function fetchWithTimeout(resource, options = {}, timeout = null) { 13 | const controller = new AbortController(); 14 | const timeoutId = setTimeout(() => controller.abort(), getApiTimeout(timeout)); 15 | 16 | const response = await fetch(resource, { 17 | ...options, 18 | signal: controller.signal, 19 | }); 20 | 21 | clearTimeout(timeoutId); 22 | 23 | return response; 24 | } 25 | 26 | async function request(resource, opt = {}, timeout = null, delay = null) { 27 | const startTime = performance.now(); 28 | 29 | const options = { 30 | ...opt, 31 | headers: opt.headers || { 'Content-Type': 'application/json' }, 32 | method: opt.method || 'GET', 33 | }; 34 | 35 | if (options.headers.Authorization === undefined && Config.api.key && Config.api.key.length) { 36 | options.headers.Authorization = Config.api.key; 37 | } 38 | 39 | if (options.method.toUpperCase() === 'GET' && typeof options.body === 'object' && options.body !== null) { 40 | const url = new URL(resource, window.location.origin); 41 | Object.entries(options.body).forEach(([key, value]) => { 42 | if (value === null || value === undefined) { 43 | return false; 44 | } 45 | if (typeof value === 'object') { 46 | url.searchParams.append(key, JSON.stringify(value)); 47 | } else { 48 | url.searchParams.append(key, value); 49 | } 50 | }); 51 | resource = url.toString(); 52 | delete options.body; 53 | } 54 | 55 | if (typeof options.body === 'object' && !(options.body instanceof FormData)) { 56 | options.body = JSON.stringify(options.body); 57 | } 58 | 59 | const result = { 60 | code: null, 61 | status: null, 62 | message: null, 63 | data: null, 64 | error: null, 65 | }; 66 | 67 | let response = {}; 68 | 69 | try { 70 | response = await fetchWithTimeout(resource, options, getApiTimeout(timeout)); 71 | result.code = response.status; 72 | } catch { 73 | result.status = 'error'; 74 | result.message = 'Request failed: resource is not reachable or response time was exceeded'; 75 | return result; 76 | } 77 | 78 | try { 79 | const responseData = await response.json() || {}; 80 | if (responseData.constructor.name === 'Object') { 81 | Object.assign(result, responseData); 82 | } 83 | 84 | result.status = responseData.status || null; 85 | result.message = responseData.message || null; 86 | result.data = responseData.data || responseData.payload || responseData || null; 87 | } catch { 88 | result.status = 'error'; 89 | result.message = 'Request failed: the response is not valid JSON'; 90 | return result; 91 | } 92 | 93 | const endTime = performance.now(); 94 | const differenceTime = endTime - startTime; 95 | const delayTime = getApiDelay(delay); 96 | 97 | if (differenceTime < delayTime) { 98 | await sleep(delayTime - differenceTime); 99 | } 100 | 101 | return result; 102 | } 103 | 104 | export { 105 | fetchWithTimeout, 106 | request, 107 | }; 108 | 109 | export default request; 110 | -------------------------------------------------------------------------------- /src/sass/component/hero.scss: -------------------------------------------------------------------------------- 1 | .hero { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 1.6rem; 5 | 6 | // .hero__main 7 | &__main { 8 | order: 2; 9 | } 10 | 11 | // .hero__title 12 | &__title { 13 | display: block; 14 | 15 | font-size: 3.2rem; 16 | font-weight: var(--font-bold); 17 | line-height: 1.2; 18 | color: var(--color-primary); 19 | text-align: center; 20 | } 21 | 22 | // .hero__subtitle 23 | &__subtitle { 24 | display: block; 25 | 26 | font-size: 3.2rem; 27 | font-weight: var(--font-bold); 28 | line-height: 1.2; 29 | color: var(--color-heading); 30 | text-align: center; 31 | } 32 | 33 | // .hero__text 34 | &__text { 35 | display: block; 36 | 37 | margin: 1.2rem 0 0; 38 | 39 | font-size: 1.8rem; 40 | font-weight: var(--font-medium); 41 | line-height: inherit; 42 | color: var(--color-text); 43 | text-align: center; 44 | } 45 | 46 | // .hero__actions 47 | &__actions { 48 | display: flex; 49 | flex-wrap: wrap; 50 | gap: 1.2rem; 51 | justify-content: center; 52 | 53 | margin: 3.2rem 0 0; 54 | } 55 | 56 | // .hero__image 57 | &__image { 58 | position: relative; 59 | 60 | display: flex; 61 | align-items: center; 62 | justify-content: center; 63 | order: 1; 64 | 65 | text-align: center; 66 | 67 | &::before { 68 | content: ""; 69 | 70 | position: absolute; 71 | z-index: var(--zi-below); 72 | top: 50%; 73 | left: 50%; 74 | transform: translate(-50%, -50%); 75 | 76 | width: 16rem; 77 | height: 16rem; 78 | 79 | background: linear-gradient(-45deg, var(--color-primary) 50%, var(--color-secondary) 50%); 80 | filter: blur(7.2rem); 81 | } 82 | 83 | & > * { 84 | display: block; 85 | flex-shrink: 0; 86 | width: auto; 87 | height: 16rem; 88 | } 89 | } 90 | } 91 | 92 | @include query(md) { 93 | .hero { 94 | // .hero__title 95 | &__title { 96 | font-size: 4rem; 97 | } 98 | 99 | // .hero__subtitle 100 | &__subtitle { 101 | font-size: 4rem; 102 | } 103 | 104 | // .hero__text 105 | &__text { 106 | font-size: 2rem; 107 | } 108 | 109 | // .hero__image 110 | &__image { 111 | &::before { 112 | width: 20rem; 113 | height: 20rem; 114 | } 115 | 116 | & > * { 117 | height: 20rem; 118 | } 119 | } 120 | } 121 | } 122 | 123 | @include query(lg) { 124 | .hero { 125 | flex-direction: row; 126 | gap: 0; 127 | 128 | // .hero__main 129 | &__main { 130 | flex: 0 0 50%; 131 | order: 1; 132 | } 133 | 134 | // .hero__image 135 | &__image { 136 | flex: 0 0 50%; 137 | order: 2; 138 | 139 | &::before { 140 | width: 26rem; 141 | height: 26rem; 142 | } 143 | 144 | & > * { 145 | height: 26rem; 146 | } 147 | } 148 | 149 | // .hero__title 150 | &__title { 151 | font-size: 4.8rem; 152 | text-align: left; 153 | } 154 | 155 | // .hero__subtitle 156 | &__subtitle { 157 | font-size: 4.8rem; 158 | text-align: left; 159 | } 160 | 161 | // .hero__text 162 | &__text { 163 | font-size: 2.4rem; 164 | text-align: left; 165 | } 166 | 167 | // .hero__actions 168 | &__actions { 169 | justify-content: flex-start; 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/sass/component/toast.scss: -------------------------------------------------------------------------------- 1 | .toasts { 2 | pointer-events: none; 3 | 4 | position: fixed; 5 | z-index: var(--zi-top); 6 | inset: 0; 7 | 8 | overflow: hidden; 9 | display: flex; 10 | flex-direction: column; 11 | gap: 1.6rem; 12 | align-items: flex-end; 13 | justify-content: flex-end; 14 | 15 | padding: var(--gap); 16 | 17 | // .toasts__item 18 | &__item { 19 | pointer-events: auto; 20 | cursor: pointer; 21 | 22 | position: relative; 23 | 24 | overflow: hidden; 25 | display: flex; 26 | flex-shrink: 0; 27 | gap: 0.8rem; 28 | align-items: center; 29 | 30 | width: auto; 31 | min-width: 20rem; 32 | max-width: 30rem; 33 | padding: 1.2rem 1.6rem; 34 | border-radius: var(--radius-md); 35 | 36 | color: #fff; 37 | 38 | opacity: 0.9; 39 | background: #222e3c; 40 | box-shadow: var(--shadow-lg-regular); 41 | 42 | transition: var(--transition); 43 | 44 | // animations 45 | animation-name: toast-appear; 46 | animation-duration: var(--transition-time); 47 | animation-fill-mode: both; 48 | 49 | &:hover { 50 | opacity: 1; 51 | box-shadow: var(--shadow-md-medium); 52 | } 53 | 54 | // .toasts__item.disappear 55 | &.disappear { 56 | animation-name: toast-disappear; 57 | } 58 | 59 | @each $color in $accent-color-list { 60 | &.#{$color} { 61 | background: var(--color-#{$color}); 62 | 63 | .toasts__icon { 64 | &::before, &::after { 65 | background: var(--color-#{$color}); 66 | } 67 | } 68 | } 69 | } 70 | 71 | // .toasts__item.warning 72 | // .toasts__item.error 73 | &.warning, 74 | &.error { 75 | .toasts__icon { 76 | &::before, &::after { 77 | top: 50%; 78 | left: 50%; 79 | width: 0.2em; 80 | height: 0.85em; 81 | } 82 | 83 | &::after { 84 | transform: translate(-50%, -50%) rotate(-45deg); 85 | } 86 | 87 | &::before { 88 | transform: translate(-50%, -50%) rotate(45deg); 89 | } 90 | } 91 | } 92 | } 93 | 94 | // .toasts__icon 95 | &__icon { 96 | position: relative; 97 | 98 | flex-shrink: 0; 99 | 100 | width: 1.5em; 101 | height: 1.5em; 102 | border-radius: 50%; 103 | 104 | background: #fff; 105 | 106 | &::before, &::after { 107 | content: ""; 108 | 109 | position: absolute; 110 | z-index: 1; 111 | transform: rotate(-45deg); 112 | 113 | display: block; 114 | 115 | border-radius: 0.15em; 116 | 117 | background: #222e3c; 118 | } 119 | 120 | &::before { 121 | top: 0.4em; 122 | left: 0.8em; 123 | transform: rotate(45deg); 124 | 125 | width: 0.2em; 126 | height: 0.8em; 127 | } 128 | 129 | &::after { 130 | top: 0.65em; 131 | left: 0.45em; 132 | transform: rotate(-45deg); 133 | 134 | width: 0.2em; 135 | height: 0.5em; 136 | } 137 | } 138 | 139 | // .toasts__text 140 | &__text { 141 | overflow: hidden; 142 | height: 100%; 143 | text-overflow: ellipsis; 144 | } 145 | } 146 | 147 | @keyframes toast-appear { 148 | 0% { 149 | transform: translateY(25%); 150 | opacity: 0; 151 | } 152 | 153 | 100% { 154 | transform: translateY(0); 155 | opacity: 1; 156 | } 157 | } 158 | 159 | @keyframes toast-disappear { 160 | 0% { 161 | transform: translateY(0); 162 | opacity: 1; 163 | } 164 | 165 | 100% { 166 | transform: translateY(25%); 167 | opacity: 0; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/sass/main.scss: -------------------------------------------------------------------------------- 1 | /* ========== Util ========== */ 2 | @import "util/_all"; 3 | 4 | /* ========== Config ========== */ 5 | @import "config/_all"; 6 | 7 | /* ========== Third party ========== */ 8 | @import "vendor/_all"; 9 | 10 | /* ========== Global ========== */ 11 | *, *::before, *::after { 12 | box-sizing: border-box; 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | html, 18 | body { 19 | position: relative; 20 | width: 100%; 21 | min-width: 320px; 22 | font-size: 10px; 23 | } 24 | 25 | html { 26 | height: 100%; 27 | } 28 | 29 | body { 30 | position: relative; 31 | 32 | overflow-x: hidden; 33 | display: flex; 34 | flex-direction: column; 35 | 36 | min-height: 100%; 37 | 38 | font-family: var(--font-family-text); 39 | font-size: 1.6rem; 40 | font-weight: var(--font-regular); 41 | line-height: 1.5; 42 | color: var(--color-text); 43 | 44 | background: var(--color-body); 45 | 46 | &.disable-scroll { 47 | touch-action: none; 48 | overflow: hidden; 49 | } 50 | } 51 | 52 | h1, h2, h3, h4, h5, h6 { 53 | margin: 0; 54 | 55 | font: inherit; 56 | font-weight: var(--font-medium); 57 | line-height: 1.2; 58 | color: var(--color-heading); 59 | } 60 | 61 | h1 { 62 | font-size: 3.4rem; 63 | } 64 | 65 | h2 { 66 | font-size: 2.8rem; 67 | } 68 | 69 | h3 { 70 | font-size: 2rem; 71 | } 72 | 73 | h4 { 74 | font-size: 1.6rem; 75 | } 76 | 77 | h5 { 78 | font-size: 1.4rem; 79 | } 80 | 81 | h6 { 82 | font-size: 1.2rem; 83 | } 84 | 85 | p { 86 | margin: 0; 87 | } 88 | 89 | a { 90 | color: var(--color-link); 91 | text-decoration: none; 92 | transition: var(--transition); 93 | 94 | &:hover, &:focus, &:active { 95 | color: var(--color-link-hover); 96 | } 97 | 98 | &[href^="tel:"], &[href^="mailto:"] { 99 | text-decoration: underline; 100 | 101 | &:hover, &:focus, &:active { 102 | text-decoration: none; 103 | } 104 | } 105 | } 106 | 107 | b, strong { 108 | font-weight: var(--font-bold); 109 | } 110 | 111 | img, svg, picture { 112 | display: inline-block; 113 | max-width: 100%; 114 | } 115 | 116 | svg { 117 | display: inline; 118 | height: 1em; 119 | } 120 | 121 | blockquote { 122 | display: block; 123 | 124 | width: 100%; 125 | margin: 0; 126 | padding: 0.5em 0 0.5em 1.5em; 127 | border-left: var(--border-xxl-medium); 128 | 129 | font-style: italic; 130 | } 131 | 132 | ul, ol { 133 | margin: 0; 134 | padding-left: 1.4em; 135 | 136 | & > li::marker { 137 | color: var(--color-border-md); 138 | } 139 | } 140 | 141 | figure { 142 | display: inline-block; 143 | margin: 0; 144 | } 145 | 146 | iframe { 147 | display: block; 148 | 149 | width: 100%; 150 | margin: 0; 151 | border: none; 152 | 153 | box-shadow: none; 154 | } 155 | 156 | hr { 157 | display: block; 158 | margin: 1rem 0; 159 | border: var(--border-md-medium); 160 | } 161 | 162 | code, kbd, pre, samp { 163 | overflow: auto; 164 | color: var(--color-heading); 165 | } 166 | 167 | ::placeholder { 168 | color: var(--color-border-md); 169 | opacity: 1; 170 | } 171 | 172 | /* ========== Custom ========== */ 173 | .container { 174 | display: block; 175 | 176 | width: 100%; 177 | max-width: 120rem; 178 | margin: 0 auto; 179 | padding: 0 var(--gap); 180 | } 181 | 182 | .container-fluid { 183 | display: block; 184 | width: 100%; 185 | padding: 0 var(--gap); 186 | } 187 | 188 | .page-content { 189 | display: flex; 190 | flex: 1 0 auto; 191 | flex-direction: column; 192 | } 193 | 194 | @import "component/box"; 195 | @import "component/button"; 196 | @import "component/form"; 197 | @import "component/grid"; 198 | @import "component/helper"; 199 | @import "component/label"; 200 | @import "component/loader"; 201 | @import "component/section"; 202 | @import "component/table"; 203 | @import "component/toast"; 204 | 205 | @import "component/header"; 206 | @import "component/hero"; 207 | @import "component/footer"; 208 | -------------------------------------------------------------------------------- /src/js/component/keyboard-focus-fix.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => { 2 | const isIOS = /iphone|ipad/.test(navigator.userAgent.toLowerCase()); 3 | if (!isIOS) { 4 | return false; 5 | } 6 | 7 | const paddingMap = new WeakMap(); 8 | const stickyElements = new Set(); 9 | 10 | function disableStickyElements(parentNode) { 11 | if (!parentNode) { 12 | return false; 13 | } 14 | 15 | parentNode.querySelectorAll('*').forEach((el) => { 16 | const style = getComputedStyle(el); 17 | if (style.position !== 'sticky') { 18 | return false; 19 | } 20 | 21 | el.originalPosition = el.style.position; 22 | el.style.position = 'static'; 23 | 24 | stickyElements.add(el); 25 | }); 26 | } 27 | 28 | function restoreStickyElements() { 29 | stickyElements.forEach((el) => { 30 | el.style.position = el.originalPosition || ''; 31 | delete el.originalPosition; 32 | }); 33 | 34 | stickyElements.clear(); 35 | } 36 | 37 | document.body.addEventListener( 38 | 'focus', 39 | (event) => { 40 | const { target } = event; 41 | if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) { 42 | return false; 43 | } 44 | 45 | const scrollContainer = findScrollableParent(target); 46 | const originalPadding = parseFloat(getComputedStyle(scrollContainer).paddingBottom) || 0; 47 | 48 | disableStickyElements(scrollContainer); 49 | 50 | // Wait for keyboard loaded 51 | setTimeout(() => { 52 | const rect = target.getBoundingClientRect(); 53 | const viewportHeight = window.visualViewport?.height || window.innerHeight; 54 | 55 | // If input is visible do nothing 56 | if (rect.top >= 0 && rect.bottom <= viewportHeight) { 57 | return false; 58 | } 59 | 60 | if (!paddingMap.has(scrollContainer)) { 61 | paddingMap.set(scrollContainer, originalPadding); 62 | scrollContainer.style.paddingBottom = `${originalPadding + window.innerHeight * 0.5}px`; 63 | } 64 | 65 | target.scrollIntoView({ 66 | block: 'center', 67 | behavior: 'smooth', 68 | }); 69 | }, 1000); 70 | }, 71 | true, 72 | ); 73 | 74 | document.body.addEventListener( 75 | 'blur', 76 | (event) => { 77 | const { target } = event; 78 | if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) { 79 | return false; 80 | } 81 | 82 | const scrollContainer = findScrollableParent(target); 83 | const originalPadding = paddingMap.get(scrollContainer); 84 | 85 | restoreStickyElements(); 86 | 87 | if (originalPadding !== undefined) { 88 | let isCleaned = false; 89 | 90 | const cleanup = () => { 91 | if (isCleaned) { 92 | return false; 93 | } 94 | 95 | scrollContainer.style.paddingBottom = `${originalPadding}px`; 96 | paddingMap.delete(scrollContainer); 97 | 98 | document.removeEventListener('click', onClick, true); 99 | document.removeEventListener('touchend', onClick, true); 100 | 101 | isCleaned = true; 102 | }; 103 | 104 | const onClick = () => { 105 | requestAnimationFrame(cleanup); 106 | }; 107 | 108 | document.addEventListener('click', onClick, true); 109 | document.addEventListener('touchend', onClick, true); 110 | 111 | setTimeout(cleanup, 1000); 112 | } 113 | }, 114 | true, 115 | ); 116 | }); 117 | 118 | function findScrollableParent(el) { 119 | let current = el.parentElement; 120 | 121 | while (current) { 122 | const { overflowY } = getComputedStyle(current); 123 | 124 | const isScrollable = overflowY === 'auto' || overflowY === 'scroll' ? true : false; 125 | if (isScrollable) { 126 | return current; 127 | } 128 | 129 | current = current.parentElement; 130 | } 131 | 132 | return document.body; 133 | } 134 | -------------------------------------------------------------------------------- /docs/.htaccess: -------------------------------------------------------------------------------- 1 | AddDefaultCharset UTF-8 2 | 3 | ############################# REWRITE AND REDIRECTION ############################# 4 | RewriteEngine on 5 | RewriteBase / 6 | 7 | # Force non-www 8 | RewriteCond %{HTTP_HOST} ^www\. 9 | RewriteCond %{HTTPS}s ^on(s)|off 10 | RewriteCond http%1://%{HTTP_HOST} ^(https?://)(www\.)?(.+)$ 11 | RewriteRule ^ %1%3%{REQUEST_URI} [L,R=301] 12 | 13 | # Force HTTPS 14 | #RewriteCond %{HTTPS} !on 15 | #RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} 16 | 17 | # Remove Trailing Slash 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_URI} (.+)/$ 20 | RewriteRule ^ %1 [L,R=301] 21 | 22 | # Router 23 | RewriteCond %{REQUEST_FILENAME} .php$ [OR] 24 | RewriteCond %{REQUEST_FILENAME} !-f [OR] 25 | RewriteCond %{REQUEST_FILENAME} -d 26 | RewriteRule ^(.*)$ index.php [L,QSA] 27 | 28 | ############################# SECURITY ############################# 29 | # Deny Access to Hidden Files and Directories 30 | #RedirectMatch 404 /\..*$ 31 | 32 | # Deny Access to Backup and Source Files 33 | 34 | ## Apache 2.2 35 | Order allow,deny 36 | Deny from all 37 | Satisfy All 38 | 39 | ## Apache 2.4 40 | # Require all denied 41 | 42 | 43 | # Disable Directory Browsing 44 | Options -Indexes 45 | 46 | # Set secutiry headers 47 | 48 | Header set Referrer-Policy "same-origin" 49 | Header set X-Frame-Options "SAMEORIGIN" 50 | Header set X-XSS-Protection "1; mode=block" 51 | Header set X-Content-Type-Options "nosniff" 52 | Header set Content-Security-Policy "frame-ancestors 'self'" 53 | Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" 54 | 55 | 56 | ############################# PERFORMANCE ############################# 57 | # Compress Text Files 58 | 59 | 60 | 61 | SetEnvIfNoCase ^(Accept-EncodXng|X-cept-Encoding|X{15}|~{15}|-{15})$ ^((gzip|deflate)\s*,?\s*)+|[X~-]{4,13}$ HAVE_Accept-Encoding 62 | RequestHeader append Accept-Encoding "gzip,deflate" env=HAVE_Accept-Encoding 63 | 64 | 65 | 66 | 67 | AddOutputFilterByType DEFLATE application/atom+xml \ 68 | application/javascript \ 69 | application/json \ 70 | application/rss+xml \ 71 | application/vnd.ms-fontobject \ 72 | application/x-font-ttf \ 73 | application/x-web-app-manifest+json \ 74 | application/xhtml+xml \ 75 | application/xml \ 76 | font/opentype \ 77 | image/svg+xml \ 78 | image/x-icon \ 79 | text/css \ 80 | text/html \ 81 | text/plain \ 82 | text/x-component \ 83 | text/xml 84 | 85 | 86 | 87 | # Set Expires Headers 88 | 89 | ExpiresActive on 90 | ExpiresDefault "access plus 1 month" 91 | ExpiresByType application/json "access plus 0 seconds" 92 | ExpiresByType application/xml "access plus 0 seconds" 93 | ExpiresByType text/xml "access plus 0 seconds" 94 | ExpiresByType text/html "access plus 0 seconds" 95 | ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds" 96 | ExpiresByType text/cache-manifest "access plus 0 seconds" 97 | ExpiresByType application/rss+xml "access plus 1 hour" 98 | 99 | 100 | ############################# MISCELLANEOUS ############################# 101 | # Allow Cross-Domain Fonts 102 | 103 | 104 | Header set Access-Control-Allow-Origin "*" 105 | 106 | 107 | -------------------------------------------------------------------------------- /src/public/.htaccess: -------------------------------------------------------------------------------- 1 | AddDefaultCharset UTF-8 2 | 3 | ############################# REWRITE AND REDIRECTION ############################# 4 | RewriteEngine on 5 | RewriteBase / 6 | 7 | # Force non-www 8 | RewriteCond %{HTTP_HOST} ^www\. 9 | RewriteCond %{HTTPS}s ^on(s)|off 10 | RewriteCond http%1://%{HTTP_HOST} ^(https?://)(www\.)?(.+)$ 11 | RewriteRule ^ %1%3%{REQUEST_URI} [L,R=301] 12 | 13 | # Force HTTPS 14 | #RewriteCond %{HTTPS} !on 15 | #RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} 16 | 17 | # Remove Trailing Slash 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_URI} (.+)/$ 20 | RewriteRule ^ %1 [L,R=301] 21 | 22 | # Router 23 | RewriteCond %{REQUEST_FILENAME} .php$ [OR] 24 | RewriteCond %{REQUEST_FILENAME} !-f [OR] 25 | RewriteCond %{REQUEST_FILENAME} -d 26 | RewriteRule ^(.*)$ index.php [L,QSA] 27 | 28 | ############################# SECURITY ############################# 29 | # Deny Access to Hidden Files and Directories 30 | #RedirectMatch 404 /\..*$ 31 | 32 | # Deny Access to Backup and Source Files 33 | 34 | ## Apache 2.2 35 | Order allow,deny 36 | Deny from all 37 | Satisfy All 38 | 39 | ## Apache 2.4 40 | # Require all denied 41 | 42 | 43 | # Disable Directory Browsing 44 | Options -Indexes 45 | 46 | # Set secutiry headers 47 | 48 | Header set Referrer-Policy "same-origin" 49 | Header set X-Frame-Options "SAMEORIGIN" 50 | Header set X-XSS-Protection "1; mode=block" 51 | Header set X-Content-Type-Options "nosniff" 52 | Header set Content-Security-Policy "frame-ancestors 'self'" 53 | Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" 54 | 55 | 56 | ############################# PERFORMANCE ############################# 57 | # Compress Text Files 58 | 59 | 60 | 61 | SetEnvIfNoCase ^(Accept-EncodXng|X-cept-Encoding|X{15}|~{15}|-{15})$ ^((gzip|deflate)\s*,?\s*)+|[X~-]{4,13}$ HAVE_Accept-Encoding 62 | RequestHeader append Accept-Encoding "gzip,deflate" env=HAVE_Accept-Encoding 63 | 64 | 65 | 66 | 67 | AddOutputFilterByType DEFLATE application/atom+xml \ 68 | application/javascript \ 69 | application/json \ 70 | application/rss+xml \ 71 | application/vnd.ms-fontobject \ 72 | application/x-font-ttf \ 73 | application/x-web-app-manifest+json \ 74 | application/xhtml+xml \ 75 | application/xml \ 76 | font/opentype \ 77 | image/svg+xml \ 78 | image/x-icon \ 79 | text/css \ 80 | text/html \ 81 | text/plain \ 82 | text/x-component \ 83 | text/xml 84 | 85 | 86 | 87 | # Set Expires Headers 88 | 89 | ExpiresActive on 90 | ExpiresDefault "access plus 1 month" 91 | ExpiresByType application/json "access plus 0 seconds" 92 | ExpiresByType application/xml "access plus 0 seconds" 93 | ExpiresByType text/xml "access plus 0 seconds" 94 | ExpiresByType text/html "access plus 0 seconds" 95 | ExpiresByType application/x-web-app-manifest+json "access plus 0 seconds" 96 | ExpiresByType text/cache-manifest "access plus 0 seconds" 97 | ExpiresByType application/rss+xml "access plus 1 hour" 98 | 99 | 100 | ############################# MISCELLANEOUS ############################# 101 | # Allow Cross-Domain Fonts 102 | 103 | 104 | Header set Access-Control-Allow-Origin "*" 105 | 106 | 107 | -------------------------------------------------------------------------------- /src/sass/component/header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | scrollbar-color: var(--color-border-medium) var(--color-border-medium); 3 | scrollbar-width: thin; 4 | 5 | position: sticky; 6 | z-index: var(--zi-header); 7 | top: 0; 8 | right: 0; 9 | left: 0; 10 | 11 | overflow: auto hidden; 12 | display: block; 13 | 14 | margin: 0 0 3rem; 15 | border-bottom: var(--border-width-md) solid transparent; 16 | 17 | transition: var(--transition); 18 | 19 | // .header__wrapper 20 | &__wrapper { 21 | display: flex; 22 | gap: 2.4rem; 23 | justify-content: space-between; 24 | } 25 | 26 | // .header__divider 27 | &__divider { 28 | position: relative; 29 | margin-left: 0.8rem; 30 | 31 | &::before { 32 | content: ""; 33 | 34 | position: absolute; 35 | top: 50%; 36 | right: calc(100% + 1.2rem + var(--border-width-md) / 2); 37 | transform: translateY(-50%); 38 | 39 | width: var(--border-width-md); 40 | height: 2rem; 41 | 42 | background-color: var(--color-border-medium); 43 | } 44 | 45 | // .header__divider + .header__divider 46 | & + .header__divider { 47 | margin-left: 0; 48 | } 49 | } 50 | 51 | // .header__logo 52 | &__logo { 53 | display: flex; 54 | flex-shrink: 0; 55 | gap: 0.8rem; 56 | align-items: center; 57 | 58 | // .header__logo-image 59 | &-image { 60 | display: block; 61 | flex-shrink: 0; 62 | width: auto; 63 | height: 2.4rem; 64 | } 65 | 66 | // .header__logo-text 67 | &-text { 68 | font-weight: var(--font-medium); 69 | color: var(--color-heading); 70 | } 71 | } 72 | 73 | // .header__nav 74 | &__nav { 75 | display: flex; 76 | flex-grow: 1; 77 | gap: 2.4rem; 78 | justify-content: flex-end; 79 | 80 | // .header__nav-item 81 | &-item { 82 | cursor: pointer; 83 | 84 | display: inline-flex; 85 | align-items: center; 86 | justify-content: center; 87 | 88 | padding: 2rem 0; 89 | 90 | font-size: 1.4rem; 91 | font-weight: var(--font-medium); 92 | line-height: 1; 93 | color: var(--color-heading); 94 | text-align: center; 95 | white-space: nowrap; 96 | 97 | transition: var(--transition); 98 | 99 | &:hover, &:focus, &:active { 100 | color: var(--color-primary-hover); 101 | } 102 | 103 | // .header__nav-item.active 104 | &.active { 105 | color: var(--color-primary); 106 | } 107 | } 108 | } 109 | 110 | // .header__language-switcher 111 | &__language-switcher { 112 | @extend .header__divider; 113 | } 114 | 115 | // .header__appearance 116 | &__appearance { 117 | @extend .header__divider; 118 | display: flex; 119 | flex-shrink: 0; 120 | gap: 1rem; 121 | 122 | // .header__appearance-item 123 | &-item { 124 | cursor: pointer; 125 | 126 | display: inline-flex; 127 | flex-shrink: 0; 128 | align-items: center; 129 | justify-content: center; 130 | 131 | width: 2rem; 132 | 133 | color: var(--color-text); 134 | text-align: center; 135 | 136 | transition: var(--transition); 137 | 138 | &:hover { 139 | color: var(--color-heading); 140 | } 141 | 142 | & > * { 143 | display: block; 144 | width: 100%; 145 | height: 100%; 146 | object-fit: contain; 147 | } 148 | } 149 | } 150 | 151 | // .header__social 152 | &__social { 153 | @extend .header__appearance; 154 | 155 | // .header__social-item 156 | &-item { 157 | @extend .header__appearance-item; 158 | } 159 | } 160 | 161 | // .header.is-scrolled 162 | &.is-scrolled { 163 | border-color: var(--color-border-medium); 164 | background: var(--color-body); 165 | } 166 | } 167 | 168 | .header__appearance-item[data-theme-set="light"] { 169 | display: none; 170 | } 171 | 172 | .header__appearance-item[data-theme-set="dark"] { 173 | display: inline-flex; 174 | } 175 | 176 | :root[data-theme="dark"] { 177 | .header.is-scrolled { 178 | border-color: #000; 179 | } 180 | 181 | .header__appearance-item[data-theme-set="light"] { 182 | display: inline-flex; 183 | } 184 | 185 | .header__appearance-item[data-theme-set="dark"] { 186 | display: none; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | 404 - Frontend Starter

404

Page not found

The page you are looking for might have been removed

HTML5 Logo
-------------------------------------------------------------------------------- /src/sass/config/color.scss: -------------------------------------------------------------------------------- 1 | $accent-color-list: primary, secondary, success, info, warning, error; 2 | 3 | $color-list: ( 4 | // BODY 5 | body: #fff, 6 | body-inverse: #1b1b1f, 7 | 8 | // BOX 9 | box: #f6f6f7, 10 | box-inverse: #202127, 11 | 12 | // HEADING 13 | heading: #3c3c43, 14 | heading-inverse: #fff, 15 | 16 | // TEXT 17 | text: rgb(60 60 67 / 78%), 18 | text-inverse: rgb(235 235 245 / 60%), 19 | 20 | // TEXT SHADES 21 | text-shade-1: rgb(0 0 0 / 5%), 22 | text-shade-2: rgb(0 0 0 / 10%), 23 | text-shade-3: rgb(0 0 0 / 15%), 24 | text-shade-4: rgb(0 0 0 / 20%), 25 | text-shade-5: rgb(0 0 0 / 25%), 26 | text-shade-6: rgb(0 0 0 / 30%), 27 | text-shade-7: rgb(0 0 0 / 35%), 28 | text-shade-8: rgb(0 0 0 / 40%), 29 | text-shade-9: rgb(0 0 0 / 45%), 30 | text-shade-10: rgb(0 0 0 / 50%), 31 | text-shade-11: rgb(0 0 0 / 55%), 32 | text-shade-12: rgb(0 0 0 / 60%), 33 | text-shade-13: rgb(0 0 0 / 65%), 34 | text-shade-14: rgb(0 0 0 / 70%), 35 | text-shade-15: rgb(0 0 0 / 75%), 36 | text-shade-16: rgb(0 0 0 / 80%), 37 | text-shade-17: rgb(0 0 0 / 85%), 38 | text-shade-18: rgb(0 0 0 / 90%), 39 | text-shade-19: rgb(0 0 0 / 95%), 40 | 41 | // TEXT SHADES INVERSE 42 | text-shade-inverse-1: rgb(255 255 255 / 5%), 43 | text-shade-inverse-2: rgb(255 255 255 / 10%), 44 | text-shade-inverse-3: rgb(255 255 255 / 15%), 45 | text-shade-inverse-4: rgb(255 255 255 / 20%), 46 | text-shade-inverse-5: rgb(255 255 255 / 25%), 47 | text-shade-inverse-6: rgb(255 255 255 / 30%), 48 | text-shade-inverse-7: rgb(255 255 255 / 35%), 49 | text-shade-inverse-8: rgb(255 255 255 / 40%), 50 | text-shade-inverse-9: rgb(255 255 255 / 45%), 51 | text-shade-inverse-10: rgb(255 255 255 / 50%), 52 | text-shade-inverse-11: rgb(255 255 255 / 55%), 53 | text-shade-inverse-12: rgb(255 255 255 / 60%), 54 | text-shade-inverse-13: rgb(255 255 255 / 65%), 55 | text-shade-inverse-14: rgb(255 255 255 / 70%), 56 | text-shade-inverse-15: rgb(255 255 255 / 75%), 57 | text-shade-inverse-16: rgb(255 255 255 / 80%), 58 | text-shade-inverse-17: rgb(255 255 255 / 85%), 59 | text-shade-inverse-18: rgb(255 255 255 / 90%), 60 | text-shade-inverse-19: rgb(255 255 255 / 95%), 61 | 62 | // LINK 63 | link: #e44d26, 64 | link-hover: #f16529, 65 | 66 | // PRIMARY 67 | primary: #41b883, 68 | primary-hover: #4fc993, 69 | primary-text: #fff, 70 | 71 | // SECONDARY 72 | secondary: #34495e, 73 | secondary-hover: #4f667d, 74 | secondary-text: #fff, 75 | 76 | // SUCCESS 77 | success: #1cbb8c, 78 | success-hover: #189f77, 79 | success-text: #fff, 80 | 81 | // INFO 82 | info: #17a2b8, 83 | info-hover: #148a9c, 84 | info-text: #fff, 85 | 86 | // WARNING 87 | warning: #fcb92c, 88 | warning-hover: #d69d25, 89 | warning-text: #fff, 90 | 91 | // ERROR 92 | error: #dc3545, 93 | error-hover: #bb2d3b, 94 | error-text: #fff, 95 | ); 96 | 97 | $color-inverse-list: ( 98 | // BODY 99 | body: #1b1b1f, 100 | body-inverse: #fff, 101 | 102 | // BOX 103 | box: #202127, 104 | box-inverse: #f6f6f7, 105 | 106 | // HEADING 107 | heading: #fff, 108 | heading-inverse: #3c3c43, 109 | 110 | // TEXT 111 | text: rgb(235 235 245 / 60%), 112 | text-inverse: rgb(60 60 67 / 78%), 113 | 114 | // TEXT SHADES 115 | text-shade-1: rgb(255 255 255 / 5%), 116 | text-shade-2: rgb(255 255 255 / 10%), 117 | text-shade-3: rgb(255 255 255 / 15%), 118 | text-shade-4: rgb(255 255 255 / 20%), 119 | text-shade-5: rgb(255 255 255 / 25%), 120 | text-shade-6: rgb(255 255 255 / 30%), 121 | text-shade-7: rgb(255 255 255 / 35%), 122 | text-shade-8: rgb(255 255 255 / 40%), 123 | text-shade-9: rgb(255 255 255 / 45%), 124 | text-shade-10: rgb(255 255 255 / 50%), 125 | text-shade-11: rgb(255 255 255 / 55%), 126 | text-shade-12: rgb(255 255 255 / 60%), 127 | text-shade-13: rgb(255 255 255 / 65%), 128 | text-shade-14: rgb(255 255 255 / 70%), 129 | text-shade-15: rgb(255 255 255 / 75%), 130 | text-shade-16: rgb(255 255 255 / 80%), 131 | text-shade-17: rgb(255 255 255 / 85%), 132 | text-shade-18: rgb(255 255 255 / 90%), 133 | text-shade-19: rgb(255 255 255 / 95%), 134 | 135 | // TEXT SHADES INVERSE 136 | text-shade-inverse-1: rgb(0 0 0 / 5%), 137 | text-shade-inverse-2: rgb(0 0 0 / 10%), 138 | text-shade-inverse-3: rgb(0 0 0 / 15%), 139 | text-shade-inverse-4: rgb(0 0 0 / 20%), 140 | text-shade-inverse-5: rgb(0 0 0 / 25%), 141 | text-shade-inverse-6: rgb(0 0 0 / 30%), 142 | text-shade-inverse-7: rgb(0 0 0 / 35%), 143 | text-shade-inverse-8: rgb(0 0 0 / 40%), 144 | text-shade-inverse-9: rgb(0 0 0 / 45%), 145 | text-shade-inverse-10: rgb(0 0 0 / 50%), 146 | text-shade-inverse-11: rgb(0 0 0 / 55%), 147 | text-shade-inverse-12: rgb(0 0 0 / 60%), 148 | text-shade-inverse-13: rgb(0 0 0 / 65%), 149 | text-shade-inverse-14: rgb(0 0 0 / 70%), 150 | text-shade-inverse-15: rgb(0 0 0 / 75%), 151 | text-shade-inverse-16: rgb(0 0 0 / 80%), 152 | text-shade-inverse-17: rgb(0 0 0 / 85%), 153 | text-shade-inverse-18: rgb(0 0 0 / 90%), 154 | text-shade-inverse-19: rgb(0 0 0 / 95%), 155 | ); 156 | -------------------------------------------------------------------------------- /src/view/guide.twig: -------------------------------------------------------------------------------- 1 | {% set title = 'Guide' %} 2 | {% set active_nav = '/guide' %} 3 | 4 | {% extends '@layout/main.twig' %} 5 | 6 | {% block content %} 7 |
8 | 9 |
10 |
11 | 12 |
13 |

Getting Started

14 |
15 | 16 |
17 |

Overview

18 | 19 |
20 |

{{ APP_DESCRIPTION }}.

21 | 22 |

This starter is delivering with ready-to-use utils, styled components, helpers and much more. Its overview is available in 23 | components 24 | section. 25 |

26 |
27 |
28 | 29 |
30 |

How to use

31 | 32 |
33 |

Instalation

34 |
35 |
 36 | # Clone the repository
 37 | git clone {{ APP_REPOSITORY }}.git
 38 | 
 39 | # Go to the folder
 40 | cd {{ APP_NAME }}
 41 | 
 42 | # Install packages
 43 | npm i
 44 | # or
 45 | npm install
 46 | 
 47 | # Remove link to the original repository
 48 | # - if you use Windows system
 49 | Remove-Item .git -Recurse -Force
 50 | # - or if you use Unix system
 51 | rm -rf .git
 52 | 
53 |
54 | 55 |

Develop

56 |
57 |
 58 | # Start development mode with live-server
 59 | npm run dev
 60 | # or with options
 61 | npm run dev --port=3000
 62 | 
63 |
64 | 65 |

Build

66 |
67 |
 68 | # Build static files for production
 69 | npm run build
 70 | # or
 71 | npm run prod
 72 | # or with options
 73 | npm run build --base=/subdomain --dist=/dest
 74 | 
 75 | # Start server for build preview
 76 | npm run preview
 77 | # or with options
 78 | npm run preview --port=3001
 79 | 
80 |
81 | 82 |

Lint

83 |
84 |
 85 | # ESLint
 86 | npm run lint:js
 87 | # or
 88 | npm run lint:js:fix
 89 | 
 90 | # StyleLint
 91 | npm run lint:css
 92 | # or
 93 | npm run lint:css:fix
 94 | 
95 |
96 | 97 |

Backend emulation

98 |
99 |
100 | # Fastify listen backend.js
101 | npm run backend
102 | 
103 |
104 | 105 |
106 |
107 | 108 |
109 | 110 |
111 |
112 |
113 |
114 | 115 |
116 | {% endblock %} 117 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Frontend Starter

Frontend Starter

Modern Frontend Tooling

FrontEnd Starter is a boilerplate kit for easy building modern static web-sites using Gulp

HTML5 Logo
⚙️

Gulp environment

Modern & Automated development environment with it's all benefits
🏗️

Twig template engine

Flexible, fast, and secure template engine integrated
📁

Structured

Well thought-out and convenient project structure
🛠️

Rich features

Ready-to-use utils, styled components, helpers etc.

Optimized content

Convenient, modern and SEO friendly templates
🤖

Аutomation features

Hot reload, SASS preprocessor, assets auto minifier etc.
-------------------------------------------------------------------------------- /docs/js/main.js: -------------------------------------------------------------------------------- 1 | console.log("%cMade by Zakandaiev","background:#e44d26;color:#fff;padding:10px;font-weight:bold;");const e={app:{name:"frontend-starter",version:"2.3.2",mode:"prod"},api:{delayMs:500,timeoutMs:3e4,backend:"http://localhost:4173",key:""}};function t(t){return t||e.api.timeoutMs||15e3}async function n(n,o={},i=null,a=null){const r=performance.now(),s={...o,headers:o.headers||{"Content-Type":"application/json"},method:o.method||"GET"};if(void 0===s.headers.Authorization&&e.api.key&&e.api.key.length&&(s.headers.Authorization=e.api.key),"GET"===s.method.toUpperCase()&&"object"==typeof s.body&&null!==s.body){const e=new URL(n,window.location.origin);Object.entries(s.body).forEach((([t,n])=>{if(null==n)return!1;"object"==typeof n?e.searchParams.append(t,JSON.stringify(n)):e.searchParams.append(t,n)})),n=e.toString(),delete s.body}"object"!=typeof s.body||s.body instanceof FormData||(s.body=JSON.stringify(s.body));const d={code:null,status:null,message:null,data:null,error:null};let c={};try{c=await async function(e,n={},o=null){const i=new AbortController,a=setTimeout((()=>i.abort()),t(o)),r=await fetch(e,{...n,signal:i.signal});return clearTimeout(a),r}(n,s,t(i)),d.code=c.status}catch{return d.status="error",d.message="Request failed: resource is not reachable or response time was exceeded",d}try{const e=await c.json()||{};"Object"===e.constructor.name&&Object.assign(d,e),d.status=e.status||null,d.message=e.message||null,d.data=e.data||e.payload||e||null}catch{return d.status="error",d.message="Request failed: the response is not valid JSON",d}const l=performance.now()-r,u=function(t){return t||e.api.delayMs}(a);var m;return lsetTimeout(e,m)))),d}const o={get urlFull(){return window.location.href}};function i(e,t){t.classList.add("disappear"),t.addEventListener("animationend",(()=>{t.remove(),e&&e.childElementCount<=0&&e.remove()}))}function a(e){let t=e.parentElement;for(;t;){const{overflowY:e}=getComputedStyle(t);if("auto"===e||"scroll"===e)return t;t=t.parentElement}return document.body}function r(e=null,t=0,n="smooth"){if(e){const o=e.getBoundingClientRect().top+window.scrollY-t;window.scrollTo({top:o,behavior:n})}else window.scrollTo({top:0,behavior:n})}function s(){const e=document.getElementById("header");if(!e)return!1;document.documentElement.scrollTop>0?e.classList.add("is-scrolled"):e.classList.remove("is-scrolled")}window.onerror=async(t,i,a,r,s)=>async function(t,i={}){if("prod"!==e.app.mode)return!1;if(t&&!t?.stack?.includes(window.location.hostname))return!1;const a=`${e.api.backend}/logError`,r={method:"POST",body:{app:e.app,client:{userAgent:window.navigator.userAgent,language:window.navigator.language,hardwareConcurrency:window.navigator.hardwareConcurrency,deviceMemory:window.navigator.deviceMemory,webdriver:window.navigator.webdriver,maxTouchPoints:window.navigator.maxTouchPoints,onLine:window.navigator.onLine,screen:{availWidth:window.screen.availWidth,availHeight:window.screen.availHeight,width:window.screen.width,height:window.screen.height,dpi:window.devicePixelRatio,orientation:window.screen?.orientation?.type},window:{innerWidth:window.innerWidth,innerHeight:window.innerHeight},deviceType:(()=>{const{userAgent:e}=window.navigator;return/ip(hone|od)|android.+mobile|blackberry|iemobile/i.test(e)?"mobile":/(tablet|ipad|playbook|silk)|(android(?!.*mobile))/i.test(e)?"tablet":"desktop"})()},error:t,url:o.urlFull,...i}};return await n(a,r)}({message:t,source:i,line:a,col:r,stack:s?.stack||null}),document.addEventListener("DOMContentLoaded",(()=>{document.addEventListener("click",(e=>{const t=e.target.closest("[data-copy]");if(!t)return!1;const n=t.getAttribute("data-copy").length>0?t.getAttribute("data-copy"):t.textContent;if(!n)return!1;!async function(e){let t=!1;if(navigator.clipboard&&window.isSecureContext)try{await navigator.clipboard.writeText(e),t=!0}catch{}else{const n=document.createElement("textarea");n.value=e,n.style.position="fixed",n.style.zIndex="-1000000",n.style.top="100%",n.style.left="100%",n.style.opacity="0",n.style.visibility="hidden",document.body.append(n),n.select();try{document.execCommand("copy"),t=!0}catch{}finally{n.remove()}}}(n)}))})),document.addEventListener("DOMContentLoaded",(()=>{document.addEventListener("click",(e=>{const t=e.target.closest("[data-toast]");if(!t)return!1;!function(e,t="default",n=null){if("string"!=typeof e||!e?.length)return!1;let o=document.querySelector(".toasts");o||(o=document.createElement("div"),o.classList.add("toasts"),document.body.appendChild(o));const a=document.createElement("div");a.classList.add("toasts__item"),t&&a.classList.add(t);const r=document.createElement("i");r.classList.add("toasts__icon");const s=document.createElement("span");s.classList.add("toasts__text"),s.textContent=e,a.appendChild(r),a.appendChild(s),o.appendChild(a),a.addEventListener("click",(()=>i(o,a))),setTimeout((()=>i(o,a)),n||5e3)}(t.getAttribute("data-toast").length>0?t.getAttribute("data-toast"):t.textContent,t.getAttribute("data-toast-type"),t.getAttribute("data-toast-duration"))}))})),document.addEventListener("DOMContentLoaded",(()=>{document.querySelectorAll("a").forEach((e=>{e.hasAttribute("target")&&"_blank"===e.getAttribute("target")&&e.setAttribute("rel","noopener noreferrer nofollow")}))})),document.addEventListener("DOMContentLoaded",(()=>{document.querySelectorAll("a").forEach((e=>{e.hasAttribute("href")&&e.href.startsWith("tel:")&&(e.href=`tel:${e.href.replaceAll(/[^\d+]/g,"")}`)}))})),document.addEventListener("DOMContentLoaded",(()=>{if(!/iphone|ipad/.test(navigator.userAgent.toLowerCase()))return!1;const e=new WeakMap,t=new Set;document.body.addEventListener("focus",(n=>{const{target:o}=n;if(!["INPUT","TEXTAREA","SELECT"].includes(o.tagName))return!1;const i=a(o),r=parseFloat(getComputedStyle(i).paddingBottom)||0;!function(e){if(!e)return!1;e.querySelectorAll("*").forEach((e=>{if("sticky"!==getComputedStyle(e).position)return!1;e.originalPosition=e.style.position,e.style.position="static",t.add(e)}))}(i),setTimeout((()=>{const t=o.getBoundingClientRect(),n=window.visualViewport?.height||window.innerHeight;if(t.top>=0&&t.bottom<=n)return!1;e.has(i)||(e.set(i,r),i.style.paddingBottom=`${r+.5*window.innerHeight}px`),o.scrollIntoView({block:"center",behavior:"smooth"})}),1e3)}),!0),document.body.addEventListener("blur",(n=>{const{target:o}=n;if(!["INPUT","TEXTAREA","SELECT"].includes(o.tagName))return!1;const i=a(o),r=e.get(i);if(t.forEach((e=>{e.style.position=e.originalPosition||"",delete e.originalPosition})),t.clear(),void 0!==r){let t=!1;const n=()=>{if(t)return!1;i.style.paddingBottom=`${r}px`,e.delete(i),document.removeEventListener("click",o,!0),document.removeEventListener("touchend",o,!0),t=!0},o=()=>{requestAnimationFrame(n)};document.addEventListener("click",o,!0),document.addEventListener("touchend",o,!0),setTimeout(n,1e3)}}),!0)})),window.onload=()=>{document.querySelectorAll("img").forEach((e=>{e.complete&&"number"==typeof e.naturalWidth&&e.naturalWidth<=0&&(e.src="./img/no-image.jpg")}))},document.addEventListener("DOMContentLoaded",(()=>{document.addEventListener("contextmenu",(e=>{"IMG"===e.target.nodeName&&e.preventDefault()}))})),document.addEventListener("DOMContentLoaded",(()=>{document.querySelectorAll("table").forEach((e=>{if(!e.parentElement.classList.contains("table-responsive")&&!e.hasAttribute("data-noresponsive")){const t=document.createElement("div");t.classList.add("table-responsive"),e.before(t),t.appendChild(e)}}))})),document.addEventListener("DOMContentLoaded",(()=>{const e=document.getElementById("header")?.offsetHeight||0;document.addEventListener("click",(t=>{const n=t.target.closest("a");if(!n)return!1;const o=n.getAttribute("href");if("#"===o)t.preventDefault(),r();else if("#"===o.charAt(0)||"/"===o.charAt(0)&&"#"===o.charAt(1)){if(!n.hash)return!1;const o=document.querySelector(n.hash);o&&(t.preventDefault(),r(o,e+32))}}))})),document.addEventListener("DOMContentLoaded",(()=>{s(),window.addEventListener("scroll",(()=>s()))})),document.addEventListener("DOMContentLoaded",(()=>{const e=document.querySelector(".section__navigation"),t=document.querySelectorAll(".section__title");if(!e||!t.length)return!1;t.forEach((t=>{const n=document.createElement("a"),o=`${function(e,t="-"){const n=new RegExp(`[^A-Za-z0-9${t}]+`,"g"),o=new RegExp(`[${t}]+`,"g"),i=new RegExp(`^${t}`),a=new RegExp(`${t}$`);return function(e){const t={"а":"a","А":"A","б":"b","Б":"B","в":"v","В":"V","г":"g","Г":"G","д":"d","Д":"D","е":"e","Е":"E","ё":"e","Ё":"E","ж":"zh","Ж":"Zh","з":"z","З":"Z","и":"i","И":"I","й":"y","Й":"Y","к":"k","К":"K","л":"l","Л":"L","м":"m","М":"M","н":"n","Н":"N","о":"o","О":"O","п":"p","П":"P","р":"r","Р":"R","с":"s","С":"S","т":"t","Т":"T","у":"u","У":"U","ф":"f","Ф":"F","х":"kh","Х":"Kh","ц":"tz","Ц":"Tz","ч":"ch","Ч":"Ch","ш":"sh","Ш":"Sh","щ":"sch","Щ":"Sch","ы":"y","Ы":"Y","э":"e","Э":"E","ю":"iu","Ю":"Iu","я":"ia","Я":"Ia","ь":"","Ь":"","ъ":"","Ъ":"","ї":"yi","Ї":"Yi","і":"i","І":"I","ґ":"g","Ґ":"G","є":"e","Є":"E"};return e.split("").map((e=>t[e]||e)).join("")}(e).replaceAll(n,t).replaceAll(o,t).replace(i,"").replace(a,"").toLowerCase()}(t.textContent)}-${Math.random().toString(32).replace("0.","")}`;t.id=o,n.href=`#${o}`,n.innerHTML=`${t.textContent}`,n.classList.add("section__navigation-item"),e.appendChild(n)})),function(e){const t=e.closest(".position-sticky");if(!t)return!1;const n=document.getElementById("header")?.offsetHeight||0;window.innerWidth>=768?t.style.top=`calc(2em + ${n}px)`:t.style.top=`${n}px`}(e),function(){const e=document.querySelectorAll(".section"),t=new IntersectionObserver((e=>{e.forEach((e=>{if(e.isIntersecting){const t=e.target.querySelector(".section__title");if(!t)return!1;document.querySelectorAll(".section__navigation-item").forEach((e=>{e.hash===`#${t.id}`?e.classList.add("active"):e.classList.remove("active")}))}}))}),{root:document,rootMargin:"-10% 0px -90% 0px"});e.forEach((e=>{t.observe(e)}))}()})); 2 | -------------------------------------------------------------------------------- /docs/guide.html: -------------------------------------------------------------------------------- 1 | Guide - Frontend Starter

Getting Started

Overview

FrontEnd Starter is a boilerplate kit for easy building modern static web-sites using Gulp.

This starter is delivering with ready-to-use utils, styled components, helpers and much more. Its overview is available in components section.

How to use

Instalation

 2 | # Clone the repository
 3 | git clone https://github.com/zakandaiev/frontend-starter.git
 4 | 
 5 | # Go to the folder
 6 | cd frontend-starter
 7 | 
 8 | # Install packages
 9 | npm i
10 | # or
11 | npm install
12 | 
13 | # Remove link to the original repository
14 | # - if you use Windows system
15 | Remove-Item .git -Recurse -Force
16 | # - or if you use Unix system
17 | rm -rf .git
18 | 

Develop

19 | # Start development mode with live-server
20 | npm run dev
21 | # or with options
22 | npm run dev --port=3000
23 | 

Build

24 | # Build static files for production
25 | npm run build
26 | # or
27 | npm run prod
28 | # or with options
29 | npm run build --base=/subdomain --dist=/dest
30 | 
31 | # Start server for build preview
32 | npm run preview
33 | # or with options
34 | npm run preview --port=3001
35 | 

Lint

36 | # ESLint
37 | npm run lint:js
38 | # or
39 | npm run lint:js:fix
40 | 
41 | # StyleLint
42 | npm run lint:css
43 | # or
44 | npm run lint:css:fix
45 | 

Backend emulation

46 | # Fastify listen backend.js
47 | npm run backend
48 | 
-------------------------------------------------------------------------------- /src/sass/component/helper.scss: -------------------------------------------------------------------------------- 1 | /* ========== Flexbox ========== */ 2 | .d-flex { 3 | display: flex !important; 4 | } 5 | 6 | .d-inline-flex { 7 | display: inline-flex !important; 8 | } 9 | 10 | .flex-wrap { 11 | flex-wrap: wrap !important; 12 | } 13 | 14 | .flex-nowrap { 15 | flex-wrap: nowrap !important; 16 | } 17 | 18 | .flex-column { 19 | flex-direction: column !important; 20 | } 21 | 22 | .justify-content-start { 23 | justify-content: flex-start !important; 24 | } 25 | 26 | .justify-content-center { 27 | justify-content: center !important; 28 | } 29 | 30 | .justify-content-end { 31 | justify-content: flex-end !important; 32 | } 33 | 34 | .justify-content-between { 35 | justify-content: space-between !important; 36 | } 37 | 38 | .justify-content-around { 39 | justify-content: space-around !important; 40 | } 41 | 42 | .justify-content-evenly { 43 | justify-content: space-evenly !important; 44 | } 45 | 46 | .justify-content-stretch { 47 | justify-content: stretch !important; 48 | } 49 | 50 | .align-items-baseline { 51 | align-items: baseline !important; 52 | } 53 | 54 | .align-items-start { 55 | align-items: flex-start !important; 56 | } 57 | 58 | .align-items-center { 59 | align-items: center !important; 60 | } 61 | 62 | .align-items-end { 63 | align-items: flex-end !important; 64 | } 65 | 66 | .align-items-stretch { 67 | align-items: stretch !important; 68 | } 69 | 70 | .align-self-baseline { 71 | align-self: baseline !important; 72 | } 73 | 74 | .align-self-start { 75 | align-self: flex-start !important; 76 | } 77 | 78 | .align-self-center { 79 | align-self: center !important; 80 | } 81 | 82 | .align-self-end { 83 | align-self: flex-end !important; 84 | } 85 | 86 | .align-self-stretch { 87 | align-self: stretch !important; 88 | } 89 | 90 | @for $i from 0 through 5 { 91 | .flex-grow-#{$i} { 92 | flex-grow: #{$i} !important; 93 | } 94 | 95 | .flex-shrink-#{$i} { 96 | flex-shrink: #{$i} !important; 97 | } 98 | } 99 | 100 | $gap-step: 0.5; 101 | 102 | @for $i from 0 through 20 { 103 | .gap-#{$i} { 104 | gap: #{$i * $gap-step}rem !important; 105 | } 106 | } 107 | 108 | /* ========== Margin ========== */ 109 | $margin-step: 0.5; 110 | 111 | @for $i from -10 through 10 { 112 | .mt-#{$i} { 113 | margin-top: #{$i * $margin-step}rem !important; 114 | } 115 | 116 | .mr-#{$i} { 117 | margin-right: #{$i * $margin-step}rem !important; 118 | } 119 | 120 | .mb-#{$i} { 121 | margin-bottom: #{$i * $margin-step}rem !important; 122 | } 123 | 124 | .ml-#{$i} { 125 | margin-left: #{$i * $margin-step}rem !important; 126 | } 127 | 128 | .mx-#{$i} { 129 | margin-right: #{$i * $margin-step}rem !important; 130 | margin-left: #{$i * $margin-step}rem !important; 131 | } 132 | 133 | .my-#{$i} { 134 | margin-top: #{$i * $margin-step}rem !important; 135 | margin-bottom: #{$i * $margin-step}rem !important; 136 | } 137 | 138 | .m-#{$i} { 139 | margin: #{$i * $margin-step}rem !important; 140 | } 141 | } 142 | 143 | .mt-auto { 144 | margin-top: auto !important; 145 | } 146 | 147 | .mr-auto { 148 | margin-right: auto !important; 149 | } 150 | 151 | .mb-auto { 152 | margin-bottom: auto !important; 153 | } 154 | 155 | .ml-auto { 156 | margin-left: auto !important; 157 | } 158 | 159 | .mx-auto { 160 | margin-right: auto !important; 161 | margin-left: auto !important; 162 | } 163 | 164 | .my-auto { 165 | margin-top: auto !important; 166 | margin-bottom: auto !important; 167 | } 168 | 169 | .m-auto { 170 | margin: auto !important; 171 | } 172 | 173 | /* ========== Padding ========== */ 174 | $padding-step: 0.5; 175 | 176 | @for $i from 0 through 10 { 177 | .pt-#{$i} { 178 | padding-top: #{$i * $padding-step}rem !important; 179 | } 180 | 181 | .pr-#{$i} { 182 | padding-right: #{$i * $padding-step}rem !important; 183 | } 184 | 185 | .pb-#{$i} { 186 | padding-bottom: #{$i * $padding-step}rem !important; 187 | } 188 | 189 | .pl-#{$i} { 190 | padding-left: #{$i * $padding-step}rem !important; 191 | } 192 | 193 | .px-#{$i} { 194 | padding-right: #{$i * $padding-step}rem !important; 195 | padding-left: #{$i * $padding-step}rem !important; 196 | } 197 | 198 | .py-#{$i} { 199 | padding-top: #{$i * $padding-step}rem !important; 200 | padding-bottom: #{$i * $padding-step}rem !important; 201 | } 202 | 203 | .p-#{$i} { 204 | padding: #{$i * $padding-step}rem !important; 205 | } 206 | } 207 | 208 | /* ========== Text ========== */ 209 | $fz-step: 0.1; 210 | @for $i from 0 through 60 { 211 | .font-size-#{$i} { 212 | font-size: #{$i * $fz-step}rem !important; 213 | } 214 | } 215 | 216 | $lh-step: 0.1; 217 | @for $i from 0 through 60 { 218 | .line-height-#{$i} { 219 | line-height: #{$i * $lh-step} !important; 220 | } 221 | } 222 | 223 | @each $key, $val in $font-weight-list { 224 | .font-#{$key} { 225 | font-weight: var(--font-#{$key}) !important; 226 | } 227 | } 228 | 229 | .font-family-heading { 230 | font-family: var(--font-family-heading) !important; 231 | } 232 | 233 | .font-family-text { 234 | font-family: var(--font-family-text) !important; 235 | } 236 | 237 | .text-left { 238 | text-align: left !important; 239 | } 240 | 241 | .text-center { 242 | text-align: center !important; 243 | } 244 | 245 | .text-right { 246 | text-align: right !important; 247 | } 248 | 249 | .text-justify { 250 | text-align: justify !important; 251 | } 252 | 253 | .text-uppercase { 254 | text-transform: uppercase !important; 255 | } 256 | 257 | .text-lowercase { 258 | text-transform: lowercase !important; 259 | } 260 | 261 | .text-capitalize { 262 | text-transform: capitalize !important; 263 | } 264 | 265 | .text-normal { 266 | font-style: normal !important; 267 | } 268 | 269 | .text-italic { 270 | font-style: italic !important; 271 | } 272 | 273 | .text-underline { 274 | text-decoration: underline !important; 275 | } 276 | 277 | .text-overline { 278 | text-decoration: overline !important; 279 | } 280 | 281 | .text-strike { 282 | text-decoration: line-through !important; 283 | } 284 | 285 | .text-ellipsis { 286 | overflow: hidden !important; 287 | text-overflow: ellipsis !important; 288 | white-space: nowrap !important; 289 | } 290 | 291 | .white-space-normal { 292 | white-space: initial !important; 293 | } 294 | 295 | .white-space-nowrap { 296 | white-space: nowrap !important; 297 | } 298 | 299 | /* ========== Decoration ========== */ 300 | .radius-round { 301 | border-radius: 50% !important; 302 | } 303 | 304 | @each $key, $val in $radius-list { 305 | .radius-#{$key} { 306 | border-radius: var(--radius-#{$key}) !important; 307 | } 308 | 309 | .radius-top-left-#{$key} { 310 | border-top-left-radius: var(--radius-#{$key}) !important; 311 | } 312 | 313 | .radius-top-right-#{$key} { 314 | border-top-right-radius: var(--radius-#{$key}) !important; 315 | } 316 | 317 | .radius-bottom-left-#{$key} { 318 | border-bottom-left-radius: var(--radius-#{$key}) !important; 319 | } 320 | 321 | .radius-bottom-right-#{$key} { 322 | border-bottom-right-radius: var(--radius-#{$key}) !important; 323 | } 324 | 325 | .radius-top-#{$key} { 326 | border-top-left-radius: var(--radius-#{$key}) !important; 327 | border-top-right-radius: var(--radius-#{$key}) !important; 328 | } 329 | 330 | .radius-right-#{$key} { 331 | border-top-right-radius: var(--radius-#{$key}) !important; 332 | border-bottom-right-radius: var(--radius-#{$key}) !important; 333 | } 334 | 335 | .radius-bottom-#{$key} { 336 | border-bottom-right-radius: var(--radius-#{$key}) !important; 337 | border-bottom-left-radius: var(--radius-#{$key}) !important; 338 | } 339 | 340 | .radius-left-#{$key} { 341 | border-top-left-radius: var(--radius-#{$key}) !important; 342 | border-bottom-left-radius: var(--radius-#{$key}) !important; 343 | } 344 | } 345 | 346 | .border { 347 | border: var(--border-md-medium) !important; 348 | } 349 | 350 | .border-top { 351 | border-top: var(--border-md-medium) !important; 352 | } 353 | 354 | .border-right { 355 | border-right: var(--border-md-medium) !important; 356 | } 357 | 358 | .border-bottom { 359 | border-bottom: var(--border-md-medium) !important; 360 | } 361 | 362 | .border-left { 363 | border-left: var(--border-md-medium) !important; 364 | } 365 | 366 | /* ========== Table ========== */ 367 | .table-fixed { 368 | table-layout: fixed !important; 369 | } 370 | 371 | /* ========== Visibility ========== */ 372 | .d-block { 373 | display: block !important; 374 | } 375 | 376 | .d-inline-block { 377 | display: inline-block !important; 378 | } 379 | 380 | .d-none { 381 | display: none !important; 382 | } 383 | 384 | .hidden { 385 | display: none !important; 386 | visibility: hidden !important; 387 | } 388 | 389 | .pointer-events-all { 390 | pointer-events: all !important; 391 | } 392 | 393 | .pointer-events-none { 394 | pointer-events: none !important; 395 | } 396 | 397 | /* ========== Dimension ========== */ 398 | $wh-step: 0.5; 399 | 400 | @for $i from 0 through 20 { 401 | .w-#{$i * 10 * $wh-step} { 402 | width: #{calc($i * $wh-step * 10) + '%'} !important; 403 | } 404 | 405 | .h-#{$i * 10 * $wh-step} { 406 | height: #{calc($i * $wh-step * 10) + '%'} !important; 407 | } 408 | } 409 | 410 | .w-auto { 411 | width: auto !important; 412 | } 413 | 414 | .h-auto { 415 | height: auto !important; 416 | } 417 | 418 | .fit-contain { 419 | object-fit: contain !important; 420 | } 421 | 422 | .fit-cover { 423 | object-fit: cover !important; 424 | } 425 | 426 | .ratio-1 { 427 | aspect-ratio: 1 !important; 428 | } 429 | 430 | .ratio-3-2 { 431 | aspect-ratio: 3 / 2 !important; 432 | } 433 | 434 | .ratio-2-3 { 435 | aspect-ratio: 2 / 3 !important; 436 | } 437 | 438 | .ratio-4-3 { 439 | aspect-ratio: 4 / 3 !important; 440 | } 441 | 442 | .ratio-3-4 { 443 | aspect-ratio: 3 / 4 !important; 444 | } 445 | 446 | .ratio-16-9 { 447 | aspect-ratio: 16 / 9 !important; 448 | } 449 | 450 | .ratio-9-16 { 451 | aspect-ratio: 9 / 16 !important; 452 | } 453 | 454 | /* ========== Color ========== */ 455 | .color-body { 456 | color: var(--color-body) !important; 457 | } 458 | 459 | .color-box { 460 | color: var(--color-box) !important; 461 | } 462 | 463 | .color-heading { 464 | color: var(--color-heading) !important; 465 | } 466 | 467 | .color-text { 468 | color: var(--color-text) !important; 469 | } 470 | 471 | .color-link { 472 | color: var(--color-link) !important; 473 | 474 | &_hover { 475 | transition: var(--transition); 476 | 477 | &:hover, &:focus, &:active { 478 | color: var(--color-link-hover) !important; 479 | } 480 | } 481 | } 482 | 483 | .bg-body { 484 | background: var(--color-body) !important; 485 | } 486 | 487 | .bg-box { 488 | background: var(--color-box) !important; 489 | } 490 | 491 | @each $color in $accent-color-list { 492 | .color-#{$color} { 493 | color: var(--color-#{$color}) !important; 494 | 495 | &_hover { 496 | transition: var(--transition); 497 | 498 | &:hover, &:focus, &:active { 499 | color: var(--color-#{$color}-hover) !important; 500 | } 501 | } 502 | } 503 | 504 | .bg-#{$color} { 505 | background: var(--color-#{$color}) !important; 506 | 507 | &_hover { 508 | transition: var(--transition); 509 | 510 | &:hover, &:focus, &:active { 511 | background: var(--color-#{$color}-hover) !important; 512 | } 513 | } 514 | } 515 | } 516 | 517 | /* ========== Cursors ========== */ 518 | .cursor-pointer { 519 | cursor: pointer !important; 520 | } 521 | 522 | .cursor-default { 523 | cursor: default !important; 524 | } 525 | 526 | /* ========== Position ========== */ 527 | .position-initial { 528 | position: initial !important; 529 | } 530 | 531 | .position-static { 532 | position: static !important; 533 | } 534 | 535 | .position-relative { 536 | position: relative !important; 537 | } 538 | 539 | .position-sticky { 540 | position: sticky !important; 541 | top: 0; 542 | 543 | // .position-sticky_bottom 544 | &_bottom { 545 | top: auto; 546 | bottom: 0; 547 | } 548 | } 549 | 550 | $vertical-align-list: baseline, sub, super, text-top, text-bottom, middle, top, bottom; 551 | 552 | @each $align in $vertical-align-list { 553 | .vertical-align-#{$align} { 554 | vertical-align: $align !important; 555 | } 556 | } 557 | 558 | @each $key, $val in $z-index-list { 559 | .zi-#{$key} { 560 | z-index: var(--zi-#{$key}) !important; 561 | } 562 | } 563 | -------------------------------------------------------------------------------- /docs/components.html: -------------------------------------------------------------------------------- 1 | Components - Frontend Starter

Components

Overview

This starter is delivering with ready-to-use utils, styled components, helpers and much more. Some majors of these are described in this section.

Button

Label

Default Cancel Primary Secondary Success Info Warning Error

Loader

Grid

Grow and auto width columns controlled with .fill class

Grow column
Auto column
Grow column

Equal-width columns defined in .row with .cols-{breakpoint}-{size} syntax

1 column
2 column
3 column
4 column
5 column
6 column
7 column
8 column

Control column width in each .col with .col-{breakpoint}-{size} syntax

1 column
2 column
3 column
4 column

Offset columns with .offset-{breakpoint}-{size} syntax

1 column
2 column
3 column
4 column

Gaps controlled with .gap-{breakpoint} for XY axes, .gap-{breakpoint}-x for X axis or .gap-{breakpoint}-y for Y axis syntax defined in .row class

1 column
2 column
3 column
4 column
5 column
6 column

Helper

There are a bunch of different css helper classes such as .d-flex, .mt-4, .bg-primary, .color-error etc.

Full list you can view in @/src/sass/component/helper.scss file.

Table

Default table

Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit

Table with .table_striped and .table_sm class

Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit

Table with .table_borderless and .table_sm class

Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit
Lorem ipsumDolor sit

Toast

Controls with data-toast, data-toast-type and data-toast-duration attributes.

If the data-toast is empty then toast text will be equal event.target.textContent .

Example with custom text, primary type and 10s duration.

Copy to clipboard

Controls with data-copy attribute.

If the data-copy is empty then text to copy will be equal event.target.textContent .

Also you can combine data attributes and set data-toast to notify user.

--------------------------------------------------------------------------------