├── .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 |
13 |
14 |
15 |
16 |
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 |
18 |
19 |
20 |
21 |
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 |
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
--------------------------------------------------------------------------------
/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 |
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 ⚙️
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 Default Cancel Primary Secondary Success Info Warning Error
Label Default Cancel Primary Secondary Success Info Warning Error
Grid Grow and auto width columns controlled with .fill class Equal-width columns defined in .row with .cols-{breakpoint}-{size} syntax Control column width in each .col with .col-{breakpoint}-{size} syntax Offset columns with .offset-{breakpoint}-{size} syntax 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 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 ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit
Table with .table_striped and .table_sm class Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit
Table with .table_borderless and .table_sm class Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor sit Lorem ipsum Dolor 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 .
Default Success Info Warning Error
Example with custom text, primary type and 10s duration.
Customization example 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.
Example
--------------------------------------------------------------------------------