1
0
Fork 0
mirror of https://github.com/codex-team/codex.docs.git synced 2025-08-07 14:35:26 +02:00

Solved conflicts, merged with branch main

This commit is contained in:
slaveeks 2022-07-27 14:13:05 +03:00
commit acdf744b6b
33 changed files with 28392 additions and 5218 deletions

View file

@ -1,2 +1,8 @@
# codex-docs password.
PASSWORD=
PASSWORD=
# Backend errors tracking Integraton Token
HAWK_TOKEN_BACKEND=
# Client errors tracking Integraton Token
HAWK_TOKEN_CLIENT=

View file

@ -17,10 +17,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@ -28,18 +28,18 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=raw,value={{branch}}-{{sha}}-{{date 'X'}}
type=raw,value={{branch}}-{{sha}}-{{date 'X'}},enable=${{ startsWith(github.ref, 'refs/heads') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push image
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3
with:
context: .
file: docker/Dockerfile.prod

View file

@ -83,6 +83,21 @@ yarn lint
yarn test
```
### Setup
You can configure application using configs in <code>/config</code> directory.
| Property | Role |
|----------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <code>port</code> | to set port of application |
| <code>database</code> | to name directory with data |
| <code>rcFile</code> | to set destination of codexdocsrc config file |
| <code>uploads</code> | to set destination of directory to save uploads |
| <code>secret</code> | to set secret |
| <code>favicon</code> | to set url or favicon path (favicon need to be in /public directory), like `/myFavicon.png`, to get favicon. Server uploads file by url and saves it to temporary directory. And you can get favicon by /favicon static route of application |
You can configure application using configs in <code>/config</code> directory.
### Authentication
To manage pages you need to authorize (available on `/auth`).

View file

@ -3,5 +3,6 @@
"database": ".db",
"rcFile": "./.codexdocsrc",
"uploads": "public/uploads",
"secret": "iamasecretstring"
"secret": "iamasecretstring",
"favicon": ""
}

View file

@ -3,5 +3,6 @@
"database": ".db",
"rcFile": "./.codexdocsrc",
"uploads": "/uploads",
"secret": "iamasecretstring"
"secret": "iamasecretstring",
"favicon": ""
}

View file

@ -3,5 +3,6 @@
"database": ".testdb",
"rcFile": "./src/test/.codexdocsrc",
"uploads": "public/uploads_test",
"secret": "iamasecretstring"
"secret": "iamasecretstring",
"favicon": ""
}

13
nodemon.json Normal file
View file

@ -0,0 +1,13 @@
{
"verbose": true,
"ignore": [
".git",
"node_modules",
"public",
"src/frontend"
],
"watch": [
"**/*"
],
"ext": "js,twig"
}

23384
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,8 @@
"editor-upgrade": "yarn add -D @editorjs/{editorjs,header,code,delimiter,list,link,image,table,inline-code,marker,warning,checklist,raw}@latest"
},
"dependencies": {
"@hawk.so/javascript": "^3.0.1",
"@hawk.so/nodejs": "^3.1.2",
"config": "^3.3.6",
"cookie-parser": "^1.4.5",
"csurf": "^1.11.0",

View file

@ -1,4 +1,4 @@
import express, { Request, Response } from 'express';
import express, { NextFunction, Request, Response } from 'express';
import path from 'path';
import cookieParser from 'cookie-parser';
import morgan from 'morgan';
@ -7,35 +7,84 @@ import routes from './routes';
import HttpException from './exceptions/httpException';
import * as dotenv from 'dotenv';
import config from 'config';
import HawkCatcher from '@hawk.so/nodejs';
import os from 'os';
import appConfig from 'config';
import { downloadFavicon, FaviconData } from './utils/downloadFavicon';
dotenv.config();
const app = express();
const localConfig = rcParser.getConfiguration();
// Initialize the backend error tracking catcher.
if (process.env.HAWK_TOKEN_BACKEND) {
HawkCatcher.init(process.env.HAWK_TOKEN_BACKEND);
}
// Get url to upload favicon from config
const favicon: string = appConfig.get('favicon');
app.locals.config = localConfig;
// Set client error tracking token as app local.
if (process.env.HAWK_TOKEN_CLIENT) {
app.locals.config.hawkClientToken = process.env.HAWK_TOKEN_CLIENT;
}
// view engine setup
app.set('views', path.join(__dirname, './', 'views'));
app.set('view engine', 'twig');
require('./utils/twig');
const downloadedFaviconFolder = os.tmpdir();
// Check if favicon is not empty
if (favicon) {
// Upload favicon by url, it's path on server is '/temp/favicon.{format}'
downloadFavicon(favicon, downloadedFaviconFolder).then((res) => {
app.locals.favicon = res;
console.log('Favicon successfully uploaded');
})
.catch( (err) => {
console.log(err);
console.log('Favicon has not uploaded');
});
} else {
console.log('Favicon is empty, using default path');
app.locals.favicon = {
destination: '/favicon.png',
type: 'image/png',
} as FaviconData;
}
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, '../../public')));
app.use('/uploads', express.static(config.get('uploads')));
app.use('/favicon', express.static(downloadedFaviconFolder));
app.use('/', routes);
// error handler
app.use(function (err: HttpException, req: Request, res: Response) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
// global error handler
app.use(function (err: unknown, req: Request, res: Response, next: NextFunction) {
// send any type of error to hawk server.
if (process.env.HAWK_TOKEN_BACKEND && err instanceof Error) {
HawkCatcher.send(err);
}
// only send Http based exception to client.
if (err instanceof HttpException) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
}
next(err);
});
export default app;

View file

@ -0,0 +1,85 @@
import path from 'path';
import fs from 'fs';
import fetch from 'node-fetch';
/**
* Uploaded favicon data
*/
export interface FaviconData {
// Uploaded favicon path
destination: string;
// File type
type: string;
}
// Initiate controller for aborting request
const controller = new AbortController();
/**
* Check if string is url
*
* @param str - string to check
*/
function checkIsUrl(str: string): boolean {
const re = new RegExp('https?://');
return re.test(str);
}
/**
* Upload favicon by url, or get it by path
*
* @param destination - url or path of favicon
* @param faviconFolder - folder to save favicon
* @returns { Promise<FaviconData> } - Promise with data about favicon
*/
export async function downloadFavicon(destination: string, faviconFolder: string): Promise<FaviconData> {
// Check of destination is empty
if (!destination) {
throw Error('Favicon destination is empty');
}
// Get file name by destination
const filename = destination.substring(destination.lastIndexOf('/')+1);
// Get file format
const format = filename.split('.')[1];
// Check if string is url
if (!checkIsUrl(destination)) {
return {
destination: `/${filename}`,
type: `image/${format}`,
} as FaviconData;
}
// Create timeout to abort request
const timeoutId = setTimeout(() => {
controller.abort();
console.log('Favicon request has timed out.');
}, 5000);
// Make get request to url
const res = await fetch(destination, { signal: controller.signal });
// Get buffer data from response
const fileData = await res.buffer();
// Clear timeout, if data was got
clearTimeout(timeoutId);
// Get file path in temporary directory
const filePath = path.join(faviconFolder, `favicon.${format}`);
// Save file
await fs.writeFile(filePath, fileData, (err) => {
if (err) {
console.log(err);
}
});
return {
destination: `/favicon/favicon.${format}`,
type: `image/${format}`,
} as FaviconData;
}

View file

@ -7,28 +7,27 @@
<meta property="og:title" content="{{ page.title | striptags }}" />
<meta property="article:modified_time" content="{{ (page.body.time / 1000) | date("c") }}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="icon" type="{{ favicon.type }}" href="{{ favicon.destination }}">
</head>
<script>
window.config = {
misprintsChatId: "{{ config.misprintsChatId }}"
misprintsChatId: "{{ config.misprintsChatId }}",
hawkClientToken:"{{ config.hawkClientToken }}",
};
</script>
<body>
{% include "components/header.twig" with res.locals.isAuthorized %}
<div class="docs">
{% include "components/sidebar.twig" %}
<div class="docs__content">
<div class="docs__content-inner">
{% block body %}{% endblock %}
</div>
<aside class="docs__aside-right">
<div style="width: 100%; height: 100px; background: grey"/>
</aside>
<aside class="docs__aside-right" id="layout-sidebar-right"></aside>
</div>
</div>
<script src="/dist/main.bundle.js"></script>

View file

@ -1,5 +1,4 @@
<a name="{{ text | urlify }}" style="display: inline-block; position: absolute; margin-top: -20px;"></a>
<h{{ level }} class="block-header block-header--{{ level }} block-header--anchor">
<h{{ level }} id="{{ text | urlify }}" class="block-header block-header--{{ level }} block-header--anchor">
<a href="#{{ text | urlify }}">
{{ text }}
</a>

View file

@ -13,14 +13,18 @@
}
</textarea>
<header class="writing-header">
<span class="writing-header__left">
<span>
New Page at the
<div class="writing-header__inner-container">
{% set currentPageId = 0 %}
{% if page is not empty %}
{% set currentPageId = page._id %}
{% endif %}
<select name="parent">
<div class="select-wrapper">
{% if parentsChildrenOrdered is not empty %}
<label for="parent">Parent Page</label>
{% else %}
<label for="parent">New Page at the</label>
{% endif %}
<select id="parent" name="parent">
<option value="0">Root</option>
{% for _page in pagesAvailableGrouped %}
{% if _page._id != currentPageId %}
@ -34,24 +38,25 @@
{% endif %}
{% endfor %}
</select>
</span>
</div>
{% if parentsChildrenOrdered is not empty %}
<span>
Put Above
<select name="above">
<div class="select-wrapper">
<label for="above">Put Above</label>
<select id="above" name="above">
<option value="0">—</option>
{% for _page in parentsChildrenOrdered %}
<option value="{{ _page._id }}">{{ _page.title }}</option>
{% endfor %}
</select>
</span>
</div>
{% endif %}
</span>
{% if page is not empty %}
<p><input type="text" class="uri-input" name="uri-input" placeholder="URI (Optional)" value="{{ page.uri }}"></p>
{% endif %}
{% if page is not empty %}
<div class="uri-input-wrapper">
<label for="uri-input">Alias</label>
<input type="text" id="uri-input" class="uri-input" name="uri-input" placeholder="URI (Optional)" value="{{ page.uri }}">
</div>
{% endif %}
</div>
</header>
<div class="writing-editor">
<div id="editorjs"></div>

View file

@ -4,12 +4,14 @@
<title>{{ config.title }}</title>
<link rel="stylesheet" href="/dist/main.css" />
<link rel="preload" href="{{ config.landingFrameSrc }}" as="document">
<link rel="icon" type="image/png" href="/favicon.png?v=2">
<link rel="icon" type="{{ favicon.type }}" href="{{ favicon.destination }}">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta property="og:title" content="{{ config.title }}" />
<meta property="og:site_name" content="{{ config.title }}" />
<meta name="description" property="og:description" content="{{ config.description }}">
</head>
<script>
</script>
<body class="landing-body">
{% include "components/header.twig" %}
<div class="landing-loader" id="frame-loader">

13
src/frontend/.eslintrc Normal file
View file

@ -0,0 +1,13 @@
{
"extends": [
"codex"
],
"parser": "babel-eslint",
"parserOptions": {
"sourceType": "module",
"allowImportExportEverywhere": true
},
"globals": {
"HTMLElement": true
}
}

View file

@ -17,6 +17,7 @@ import Writing from './modules/writing';
import Page from './modules/page';
import Extensions from './modules/extensions';
import Sidebar from './modules/sidebar';
import HawkCatcher from '@hawk.so/javascript';
/**
* Main app class
@ -30,6 +31,9 @@ class Docs {
this.page = new Page();
this.extensions = new Extensions();
this.sidebar = new Sidebar();
if (window.config.hawkClientToken) {
this.hawk = new HawkCatcher(window.config.hawkClientToken);
}
document.addEventListener('DOMContentLoaded', (event) => {
this.docReady();

View file

@ -0,0 +1,405 @@
import * as Decorators from '../utils/decorators';
import * as $ from '../utils/dom';
/**
* Generate dynamic table of content
*/
export default class TableOfContent {
/**
* Initialize table of content
*
* @param {object} options - constructor params
* @param {string} options.tagSelector - selector for tags to observe
* @param {HTMLElement} options.appendTo - element for appending of the table of content
*/
constructor({ tagSelector, appendTo }) {
/**
* Array of tags to observe
*/
this.tags = [];
this.tagsSectionsMap = [];
/**
* Selector for tags to observe
*/
this.tagSelector = tagSelector || 'h2,h3,h4';
/**
* Element to append the Table of Content
*/
this.tocParentElement = appendTo;
if (!this.tocParentElement) {
throw new Error('Table of Content wrapper not found');
}
this.nodes = {
/**
* Main Table of Content element
*/
wrapper: null,
/**
* List of Table of Content links
*/
items: [],
};
/**
* Currently highlighted element of ToC
*/
this.activeItem = null;
this.CSS = {
tocContainer: 'table-of-content',
tocHeader: 'table-of-content__header',
tocElement: 'table-of-content__list',
tocElementItem: 'table-of-content__list-item',
tocElementItemActive: 'table-of-content__list-item--active',
tocElementItemIndent: number => `table-of-content__list-item--indent-${number}x`,
};
this.init();
}
/**
* Initialize table of content
*/
init() {
this.tags = this.getSectionTagsOnThePage();
/**
* Check if no tags found then table of content is not needed
*/
if (this.tags.length === 0) {
return;
}
/**
* Initialize table of content element
*/
this.createTableOfContent();
this.addTableOfContent();
/**
* Calculate bounds for each tag and watch active section
*/
this.calculateBounds();
this.watchActiveSection();
}
/**
* Find all section tags on the page
*
* @returns {HTMLElement[]}
*/
getSectionTagsOnThePage() {
return Array.from(document.querySelectorAll(this.tagSelector));
}
/**
* Calculate top line position for each tag
*/
calculateBounds() {
this.tagsSectionsMap = this.tags.map((tag) => {
const rect = tag.getBoundingClientRect();
const top = Math.floor(rect.top + window.scrollY);
return {
top,
tag,
};
});
}
/**
* Watch active section while scrolling
*/
watchActiveSection() {
/**
* Where content zone starts in document
*/
const contentTopOffset = this.getScrollPadding();
/**
* Additional offset for correct calculation of active section
*
* Cause opening page with anchor could scroll almost
* near to the target section and we need to add 1px to calculations
*/
const activationOffset = 1;
const detectSection = () => {
/**
* Calculate scroll position
*
* @todo research how not to use magic number
*/
const scrollPosition = contentTopOffset + window.scrollY + activationOffset;
/**
* Find the nearest section above the scroll position
*/
const section = this.tagsSectionsMap.filter((tag) => {
return tag.top <= scrollPosition;
}).pop();
/**
* If section found then set it as active
*/
if (section) {
const targetLink = section.tag.querySelector('a').getAttribute('href');
this.setActiveItem(targetLink);
} else {
/**
* Otherwise no active link will be highlighted
*/
this.setActiveItem(null);
}
};
/**
* Define a flag to reduce number of calls to detectSection function
*/
const throttledDetectSectionFunction = Decorators.throttle(() => {
detectSection();
}, 400);
/**
* Scroll listener
*/
document.addEventListener('scroll', throttledDetectSectionFunction, {
passive: true,
});
}
/**
* Create table of content
*
* <section>
* <header>On this page</header>
* <ul>
* <li><a href="#"></a></li>
* ...
* </ul>
* </section>
*/
createTableOfContent() {
this.tocElement = $.make('section', this.CSS.tocElement);
this.tags.forEach((tag) => {
const linkTarget = tag.querySelector('a').getAttribute('href');
const linkWrapper = $.make('li', this.CSS.tocElementItem);
const linkBlock = $.make('a', null, {
innerText: tag.innerText,
href: `${linkTarget}`,
});
/**
* Additional indent for h3-h6 headers
*/
switch (tag.tagName.toLowerCase()) {
case 'h3':
linkWrapper.classList.add(this.CSS.tocElementItemIndent(1));
break;
case 'h4':
linkWrapper.classList.add(this.CSS.tocElementItemIndent(2));
break;
case 'h5':
linkWrapper.classList.add(this.CSS.tocElementItemIndent(3));
break;
case 'h6':
linkWrapper.classList.add(this.CSS.tocElementItemIndent(4));
break;
}
linkWrapper.appendChild(linkBlock);
this.tocElement.appendChild(linkWrapper);
this.nodes.items.push(linkWrapper);
});
}
/**
* Add table of content to the page
*/
addTableOfContent() {
this.nodes.wrapper = $.make('section', this.CSS.tocContainer);
const header = $.make('header', this.CSS.tocHeader, {
textContent: 'On this page',
});
this.nodes.wrapper.appendChild(header);
this.nodes.wrapper.appendChild(this.tocElement);
this.tocParentElement.appendChild(this.nodes.wrapper);
}
/**
* Highlight link's item with a given href
*
* @param {string|null} targetLink - href of the link. Null if we need to clear all highlights
*/
setActiveItem(targetLink) {
/**
* Clear current highlight
*/
if (this.activeItem) {
this.activeItem.classList.remove(this.CSS.tocElementItemActive);
}
/**
* If targetLink is null, that means we reached top, nothing to highlight
*/
if (targetLink === null) {
this.activeItem = null;
return;
}
/**
* Looking for a target link
*
* @todo do not fire DOM search, use saved map instead
*/
const targetElement = this.tocElement.querySelector(`a[href="${targetLink}"]`);
/**
* Getting link's wrapper
*/
const listItem = targetElement.parentNode;
/**
* Highlight and save current item
*/
listItem.classList.add(this.CSS.tocElementItemActive);
this.activeItem = listItem;
/**
* If need, scroll parent to active item
*/
this.scrollToActiveItemIfNeeded();
}
/**
* Document scroll ending callback
*
* @returns {void}
*/
scrollToActiveItemIfNeeded() {
/**
* Do nothing if the Table of Content has no internal scroll at this page
*
* @todo compute it once
*/
const hasScroll = this.nodes.wrapper.scrollHeight > this.nodes.wrapper.clientHeight;
if (!hasScroll) {
return;
}
/**
* If some item is highlighted, check whether we need to scroll to it or not
*/
if (this.activeItem) {
/**
* First, check do we need to scroll to item?
* We need to scroll in case when:
* item bottom coord is bigger than parent height + current parent scroll
*
* @todo use memoization for calculating of the itemBottomCoordWithPadding
*/
const itemOffsetTop = this.activeItem.offsetTop;
const itemHeight = this.activeItem.offsetHeight;
const itemBottomCoord = itemOffsetTop + itemHeight;
const scrollPadding = 10; // scroll offset below/above item
const itemBottomCoordWithPadding = itemBottomCoord + scrollPadding;
const itemTopCoordWithPadding = itemOffsetTop - scrollPadding;
const scrollableParentHeight = this.nodes.wrapper.offsetHeight; // @todo compute it once
const scrollableParentScrolledDistance = this.nodes.wrapper.scrollTop;
/**
* Scroll bottom required if item ends below the parent bottom boundary
*/
const isScrollDownRequired = itemBottomCoordWithPadding > scrollableParentHeight + scrollableParentScrolledDistance;
/**
* Scroll upward required when item starts above the visible parent zone
*/
const isScrollUpRequired = itemTopCoordWithPadding < scrollableParentScrolledDistance;
/**
* If item is fully visible, scroll is not required
*/
const isScrollRequired = isScrollDownRequired || isScrollUpRequired;
if (isScrollRequired === false) {
/**
* Item is visible, scroll is not needed
*/
return;
}
/**
* Now compute the scroll distance to make item visible
*/
let distanceToMakeItemFullyVisible;
if (isScrollDownRequired) {
distanceToMakeItemFullyVisible = itemBottomCoordWithPadding - scrollableParentHeight;
} else { // scrollUpRequired=true
distanceToMakeItemFullyVisible = itemTopCoordWithPadding;
}
/**
* Change the scroll
* Using RAF to prevent overloading of regular scroll animation FPS
*/
window.requestAnimationFrame(() => {
this.nodes.wrapper.scrollTop = distanceToMakeItemFullyVisible;
});
}
}
/**
* Get scroll padding top value from HTML element
*
* @returns {number}
*/
getScrollPadding() {
const defaultScrollPaddingValue = 0;
/**
* Try to get calculated value or fallback to default value
*/
try {
/**
* Getting the HTML element
*/
const htmlElement = document.documentElement;
/**
* Getting css scroll padding value
*/
const scrollPaddingTopValue = window.getComputedStyle(htmlElement)
.getPropertyValue('scroll-padding-top');
/**
* Parse value to number
*/
return parseInt(scrollPaddingTopValue, 10);
} catch (e) {}
/**
* On any errors return default value
*/
return defaultScrollPaddingValue;
}
}

View file

@ -1,27 +1,23 @@
/**
* @typedef {object} pageModuleSettings
*/
/**
* @class Page
* @classdesc Class for page module
*/
export default class Writing {
export default class Page {
/**
* Creates base properties
*/
constructor() {
this.codeStyler = null;
this.tableOfContent = null;
}
/**
* Called by ModuleDispatcher to initialize module from DOM
* @param {pageModuleSettings} settings - module settings
* @param {HTMLElement} moduleEl - module element
*/
init(settings = {}, moduleEl) {
init() {
this.codeStyler = this.createCodeStyling();
};
this.tableOfContent = this.createTableOfContent();
}
/**
* Init code highlighting
@ -29,8 +25,35 @@ export default class Writing {
async createCodeStyling() {
const { default: CodeStyler } = await import(/* webpackChunkName: "code-styling" */ './../classes/codeStyler');
return new CodeStyler({
selector: '.block-code__content'
});
};
try {
// eslint-disable-next-line no-new
new CodeStyler({
selector: '.block-code__content',
});
} catch (error) {
console.error(error); // @todo send to Hawk
}
}
/**
* Init table of content
*
* @returns {Promise<TableOfContent>}
*/
async createTableOfContent() {
const { default: TableOfContent } = await import(/* webpackChunkName: "table-of-content" */ '../classes/table-of-content');
try {
// eslint-disable-next-line no-new
new TableOfContent({
tagSelector:
'h2.block-header--anchor,' +
'h3.block-header--anchor,' +
'h4.block-header--anchor',
appendTo: document.getElementById('layout-sidebar-right'),
});
} catch (error) {
console.error(error); // @todo send to Hawk
}
}
}

View file

@ -0,0 +1,62 @@
/**
* A few useful utility functions
*/
/**
* Throttle decorator function
*
* @param {Function} func - function to throttle
* @param {number} ms - milliseconds to throttle
*
* @returns {Function}
*/
export function throttle(func, ms) {
let isThrottled = false,
savedArgs,
savedThis;
// eslint-disable-next-line jsdoc/require-jsdoc
function wrapper() {
if (isThrottled) {
savedArgs = arguments;
savedThis = this;
return;
}
func.apply(this, arguments);
isThrottled = true;
setTimeout(function () {
isThrottled = false;
if (savedArgs) {
wrapper.apply(savedThis, savedArgs);
savedArgs = savedThis = null;
}
}, ms);
}
return wrapper;
}
/**
* Debounce decorator function
*
* @param {Function} f - function to debounce
* @param {number} ms - milliseconds to debounce
*
* @returns {(function(): void)|*}
*/
export function debounce(f, ms) {
let timeoutId = null;
return function () {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => f.apply(this, arguments), ms);
};
}

View file

@ -0,0 +1,23 @@
/**
* Helper method for elements creation
*
* @param {string} tagName - name of tag to create
* @param {string | string[]} classNames - list of CSS classes
* @param {object} attributes - any properties to add
* @returns {HTMLElement}
*/
export function make(tagName, classNames = null, attributes = {}) {
const el = document.createElement(tagName);
if (Array.isArray(classNames)) {
el.classList.add(...classNames);
} else if (classNames) {
el.classList.add(classNames);
}
for (const attrName in attributes) {
el[attrName] = attributes[attrName];
}
return el;
}

View file

@ -1,4 +1,3 @@
/**
* Utility class to handle interaction with local storage
*/
@ -27,4 +26,4 @@ export class Storage {
get() {
return localStorage.getItem(this.key);
}
}
}

View file

@ -1,3 +1,7 @@
html {
scroll-padding-top: calc(var(--layout-height-header) + 50px);
}
.docs-header {
display: flex;
justify-content: space-between;
@ -6,7 +10,6 @@
border-bottom: 1px solid var(--color-line-gray);
font-size: 18px;
flex-wrap: wrap;
position: relative;
height: var(--layout-height-header);
box-sizing: border-box;
position: sticky;

View file

@ -1,6 +1,6 @@
.docs-sidebar {
width: 100vw;
@media (--desktop) {
width: var(--layout-sidebar-width);
}
@ -14,7 +14,7 @@
display: flex;
flex-direction: column;
overflow: auto;
@media (--desktop) {
height: calc(100vh - var(--layout-height-header));
border-right: 1px solid var(--color-line-gray);
@ -157,7 +157,7 @@
height: 24px;
transition-property: background-color;
transition-duration: 0.1s;
@apply --squircle;
@media (--can-hover) {
@ -178,7 +178,7 @@
color: var(--color-text-second);
padding: 20px 15px;
border-bottom: 1px solid var(--color-line-gray);
@media (--desktop) {
display: none;
}
@ -217,4 +217,4 @@
}
}
}
}

View file

@ -0,0 +1,58 @@
.table-of-content {
border-left: 1px solid var(--color-line-gray);
padding-left: var(--layout-padding-horizontal);
height: 100%;
overflow: auto;
padding: var(--layout-padding-vertical) var(--layout-padding-horizontal);
box-sizing: border-box;
&__header {
font-size: 16px;
font-weight: 600;
line-height: 21px;
letter-spacing: -0.01em;
margin-bottom: 12px;
padding: 0 6px;
}
&__list {
margin: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0;
list-style: none;
gap: 6px;
&-item {
@apply --squircle;
&:hover {
background-color: var(--color-link-hover);
cursor: pointer;
}
&--active {
background-color: var(--color-link-hover);
}
&--indent-1x { margin-left: 6px; }
&--indent-2x { margin-left: 12px; }
&--indent-3x { margin-left: 18px; }
&--indent-4x { margin-left: 24px; }
& > a {
padding: 4px 8px;
display: block;
font-size: 14px;
letter-spacing: -0.01em;
line-height: 150%;
}
}
}
}

View file

@ -1,5 +1,4 @@
.writing-header {
display: flex;
position: sticky;
top: 0;
padding: 15px 0;
@ -15,18 +14,32 @@
margin: auto;
}
&__left {
&__inner-container {
margin: auto 0;
display: flex;
color: var(--color-text-second);
gap: 10px;
& span {
margin-right: 10px;
@media(--mobile) {
flex-flow: column;
}
}
select {
max-width: 100px;
vertical-align: bottom;
& > * {
flex: 0 1 33.3%;
}
label {
display: block;
margin-bottom: 6px;
}
select {
@apply --select;
}
input {
@apply --input;
}
}
}
@ -38,16 +51,6 @@
}
}
.uri-input {
box-sizing: border-box;
width: 100%;
padding: 10px 12px;
border-radius: 3px;
border: 1px solid rgba(201, 201, 204, 0.48);
box-shadow: inset 0 1px 2px 0 rgba(35, 44, 72, 0.06);
outline: none;
}
.writing-editor {
font-size: 15px;
line-height: 1.6;

View file

@ -20,15 +20,15 @@
margin: 0 auto;
@media (--desktop) {
margin-right: var(--layout-padding-horizontal);
margin-left: 0;
padding: var(--layout-padding-vertical) var(--layout-padding-horizontal);
}
}
}
&__content {
--max-space-between-cols: 160px;
padding: var(--layout-padding-vertical) var(--layout-padding-horizontal);
display: flex;
justify-content: space-between;
max-width: calc(var(--layout-width-main-col) + var(--max-space-between-cols) + var(--layout-sidebar-width));
@ -46,7 +46,10 @@
display: none;
position: sticky;
top: calc(var(--layout-height-header) + var(--layout-padding-vertical));
overflow: auto;
height: calc(100vh - var(--layout-height-header));
top: calc(var(--layout-height-header));
align-self: flex-start;
@media (--desktop) {

View file

@ -1,4 +1,5 @@
@import 'normalize.css';
@import './vars.pcss';
@import './layout.pcss';
@import './carbon.pcss';
@ -10,6 +11,7 @@
@import './components/button.pcss';
@import './components/sidebar.pcss';
@import './components/navigator.pcss';
@import './components/table-of-content.pcss';
body {
font-family: system-ui, Helvetica, Arial, Verdana;

View file

@ -7,6 +7,8 @@
--color-link-hover: #F3F6F8;
--color-bg-light: #f8f7fa;
--color-page-active: #ff1767;
--color-input-primary: #F3F6F8;
--color-input-border: #477CFF;
--color-button-primary: #3389FF;
--color-button-primary-hover: #2E7AE6;
@ -78,6 +80,49 @@
}
}
--select {
position: relative;
padding: 10px 30px 10px 10px;
width: 100%;
border: solid 1px transparent;
border-radius: 8px;
background-color: var(--color-input-primary);
box-sizing: border-box;
appearance: none;
line-height: 18px;
background-image: url("../svg/arrow-down.svg");
background-repeat: no-repeat;
background-position: right 15px center;
&:focus {
border: solid 1px var(--color-input-border);
box-shadow: 0 0 0 3px rgba(18, 155, 255, 0.33);
}
&:focus-visible {
outline: none;
}
}
--input {
padding: 10px;
width: 100%;
line-height: 18px;
border: solid 1px transparent;
border-radius: 8px;
background-color: var(--color-input-primary);
box-sizing: border-box;
&:focus {
border: solid 1px var(--color-input-border);
box-shadow: 0 0 0 3px rgba(18, 155, 255, 0.33);
}
&:focus-visible {
outline: none;
}
}
--squircle {
border-radius: 8px;
@ -96,4 +141,4 @@
@custom-media --tablet all and (min-width: 980px) and (max-width: 1050px);
@custom-media --mobile all and (max-width: 980px);
@custom-media --retina all and (-webkit-min-device-pixel-ratio: 1.5);
@custom-media --can-hover all and (hover:hover)
@custom-media --can-hover all and (hover:hover);

View file

@ -0,0 +1,3 @@
<svg width="8" height="5" viewBox="0 0 8 5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.83053 0.193862C6.70562 0.0696944 6.53666 0 6.36053 0C6.18441 0 6.01544 0.0696944 5.89053 0.193862L3.4972 2.55386L1.1372 0.193862C1.01229 0.0696944 0.843323 0 0.667199 0C0.491075 0 0.322107 0.0696944 0.197199 0.193862C0.134713 0.255837 0.0851169 0.329571 0.0512711 0.410811C0.0174253 0.49205 0 0.579187 0 0.667195C0 0.755203 0.0174253 0.84234 0.0512711 0.92358C0.0851169 1.00482 0.134713 1.07855 0.197199 1.14053L3.02387 3.9672C3.08584 4.02968 3.15957 4.07928 3.24081 4.11312C3.32205 4.14697 3.40919 4.16439 3.4972 4.16439C3.58521 4.16439 3.67234 4.14697 3.75358 4.11312C3.83482 4.07928 3.90856 4.02968 3.97053 3.9672L6.83053 1.14053C6.89302 1.07855 6.94261 1.00482 6.97646 0.92358C7.0103 0.84234 7.02773 0.755203 7.02773 0.667195C7.02773 0.579187 7.0103 0.49205 6.97646 0.410811C6.94261 0.329571 6.89302 0.255837 6.83053 0.193862Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

View file

@ -40,7 +40,7 @@ module.exports = () => {
options: {
// you can specify a publicPath here
// by default it use publicPath in webpackOptions.output
publicPath: '../',
// publicPath: '../',
},
},
{

9226
yarn.lock

File diff suppressed because it is too large Load diff