Merge branch 'editor' of gitlab.com:discoursio/discoursio-webapp into editor

This commit is contained in:
ilia tapazukk 2023-04-26 02:37:29 +00:00
parent c173ff3135
commit ec6f7e0ec6
76 changed files with 12078 additions and 3949 deletions

View File

@ -12,7 +12,7 @@ jobs:
node-version: '18'
- name: Install deps
run: npm install
run: npm ci
- name: Check
run: npm run check

1
.npmrc Normal file
View File

@ -0,0 +1 @@
save-exact=true

View File

@ -1,8 +1,6 @@
{
"extends": [
"stylelint-config-standard-scss",
"stylelint-config-prettier-scss",
"stylelint-config-css-modules"
"stylelint-config-standard-scss"
],
"plugins": [
"stylelint-order",

View File

@ -1,4 +1,4 @@
import { renderPage } from 'vite-plugin-ssr'
import { renderPage } from 'vite-plugin-ssr/server'
export default async function handler(req, res) {
const { url, cookies } = req

13559
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,118 +29,162 @@
"typecheck:watch": "tsc --noEmit --watch"
},
"dependencies": {
"@aws-sdk/abort-controller": "^3.272.0",
"@aws-sdk/client-s3": "^3.282.0",
"@aws-sdk/lib-storage": "^3.282.0",
"formidable": "^2.1.1",
"i18next": "^22.4.10",
"mailgun.js": "^8.2.0",
"node-fetch": "^3.3.1"
"@aws-sdk/abort-controller": "3.303.0",
"@aws-sdk/client-s3": "3.303.0",
"@aws-sdk/lib-storage": "3.303.0",
"@hocuspocus/provider": "2.0.1",
"formidable": "2.1.1",
"html-to-json-parser": "1.1.0",
"i18next": "22.4.13",
"mailgun.js": "8.2.1",
"node-fetch": "3.3.1"
},
"devDependencies": {
"@babel/core": "^7.21.0",
"@graphql-codegen/cli": "^3.2.1",
"@graphql-codegen/typescript": "^3.0.1",
"@graphql-codegen/typescript-operations": "^3.0.1",
"@graphql-codegen/typescript-urql": "^3.7.3",
"@graphql-codegen/urql-introspection": "^2.2.1",
"@graphql-tools/url-loader": "^7.17.13",
"@graphql-typed-document-node/core": "^3.1.2",
"@nanostores/router": "^0.8.1",
"@nanostores/solid": "^0.3.2",
"@popperjs/core": "^2.11.6",
"@solid-primitives/memo": "^1.2.0",
"@solid-primitives/share": "^2.0.3",
"@solid-primitives/storage": "^1.3.7",
"@solid-primitives/upload": "^0.0.109",
"@solidjs/meta": "^0.28.2",
"@types/express": "^4.17.15",
"@types/node": "^18.14.6",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"@urql/core": "^3.1.1",
"@urql/devtools": "^2.0.3",
"@urql/exchange-graphcache": "^5.0.9",
"babel-preset-solid": "^1.5.6",
"bcryptjs": "^2.4.3",
"bootstrap": "^5.2.3",
"clsx": "^1.2.1",
"cookie": "^0.5.0",
"cookie-signature": "^1.2.1",
"cosmiconfig-toml-loader": "^1.0.0",
"cross-env": "^7.0.3",
"eslint": "^8.35.0",
"eslint-config-stylelint": "^18.0.0",
"eslint-import-resolver-typescript": "^3.5.3",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-solid": "^0.10.0",
"eslint-plugin-sonarjs": "^0.18.0",
"eslint-plugin-unicorn": "^46.0.0",
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
"graphql-ws": "^5.11.2",
"hast-util-select": "^5.0.4",
"husky": "^8.0.3",
"hygen": "^6.2.11",
"i18next-http-backend": "^2.1.1",
"idb": "^7.1.1",
"jest": "^29.4.3",
"js-cookie": "^3.0.1",
"lint-staged": "^13.1.2",
"loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
"markdown-it-implicit-figures": "^0.11.0",
"markdown-it-mark": "^3.0.1",
"markdown-it-replace-link": "^1.1.0",
"nanostores": "^0.7.4",
"orderedmap": "^2.1.0",
"prettier": "^2.7.1",
"prettier-eslint": "^15.0.1",
"prosemirror-commands": "^1.5.1",
"prosemirror-dropcursor": "^1.7.1",
"prosemirror-example-setup": "^1.2.1",
"prosemirror-gapcursor": "^1.3.1",
"prosemirror-history": "^1.3.0",
"prosemirror-inputrules": "^1.2.0",
"prosemirror-keymap": "^1.2.1",
"prosemirror-markdown": "^1.10.1",
"prosemirror-menu": "^1.2.1",
"prosemirror-model": "^1.19.0",
"prosemirror-schema-list": "^1.2.2",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.30.0",
"rollup": "^3.18.0",
"rollup-plugin-visualizer": "^5.9.0",
"sass": "^1.58.3",
"solid-js": "^1.6.11",
"solid-transition-group": "^0.0.13",
"sort-package-json": "^2.3.0",
"stylelint": "^15.2.0",
"stylelint-config-css-modules": "^4.1.0",
"stylelint-config-prettier-scss": "^0.0.1",
"stylelint-config-standard-scss": "^7.0.1",
"stylelint-order": "^6.0.1",
"stylelint-scss": "^4.4.0",
"swiper": "^8.4.7",
"ts-node": "^10.9.1",
"typescript": "^4.9.4",
"undici": "^5.20.0",
"unique-names-generator": "^4.7.1",
"uuid": "^9.0.0",
"vite": "^4.1.4",
"vite-plugin-sass-dts": "^1.2.16",
"vite-plugin-solid": "^2.6.1",
"vite-plugin-ssr": "^0.4.90",
"wonka": "^6.2.3",
"ws": "^8.12.1",
"y-prosemirror": "^1.2.0",
"y-protocols": "^1.0.5",
"y-webrtc": "^10.2.4",
"yjs": "^13.5.48"
"@babel/core": "7.21.3",
"@graphql-codegen/cli": "3.2.2",
"@graphql-codegen/typescript": "3.0.2",
"@graphql-codegen/typescript-operations": "3.0.2",
"@graphql-codegen/typescript-urql": "3.7.3",
"@graphql-codegen/urql-introspection": "2.2.1",
"@graphql-tools/url-loader": "7.17.14",
"@graphql-typed-document-node/core": "3.2.0",
"@nanostores/router": "0.8.3",
"@nanostores/solid": "0.3.2",
"@popperjs/core": "2.11.7",
"@solid-primitives/memo": "1.2.3",
"@solid-primitives/share": "2.0.4",
"@solid-primitives/storage": "1.3.8",
"@solid-primitives/upload": "0.0.110",
"@solidjs/meta": "0.28.2",
"@thisbeyond/solid-select": "0.13.0",
"@tiptap/core": "2.0.1",
"@tiptap/extension-blockquote": "2.0.1",
"@tiptap/extension-bold": "2.0.1",
"@tiptap/extension-bubble-menu": "2.0.1",
"@tiptap/extension-bullet-list": "2.0.1",
"@tiptap/extension-character-count": "2.0.1",
"@tiptap/extension-collaboration": "2.0.1",
"@tiptap/extension-collaboration-cursor": "2.0.1",
"@tiptap/extension-document": "2.0.1",
"@tiptap/extension-dropcursor": "2.0.1",
"@tiptap/extension-floating-menu": "2.0.1",
"@tiptap/extension-focus": "2.0.1",
"@tiptap/extension-gapcursor": "2.0.1",
"@tiptap/extension-hard-break": "2.0.1",
"@tiptap/extension-heading": "2.0.1",
"@tiptap/extension-highlight": "2.0.1",
"@tiptap/extension-history": "2.0.1",
"@tiptap/extension-horizontal-rule": "2.0.1",
"@tiptap/extension-image": "2.0.1",
"@tiptap/extension-italic": "2.0.1",
"@tiptap/extension-link": "2.0.1",
"@tiptap/extension-list-item": "2.0.1",
"@tiptap/extension-ordered-list": "2.0.1",
"@tiptap/extension-paragraph": "2.0.1",
"@tiptap/extension-placeholder": "2.0.1",
"@tiptap/extension-strike": "2.0.1",
"@tiptap/extension-text": "2.0.1",
"@tiptap/extension-underline": "2.0.1",
"@tiptap/extension-youtube": "2.0.1",
"@types/express": "4.17.17",
"@types/node": "18.15.11",
"@types/uuid": "9.0.1",
"@typescript-eslint/eslint-plugin": "5.57.0",
"@typescript-eslint/parser": "5.57.0",
"@urql/core": "3.2.2",
"@urql/devtools": "2.0.3",
"@urql/exchange-graphcache": "5.2.0",
"babel-preset-solid": "1.7.0",
"bcryptjs": "2.4.3",
"bootstrap": "5.2.3",
"clsx": "1.2.1",
"cookie": "0.5.0",
"cookie-signature": "1.2.1",
"cosmiconfig-toml-loader": "1.0.0",
"cross-env": "7.0.3",
"eslint": "8.37.0",
"eslint-config-stylelint": "18.0.0",
"eslint-import-resolver-typescript": "3.5.4",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-jsx-a11y": "6.7.1",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-solid": "0.12.0",
"eslint-plugin-sonarjs": "0.19.0",
"eslint-plugin-unicorn": "46.0.0",
"graphql": "16.6.0",
"graphql-tag": "2.12.6",
"graphql-ws": "5.12.0",
"hast-util-select": "5.0.5",
"husky": "8.0.3",
"hygen": "6.2.11",
"i18next-http-backend": "2.2.0",
"idb": "7.1.1",
"install": "0.13.0",
"jest": "29.5.0",
"js-cookie": "3.0.1",
"lint-staged": "13.2.0",
"loglevel": "1.8.1",
"loglevel-plugin-prefix": "0.8.4",
"markdown-it": "13.0.1",
"markdown-it-container": "3.0.0",
"markdown-it-implicit-figures": "0.11.0",
"markdown-it-mark": "3.0.1",
"markdown-it-replace-link": "1.2.0",
"nanostores": "0.7.4",
"npm": "9.6.3",
"orderedmap": "2.1.0",
"prettier": "2.8.7",
"prettier-eslint": "15.0.1",
"prosemirror-commands": "1.5.1",
"prosemirror-dropcursor": "1.8.0",
"prosemirror-example-setup": "1.2.1",
"prosemirror-gapcursor": "1.3.1",
"prosemirror-history": "1.3.0",
"prosemirror-inputrules": "1.2.0",
"prosemirror-keymap": "1.2.1",
"prosemirror-markdown": "1.10.1",
"prosemirror-menu": "1.2.1",
"prosemirror-model": "1.19.0",
"prosemirror-schema-list": "1.2.2",
"prosemirror-state": "1.4.2",
"prosemirror-view": "1.30.2",
"rollup": "3.20.2",
"rollup-plugin-visualizer": "5.9.0",
"sass": "1.60.0",
"solid-js": "1.7.0",
"solid-tiptap": "0.5.1",
"solid-transition-group": "0.2.2",
"sort-package-json": "2.4.1",
"stylelint": "15.3.0",
"stylelint-config-standard-scss": "7.0.1",
"stylelint-order": "6.0.3",
"stylelint-scss": "4.6.0",
"swiper": "8.4.7",
"ts-node": "10.9.1",
"typescript": "5.0.3",
"undici": "5.21.0",
"uniqolor": "1.1.0",
"unique-names-generator": "4.7.1",
"uuid": "9.0.0",
"vite": "4.2.1",
"vite-plugin-sass-dts": "1.3.2",
"vite-plugin-solid": "2.6.1",
"vite-plugin-ssr": "0.4.108",
"wonka": "6.3.1",
"ws": "8.13.0",
"y-indexeddb": "9.0.10",
"y-prosemirror": "1.2.0",
"y-protocols": "1.0.5",
"y-webrtc": "10.2.5",
"y-websocket": "1.5.0",
"yjs": "13.5.51"
},
"overrides": {
"@tiptap/extension-collaboration": {
"y-prosemirror": "1.2.0"
},
"@tiptap/extension-collaboration-cursor": {
"y-prosemirror": "1.2.0"
}
}
}

3
public/icons/burger.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0.25H18V2.75H0V0.25ZM0 6.75H18V9.25H0V6.75ZM18 13.25H0V15.75H18V13.25Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="6" viewBox="0 0 13 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 6L0.870836 -9.53674e-07L12.1292 -9.53674e-07L6.5 6Z" fill="#898C94"/>
</svg>

After

Width:  |  Height:  |  Size: 185 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 16H0V0H2V16ZM4 5V3H16V5H4ZM4 7V9H16V7H4ZM4 13V11H16V13H4Z" fill="#898C94"/>
</svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1,3 @@
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.82857 7.76C10.9371 6.99429 11.7143 5.73714 11.7143 4.57143C11.7143 1.98857 9.71428 0 7.14286 0H0V16H8.04571C10.4343 16 12.2857 14.0571 12.2857 11.6686C12.2857 9.93143 11.3029 8.44571 9.82857 7.76ZM3.42799 2.85708H6.85656C7.80513 2.85708 8.57085 3.6228 8.57085 4.57137C8.57085 5.51994 7.80513 6.28565 6.85656 6.28565H3.42799V2.85708ZM3.42799 13.1429H7.42799C8.37656 13.1429 9.14228 12.3772 9.14228 11.4286C9.14228 10.4801 8.37656 9.71434 7.42799 9.71434H3.42799V13.1429Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 648 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.66106 15.5L8.02241 10.0122L11.3838 15.5L14.0728 13.5793L10.1737 8.73171L16 7.26829L14.8796 4.06707L9.27731 6.30793L9.68067 0.5H6.36415L6.72269 6.30793L1.16527 4.06707L0 7.26829L5.82633 8.73171L1.92717 13.5793L4.66106 15.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

View File

@ -0,0 +1,4 @@
<svg width="21" height="12" viewBox="0 0 21 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 11.7647H2.52101V7.02521H7.79832V11.7647H10.3193V0H7.79832V4.7395H2.52101V0H0V11.7647Z" fill="currentColor"/>
<path d="M16.3474 12C18.7004 12 20.9189 11.042 20.9189 8.63866C20.9189 6.95798 19.8936 6.06723 18.7172 5.71429C19.7928 5.34454 20.4483 4.43697 20.4483 3.2605C20.4483 1.17647 18.6836 0.100841 16.3138 0.100841C14.9189 0.100841 13.6079 0.436975 12.5827 0.991597V3.34454C13.7088 2.63865 14.9357 2.31933 15.9609 2.31933C17.339 2.31933 18.0617 2.78992 18.0617 3.61345C18.0617 4.40336 17.3558 4.82353 16.2466 4.80672L14.6668 4.78992L14.6499 6.97479H16.5323C17.6752 6.97479 18.5155 7.31092 18.5155 8.28571C18.5155 9.36134 17.4399 9.7647 16.1457 9.78151C14.8348 9.79832 13.692 9.59664 12.381 8.87395V11.2269C13.692 11.7647 14.8852 12 16.3474 12Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 884 B

View File

@ -1,3 +1,3 @@
<svg width="18" height="10" viewBox="0 0 18 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.71 5C1.71 3.461 2.961 2.21 4.5 2.21H8.1V0.5H4.5C2.016 0.5 0 2.516 0 5C0 7.484 2.016 9.5 4.5 9.5H8.1V7.79H4.5C2.961 7.79 1.71 6.539 1.71 5ZM5.39844 5.90156H12.5984V4.10156H5.39844V5.90156ZM9.89941 0.5H13.4994C15.9834 0.5 17.9994 2.516 17.9994 5C17.9994 7.484 15.9834 9.5 13.4994 9.5H9.89941V7.79H13.4994C15.0384 7.79 16.2894 6.539 16.2894 5C16.2894 3.461 15.0384 2.21 13.4994 2.21H9.89941V0.5Z" fill="currentColor"/>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.27177 14.7277C2.06258 13.5186 2.06258 11.5527 3.27177 10.3435L6.10029 7.51502L4.75675 6.17148L1.92823 9C-0.0234511 10.9517 -0.0234511 14.1196 1.92823 16.0713C3.87991 18.023 7.04785 18.023 8.99952 16.0713L11.828 13.2428L10.4845 11.8992L7.65598 14.7277C6.44679 15.9369 4.48097 15.9369 3.27177 14.7277ZM6.87756 12.536L12.5346 6.87895L11.1203 5.46469L5.4633 11.1217L6.87756 12.536ZM6.17055 4.75768L8.99907 1.92916C10.9507 -0.0225206 14.1187 -0.0225201 16.0704 1.92916C18.022 3.88084 18.022 7.04878 16.0704 9.00046L13.2418 11.829L11.8983 10.4854L14.7268 7.65691C15.936 6.44772 15.936 4.4819 14.7268 3.27271C13.5176 2.06351 11.5518 2.06351 10.3426 3.2727L7.51409 6.10122L6.17055 4.75768Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 571 B

After

Width:  |  Height:  |  Size: 860 B

View File

@ -0,0 +1,3 @@
<svg width="19" height="16" viewBox="0 0 19 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.00002 4.00003H1.00001V1.00001H0V0H2.00002V4.00003ZM2.00002 13.5V13H0V12H3.00003V16H0V15H2.00002V14.5H1.00001V13.5H2.00002ZM0 6.99998H1.80002L0 9.1V10H3.00003V9H1.20001L3.00003 6.89998V5.99998H0V6.99998ZM4.9987 2.99967V0.999648H18.9988V2.99967H4.9987ZM4.9987 15.0001H18.9988V13.0001H4.9987V15.0001ZM18.9988 8.99987H4.9987V6.99986H18.9988V8.99987Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 524 B

View File

@ -0,0 +1,4 @@
<svg width="21" height="22" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21 9L0 9L1.50847e-07 13L21 13V9Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.5 21.5L12.5 0.5L8.5 0.5L8.5 21.5H12.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@ -0,0 +1,3 @@
<svg width="21" height="16" viewBox="0 0 21 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.39999 3.19998V0H20.2666V3.19998H14.9333V15.9999H11.7333V3.19998H6.39999ZM3.19998 8.5334H0V5.33342H9.59994V8.5334H6.39996V16H3.19998V8.5334Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="16" viewBox="0 0 20 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.000114441 1.6C0.000114441 0.714665 0.71478 0 1.60011 0C2.48544 0 3.20011 0.714665 3.20011 1.6C3.20011 2.48533 2.48544 3.19999 1.60011 3.19999C0.71478 3.19999 0.000114441 2.48533 0.000114441 1.6ZM0 8.00013C0 7.1148 0.714665 6.40014 1.6 6.40014C2.48533 6.40014 3.19999 7.1148 3.19999 8.00013C3.19999 8.88547 2.48533 9.60013 1.6 9.60013C0.714665 9.60013 0 8.88547 0 8.00013ZM1.6 12.8C0.714665 12.8 0 13.5254 0 14.4C0 15.2747 0.725332 16 1.6 16C2.47466 16 3.19999 15.2747 3.19999 14.4C3.19999 13.5254 2.48533 12.8 1.6 12.8ZM19.7333 15.4662H4.79999V13.3329H19.7333V15.4662ZM4.79999 9.06677H19.7333V6.93344H4.79999V9.06677ZM4.79999 2.66664V0.533307H19.7333V2.66664H4.79999Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 846 B

View File

@ -0,0 +1,3 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5542 12.1407L16.417 15.0035L15.4205 16L11.657 12.2365H9.26153V10.6123H10.0497L8.25384 8.84457H5.0531V7.18383H6.58994L4.80805 5.40195H4.19466C3.50174 5.39979 2.83667 5.6739 2.34637 6.16338C1.85628 6.65308 1.58137 7.31794 1.58278 8.01086C1.58415 8.70357 1.8616 9.36744 2.35366 9.85502C2.83688 10.3504 3.50289 10.6243 4.19466 10.6123H7.60079V12.2365H4.19466C3.07508 12.2532 1.99923 11.801 1.2278 10.9895C0.441236 10.1933 0 9.11922 0 8C0 6.88077 0.4412 5.80674 1.2278 5.01052C1.78565 4.42814 2.50526 4.02601 3.29384 3.85636L0.417108 0.996475L1.41358 0L5.17706 3.7776H5.19669L6.82089 5.4018H6.79831L8.58019 7.18368L10.2551 8.84442L12.0369 10.6263L13.5683 12.1378L13.5542 12.1407ZM15.6203 10.9895C16.4069 10.1933 16.8481 9.11927 16.8481 8.00005C16.8481 6.88083 16.4069 5.80679 15.6203 5.01057C14.8463 4.20416 13.7709 3.75724 12.6535 3.7777H9.26153V5.4019H12.6677H12.6675C13.3604 5.39974 14.0255 5.67385 14.5157 6.16333C15.0058 6.65303 15.2807 7.31789 15.2793 8.01081C15.278 8.70352 15.0005 9.36739 14.5085 9.85497C14.3296 10.0348 14.1269 10.1892 13.9061 10.3138L15.0771 11.4819V11.4821C15.2715 11.3333 15.4533 11.1685 15.6204 10.9895L15.6203 10.9895Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,3 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.80828 3.77898L0.439624 13.1393L0.00467497 17.078C-0.0505566 17.6022 0.391296 18.0506 0.9229 17.9954L4.86505 17.5608L14.2337 8.20049C14.2337 8.20049 14.3925 7.77972 14.2337 7.61417C14.0749 7.44862 10.402 3.78588 10.402 3.78588C10.2294 3.62033 9.96707 3.62033 9.80828 3.77898ZM17.1886 2.89606L15.1105 0.819817C14.4615 0.171422 14.2337 -0.644285 12.7701 0.819817C11.3064 2.28392 11.1753 2.41321 11.1753 2.41321C11.0165 2.57186 11.0165 2.83398 11.1753 2.99953L15.007 6.82782C15.1657 6.98647 15.4281 6.98647 15.5938 6.82782L17.1886 5.23442C18.6374 3.78588 17.8307 3.54446 17.1886 2.89606Z" fill="black"/>
<path
d="M9.80828 3.77898L0.439624 13.1393L0.00467497 17.078C-0.0505566 17.6022 0.391296 18.0506 0.9229 17.9954L4.86505 17.5608L14.2337 8.20049C14.2337 8.20049 14.3925 7.77972 14.2337 7.61417C14.0749 7.44862 10.402 3.78588 10.402 3.78588C10.2294 3.62033 9.96707 3.62033 9.80828 3.77898ZM17.1886 2.89606L15.1105 0.819817C14.4615 0.171422 14.2337 -0.644285 12.7701 0.819817C11.3064 2.28392 11.1753 2.41321 11.1753 2.41321C11.0165 2.57186 11.0165 2.83398 11.1753 2.99953L15.007 6.82782C15.1657 6.98647 15.4281 6.98647 15.5938 6.82782L17.1886 5.23442C18.6374 3.78588 17.8307 3.54446 17.1886 2.89606Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 715 B

After

Width:  |  Height:  |  Size: 724 B

3
public/icons/publish.svg Normal file
View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g id="icon" fill="#000000" transform="translate(42.666667, 33.830111)"><path d="M170.666667,51.5032227 L256,136.836556 L256,392.836556 L-2.13162821e-14,392.836556 L-2.13162821e-14,51.5032227 L170.666667,51.5032227 Z M152.993555,94.1698893 L42.6666667,94.1698893 L42.6666667,350.169889 L213.333333,350.169889 L213.333333,154.509668 L152.993555,94.1698893 Z M341.333333,7.10542736e-15 L431.084945,89.7516113 L400.915055,119.921501 L362.666,81.683 L362.666667,222.169889 C362.666667,267.870058 326.742006,305.179572 281.592327,307.398789 L277.333333,307.503223 L277.333333,264.836556 C299.826385,264.836556 318.254189,247.431163 319.882971,225.354153 L320,222.169889 L319.999,81.684 L281.751611,119.921501 L251.581722,89.7516113 L341.333333,7.10542736e-15 Z">
</path></g></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

2
public/icons/save.svg Normal file
View File

@ -0,0 +1,2 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-save" viewBox="0 0 16 16"> <path d="M2 1a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H9.5a1 1 0 0 0-1 1v7.293l2.646-2.647a.5.5 0 0 1 .708.708l-3.5 3.5a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L7.5 9.293V2a2 2 0 0 1 2-2H14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h2.5a.5.5 0 0 1 0 1H2z"/></svg>

After

Width:  |  Height:  |  Size: 475 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1512 4.42386L4.42326 17.1518L6.84763 19.5761L19.5756 6.84822L17.1512 4.42386Z" fill="#393840"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.5755 17.1518L6.84763 4.42386L4.42326 6.84822L17.1512 19.5761L19.5755 17.1518Z" fill="#393840"/>
</svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M22 6.73787L19.2621 4L9.78964 13.4725L5.73787 9.42071L3 12.1586L9.78964 18.9482L22 6.73787Z" fill="#393840"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

View File

@ -2,24 +2,22 @@
"...subscribing": "...subscribing",
"About myself": "About myself",
"About the project": "About the project",
"actions": "actions",
"Add comment": "Comment",
"Address on Discourse": "Address on Discourse",
"All": "All",
"All authors": "All authors",
"All posts": "All posts",
"all topics": "all topics",
"All topics": "All topics",
"Almost done! Check your email.": "Almost done! Just checking your email.",
"Artworks": "Artworks",
"Audio": "Audio",
"author": "author",
"Author": "Author",
"authors": "authors",
"Author subscriptions": "Подписки на авторов",
"Authors": "Authors",
"Back to main page": "Back to main page",
"Become an author": "Become an author",
"Bookmarked": "Saved",
"Bookmarks": "Bookmarks",
"By alphabet": "By alphabet",
"By authors": "By authors",
"By name": "By name",
@ -28,23 +26,23 @@
"By relevance": "By relevance",
"By shouts": "By publications",
"By signing up you agree with our": "By signing up you agree with our",
"By time": "By time",
"By title": "By title",
"By updates": "By updates",
"By views": "By views",
"cancel": "Cancel",
"Characters": "Знаков",
"Chat Title": "Chat Title",
"Choose who you want to write to": "Choose who you want to write to",
"Collaborate": "Help Edit",
"collections": "collections",
"Comments": "Comments",
"Communities": "Communities",
"community": "community",
"Cooperate": "Cooperate",
"Copy": "Copy",
"Copy link": "Copy link",
"Create account": "Create an account",
"Corrections history": "Corrections history",
"Create Chat": "Create Chat",
"Create Group": "Create a group",
"Create account": "Create an account",
"Create post": "Create post",
"Date of Birth": "Date of Birth",
"Delete": "Delete",
@ -52,30 +50,28 @@
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects",
"Discours is created with our common effort": "Discours exists because of our common effort",
"Discussing": "Discussing",
"discussion": "discourse",
"Discussion rules": "Discussion rules",
"Dogma": "Dogma",
"Drafts": "Drafts",
"Edit": "Edit",
"Editing": "Editing",
"Email": "Mail",
"email not confirmed": "email not confirmed",
"enter": "enter",
"Enter": "Enter",
"Enter URL address": "Enter URL address",
"Enter text": "Enter text",
"Enter the code or click the link from email to confirm": "Enter the code from the email or follow the link in the email to confirm registration",
"Enter the Discours": "Enter the Discours",
"Enter the code or click the link from email to confirm": "Enter the code from the email or follow the link in the email to confirm registration",
"Enter your new password": "Enter your new password",
"Error": "Error",
"Everything is ok, please give us your email address": "It's okay, just enter your email address to receive a password reset link.",
"FAQ": "Tips and suggestions",
"Favorite": "Favorites",
"Favorite topics": "Favorite topics",
"feed": "feed",
"Feed settings": "Feed settings",
"Feedback": "Feedback",
"Fill email": "Fill email",
"Follow": "Follow",
"Follow the topic": "Follow the topic",
"follower": "follower",
"Followers": "Followers",
"Forgot password?": "Forgot your password?",
"Forward": "Forward",
@ -84,12 +80,16 @@
"Go to main page": "Go to main page",
"Group Chat": "Group Chat",
"Groups": "Groups",
"Headers": "Headers",
"Help": "Помощь",
"Help to edit": "Help to edit",
"Here you can customize your profile the way you want.": "Here you can customize your profile the way you want.",
"Hooray! Welcome!": "Hooray! Welcome!",
"Horizontal collaborative journalistic platform": "Horizontal collaborative journalism platform",
"Hotkeys": "Горячие клавиши",
"How can I help/skills": "How can I help/skills",
"How it works": "How it works",
"How to write a good article": "Как написать хорошую статью",
"How to write an article": "How to write an article",
"I have an account": "I have an account!",
"I have no account yet": "I don't have an account yet",
@ -97,7 +97,8 @@
"Independant magazine with an open horizontal cooperation about culture, science and society": "Independant magazine with an open horizontal cooperation about culture, science and society",
"Introduce": "Introduction",
"Invalid email": "Check if your email is correct",
"invalid password": "invalid password",
"Invalid url format": "Invalid url format",
"Invite co-authors": "Invite co-authors",
"Invite to collab": "Invite to Collab",
"It does not look like url": "It doesn't look like a link",
"Join": "Join",
@ -106,10 +107,13 @@
"Join the global community of authors!": "Join the global community of authors from all over the world!",
"Just start typing...": "Just start typing...",
"Knowledge base": "Knowledge base",
"Last rev.": "Посл. изм.",
"Link sent, check your email": "Link sent, check your email",
"Lists": "Lists",
"Literature": "Literature",
"Load more": "Show more",
"Loading": "Loading",
"Logout": "Logout",
"Manifest": "Manifest",
"More": "More",
"Most commented": "Commented",
@ -117,6 +121,7 @@
"My feed": "My feed",
"My subscriptions": "Subscriptions",
"Name": "Name",
"New only": "New only",
"New password": "New password",
"New stories every day and even more!": "New stories and more are waiting for you every day!",
"No such account, please try to register": "No such account found, please try to register",
@ -124,13 +129,14 @@
"Nothing is here": "There is nothing here",
"Or continue with social network": "Or continue with social network",
"Our regular contributor": "Our regular contributor",
"Paragraphs": "Абзацев",
"Participating": "Participating",
"Partners": "Partners",
"Password": "Password",
"Password again": "Password again",
"Passwords are not equal": "Passwords are not equal",
"Paste Embed code": "Paste Embed code",
"Personal": "Personal",
"personal data usage and email notifications": "to process personal data and receive email notifications",
"Pin": "Pin",
"Please check your email address": "Please check your email address",
"Please confirm your email to finish": "Confirm your email and the action will complete",
@ -141,19 +147,19 @@
"Please, confirm email": "Please confirm email",
"Popular": "Popular",
"Popular authors": "Popular authors",
"post": "post",
"Principles": "Community principles",
"Profile": "Profile",
"Profile settings": "Profile settings",
"Publications": "Publications",
"Quit": "Quit",
"Quotes": "Quotes",
"Reason uknown": "Reason unknown",
"Recent": "Fresh",
"register": "register",
"Reply": "Reply",
"Report": "Complain",
"Resend code": "Send confirmation",
"Restore password": "Restore password",
"Save draft": "Save draft",
"Save settings": "Save settings",
"Search": "Search",
"Search author": "Search author",
@ -165,10 +171,8 @@
"Send link again": "Send link again",
"Settings": "Settings",
"Share": "Share",
"shout": "post",
"Short opening": "Short opening",
"Show": "Show",
"sign up or sign in": "sign up or sign in",
"slug is used by another user": "Slug is already taken by another user",
"Social networks": "Social networks",
"Something went wrong, check email and password": "Something went wrong. Check your email and password",
"Something went wrong, please try again": "Something went wrong, please try again",
@ -184,10 +188,11 @@
"Successfully authorized": "Authorization successful",
"Suggest an idea": "Suggest an idea",
"Support us": "Help the magazine",
"terms of use": "terms of use",
"Terms of use": "Site rules",
"Thank you": "Thank you",
"This comment has not yet been rated": "This comment has not yet been rated",
"This email is already taken. If it's you": "This email is already taken. If it's you",
"This post has not been rated yet": "This post has not been rated yet",
"To leave a comment please": "To leave a comment please",
"To write a comment, you must": "To write a comment, you must",
"Top authors": "Authors rating",
@ -199,17 +204,15 @@
"Top topics": "Interesting topics",
"Top viewed": "Most viewed",
"Topic is supported by": "Topic is supported by",
"topics": "topics",
"Topic subscriptions": "Подписки на темы",
"Topics": "Topics",
"Topics which supported by author": "Topics which supported by author",
"Try to find another way": "Try to find another way",
"Unfollow": "Unfollow",
"Unfollow the topic": "Unfollow the topic",
"user already exist": "user already exists",
"Username": "Username",
"Userpic": "Userpic",
"Video": "Video",
"view": "view",
"Views": "Views",
"We are convinced that one voice is good, but many is better": "We are convinced that one voice is good, but many is better",
"We can't find you, check email or": "We can't find you, check email or",
@ -217,10 +220,12 @@
"We know you, please try to login": "This email address is already registered, please try to login",
"We've sent you a message with a link to enter our website.": "We've sent you an email with a link to your email. Follow the link in the email to enter our website.",
"Where": "From",
"Words": "Слов",
"Work with us": "Cooperate with Discourse",
"Write": "Write",
"Write a comment...": "Write a comment...",
"Write about the topic": "Write about the topic",
"Write an article": "Write an article",
"Write comment": "Write comment",
"Write message": "Write a message",
"Write to us": "Write to us",
@ -229,13 +234,28 @@
"You've confirmed email": "You've confirmed email",
"You've reached a non-existed page": "You've reached a non-existed page",
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Your name will appear on your profile page and as your signature in publications, comments and responses",
"zine": "zine",
"By time": "By time",
"New only": "New only",
"Bookmarks": "Bookmarks",
"Logout": "Logout",
"This comment has not yet been rated": "This comment has not yet been rated",
"This post has not been rated yet": "This post has not been rated yet",
"Author subscriptions": "Подписки на авторов",
"Topic subscriptions": "Подписки на темы"
"actions": "actions",
"all topics": "all topics",
"author": "author",
"authors": "authors",
"cancel": "Cancel",
"collections": "collections",
"community": "community",
"discussion": "discourse",
"email not confirmed": "email not confirmed",
"enter": "enter",
"feed": "feed",
"follower": "follower",
"invalid password": "invalid password",
"personal data usage and email notifications": "to process personal data and receive email notifications",
"post": "post",
"register": "register",
"shout": "post",
"sign up or sign in": "sign up or sign in",
"slug is used by another user": "Slug is already taken by another user",
"terms of use": "terms of use",
"topics": "topics",
"user already exist": "user already exists",
"view": "view",
"zine": "zine"
}

View File

@ -14,10 +14,12 @@
"Artworks": "Артворки",
"Audio": "Аудио",
"Author": "Автор",
"Author subscriptions": "Подписки на авторов",
"Authors": "Авторы",
"Back to main page": "Вернуться на главную",
"Become an author": "Стать автором",
"Bookmarked": "Сохранено",
"Bookmarks": "Закладки",
"By alphabet": "По алфавиту",
"By authors": "По авторам",
"By name": "По имени",
@ -26,9 +28,11 @@
"By relevance": "По релевантности",
"By shouts": "По публикациям",
"By signing up you agree with our": "Регистрируясь, вы соглашаетесь с",
"By time": "По порядку",
"By title": "По названию",
"By updates": "По обновлениям",
"By views": "По просмотрам",
"Characters": "Знаков",
"Chat Title": "Тема дискурса",
"Choose who you want to write to": "Выберите кому хотите написать",
"Collaborate": "Помочь редактировать",
@ -37,6 +41,7 @@
"Cooperate": "Соучаствовать",
"Copy": "Скопировать",
"Copy link": "Скопировать ссылку",
"Corrections history": "История правок",
"Create Chat": "Создать чат",
"Create Group": "Создать группу",
"Create account": "Создать аккаунт",
@ -52,14 +57,17 @@
"Drafts": "Черновики",
"Edit": "Редактировать",
"Edited": "Отредактирован",
"Editing": "Редактирование",
"Email": "Почта",
"Enter": "Войти",
"Enter URL address": "Введите адрес ссылки",
"Enter text": "Введите текст",
"Enter the Discours": "Войти в Дискурс",
"Enter the code or click the link from email to confirm": "Введите код из письма или пройдите по ссылке в письме для подтверждения регистрации",
"Enter your new password": "Введите новый пароль",
"Error": "Ошибка",
"Everything is ok, please give us your email address": "Ничего страшного, просто укажите свою почту, чтобы получить ссылку для сброса пароля.",
"FAQ": "Советы и предложения",
"Favorite": "Избранное",
"Favorite topics": "Избранные темы",
"Feed settings": "Настройки ленты",
@ -77,12 +85,16 @@
"Group Chat": "Общий чат",
"Groups": "Группы",
"Header": "Заголовок",
"Headers": "Заголовки",
"Help": "Помощь",
"Help to edit": "Помочь редактировать",
"Here you can customize your profile the way you want.": "Здесь можно настроить свой профиль так, как вы хотите.",
"Hooray! Welcome!": "Ура! Добро пожаловать!",
"Horizontal collaborative journalistic platform": "Горизонтальная платформа для коллаборативной журналистики",
"Hotkeys": "Горячие клавиши",
"How can I help/skills": "Чем могу помочь/навыки",
"How it works": "Как это работает",
"How to write a good article": "Как написать хорошую статью",
"How to write an article": "Как написать статью",
"I have an account": "У меня есть аккаунт!",
"I have no account yet": "У меня еще нет аккаунта",
@ -90,6 +102,8 @@
"Independant magazine with an open horizontal cooperation about culture, science and society": "Независимый журнал с открытой горизонтальной редакцией о культуре, науке и обществе",
"Introduce": "Представление",
"Invalid email": "Проверьте правильность ввода почты",
"Invalid url format": "Неверный формат ссылки",
"Invite co-authors": "Пригласить соавторов",
"Invite experts": "Пригласить экспертов",
"Invite to collab": "Пригласить к участию",
"It does not look like url": "Это не похоже на ссылку",
@ -100,10 +114,13 @@
"Just start typing...": "Просто начните печатать...",
"Karma": "Карма",
"Knowledge base": "База знаний",
"Last rev.": "Посл. изм.",
"Link sent, check your email": "Ссылка отправлена, проверьте почту",
"Lists": "Списки",
"Literature": "Литература",
"Load more": "Показать ещё",
"Loading": "Загрузка",
"Logout": "Выход",
"Manifest": "Манифест",
"More": "Ещё",
"Most commented": "Комментируемое",
@ -111,6 +128,7 @@
"My feed": "Моя лента",
"My subscriptions": "Подписки",
"Name": "Имя",
"New only": "Только новые",
"New password": "Новый пароль",
"New stories every day and even more!": "Каждый день вас ждут новые истории и ещё много всего интересного!",
"No such account, please try to register": "Такой адрес не найден, попробуйте зарегистрироваться",
@ -118,11 +136,13 @@
"Nothing is here": "Здесь ничего нет",
"Or continue with social network": "Или продолжите через соцсеть",
"Our regular contributor": "Наш постоянный автор",
"Paragraphs": "Абзацев",
"Participating": "Участвовать",
"Partners": "Партнёры",
"Password": "Пароль",
"Password again": "Пароль ещё раз",
"Passwords are not equal": "Пароли не совпадают",
"Paste Embed code": "Вставьте embed код",
"Personal": "Личные",
"Pin": "Закрепить",
"Please check your email address": "Пожалуйста, проверьте введенный адрес почты",
@ -141,12 +161,15 @@
"Publications": "Публикации",
"Publish": "Опубликовать",
"Quit": "Выйти",
"Quotes": "Цитаты",
"Reason uknown": "Причина неизвестна",
"Recent": "Свежее",
"Reply": "Ответить",
"Report": "Пожаловаться",
"Resend code": "Выслать подтверждение",
"Restore password": "Восстановить пароль",
"Save": "Сохранить",
"Save draft": "Сохранить черновик",
"Save settings": "Сохранить настройки",
"Search": "Поиск",
"Search author": "Поиск автора",
@ -158,6 +181,7 @@
"Send link again": "Прислать ссылку ещё раз",
"Settings": "Настройки",
"Share": "Поделиться",
"Short opening": "Небольшое вступление, чтобы заинтересовать читателя",
"Show": "Показать",
"Social networks": "Социальные сети",
"Something went wrong, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль",
@ -177,7 +201,9 @@
"Support us": "Помочь журналу",
"Terms of use": "Правила сайта",
"Thank you": "Благодарности",
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
"This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы",
"This post has not been rated yet": "Эту публикацию еще пока никто не оценил",
"To leave a comment please": "Чтобы оставить комментарий, необходимо",
"To write a comment, you must": "Чтобы написать комментарий, необходимо",
"Top authors": "Рейтинг авторов",
@ -189,6 +215,7 @@
"Top topics": "Интересные темы",
"Top viewed": "Самое читаемое",
"Topic is supported by": "Тему поддерживают",
"Topic subscriptions": "Подписки на темы",
"Topics": "Темы",
"Topics which supported by author": "Автор поддерживает темы",
"Try to find another way": "Попробуйте найти по-другому",
@ -205,10 +232,12 @@
"We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.",
"Welcome!": "Добро пожаловать!",
"Where": "Откуда",
"Words": "Слов",
"Work with us": "Сотрудничать с Дискурсом",
"Write": "Написать",
"Write a comment...": "Написать комментарий...",
"Write about the topic": "Написать в тему",
"Write an article": "Написать статью",
"Write comment": "Написать комментарий",
"Write message": "Написать сообщение",
"Write to us": "Напишите нам",
@ -247,13 +276,5 @@
"topics": "темы",
"user already exist": "пользователь уже существует",
"view": "просмотр",
"zine": "журнал",
"By time": "По порядку",
"New only": "Только новые",
"Bookmarks": "Закладки",
"Logout": "Выход",
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
"This post has not been rated yet": "Эту публикацию еще пока никто не оценил",
"Author subscriptions": "Подписки на авторов",
"Topic subscriptions": "Подписки на темы"
"zine": "журнал"
}

View File

@ -34,9 +34,9 @@ import { SessionProvider } from '../context/session'
import { ProfileSettingsPage } from '../pages/profile/profileSettings.page'
import { ProfileSecurityPage } from '../pages/profile/profileSecurity.page'
import { ProfileSubscriptionsPage } from '../pages/profile/profileSubscriptions.page'
import { CreateSettingsPage } from '../pages/createSettings.page'
import { SnackbarProvider } from '../context/snackbar'
import { LocalizeProvider } from '../context/localize'
import { EditorProvider } from '../context/editor'
// TODO: lazy load
// const SomePage = lazy(() => import('./Pages/SomePage'))
@ -46,7 +46,7 @@ const pagesMap: Record<keyof typeof ROUTES, Component<PageProps>> = {
expo: LayoutShoutsPage,
connect: ConnectPage,
create: CreatePage,
createSettings: CreateSettingsPage,
createSettings: CreatePage,
home: HomePage,
topics: AllTopicsPage,
topic: TopicPage,
@ -92,11 +92,13 @@ export const App = (props: PageProps) => {
return (
<LocalizeProvider>
<SnackbarProvider>
<SessionProvider>
<Dynamic component={pageComponent()} {...props} />
</SessionProvider>
</SnackbarProvider>
<EditorProvider>
<SnackbarProvider>
<SessionProvider>
<Dynamic component={pageComponent()} {...props} />
</SessionProvider>
</SnackbarProvider>
</EditorProvider>
</LocalizeProvider>
)
}

View File

@ -182,9 +182,27 @@ img {
}
}
}
}
.shoutStatsItemInner {
cursor: pointer;
margin: -0.3em -0.3em 0;
padding: 0.3em;
.icon {
margin-right: 0;
}
.iconEdit {
margin-right: 0.3em;
}
&:hover {
background: #000;
cursor: pointer;
img {
filter: invert(1);
}
}
}
@ -307,6 +325,11 @@ img {
margin-top: 0;
}
.commentsViewSwitcherButton {
padding-left: 0 !important;
padding-right: 0 !important;
}
.help {
border-bottom: 1px solid #e8e8e8;
margin-bottom: 1.6rem;

View File

@ -134,11 +134,11 @@ export const Comment = (props: Props) => {
</Show>
<div class={styles.commentDates}>
<div class={styles.date}>{formattedDate(comment()?.createdAt)}</div>
<div class={styles.date}>{formattedDate(comment()?.createdAt)()}</div>
<Show when={comment()?.updatedAt}>
<div class={styles.date}>
<Icon name="edit" class={styles.icon} />
{t('Edited')} {formattedDate(comment()?.updatedAt)}
{t('Edited')} {formattedDate(comment()?.updatedAt)()}
</div>
</Show>
</div>

View File

@ -17,7 +17,7 @@ type Props = {
export const CommentRatingControl = (props: Props) => {
const { t } = useLocalize()
const { userSlug } = useSession()
const { user } = useSession()
const {
actions: { showSnackbar }
} = useSnackbar()
@ -30,13 +30,13 @@ export const CommentRatingControl = (props: Props) => {
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.createdBy.slug === userSlug() &&
r.createdBy.slug === user()?.slug &&
r.shout.id === props.comment.shout.id &&
r.replyTo === props.comment.id
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const canVote = createMemo(() => userSlug() !== props.comment.createdBy.slug)
const canVote = createMemo(() => user()?.slug !== props.comment.createdBy.slug)
const commentRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter(
@ -51,7 +51,7 @@ export const CommentRatingControl = (props: Props) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.createdBy.slug === userSlug() &&
r.createdBy.slug === user()?.slug &&
r.shout.id === props.comment.shout.id &&
r.replyTo === props.comment.id
)
@ -85,7 +85,7 @@ export const CommentRatingControl = (props: Props) => {
<div class={styles.commentRating}>
<button
role="button"
disabled={!canVote() || !userSlug()}
disabled={!canVote() || !user()}
onClick={() => handleRatingChange(true)}
class={clsx(styles.commentRatingControl, styles.commentRatingControlUp, {
[styles.voted]: isUpvoted()
@ -111,7 +111,7 @@ export const CommentRatingControl = (props: Props) => {
</Popup>
<button
role="button"
disabled={!canVote() || !userSlug()}
disabled={!canVote() || !user()}
onClick={() => handleRatingChange(false)}
class={clsx(styles.commentRatingControl, styles.commentRatingControlDown, {
[styles.voted]: isDownvoted()

View File

@ -1,6 +1,6 @@
import { Show, createMemo, createSignal, onMount, For } from 'solid-js'
import { Comment } from './Comment'
import styles from '../../styles/Article.module.scss'
import styles from './Article.module.scss'
import { clsx } from 'clsx'
import { Author, Reaction, ReactionKind } from '../../graphql/types.gen'
import { useSession } from '../../context/session'

View File

@ -1,163 +0,0 @@
h1 {
@include font-size(4rem);
line-height: 1.1;
margin-top: 0.5em;
}
h2 {
line-height: 1.1;
}
img {
max-width: 100%;
}
.article {
padding-top: 2em;
}
.article__header {
margin-bottom: 2em;
@include media-breakpoint-up(md) {
margin: 0 -16.6666% 2em;
}
}
.article__cover {
background-size: cover;
height: 0;
padding-bottom: 56.2%;
}
.article__body {
font-size: 1.7rem;
line-height: 1.6;
img {
display: block;
margin-bottom: 0.5em;
}
blockquote {
border-left: 4px solid;
font-size: 2rem;
font-weight: 500;
font-style: italic;
line-height: 1.4;
margin: 1.5em 0 1.5em -16.6666%;
padding: 0 0 0 1em;
}
mark {
background: none;
font-size: 2rem;
font-weight: bold;
line-height: 1.4;
}
}
.article__author {
margin-bottom: 2em;
}
.article__authors-list {
margin-top: 2em;
h4 {
color: #696969;
font-size: 1.5rem;
font-weight: normal;
}
}
.write-comment {
border: 2px solid #f6f6f6;
@include font-size(1.7rem);
outline: none;
padding: 0.2em 0.4em;
width: 100%;
&::placeholder {
color: #858585;
}
}
.comment-warning {
background: #f6f6f6;
@include font-size(2.2rem);
margin-bottom: 1em;
padding: 2.4rem 1.8rem;
}
.article-stats {
border-bottom: 1px solid #e8e8e8;
border-top: 4px solid #000;
padding: 3.2rem 0;
}
.article-stats__item {
@include font-size(1.7rem);
font-weight: 500;
display: inline-block;
margin-right: $grid-gutter-width;
vertical-align: baseline;
.icon {
display: inline-block;
margin-right: 0.2em;
transition: filter 0.2s;
vertical-align: middle;
}
img {
display: block;
}
a {
border: none;
&:hover {
.icon {
filter: invert(1);
}
}
}
}
.article-stats__item--likes {
.icon {
vertical-align: baseline;
}
.icon:last-of-type {
// transform: rotate(180deg);
transform-origin: center;
margin-left: 0.3em;
vertical-align: middle;
}
}
.topics-list {
margin: 2.4rem 0;
.article__topic {
display: inline-block;
margin: 0 0.8rem 0.8rem 0;
a {
background: #f6f6f6;
color: #000;
padding: 0.4rem 0.8rem;
transition: background-color 0.2s;
&:hover {
background-color: rgb(0 0 0 / 20%);
}
}
}
}

View File

@ -1,14 +1,11 @@
import { capitalize, formatDate } from '../../utils'
import './Full.scss'
import { Icon } from '../_shared/Icon'
import { AuthorCard } from '../Author/Card'
import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js'
import { createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js'
import type { Author, Shout } from '../../graphql/types.gen'
import MD from './MD'
import { SharePopup } from './SharePopup'
import { getDescription } from '../../utils/meta'
import stylesHeader from '../Nav/Header.module.scss'
import styles from '../../styles/Article.module.scss'
import { ShoutRatingControl } from './ShoutRatingControl'
import { clsx } from 'clsx'
import { CommentsTree } from './CommentsTree'
@ -16,10 +13,12 @@ import { useSession } from '../../context/session'
import VideoPlayer from './VideoPlayer'
import Slider from '../_shared/Slider'
import { getPagePath } from '@nanostores/router'
import { router, useRouter } from '../../stores/router'
import { router } from '../../stores/router'
import { useReactions } from '../../context/reactions'
import { Title } from '@solidjs/meta'
import { useLocalize } from '../../context/localize'
import stylesHeader from '../Nav/Header.module.scss'
import styles from './Article.module.scss'
interface ArticleProps {
article: Shout
@ -57,7 +56,7 @@ const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
export const FullArticle = (props: ArticleProps) => {
const { t } = useLocalize()
const { userSlug, isAuthenticated } = useSession()
const { user, isAuthenticated } = useSession()
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt)))
@ -75,7 +74,7 @@ export const FullArticle = (props: ArticleProps) => {
setIsReactionsLoaded(true)
})
const canEdit = () => props.article.authors?.some((a) => a.slug === userSlug())
const canEdit = () => props.article.authors?.some((a) => a.slug === user()?.slug)
const bookmark = (ev) => {
// TODO: implement bookmark clicked
@ -119,7 +118,7 @@ export const FullArticle = (props: ArticleProps) => {
return (
<>
<Title>{props.article.title}</Title>
<div class="shout wide-container">
<div class="wide-container">
<div class="row">
<article class="col-md-16 col-lg-14 col-xl-12 offset-md-5">
<div class={styles.shoutHeader}>
@ -183,7 +182,7 @@ export const FullArticle = (props: ArticleProps) => {
<Show when={media() && props.article.layout === 'image'}>
<Slider slidesPerView={1} isPageGallery={true} isCardsWithCover={true} hasThumbs={true}>
<For each={media() || []}>
{(m: MediaItem) => (
{(m) => (
<div class="swiper-slide">
<div class="swiper-slide__inner">
<img src={m.url || m.pic} alt={m.title} loading="lazy" />
@ -196,7 +195,7 @@ export const FullArticle = (props: ArticleProps) => {
</Slider>
</Show>
<div class="shout wide-container">
<div class="wide-container">
<div class="row">
<div class="col-md-16 offset-md-5">
<div class={styles.shoutStats}>
@ -220,18 +219,24 @@ export const FullArticle = (props: ArticleProps) => {
description={getDescription(props.article.body)}
imageUrl={props.article.cover}
containerCssClass={stylesHeader.control}
trigger={<Icon name="share-outline" class={styles.icon} />}
trigger={
<div class={styles.shoutStatsItemInner}>
<Icon name="share-outline" class={styles.icon} />
</div>
}
/>
</div>
<div class={styles.shoutStatsItem} onClick={bookmark}>
<Icon name="bookmark" class={styles.icon} />
<div class={styles.shoutStatsItemInner}>
<Icon name="bookmark" class={styles.icon} />
</div>
</div>
<Show when={canEdit()}>
<div class={styles.shoutStatsItem}>
<a href="/edit">
<Icon name="edit" />
<a href="/edit" class={styles.shoutStatsItemInner}>
<Icon name="edit" class={clsx(styles.icon, styles.iconEdit)} />
{t('Edit')}
</a>
</div>

View File

@ -16,7 +16,7 @@ interface ShoutRatingControlProps {
export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const { t } = useLocalize()
const { userSlug } = useSession()
const { user } = useSession()
const {
reactionEntities,
@ -27,7 +27,7 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.createdBy.slug === userSlug() &&
r.createdBy.slug === user()?.slug &&
r.shout.id === props.shout.id &&
!r.replyTo
)
@ -38,7 +38,10 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const shoutRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter(
(r) => [ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) && r.shout.id === props.shout.id
(r) =>
[ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) &&
r.shout.id === props.shout.id &&
!r.replyTo
)
)
@ -46,7 +49,7 @@ export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.createdBy.slug === userSlug() &&
r.createdBy.slug === user()?.slug &&
r.shout.id === props.shout.id &&
!r.replyTo
)

View File

@ -0,0 +1,168 @@
import { createEffect } from 'solid-js'
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import { useLocalize } from '../../context/localize'
import { Blockquote } from '@tiptap/extension-blockquote'
import { Bold } from '@tiptap/extension-bold'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { Dropcursor } from '@tiptap/extension-dropcursor'
import { Italic } from '@tiptap/extension-italic'
import { Strike } from '@tiptap/extension-strike'
import { HorizontalRule } from '@tiptap/extension-horizontal-rule'
import { Underline } from '@tiptap/extension-underline'
import { FloatingMenu } from '@tiptap/extension-floating-menu'
import { BulletList } from '@tiptap/extension-bullet-list'
import { OrderedList } from '@tiptap/extension-ordered-list'
import { ListItem } from '@tiptap/extension-list-item'
import { CharacterCount } from '@tiptap/extension-character-count'
import { Placeholder } from '@tiptap/extension-placeholder'
import { Gapcursor } from '@tiptap/extension-gapcursor'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Heading } from '@tiptap/extension-heading'
import { Highlight } from '@tiptap/extension-highlight'
import { Link } from '@tiptap/extension-link'
import { Document } from '@tiptap/extension-document'
import { Text } from '@tiptap/extension-text'
import { Image } from '@tiptap/extension-image'
import { Paragraph } from '@tiptap/extension-paragraph'
import Focus from '@tiptap/extension-focus'
import { TrailingNode } from './extensions/TrailingNode'
import { EditorBubbleMenu } from './EditorBubbleMenu/EditorBubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
import * as Y from 'yjs'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
import { Collaboration } from '@tiptap/extension-collaboration'
import './Prosemirror.scss'
import { IndexeddbPersistence } from 'y-indexeddb'
import { useSession } from '../../context/session'
import uniqolor from 'uniqolor'
import { HocuspocusProvider } from '@hocuspocus/provider'
import { Embed } from './extensions/embed'
import { useEditorContext } from '../../context/editor'
type EditorProps = {
shoutId: number
initialContent?: string
onChange: (text: string) => void
}
const yDoc = new Y.Doc()
const persisters: Record<string, IndexeddbPersistence> = {}
const providers: Record<string, HocuspocusProvider> = {}
export const Editor = (props: EditorProps) => {
const { t } = useLocalize()
const { user } = useSession()
const docName = `shout-${props.shoutId}`
if (!providers[docName]) {
providers[docName] = new HocuspocusProvider({
url: 'wss://hocuspocus.discours.io',
// url: 'ws://localhost:4242',
name: docName,
document: yDoc
})
}
if (!persisters[docName]) {
persisters[docName] = new IndexeddbPersistence(docName, yDoc)
}
const editorElRef: {
current: HTMLDivElement
} = {
current: null
}
const bubbleMenuRef: {
current: HTMLDivElement
} = {
current: null
}
const floatingMenuRef: {
current: HTMLDivElement
} = {
current: null
}
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
extensions: [
Document,
Text,
Paragraph,
Dropcursor,
Blockquote,
Bold,
Italic,
Strike,
HorizontalRule,
Underline,
Link.configure({
openOnClick: false
}),
Heading.configure({
levels: [1, 2, 3]
}),
BubbleMenu.configure({
element: bubbleMenuRef.current
}),
FloatingMenu.configure({
tippyOptions: {
placement: 'left'
},
element: floatingMenuRef.current
}),
BulletList,
OrderedList,
ListItem,
Collaboration.configure({
document: yDoc
}),
CollaborationCursor.configure({
provider: providers[docName],
user: {
name: user().name,
color: uniqolor(user().slug).color
}
}),
Placeholder.configure({
placeholder: t('Short opening')
}),
Focus,
Gapcursor,
HardBreak,
Highlight,
Image,
TrailingNode,
Embed,
TrailingNode,
CharacterCount
]
}))
const html = useEditorHTML(() => editor())
const {
actions: { countWords }
} = useEditorContext()
createEffect(() => {
props.onChange(html())
if (html()) {
countWords({
characters: editor().storage.characterCount.characters(),
words: editor().storage.characterCount.words()
})
}
})
return (
<>
<div ref={(el) => (editorElRef.current = el)} />
<EditorBubbleMenu editor={editor()} ref={(el) => (bubbleMenuRef.current = el)} />
<EditorFloatingMenu editor={editor()} ref={(el) => (floatingMenuRef.current = el)} />
</>
)
}

View File

@ -0,0 +1,86 @@
.bubbleMenu {
background: #fff;
box-shadow: 0 4px 10px rgba(#000, 0.25);
.bubbleMenuButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
opacity: 0.5;
padding: 1rem;
.triangle {
margin-left: 4px;
}
.colorWheel {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 50%;
background: #f6e3a1;
}
}
.bubbleMenuButtonActive {
opacity: 1;
}
.delimiter {
background: #999;
display: inline-block;
height: 1.4em;
margin: 0 0.2em;
vertical-align: text-bottom;
width: 1px;
}
.dropDownHolder {
position: relative;
cursor: pointer;
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: center;
.dropDown {
position: absolute;
padding: 6px;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
background: #fff;
color: #898c94;
& > header {
font-size: 10px;
border-bottom: 1px solid #898c94;
}
.actions {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: nowrap;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.bubbleMenuButton {
min-width: 40px;
}
}
}
}
.dropDownEnter,
.dropDownExit {
height: 0;
color: transparent;
}
}

View File

@ -0,0 +1,258 @@
import { Switch, Match, createSignal, Show } from 'solid-js'
import type { Editor } from '@tiptap/core'
import styles from './EditorBubbleMenu.module.scss'
import { Icon } from '../../_shared/Icon'
import { clsx } from 'clsx'
import { createEditorTransaction } from 'solid-tiptap'
import { useLocalize } from '../../../context/localize'
import { InlineForm } from '../InlineForm'
import validateUrl from '../../../utils/validateUrl'
type BubbleMenuProps = {
editor: Editor
ref: (el: HTMLDivElement) => void
}
export const EditorBubbleMenu = (props: BubbleMenuProps) => {
const { t } = useLocalize()
const [textSizeBubbleOpen, setTextSizeBubbleOpen] = createSignal<boolean>(false)
const [listBubbleOpen, setListBubbleOpen] = createSignal<boolean>(false)
const [linkEditorOpen, setLinkEditorOpen] = createSignal<boolean>(false)
const isActive = (name: string, attributes?: any) =>
createEditorTransaction(
() => props.editor,
(editor) => {
return editor && editor.isActive(name, attributes)
}
)
const isBold = isActive('bold')
const isItalic = isActive('italic')
const isH1 = isActive('heading', { level: 1 })
const isH2 = isActive('heading', { level: 2 })
const isH3 = isActive('heading', { level: 3 })
const isBlockQuote = isActive('blockquote')
const isOrderedList = isActive('isOrderedList')
const isBulletList = isActive('isBulletList')
const isLink = isActive('link')
const toggleLinkForm = () => {
setLinkEditorOpen(true)
}
const toggleTextSizePopup = () => {
if (listBubbleOpen()) {
setListBubbleOpen(false)
}
setTextSizeBubbleOpen((prev) => !prev)
}
const toggleListPopup = () => {
if (textSizeBubbleOpen()) {
setTextSizeBubbleOpen(false)
}
setListBubbleOpen((prev) => !prev)
}
const handleLinkFormSubmit = (value: string) => {
console.log('!!! value:', value)
props.editor.chain().focus().setLink({ href: value }).run()
}
const currentUrl = createEditorTransaction(
() => props.editor,
(editor) => {
return (editor && editor.getAttributes('link').href) || ''
}
)
const handleClearLinkForm = () => {
if (currentUrl()) {
props.editor.chain().focus().unsetLink().run()
}
setLinkEditorOpen(false)
}
return (
<>
<div ref={props.ref} class={styles.bubbleMenu}>
<Switch>
<Match when={linkEditorOpen()}>
<InlineForm
variant="inBubble"
initialValue={currentUrl() ?? ''}
onClear={handleClearLinkForm}
validate={(value) => (validateUrl(value) ? '' : t('Invalid url format'))}
onSubmit={handleLinkFormSubmit}
onClose={() => setLinkEditorOpen(false)}
/>
</Match>
<Match when={!linkEditorOpen()}>
<>
<div class={styles.dropDownHolder}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: textSizeBubbleOpen()
})}
onClick={toggleTextSizePopup}
>
<Icon name="editor-text-size" />
<Icon name="down-triangle" class={styles.triangle} />
</button>
<Show when={textSizeBubbleOpen()}>
<div class={styles.dropDown}>
<header>{t('Headers')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH1()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 1 }).run()
toggleTextSizePopup()
}}
>
<Icon name="editor-h1" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH2()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 2 }).run()
toggleTextSizePopup()
}}
>
<Icon name="editor-h2" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isH3()
})}
onClick={() => {
props.editor.chain().focus().toggleHeading({ level: 3 }).run()
toggleTextSizePopup()
}}
>
<Icon name="editor-h3" />
</button>
</div>
<header>{t('Quotes')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBlockQuote()
})}
onClick={() => {
props.editor.chain().focus().toggleBlockquote().run()
toggleTextSizePopup()
}}
>
<Icon name="editor-blockquote" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBlockQuote()
})}
onClick={() => {
props.editor.chain().focus().toggleBlockquote().run()
toggleTextSizePopup()
}}
>
<Icon name="editor-quote" />
</button>
</div>
</div>
</Show>
</div>
<div class={styles.delimiter} />
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBold()
})}
onClick={() => props.editor.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isItalic()
})}
onClick={() => props.editor.chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</button>
<div class={styles.delimiter} />
<button
type="button"
onClick={toggleLinkForm}
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isLink()
})}
>
<Icon name="editor-link" />
</button>
<button type="button" class={styles.bubbleMenuButton}>
<Icon name="editor-footnote" />
</button>
<div class={styles.delimiter} />
<div class={styles.dropDownHolder}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: listBubbleOpen()
})}
onClick={toggleListPopup}
>
<Icon name="editor-ul" />
<Icon name="down-triangle" class={styles.triangle} />
</button>
<Show when={listBubbleOpen()}>
<div class={styles.dropDown}>
<header>{t('Lists')}</header>
<div class={styles.actions}>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isBulletList()
})}
onClick={() => {
props.editor.chain().focus().toggleBulletList().run()
toggleListPopup()
}}
>
<Icon name="editor-ul" />
</button>
<button
type="button"
class={clsx(styles.bubbleMenuButton, {
[styles.bubbleMenuButtonActive]: isOrderedList()
})}
onClick={() => {
props.editor.chain().focus().toggleOrderedList().run()
toggleListPopup()
}}
>
<Icon name="editor-ol" />
</button>
</div>
</div>
</Show>
</div>
</>
</Match>
</Switch>
</div>
</>
)
}

View File

@ -0,0 +1,15 @@
.editorFloatingMenu {
left: 0;
position: relative;
vertical-align: middle;
button {
opacity: 0.3;
vertical-align: text-bottom;
transition: opacity 0.3s ease-in-out;
&:hover {
opacity: 1;
}
}
}

View File

@ -0,0 +1,53 @@
import { createSignal, Show } from 'solid-js'
import type { Editor } from '@tiptap/core'
import { Icon } from '../_shared/Icon'
import { InlineForm } from './InlineForm'
import styles from './EditorFloatingMenu.module.scss'
import HTMLParser from 'html-to-json-parser'
import { useLocalize } from '../../context/localize'
type FloatingMenuProps = {
editor: Editor
ref: (el: HTMLDivElement) => void
}
const embedData = async (data) => {
const result = await HTMLParser(data, false)
if (result && 'type' in result && result.type === 'iframe') {
return result.attributes
}
}
export const EditorFloatingMenu = (props: FloatingMenuProps) => {
const { t } = useLocalize()
const [inlineEditorOpen, setInlineEditorOpen] = createSignal<boolean>(false)
const handleEmbedFormSubmit = async (value: string) => {
// TODO: add support instagram embed (blockquote)
const emb = await embedData(value)
props.editor.chain().focus().setIframe(emb).run()
}
const validateEmbed = async (value) => {
const iframeData = await HTMLParser(value, false)
if (iframeData && iframeData.type !== 'iframe') {
return
}
}
return (
<div ref={props.ref} class={styles.editorFloatingMenu}>
<button type="button" onClick={() => setInlineEditorOpen(true)}>
<Icon name="editor-plus" />
</button>
<Show when={inlineEditorOpen()}>
<InlineForm
variant="inFloating"
onClose={() => setInlineEditorOpen(false)}
validate={validateEmbed}
onSubmit={handleEmbedFormSubmit}
/>
</Show>
</div>
)
}

View File

@ -0,0 +1,67 @@
.InlineForm {
position: relative;
&.inBubble {
//...
}
&.inFloating {
position: absolute;
left: calc(100% + 1rem);
top: -0.8rem;
min-width: 64vw;
background: #fff;
box-shadow: 0 4px 10px rgba(#000, 0.25);
button {
opacity: 1;
&:disabled,
&:disabled:hover {
opacity: 0.3;
}
}
}
.form {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
padding: 6px 11px;
input {
margin: 0 12px 0 0;
padding: 0;
flex: 1;
border: none;
min-width: 200px;
display: block;
&::placeholder {
color: rgba(#000, 0.3);
}
&:focus {
outline: none;
}
}
}
.linkError {
padding: 6px 11px;
color: red;
font-size: 0.7em;
position: absolute;
bottom: -3rem;
left: 0;
right: 0;
height: 0;
background: #fff;
box-shadow: 0 4px 10px rgba(#000, 0.25);
opacity: 0;
transition: height 0.3s ease-in-out, opacity 0.3s ease-in-out;
&.visible {
height: 32px;
opacity: 1;
}
}
}

View File

@ -0,0 +1,90 @@
import styles from './InlineForm.module.scss'
import { Icon } from '../../_shared/Icon'
import { createEditorTransaction } from 'solid-tiptap'
import type { Editor } from '@tiptap/core'
import { createSignal, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { clsx } from 'clsx'
type Props = {
onClose: () => void
onClear?: () => void
onSubmit: (value: string) => void
variant: 'inBubble' | 'inFloating'
validate?: (value: string) => string | Promise<string>
initialValue?: string
}
export const InlineForm = (props: Props) => {
const { t } = useLocalize()
const [formValue, setFormValue] = createSignal(props.initialValue || '')
const [formValueError, setFormValueError] = createSignal('')
const handleFormInput = (value) => {
setFormValue(value)
}
const handleSaveButtonClick = async () => {
const errorMessage = await props.validate(formValue())
if (errorMessage) {
setFormValueError(errorMessage)
return
}
props.onSubmit(formValue())
props.onClose()
}
const handleKeyPress = async (event) => {
setFormValueError('')
const key = event.key
if (key === 'Enter') {
await handleSaveButtonClick()
}
if (key === 'Esc') {
props.onClear
}
}
return (
<div
class={clsx(styles.InlineForm, {
[styles.inBubble]: props.variant === 'inBubble',
[styles.inFloating]: props.variant === 'inFloating'
})}
>
<div class={styles.form}>
<Show when={props.variant === 'inBubble'}>
<input
type="text"
placeholder={t('Enter URL address')}
autofocus
value={props.initialValue}
onKeyPress={(e) => handleKeyPress(e)}
onInput={(e) => handleFormInput(e.currentTarget.value)}
/>
</Show>
<Show when={props.variant === 'inFloating'}>
<input
autofocus
type="text"
placeholder={t('Paste Embed code')}
onKeyPress={(e) => handleKeyPress(e)}
onInput={(e) => handleFormInput(e.currentTarget.value)}
/>
</Show>
<button type="button" onClick={handleSaveButtonClick} disabled={formValueError() !== ''}>
<Icon name="status-done" />
</button>
<button type="button" onClick={props.onClear}>
{props.initialValue ? <Icon name="editor-unlink" /> : <Icon name="status-cancel" />}
</button>
</div>
<div class={clsx(styles.linkError, { [styles.visible]: Boolean(formValueError()) })}>
{formValueError()}
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export { InlineForm } from './InlineForm'

View File

@ -0,0 +1,93 @@
.Panel {
background: #1f1f1f;
color: rgb(255 255 255 / 0.35);
display: flex;
flex-direction: column;
font-size: 1.7rem;
justify-content: flex-start;
height: 100%;
line-height: 1.4;
padding: $grid-gutter-width $grid-gutter-width / 2;
position: fixed;
transition: transform 0.3s;
right: 0;
top: 0;
z-index: 10;
.close {
filter: invert(1);
margin: -1.6rem 0 0 -1.6rem;
}
.actionsHolder {
padding: 0 $grid-gutter-width / 2;
&.scrolled {
overflow-y: auto;
scroll-behavior: smooth;
}
}
section {
border-bottom: 2px solid rgb(255 255 255 / 0.1);
padding: 1.8rem 0;
&:first-child {
padding-top: 0;
}
p {
margin: 0.6em 0;
&:last-child {
margin-bottom: 0;
}
}
}
.button {
font-weight: normal;
margin-left: -1.6rem;
text-align: left;
&:hover {
color: #fff;
text-decoration: none;
}
}
.buttonWithIcon {
margin-left: -1.6rem;
.icon {
filter: invert(0.5);
margin-right: 0.3em;
width: 1em;
}
img {
vertical-align: middle;
}
}
.stats {
display: flex;
flex: 1;
flex-direction: column;
justify-content: flex-end;
margin-top: 3em;
}
a {
color: rgb(255 255 255 / 0.35);
font-weight: normal !important;
&:hover {
background: none;
color: #fff;
}
}
&.hidden {
transform: translateX(100%);
}
}

View File

@ -0,0 +1,106 @@
import { clsx } from 'clsx'
import { Button } from '../../_shared/Button'
import { Icon } from '../../_shared/Icon'
import { useLocalize } from '../../../context/localize'
import styles from './Panel.module.scss'
import { useEditorContext } from '../../../context/editor'
type Props = {
// isVisible: boolean
}
export const Panel = (props: Props) => {
const { t } = useLocalize()
const {
isEditorPanelVisible,
wordCounter,
actions: { toggleEditorPanel }
} = useEditorContext()
return (
<aside class={clsx('col-md-6', styles.Panel, { [styles.hidden]: !isEditorPanelVisible() })}>
<div class={styles.actionsHolder}>
<Button
value={<Icon name="close" />}
variant={'inline'}
class={styles.close}
onClick={() => toggleEditorPanel()}
/>
</div>
<div class={clsx(styles.actionsHolder, styles.scrolled)}>
<section>
<Button value={t('Publish')} variant={'inline'} class={styles.button} />
<Button value={t('Save draft')} variant={'inline'} class={styles.button} />
</section>
<section>
<Button
value={
<>
<Icon name="eye" class={styles.icon} />
{t('Preview')}
</>
}
variant={'inline'}
class={clsx(styles.button, styles.buttonWithIcon)}
/>
<Button
value={
<>
<Icon name="pencil-outline" class={styles.icon} />
{t('Editing')}
</>
}
variant={'inline'}
class={clsx(styles.button, styles.buttonWithIcon)}
/>
<Button
value={
<>
<Icon name="feed-discussion" class={styles.icon} />
{t('FAQ')}
</>
}
variant={'inline'}
class={clsx(styles.button, styles.buttonWithIcon)}
/>
</section>
<section>
<Button value={t('Invite co-authors')} variant={'inline'} class={styles.button} />
<Button value={t('Publication settings')} variant={'inline'} class={styles.button} />
<Button value={t('Corrections history')} variant={'inline'} class={styles.button} />
</section>
<section>
<p>
<a href="/how-to-write-a-good-article">{t('How to write a good article')}</a>
</p>
<p>
<a href="#">{t('Hotkeys')}</a>
</p>
<p>
<a href="#">{t('Help')}</a>
</p>
</section>
<div class={styles.stats}>
<div>
{t('Characters')}: <em>{wordCounter().characters}</em>
</div>
<div>
{t('Words')}: <em>{wordCounter().words}</em>
</div>
<Show when={wordCounter().paragraphs}>
<div>
{t('Paragraphs')}: <em>{wordCounter().paragraphs}</em>
</div>
</Show>
<div>
{t('Last rev.')}: <em>22.03.22 в 18:20</em>
</div>
</div>
</div>
</aside>
)
}

View File

@ -0,0 +1 @@
export { Panel } from './Panel'

View File

@ -0,0 +1,63 @@
.ProseMirror {
outline: none;
blockquote {
border-left: 2px solid;
@include font-size(1.6rem);
margin: 1.5em 0;
padding-left: 1.6em;
}
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
font-weight: 500;
font-size: 20px;
line-height: 30px;
opacity: 0.3;
}
/* Give a remote user a caret */
.collaboration-cursor__caret {
border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
/* Render the username above the caret */
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 12px;
font-style: normal;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
}
.embed-wrapper {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: #f1f1f1;
margin: 4rem 0;
iframe {
border: none;
overflow: hidden;
}
}

View File

@ -0,0 +1,7 @@
.TopicSelect .solid-select-list {
background: #fff;
}
.TopicSelect .solid-select-option[data-disabled='true'] {
display: none;
}

View File

@ -0,0 +1,38 @@
import type { Topic } from '../../../graphql/types.gen'
import { createOptions, Select } from '@thisbeyond/solid-select'
import { useLocalize } from '../../../context/localize'
import '@thisbeyond/solid-select/style.css'
import './TopicSelect.scss'
import { clone } from '../../../utils/clone'
type TopicSelectProps = {
topics: Topic[]
selectedTopics: Topic[]
onChange: (selectedTopics: Topic[]) => void
}
export const TopicSelect = (props: TopicSelectProps) => {
const { t } = useLocalize()
const selectProps = createOptions(props.topics, {
key: 'title',
disable: (topic) => {
// console.log({ selectedTopics: clone(props.selectedTopics) })
return props.selectedTopics.some((selectedTopic) => selectedTopic.slug === topic.slug)
}
})
const handleChange = (selectedTopics: Topic[]) => {
props.onChange(selectedTopics)
}
return (
<Select
multiple={true}
{...selectProps}
placeholder={t('Topics')}
class="TopicSelect"
onChange={handleChange}
/>
)
}

View File

@ -0,0 +1,69 @@
import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
function nodeEqualsType({ types, node }) {
return (Array.isArray(types) && types.includes(node.type)) || node.type === types
}
/**
* Extension based on:
* - https://github.com/ueberdosis/tiptap/blob/v1/packages/tiptap-extensions/src/extensions/TrailingNode.js
* - https://github.com/remirror/remirror/blob/e0f1bec4a1e8073ce8f5500d62193e52321155b9/packages/prosemirror-trailing-node/src/trailing-node-plugin.ts
*/
export interface TrailingNodeOptions {
node: string
notAfter: string[]
}
export const TrailingNode = Extension.create<TrailingNodeOptions>({
name: 'trailingNode',
addOptions() {
return {
node: 'paragraph',
notAfter: ['paragraph']
}
},
addProseMirrorPlugins() {
const plugin = new PluginKey(this.name)
const disabledNodes = Object.entries(this.editor.schema.nodes)
.map(([, value]) => value)
.filter((node) => this.options.notAfter.includes(node.name))
return [
new Plugin({
key: plugin,
appendTransaction: (_, __, state) => {
const { doc, tr, schema } = state
const shouldInsertNodeAtEnd = plugin.getState(state)
const endPosition = doc.content.size
const type = schema.nodes[this.options.node]
if (!shouldInsertNodeAtEnd) {
return
}
return tr.insert(endPosition, type.create())
},
state: {
init: (_, state) => {
const lastNode = state.tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
},
apply: (tr, value) => {
if (!tr.docChanged) {
return value
}
const lastNode = tr.doc.lastChild
return !nodeEqualsType({ node: lastNode, types: disabledNodes })
}
}
})
]
}
})

View File

@ -0,0 +1,73 @@
import { mergeAttributes, Node } from '@tiptap/core'
import { NodeRange } from 'prosemirror-model'
import { insert } from 'solid-js/web'
import { TextSelection } from 'prosemirror-state'
export interface IframeOptions {
allowFullscreen: boolean
HTMLAttributes: {
[key: string]: any
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
setIframe: (options: { src: string }) => ReturnType
}
}
}
export const Embed = Node.create<IframeOptions>({
name: 'embed',
group: 'block',
selectable: true,
atom: true,
draggable: true,
addAttributes() {
return {
src: { default: null },
width: { default: null },
height: { default: null }
}
},
parseHTML() {
return [
{
tag: 'iframe'
}
]
},
renderHTML({ HTMLAttributes }) {
return ['iframe', mergeAttributes(HTMLAttributes)]
},
addNodeView() {
return ({ node }) => {
const div = document.createElement('div')
div.className = 'embed-wrapper'
const iframe = document.createElement('iframe')
iframe.width = node.attrs.width
iframe.height = node.attrs.height
iframe.allowfullscreen = node.attrs.allowfullscreen
iframe.src = node.attrs.src
div.append(iframe)
return {
dom: div
}
}
},
addCommands() {
return {
setIframe:
(options) =>
({ tr, dispatch }) => {
const { selection } = tr
const node = this.type.create(options)
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node)
}
return true
}
}
}
})

View File

@ -31,9 +31,9 @@ const getById = (letter: string) =>
colors[Math.abs(Number(BigInt(letter.toLowerCase().codePointAt(0) - 97) % BigInt(colors.length)))]
const DialogAvatar = (props: Props) => {
const nameFirstLetter = props.name.slice(0, 1)
const nameFirstLetter = createMemo(() => props.name.slice(0, 1))
const randomBg = createMemo(() => {
return getById(nameFirstLetter)
return getById(nameFirstLetter())
})
return (
@ -45,7 +45,7 @@ const DialogAvatar = (props: Props) => {
})}
style={{ 'background-color': `${randomBg()}` }}
>
<Show when={props.url} fallback={() => <div class={styles.letter}>{nameFirstLetter}</div>}>
<Show when={Boolean(props.url)} fallback={<div class={styles.letter}>{nameFirstLetter()}</div>}>
<div class={styles.imageHolder} style={{ 'background-image': `url(${props.url})` }} />
</Show>
</div>

View File

@ -61,7 +61,7 @@ const DialogCard = (props: DialogProps) => {
<Show when={!props.isChatHeader}>
<div class={styles.activity}>
<Show when={props.lastUpdate}>
<div class={styles.time}>{formattedTime(props.lastUpdate * 1000)}</div>
<div class={styles.time}>{formattedTime(props.lastUpdate * 1000)()}</div>
</Show>
<Show when={props.counter > 0}>
<div class={styles.counter}>

View File

@ -53,7 +53,7 @@ export const Message = (props: Props) => {
<div innerHTML={md.render(props.content.body)} />
</div>
</div>
<div class={styles.time}>{formattedTime(props.content.createdAt * 1000)}</div>
<div class={styles.time}>{formattedTime(props.content.createdAt * 1000)()}</div>
</div>
)
}

View File

@ -502,7 +502,10 @@
}
.userControlItemVerbose {
margin-right: 0.5em;
@include media-breakpoint-up(lg) {
margin-right: 0;
width: auto;
.icon {
@ -511,7 +514,7 @@
.textLabel {
display: inline;
padding: 0 1.2rem;
//padding: 0 1.2rem;
position: relative;
z-index: 1;
}

View File

@ -1,6 +1,6 @@
import styles from './Header.module.scss'
import { clsx } from 'clsx'
import { useRouter } from '../../stores/router'
import { router, useRouter } from '../../stores/router'
import { Icon } from '../_shared/Icon'
import { createSignal, Show } from 'solid-js'
@ -12,6 +12,9 @@ import { showModal, useWarningsStore } from '../../stores/ui'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { useSession } from '../../context/session'
import { useLocalize } from '../../context/localize'
import { getPagePath } from '@nanostores/router'
import { Button } from '../_shared/Button'
import { useEditorContext } from '../../context/editor'
type HeaderAuthProps = {
setIsProfilePopupVisible: (value: boolean) => void
@ -25,6 +28,10 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
const { session, isSessionLoaded, isAuthenticated } = useSession()
const {
actions: { toggleEditorPanel }
} = useEditorContext()
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
const handleBellIconClick = (event: Event) => {
@ -34,7 +41,6 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
showModal('auth')
return
}
toggleWarnings()
}
@ -43,14 +49,16 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
<Show when={isSessionLoaded()} keyed={true}>
<div class={clsx(styles.usernav, 'col')}>
<div class={clsx(styles.userControl, styles.userControl, 'col')}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<a href="/create">
<span class={styles.textLabel}>{t('Create post')}</span>
<Icon name="pencil" class={styles.icon} />
</a>
</div>
<Show when={page().route !== 'create'}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<a href={getPagePath(router, 'create')}>
<span class={styles.textLabel}>{t('Create post')}</span>
<Icon name="pencil" class={styles.icon} />
</a>
</div>
</Show>
<Show when={isAuthenticated()}>
<Show when={isAuthenticated() && page().route !== 'create'}>
<div class={styles.userControlItem}>
<a href="#" onClick={handleBellIconClick}>
<div>
@ -60,6 +68,40 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
</div>
</Show>
<Show when={isAuthenticated() && page().route === 'create'}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<Button
value={
<>
<span class={styles.textLabel}>{t('Save')}</span>
<Icon name="save" class={styles.icon} />
</>
}
variant={'outline'}
/>
</div>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<Button
value={
<>
<span class={styles.textLabel}>{t('Publish')}</span>
<Icon name="publish" class={styles.icon} />
</>
}
variant={'outline'}
/>
</div>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
<Button
value={<Icon name="burger" />}
variant={'outline'}
onClick={() => toggleEditorPanel()}
/>
</div>
</Show>
<Show when={visibleWarnings()}>
<div class={clsx(styles.userControlItem, 'notifications')}>
<Notifications />
@ -67,7 +109,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
</Show>
<Show
when={isAuthenticated()}
when={isAuthenticated() && page().route !== 'create'}
fallback={
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
<a href="?modal=auth&mode=login">

View File

@ -10,7 +10,7 @@ type ProfilePopupProps = Omit<PopupProps, 'children'>
export const ProfilePopup = (props: ProfilePopupProps) => {
const {
userSlug,
user,
actions: { signOut }
} = useSession()
@ -20,7 +20,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
<Popup {...props} horizontalAnchor="right" variant="bordered">
<ul class="nodash">
<li>
<a href={getPagePath(router, 'author', { slug: userSlug() })}>{t('Profile')}</a>
<a href={getPagePath(router, 'author', { slug: user().slug })}>{t('Profile')}</a>
</li>
<li>
<a href="#">{t('Drafts')}</a>
@ -29,7 +29,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
<a href="#">{t('Subscriptions')}</a>
</li>
<li>
<a href={`${getPagePath(router, 'author', { slug: userSlug() })}/?by=commented`}>
<a href={`${getPagePath(router, 'author', { slug: user().slug })}/?by=commented`}>
{t('Comments')}
</a>
</li>

View File

@ -1,3 +0,0 @@
// aka TopicInput
export default () => <></>

View File

@ -94,7 +94,7 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
const AllAuthorsHead = () => (
<div class="row">
<div class={clsx('col-lg-20 col-xl-18')}>
<div class="col-lg-20 col-xl-18">
<h1>{t('Authors')}</h1>
<p>{t('Subscribe who you like to tune your personal feed')}</p>

View File

@ -96,7 +96,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
const AllTopicsHead = () => (
<div class="row">
<div class={clsx('col-lg-20 col-xl-18')}>
<div class="col-lg-20 col-xl-18">
<h1>{t('Topics')}</h1>
<p>{t('Subscribe what you like to tune your personal feed')}</p>

View File

@ -10,7 +10,7 @@ import { useRouter } from '../../stores/router'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages'
import styles from './Author.module.scss'
import stylesArticle from '../../styles/Article.module.scss'
import stylesArticle from '../Article/Article.module.scss'
import { clsx } from 'clsx'
import Userpic from '../Author/Userpic'
import { Popup } from '../_shared/Popup'

View File

@ -0,0 +1,57 @@
:global(.main-content) {
position: static;
}
.articlePreview {
border: 2px solid #e8e8e8;
min-height: 10em;
padding: 1rem 1.2rem;
}
.formHolder {
padding: 0 4rem;
}
.saveBlock {
background: #f1f1f1;
line-height: 1.4;
margin-top: 6.4rem;
padding: 1.6rem 3.2rem;
text-align: center;
@include media-breakpoint-up(md) {
padding: 3.2rem 8rem;
}
.button {
margin: 0 divide($container-padding-x, 2);
}
}
.container {
.titleInput,
.subtitleInput {
border: 0;
outline: 0;
padding: 0;
font-size: 36px;
&::placeholder {
opacity: 0.3;
color: #000;
}
}
.titleInput {
font-weight: 700;
}
}
.createSettings,
.create {
display: none;
&.visible {
display: block;
}
}

View File

@ -1,20 +1,224 @@
import { lazy, Suspense } from 'solid-js'
import { Loading } from '../_shared/Loading'
import { createSignal, onMount, Show } from 'solid-js'
import { useLocalize } from '../../context/localize'
import { clsx } from 'clsx'
import styles from './Create.module.scss'
import { Title } from '@solidjs/meta'
import { createStore } from 'solid-js/store'
import type { Topic } from '../../graphql/types.gen'
import { apiClient } from '../../utils/apiClient'
import { TopicSelect } from '../Editor/TopicSelect/TopicSelect'
import { router, useRouter } from '../../stores/router'
import { getPagePath } from '@nanostores/router'
import { translit } from '../../utils/ru2en'
import { Editor } from '../Editor/Editor'
import { Panel } from '../Editor/Panel'
const Editor = lazy(() => import('../EditorNew/Editor'))
type ShoutForm = {
slug: string
title: string
subtitle: string
selectedTopics: Topic[]
mainTopic: Topic
body: string
coverImageUrl: string
}
export const CreateView = () => {
const { t } = useLocalize()
const newArticleIpsum = `<h1>${t('Header')}</h1>
<h2>${t('Subheader')}</h2>
<p>${t('A short introduction to keep the reader interested')}</p>`
const [topics, setTopics] = createSignal<Topic[]>(null)
const { page } = useRouter()
const [isSlugChanged, setIsSlugChanged] = createSignal(false)
const [form, setForm] = createStore<ShoutForm>({
slug: '',
title: '',
subtitle: '',
selectedTopics: [],
mainTopic: null,
body: '',
coverImageUrl: ''
})
onMount(async () => {
const allTopics = await apiClient.getAllTopics()
setTopics(allTopics)
})
const handleFormSubmit = async (e) => {
e.preventDefault()
const newShout = await apiClient.createArticle({
article: {
slug: form.slug,
title: form.title,
subtitle: form.subtitle,
body: form.body,
topics: form.selectedTopics.map((topic) => topic.slug),
mainTopic: form.selectedTopics[0].slug
}
})
router.open(getPagePath(router, 'article', { slug: newShout.slug }))
}
const handleTitleInputChange = (e) => {
const title = e.currentTarget.value
setForm('title', title)
if (!isSlugChanged()) {
const slug = translit(title).replaceAll(' ', '-')
setForm('slug', slug)
}
}
const handleSlugInputChange = (e) => {
const slug = e.currentTarget.value
if (slug !== form.slug) {
setIsSlugChanged(true)
}
setForm('slug', slug)
}
return (
<Suspense fallback={<Loading />}>
<Editor initialContent={newArticleIpsum} />
</Suspense>
<>
<div class={styles.container}>
<Title>{t('Write an article')}</Title>
<form onSubmit={handleFormSubmit}>
<div class="wide-container">
<div class="row">
<div class="col-md-19 col-lg-18 col-xl-16 offset-md-5">
<div
class={clsx(styles.create, {
[styles.visible]: page().route === 'create'
})}
>
<input
class={styles.titleInput}
type="text"
name="title"
id="title"
placeholder="Заголовок"
value={form.title}
onChange={handleTitleInputChange}
/>
<input
class={styles.subtitleInput}
type="text"
name="subtitle"
id="subtitle"
placeholder="Подзаголовок"
value={form.subtitle}
onChange={(e) => setForm('subtitle', e.currentTarget.value)}
/>
<Editor shoutId={42} onChange={(body) => setForm('body', body)} />
<div class={styles.saveBlock}>
{/*<button class={clsx('button button--outline', styles.button)}>Сохранить</button>*/}
<a href={getPagePath(router, 'createSettings')}>Настройки</a>
</div>
</div>
<div
class={clsx(styles.createSettings, {
[styles.visible]: page().route === 'createSettings'
})}
>
<h1>Настройки публикации</h1>
<h4>Slug</h4>
<div class="pretty-form__item">
<input
type="text"
name="slug"
id="slug"
value={form.slug}
onChange={handleSlugInputChange}
/>
<label for="slug">Slug</label>
</div>
{/*<h4>Лид</h4>*/}
{/*<div class="pretty-form__item">*/}
{/* <textarea name="lead" id="lead" placeholder="Лид"></textarea>*/}
{/* <label for="lead">Лид</label>*/}
{/*</div>*/}
{/*<h4>Выбор сообщества</h4>*/}
{/*<p class="description">Сообщества можно перечислить через запятую</p>*/}
{/*<div class="pretty-form__item">*/}
{/* <input*/}
{/* type="text"*/}
{/* name="community"*/}
{/* id="community"*/}
{/* placeholder="Сообщества"*/}
{/* class="nolabel"*/}
{/* />*/}
{/*</div>*/}
<h4>Темы</h4>
{/*<p class="description">*/}
{/* Добавьте несколько тем, чтобы читатель знал, о&nbsp;чем ваш материал, и&nbsp;мог найти*/}
{/* его на&nbsp;страницах интересных ему тем. Темы можно менять местами, первая тема*/}
{/* становится заглавной*/}
{/*</p>*/}
<div class="pretty-form__item">
<Show when={topics()}>
<TopicSelect
topics={topics()}
onChange={(newSelectedTopics) => setForm('selectedTopics', newSelectedTopics)}
selectedTopics={form.selectedTopics}
/>
</Show>
{/*<input type="text" name="topics" id="topics" placeholder="Темы" class="nolabel" />*/}
</div>
{/*<h4>Соавторы</h4>*/}
{/*<p class="description">У каждого соавтора можно добавить роль</p>*/}
{/*<div class="pretty-form__item--with-button">*/}
{/* <div class="pretty-form__item">*/}
{/* <input type="text" name="authors" id="authors" placeholder="Введите имя или e-mail" />*/}
{/* <label for="authors">Введите имя или e-mail</label>*/}
{/* </div>*/}
{/* <button class="button button--submit">Добавить</button>*/}
{/*</div>*/}
{/*<div class="row">*/}
{/* <div class="col-md-6">Михаил Драбкин</div>*/}
{/* <div class="col-md-6">*/}
{/* <input type="text" name="coauthor" id="coauthor1" class="nolabel" />*/}
{/* </div>*/}
{/*</div>*/}
<h4>Карточка материала на&nbsp;главной</h4>
<p class="description">
Выберите заглавное изображение для статьи, тут сразу можно увидеть как карточка будет
выглядеть на&nbsp;главной странице
</p>
<div class={styles.articlePreview} />
<div class={styles.saveBlock}>
<p>
Проверьте ещё раз введённые данные, если всё верно, вы&nbsp;можете сохранить или
опубликовать ваш текст
</p>
{/*<button class={clsx('button button--outline', styles.button)}>Сохранить</button>*/}
<a href={getPagePath(router, 'create')}>Назад</a>
<button type="submit" class={clsx('button button--submit', styles.button)}>
Опубликовать
</button>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<Panel />
</>
)
}

View File

@ -29,7 +29,7 @@ export const FeedView = () => {
const { sortedAuthors } = useAuthorsStore()
const { topTopics } = useTopicsStore()
const { topAuthors } = useTopAuthorsStore()
const { session } = useSession()
const { session, user } = useSession()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [topComments, setTopComments] = createSignal<Reaction[]>([])
@ -48,11 +48,10 @@ export const FeedView = () => {
// }
// })
const userSlug = createMemo(() => session()?.user?.slug)
createEffect(async () => {
if (userSlug()) {
if (user()) {
// load recent editing shouts ( visibility = authors )
await loadShouts({ filters: { author: userSlug(), visibility: 'authors' }, limit: 15 })
await loadShouts({ filters: { author: user().slug, visibility: 'authors' }, limit: 15 })
}
})

View File

@ -37,13 +37,41 @@
line-height: 21px;
color: #696969;
&.hover,
&.active {
&:hover,
&:active {
text-decoration: underline;
color: #141414;
}
}
&.outline {
border: 3px solid #f2f2f2;
border-radius: 1.2em;
cursor: pointer;
font-weight: bold;
margin-right: 0.8em;
min-width: auto !important;
padding: 0;
transition: border-color 0.3s, background-color 0.3s, color 0.3s;
&:hover,
&:active {
background: #000;
border-color: #000;
color: #fff;
:global(.icon) {
filter: invert(1);
}
}
:global(.icon) {
margin: 0 -0.5em;
filter: invert(0);
transition: filter 0.3s;
}
}
&:disabled,
&:disabled:hover {
cursor: default;

View File

@ -5,11 +5,12 @@ import styles from './Button.module.scss'
type Props = {
value: string | JSX.Element
size?: 'S' | 'M' | 'L'
variant?: 'primary' | 'secondary' | 'inline'
variant?: 'primary' | 'secondary' | 'inline' | 'outline'
type?: 'submit' | 'button'
loading?: boolean
disabled?: boolean
onClick?: () => void
class?: string
}
export const Button = (props: Props) => {
@ -18,9 +19,15 @@ export const Button = (props: Props) => {
onClick={props.onClick}
type={props.type ?? 'button'}
disabled={props.loading || props.disabled}
class={clsx(styles.button, styles[props.size ?? 'M'], styles[props.variant ?? 'primary'], {
[styles.loading]: props.loading
})}
class={clsx(
styles.button,
styles[props.size ?? 'M'],
styles[props.variant ?? 'primary'],
{
[styles.loading]: props.loading
},
props.class
)}
>
{props.value}
</button>

41
src/context/editor.tsx Normal file
View File

@ -0,0 +1,41 @@
import type { JSX } from 'solid-js'
import { Accessor, createContext, createSignal, useContext } from 'solid-js'
type WordCounter = {
characters: number
words: number
paragraphs?: number
}
type EditorContextType = {
isEditorPanelVisible: Accessor<boolean>
wordCounter: Accessor<WordCounter>
actions: {
toggleEditorPanel: () => void
countWords: (value: WordCounter) => void
}
}
const EditorContext = createContext<EditorContextType>()
export function useEditorContext() {
return useContext(EditorContext)
}
export const EditorProvider = (props: { children: JSX.Element }) => {
const [isEditorPanelVisible, setEditorPanelVisible] = createSignal<boolean>(false)
const [wordCounter, setWordCounter] = createSignal<WordCounter>({
characters: 0,
words: 0
})
const toggleEditorPanel = () => setEditorPanelVisible(!isEditorPanelVisible())
const countWords = (value) => setWordCounter(value)
const actions = {
toggleEditorPanel,
countWords
}
const value: EditorContextType = { actions, isEditorPanelVisible, wordCounter }
return <EditorContext.Provider value={value}>{props.children}</EditorContext.Provider>
}

View File

@ -69,10 +69,9 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
const deleteReaction = async (id: number): Promise<void> => {
const reaction = await apiClient.destroyReaction(id)
setReactionEntities((oldState) => ({
...oldState,
setReactionEntities({
[reaction.id]: undefined
}))
})
}
const updateReaction = async (id: number, input: ReactionInput): Promise<void> => {

View File

@ -1,6 +1,6 @@
import type { Accessor, JSX, Resource } from 'solid-js'
import { createContext, createMemo, createResource, createSignal, onMount, useContext } from 'solid-js'
import type { AuthResult } from '../graphql/types.gen'
import type { AuthResult, User } from '../graphql/types.gen'
import { apiClient } from '../utils/apiClient'
import { resetToken, setToken } from '../graphql/privateGraphQLClient'
import { useSnackbar } from './snackbar'
@ -9,7 +9,7 @@ import { useLocalize } from './localize'
type SessionContextType = {
session: Resource<AuthResult>
isSessionLoaded: Accessor<boolean>
userSlug: Accessor<string>
user: Accessor<User>
isAuthenticated: Accessor<boolean>
actions: {
loadSession: () => AuthResult | Promise<AuthResult>
@ -55,6 +55,7 @@ export const SessionProvider = (props: { children: JSX.Element }) => {
})
const userSlug = createMemo(() => session()?.user?.slug)
const user = createMemo(() => session()?.user)
const isAuthenticated = createMemo(() => Boolean(session()?.user?.slug))
@ -85,7 +86,7 @@ export const SessionProvider = (props: { children: JSX.Element }) => {
confirmEmail
}
const value: SessionContextType = { session, isSessionLoaded, userSlug, isAuthenticated, actions }
const value: SessionContextType = { session, isSessionLoaded, user, isAuthenticated, actions }
onMount(() => {
loadSession()

View File

@ -5,13 +5,13 @@ export default gql`
createShout(inp: $shout) {
error
shout {
_id: slug
id
slug
title
subtitle
body
topics {
# id
id
title
slug
}

View File

@ -1,15 +1,22 @@
import { lazy, Suspense } from 'solid-js'
import { lazy, Show, Suspense } from 'solid-js'
import { PageLayout } from '../components/_shared/PageLayout'
import { Loading } from '../components/_shared/Loading'
import { useSession } from '../context/session'
const CreateView = lazy(() => import('../components/Views/Create'))
export const CreatePage = () => {
const { isAuthenticated, isSessionLoaded } = useSession()
return (
<PageLayout>
<Suspense fallback={<Loading />}>
<CreateView />
</Suspense>
<Show when={isSessionLoaded()}>
<Show when={isAuthenticated()} fallback="Давайте авторизуемся">
<Suspense fallback={<Loading />}>
<CreateView />
</Suspense>
</Show>
</Show>
</PageLayout>
)
}

View File

@ -1,7 +0,0 @@
import { PageLayout } from '../components/_shared/PageLayout'
export const CreateSettingsPage = () => {
return <PageLayout>Настройки публикации</PageLayout>
}
export const Page = CreateSettingsPage

View File

@ -1,5 +1,5 @@
import { generateHydrationScript, renderToString } from 'solid-js/web'
import { escapeInject, dangerouslySkipEscape } from 'vite-plugin-ssr'
import { escapeInject, dangerouslySkipEscape } from 'vite-plugin-ssr/server'
import { App } from '../components/App'
import { initRouter } from '../stores/router'
import type { PageContext } from './types'

View File

@ -64,7 +64,7 @@ const scrollToHash = (hash: string) => {
}
const anchor = document.querySelector(selector)
const headerOffset = 80 // 100px for header
const headerOffset = 80 // 80px for header
const elementPosition = anchor ? anchor.getBoundingClientRect().top : 0
const newScrollTop = elementPosition + window.scrollY - headerOffset

View File

@ -157,6 +157,7 @@ a:visited,
a:link {
border-bottom: 1px solid rgb(0 0 0 / 30%);
text-decoration: none;
cursor: pointer;
}
a {
@ -320,12 +321,22 @@ button {
}
}
.button--submit {
.button--submit,
.button--outline {
@include font-size(2rem);
padding: 1.6rem 2rem;
}
.button--outline {
background: none;
box-shadow: inset 0 0 0 2px #000;
color: #000;
&:hover {
box-shadow: inset 0 0 0 2px #ccc;
}
}
form {
.pretty-form__item {
position: relative;
@ -339,6 +350,30 @@ form {
}
}
.pretty-form__item--with-button {
margin-bottom: 1.6rem;
@include media-breakpoint-up(sm) {
display: flex;
}
input {
flex: 1;
@include media-breakpoint-up(sm) {
margin-bottom: 0 !important;
}
}
*:first-child {
flex: 1;
@include media-breakpoint-up(sm) {
margin-right: 1em;
}
}
}
input[type='text'],
input[type='email'],
input[type='password'],

View File

@ -14,7 +14,8 @@ import type {
ProfileInput,
ReactionInput,
Chat,
ReactionBy
ReactionBy,
Shout
} from '../graphql/types.gen'
import { publicGraphQLClient } from '../graphql/publicGraphQLClient'
import { getToken, privateGraphQLClient } from '../graphql/privateGraphQLClient'
@ -239,10 +240,10 @@ export const apiClient = {
const response = await publicGraphQLClient.query(topicBySlug, { slug }).toPromise()
return response.data.getTopic
},
createArticle: async ({ article }: { article: ShoutInput }) => {
createArticle: async ({ article }: { article: ShoutInput }): Promise<Shout> => {
const response = await privateGraphQLClient.mutation(createArticle, { shout: article }).toPromise()
console.debug('[createArticle]:', response.data)
return response.data.createShout
return response.data.createShout.shout
},
createReaction: async (input: ReactionInput) => {
const response = await privateGraphQLClient.mutation(reactionCreate, { reaction: input }).toPromise()

View File

@ -1,8 +1,9 @@
import { createMemo } from 'solid-js'
import { Accessor, createMemo } from 'solid-js'
import { useLocalize } from '../context/localize'
// unix timestamp in seconds
const formattedTime = (time: number) => {
const formattedTime = (time: number): Accessor<string> => {
// FIXME: maybe it's better to move it from here
const { lang } = useLocalize()
return createMemo<string>(() => {

View File

@ -1,7 +1,6 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"importsNotUsedAsValues": "error",
// Solid specific settings
"jsx": "preserve",
"jsxImportSource": "solid-js",