Merge branch 'dev' into prepare-inbox

This commit is contained in:
Igor Lobanov 2022-12-08 11:15:48 +01:00
commit eeaa44bfc9
124 changed files with 2298 additions and 867 deletions

View File

@ -50,16 +50,8 @@ module.exports = {
}, },
globals: {}, globals: {},
rules: { rules: {
// FIXME // Solid
'unicorn/prefer-dom-node-append': 'off', 'solid/reactivity': 'off', // FIXME
// TEMP
// FIXME
'solid/reactivity': 'off',
// Should be enabled
// 'promise/catch-or-return': 'off',
'solid/no-innerhtml': 'off', 'solid/no-innerhtml': 'off',
/** Unicorn **/ /** Unicorn **/
@ -73,8 +65,13 @@ module.exports = {
'unicorn/import-style': 'off', 'unicorn/import-style': 'off',
'unicorn/numeric-separators-style': 'off', 'unicorn/numeric-separators-style': 'off',
'unicorn/prefer-node-protocol': 'off', 'unicorn/prefer-node-protocol': 'off',
'unicorn/prefer-dom-node-append': 'off', // FIXME
'unicorn/prefer-top-level-await': 'warn',
'unicorn/consistent-function-scoping': 'warn', 'unicorn/consistent-function-scoping': 'warn',
'sonarjs/no-duplicate-string': 'warn',
// Promise
// 'promise/catch-or-return': 'off', // Should be enabled
'promise/always-return': 'off', 'promise/always-return': 'off',
eqeqeq: 'error', eqeqeq: 'error',

View File

@ -1,3 +1,9 @@
[0.7.0]
[+] inbox: context provider, chats
[+] comments: show
[+] session: context provider
[+] views tracker: counting for shouts
[0.6.1] [0.6.1]
[+] auth ver. 0.9 [+] auth ver. 0.9
[+] load-by interfaces for shouts, authors and messages [+] load-by interfaces for shouts, authors and messages

View File

@ -1,4 +1,8 @@
# How to start
If you use yarn
``` ```
yarn install yarn
npm start PUBLIC_API_URL=https://v2.discours.io yarn dev
``` ```

View File

@ -1,6 +1,6 @@
{ {
"name": "discoursio-webapp", "name": "discoursio-webapp",
"version": "0.6.1", "version": "0.7.0",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@ -26,6 +26,7 @@
"server": "node server/server.mjs", "server": "node server/server.mjs",
"start": "astro dev", "start": "astro dev",
"start:local": "cross-env PUBLIC_API_URL=http://127.0.0.1:8080 astro dev", "start:local": "cross-env PUBLIC_API_URL=http://127.0.0.1:8080 astro dev",
"start:production": "cross-env PUBLIC_API_URL=https://v2.discours.io astro dev",
"start:staging": "cross-env PUBLIC_API_URL=https://testapi.discours.io astro dev", "start:staging": "cross-env PUBLIC_API_URL=https://testapi.discours.io astro dev",
"typecheck": "astro check && tsc --noEmit", "typecheck": "astro check && tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch", "typecheck:watch": "tsc --noEmit --watch",
@ -34,6 +35,8 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.216.0", "@aws-sdk/client-s3": "^3.216.0",
"@aws-sdk/s3-presigned-post": "^3.216.0", "@aws-sdk/s3-presigned-post": "^3.216.0",
"@aws-sdk/signature-v4-multi-region": "^3.215.0",
"@aws-sdk/util-user-agent-node": "^3.215.0",
"mailgun.js": "^8.0.2" "mailgun.js": "^8.0.2"
}, },
"devDependencies": { "devDependencies": {
@ -54,6 +57,7 @@
"@solid-devtools/logger": "^0.5.0", "@solid-devtools/logger": "^0.5.0",
"@solid-primitives/memo": "^1.1.2", "@solid-primitives/memo": "^1.1.2",
"@solid-primitives/storage": "^1.3.3", "@solid-primitives/storage": "^1.3.3",
"@solid-primitives/upload": "^0.0.105",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",

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

@ -0,0 +1,3 @@
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.9159 0C13.0948 1.21486 12.6003 2.40503 11.948 3.24708C11.2501 4.15029 10.0472 4.84886 8.88168 4.8124C8.66892 3.64929 9.21368 2.45089 9.87651 1.6453C10.6036 0.756201 11.8498 0.0740913 12.9159 0ZM16.4177 17.0985C17.0185 16.1776 17.243 15.7131 17.7094 14.6735C14.3169 13.3833 13.7733 8.56035 17.1308 6.70925C16.1067 5.425 14.6676 4.68056 13.3093 4.68056C12.3305 4.68056 11.6599 4.93598 11.0502 5.16818C10.5421 5.36169 10.0764 5.53907 9.50996 5.53907C8.89784 5.53907 8.35578 5.34472 7.78819 5.14121C7.1645 4.91758 6.50998 4.68291 5.69781 4.68291C4.17342 4.68291 2.55083 5.61434 1.5221 7.20672C0.0760363 9.44945 0.322698 13.6656 2.66774 17.2573C3.50592 18.5427 4.62583 19.9869 6.0906 19.9998C6.69839 20.0058 7.10283 19.8244 7.5405 19.6281C8.04146 19.4035 8.58593 19.1592 9.52867 19.1542C10.477 19.1485 11.0128 19.3957 11.5071 19.6237C11.9336 19.8204 12.3291 20.0028 12.9317 19.9963C14.3976 19.9845 15.5795 18.3839 16.4177 17.0985Z" fill="#0B0B0A"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M20.5,15.1a1,1,0,0,0-1.34.45A8,8,0,1,1,12,4a7.93,7.93,0,0,1,7.16,4.45,1,1,0,0,0,1.8-.9,10,10,0,1,0,0,8.9A1,1,0,0,0,20.5,15.1ZM21,11H11.41l2.3-2.29a1,1,0,1,0-1.42-1.42l-4,4a1,1,0,0,0-.21.33,1,1,0,0,0,0,.76,1,1,0,0,0,.21.33l4,4a1,1,0,0,0,1.42,0,1,1,0,0,0,0-1.42L11.41,13H21a1,1,0,0,0,0-2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12.59,13l-2.3,2.29a1,1,0,0,0,0,1.42,1,1,0,0,0,1.42,0l4-4a1,1,0,0,0,.21-.33,1,1,0,0,0,0-.76,1,1,0,0,0-.21-.33l-4-4a1,1,0,1,0-1.42,1.42L12.59,11H3a1,1,0,0,0,0,2ZM12,2A10,10,0,0,0,3,7.55a1,1,0,0,0,1.8.9A8,8,0,1,1,12,20a7.93,7.93,0,0,1-7.16-4.45,1,1,0,0,0-1.8.9A10,10,0,1,0,12,2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 357 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="256" height="256">
<path d="M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,18a8,8,0,1,1,8-8A8,8,0,0,1,12,20Zm4-9H13V8a1,1,0,0,0-2,0v3H8a1,1,0,0,0,0,2h3v3a1,1,0,0,0,2,0V13h3a1,1,0,0,0,0-2Z" fill="#000000" class="color000 svgShape">
</path>
</svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@ -1,3 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L6 6M6 6L1 11M6 6L11 1M6 6L11 11" stroke="#696969" stroke-width="2"/> <path d="M1 1L6 6M6 6L1 11M6 6L11 1M6 6L11 11" stroke="#000" stroke-width="2"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 186 B

After

Width:  |  Height:  |  Size: 183 B

View File

@ -1,4 +1 @@
<svg width="18" height="18" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19,2H6.27A3,3,0,0,0,3.32,4.46l-1.27,7A3,3,0,0,0,5,15H9.56L9,16.43A4.13,4.13,0,0,0,12.89,22a1,1,0,0,0,.91-.59L16.65,15H19a3,3,0,0,0,3-3V5A3,3,0,0,0,19,2ZM15,13.79l-2.72,6.12a2.13,2.13,0,0,1-1.38-2.78l.53-1.43A2,2,0,0,0,9.56,13H5a1,1,0,0,1-.77-.36A1,1,0,0,1,4,11.82l1.27-7a1,1,0,0,1,1-.82H15ZM20,12a1,1,0,0,1-1,1H17V4h2a1,1,0,0,1,1,1Z"/></svg>
<path transform="rotate(180 9 9)" d="M0 8H3V18H0V8Z" fill="#141414"/>
<path transform="rotate(180 9 9)" d="M4 18V8H5.50049C5.81525 8 6.11164 7.85181 6.30049 7.6L8.87932 4.16155C8.95929 4.05493 9.01714 3.93339 9.04947 3.80409L9.93965 0.243377C9.9754 0.100343 10.1039 0 10.2514 0C11.4935 0 12.5005 1.00697 12.5005 2.24913V4.5L12.1636 6.85858C12.0775 7.46101 12.545 8 13.1535 8H19.0005C19.5528 8 20.0005 8.44771 20.0005 9V9.3702C20.0005 9.93081 19.7652 10.4657 19.3519 10.8445L19.2854 10.9055C18.8721 11.2843 18.6369 11.8192 18.6369 12.3798V13.1202C18.6369 13.6808 18.4016 14.2157 17.9883 14.5945L17.651 14.9037C17.4031 15.1309 17.2166 15.4169 17.1085 15.7353L16.256 18.2473C16.1064 18.6879 15.6726 18.9672 15.2095 18.9209L6.00049 18H4Z" fill="#141414"/>
</svg>

Before

Width:  |  Height:  |  Size: 855 B

After

Width:  |  Height:  |  Size: 411 B

1
public/icons/edit-2.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5,18H9.24a1,1,0,0,0,.71-.29l6.92-6.93h0L19.71,8a1,1,0,0,0,0-1.42L15.47,2.29a1,1,0,0,0-1.42,0L11.23,5.12h0L4.29,12.05a1,1,0,0,0-.29.71V17A1,1,0,0,0,5,18ZM14.76,4.41l2.83,2.83L16.17,8.66,13.34,5.83ZM6,13.17l5.93-5.93,2.83,2.83L8.83,16H6ZM21,20H3a1,1,0,0,0,0,2H21a1,1,0,0,0,0-2Z"/></svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@ -1,4 +1,6 @@
<svg width="20" height="13" viewBox="0 0 20 13" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="512px" height="512px" viewBox="0 0 512 512">
<path d="M13 6C13 7.65685 11.6569 9 10 9C8.34315 9 7 7.65685 7 6C7 4.34315 8.34315 3 10 3C11.6569 3 13 4.34315 13 6Z" fill="#141414"/> <path
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.7784 6.5C16.6611 8.44191 13.9671 11 10 11C6.03294 11 3.33893 8.44191 2.22163 6.5C3.33893 4.55809 6.03294 2 10 2C13.9671 2 16.6611 4.55809 17.7784 6.5ZM10 13C15.5228 13 19 9 20 6.5C19 4 15.5228 0 10 0C4.47715 0 1 4 0 6.5C1 9 4.47715 13 10 13Z" fill="#141414"/> d="M255.66,112c-77.94,0-157.89,45.11-220.83,135.33a16,16,0,0,0-.27,17.77C82.92,340.8,161.8,400,255.66,400,348.5,400,429,340.62,477.45,264.75a16.14,16.14,0,0,0,0-17.47C428.89,172.28,347.8,112,255.66,112Z"
style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:32px" />
<circle cx="256" cy="256" r="80" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:32px" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 551 B

After

Width:  |  Height:  |  Size: 532 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M22,9.67A1,1,0,0,0,21.14,9l-5.69-.83L12.9,3a1,1,0,0,0-1.8,0L8.55,8.16,2.86,9a1,1,0,0,0-.81.68,1,1,0,0,0,.25,1l4.13,4-1,5.68A1,1,0,0,0,6.9,21.44L12,18.77l5.1,2.67a.93.93,0,0,0,.46.12,1,1,0,0,0,.59-.19,1,1,0,0,0,.4-1l-1-5.68,4.13-4A1,1,0,0,0,22,9.67Zm-6.15,4a1,1,0,0,0-.29.88l.72,4.2-3.76-2a1.06,1.06,0,0,0-.94,0l-3.76,2,.72-4.2a1,1,0,0,0-.29-.88l-3-3,4.21-.61a1,1,0,0,0,.76-.55L12,5.7l1.88,3.82a1,1,0,0,0,.76.55l4.21.61Z"/></svg>

After

Width:  |  Height:  |  Size: 497 B

1
public/icons/feather.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32px" height="32px" viewBox="0 0 32 32"><path d="M 27 4 C 18.197 4 13.798547 8.7946094 11.685547 11.099609 L 8.6367188 14.175781 C 6.9357187 15.874781 6 18.134063 6 20.539062 L 6 22 L 8.0273438 19.972656 C 8.1593437 18.316656 8.862875 16.775797 10.046875 15.591797 L 13.160156 12.451172 C 14.996156 10.449172 18.728609 6.3784375 25.974609 6.0234375 C 25.75316 10.544739 24.085863 13.696801 22.376953 15.875 L 19 17 L 21.417969 17 C 20.723657 17.756409 20.066554 18.365046 19.548828 18.839844 L 18.568359 19.810547 L 15 21 L 17.367188 21 L 16.410156 21.947266 C 15.088156 23.269266 13.330937 23.998047 11.460938 23.998047 L 9.4160156 23.998047 L 18.707031 14.707031 L 17.292969 13.292969 L 4 26.585938 L 5.4140625 28 L 7.4160156 25.998047 L 11.460938 25.998047 C 13.864937 25.998047 16.125125 25.061422 17.828125 23.357422 L 20.898438 20.3125 C 23.203437 18.2015 28 13.804 28 5 L 28 4 L 27 4 z"/></svg>

After

Width:  |  Height:  |  Size: 949 B

1
public/icons/filters.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19,2H5A3,3,0,0,0,2,5V6.17a3,3,0,0,0,.25,1.2l0,.06a2.81,2.81,0,0,0,.59.86L9,14.41V21a1,1,0,0,0,.47.85A1,1,0,0,0,10,22a1,1,0,0,0,.45-.11l4-2A1,1,0,0,0,15,19V14.41l6.12-6.12a2.81,2.81,0,0,0,.59-.86l0-.06A3,3,0,0,0,22,6.17V5A3,3,0,0,0,19,2ZM13.29,13.29A1,1,0,0,0,13,14v4.38l-2,1V14a1,1,0,0,0-.29-.71L5.41,8H18.59ZM20,6H4V5A1,1,0,0,1,5,4H19a1,1,0,0,1,1,1Z"/></svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.41,8.64s0,0,0-.05a10,10,0,0,0-18.78,0s0,0,0,.05a9.86,9.86,0,0,0,0,6.72s0,0,0,.05a10,10,0,0,0,18.78,0s0,0,0-.05a9.86,9.86,0,0,0,0-6.72ZM4.26,14a7.82,7.82,0,0,1,0-4H6.12a16.73,16.73,0,0,0,0,4Zm.82,2h1.4a12.15,12.15,0,0,0,1,2.57A8,8,0,0,1,5.08,16Zm1.4-8H5.08A8,8,0,0,1,7.45,5.43,12.15,12.15,0,0,0,6.48,8ZM11,19.7A6.34,6.34,0,0,1,8.57,16H11ZM11,14H8.14a14.36,14.36,0,0,1,0-4H11Zm0-6H8.57A6.34,6.34,0,0,1,11,4.3Zm7.92,0h-1.4a12.15,12.15,0,0,0-1-2.57A8,8,0,0,1,18.92,8ZM13,4.3A6.34,6.34,0,0,1,15.43,8H13Zm0,15.4V16h2.43A6.34,6.34,0,0,1,13,19.7ZM15.86,14H13V10h2.86a14.36,14.36,0,0,1,0,4Zm.69,4.57a12.15,12.15,0,0,0,1-2.57h1.4A8,8,0,0,1,16.55,18.57ZM19.74,14H17.88A16.16,16.16,0,0,0,18,12a16.28,16.28,0,0,0-.12-2h1.86a7.82,7.82,0,0,1,0,4Z"/></svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@ -1,4 +1 @@
<svg width="20" height="19" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.3,10.08A3,3,0,0,0,19,9H14.44L15,7.57A4.13,4.13,0,0,0,11.11,2a1,1,0,0,0-.91.59L7.35,9H5a3,3,0,0,0-3,3v7a3,3,0,0,0,3,3H17.73a3,3,0,0,0,2.95-2.46l1.27-7A3,3,0,0,0,21.3,10.08ZM7,20H5a1,1,0,0,1-1-1V12a1,1,0,0,1,1-1H7Zm13-7.82-1.27,7a1,1,0,0,1-1,.82H9V10.21l2.72-6.12A2.11,2.11,0,0,1,13.1,6.87L12.57,8.3A2,2,0,0,0,14.44,11H19a1,1,0,0,1,.77.36A1,1,0,0,1,20,12.18Z"/></svg>
<path d="M0 8H3V18H0V8Z" fill="#141414"/>
<path d="M4 18V8H5.50049C5.81525 8 6.11164 7.85181 6.30049 7.6L8.87932 4.16155C8.95929 4.05493 9.01714 3.93339 9.04947 3.80409L9.93965 0.243377C9.9754 0.100343 10.1039 0 10.2514 0C11.4935 0 12.5005 1.00697 12.5005 2.24913V4.5L12.1636 6.85858C12.0775 7.46101 12.545 8 13.1535 8H19.0005C19.5528 8 20.0005 8.44771 20.0005 9V9.3702C20.0005 9.93081 19.7652 10.4657 19.3519 10.8445L19.2854 10.9055C18.8721 11.2843 18.6369 11.8192 18.6369 12.3798V13.1202C18.6369 13.6808 18.4016 14.2157 17.9883 14.5945L17.651 14.9037C17.4031 15.1309 17.2166 15.4169 17.1085 15.7353L16.256 18.2473C16.1064 18.6879 15.6726 18.9672 15.2095 18.9209L6.00049 18H4Z" fill="#141414"/>
</svg>

Before

Width:  |  Height:  |  Size: 799 B

After

Width:  |  Height:  |  Size: 438 B

1
public/icons/link.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8,12a1,1,0,0,0,1,1h6a1,1,0,0,0,0-2H9A1,1,0,0,0,8,12Zm2,3H7A3,3,0,0,1,7,9h3a1,1,0,0,0,0-2H7A5,5,0,0,0,7,17h3a1,1,0,0,0,0-2Zm7-8H14a1,1,0,0,0,0,2h3a3,3,0,0,1,0,6H14a1,1,0,0,0,0,2h3A5,5,0,0,0,17,7Z"/></svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="18" viewBox="0 0 24 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.65441 0.231661L9.68495 5.65813C9.7181 5.6779 9.75068 5.69975 9.78214 5.72348C9.82187 5.75305 9.85898 5.78488 9.89344 5.81859L22.6933 15.6986C23.1773 16.0711 23.266 16.7642 22.8933 17.2482C22.6752 17.5322 22.3479 17.6776 22.0184 17.6776C21.7821 17.6776 21.5434 17.6027 21.3436 17.4482L18.8218 15.502C16.7955 16.7934 14.453 17.4731 12 17.4731C6.69216 17.4731 2.01349 14.2511 0.0752888 9.26566C-0.0247164 9.01122 -0.0247164 8.72721 0.0730288 8.47259C0.783219 6.61144 1.93866 4.91947 3.39091 3.59094L1.30455 1.9807C0.820536 1.60798 0.732016 0.914929 1.10473 0.431054C1.47726 -0.0552304 2.17484 -0.141678 2.65437 0.231044L2.65441 0.231661ZM2.29996 8.86387C3.9927 12.7677 7.75343 15.2647 12.0001 15.2647C13.7543 15.2647 15.4355 14.8474 16.9372 14.0475L14.9695 12.529C14.1267 13.1978 13.0877 13.5675 12 13.5675C9.3778 13.5675 7.24641 11.4589 7.24641 8.86623C7.24641 8.18391 7.392 7.51721 7.67224 6.8966L5.1736 4.96766C3.94473 6.0071 2.94617 7.35878 2.29982 8.86377L2.29996 8.86387ZM12.0025 0.209C17.3195 0.209 22.0001 3.45157 23.9272 8.47092C24.025 8.72781 24.025 9.01184 23.9227 9.26853C23.5252 10.291 22.9571 11.3067 22.2868 12.1998C22.0708 12.4906 21.7369 12.6427 21.4028 12.6427C21.1711 12.6427 20.9393 12.5701 20.7415 12.4224C20.253 12.0567 20.1532 11.3636 20.5212 10.8773C20.9869 10.2569 21.3892 9.56631 21.7005 8.86171C20.0145 4.93305 16.2538 2.41745 12.0028 2.41745C10.9849 2.41745 10.0032 2.55832 9.08288 2.83329C8.49452 3.01278 7.88317 2.6766 7.7082 2.09258C7.53324 1.50857 7.86509 0.892876 8.44891 0.717908C9.57365 0.381539 10.7713 0.208817 12.0028 0.208817L12.0025 0.209ZM9.45528 8.86628C9.45528 10.241 10.596 11.3589 12.0003 11.3589C12.3922 11.3589 12.7726 11.2687 13.1182 11.0997L9.51502 8.31838C9.47754 8.48806 9.45532 8.67094 9.45532 8.86623L9.45528 8.86628ZM12.4319 4.11948C14.1339 4.31497 15.5948 5.35327 16.3357 6.89612C16.5992 7.44604 16.3674 8.1073 15.8176 8.37078C15.663 8.44593 15.5018 8.48002 15.3404 8.48002C14.9291 8.48002 14.536 8.25044 14.3429 7.85285C13.9339 6.99836 13.125 6.42355 12.182 6.31454C11.5754 6.24636 11.1413 5.69644 11.2094 5.09208C11.2799 4.48526 11.8479 4.05587 12.4319 4.11951L12.4319 4.11948Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,3 @@
<svg width="24" height="18" viewBox="0 0 24 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9998 0C17.3178 0 21.9994 3.24315 23.927 8.26336C24.0247 8.52025 24.0247 8.80427 23.9247 9.06115C21.9884 14.0474 17.3093 17.27 12 17.27C6.6907 17.27 2.01155 14.0474 0.0752887 9.06115C-0.0247163 8.80426 -0.0247163 8.52024 0.0730287 8.26336C2.00028 3.24334 6.68169 0 12.0002 0H11.9998ZM11.9998 2.21123C7.74537 2.21123 3.9819 4.72722 2.29779 8.65886C3.99092 12.561 7.75463 15.0588 11.9998 15.0588C16.2451 15.0588 20.0086 12.561 21.7019 8.65886C20.018 4.72717 16.2547 2.21123 11.9998 2.21123V2.21123ZM11.9998 3.90662C14.6224 3.90662 16.7542 6.03839 16.7542 8.66112C16.7542 11.2564 14.6201 13.3631 11.9998 13.3631C9.37958 13.3631 7.24554 11.2541 7.24554 8.66112C7.24554 6.03839 9.37731 3.90662 11.9998 3.90662V3.90662ZM11.9998 6.11804C10.5977 6.11804 9.4545 7.25878 9.4545 8.66339C9.4545 10.0383 10.5954 11.1564 11.9998 11.1564C13.4043 11.1564 14.5452 10.0383 14.5452 8.66339C14.5452 7.25878 13.402 6.11804 11.9998 6.11804V6.11804Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

6
public/icons/remove.svg Normal file
View File

@ -0,0 +1,6 @@
<svg width="18" height="21" viewBox="0 0 18 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.9082 6.97827L6.35974 6.92188L6.73364 16.535L5.2821 16.5914L4.9082 6.97827Z" fill="black"/>
<path d="M11.0469 16.538L11.4228 6.9248L12.8744 6.98149L12.4984 16.5946L11.0469 16.538Z" fill="black"/>
<path d="M8.16797 6.94727H9.6207V16.5683H8.16797V6.94727Z" fill="black"/>
<path d="M17.7692 3.01065H11.9374V0.484375H5.83184V3.01065H0V4.46332H1.32641L2.12649 20.4844H15.6218L16.4219 4.46332H17.7483L17.7482 3.01065H17.7692ZM7.28454 1.93701H10.5057V3.03178H7.28454V1.93701ZM14.2533 19.0317H3.51595L2.77915 4.46332H14.9902L14.2533 19.0317Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 664 B

View File

@ -1,3 +1,4 @@
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 0C2.69432 0 0 2.6826 0 5.97389C0 9.26518 2.69432 11.9478 6 11.9478V14L7.02604 13.3453C8.5166 12.3934 11.2347 10.3384 11.8659 7.22363C11.9523 6.82188 12 6.40385 12 5.97389C12 2.6826 9.30568 0 6 0Z" fill="#696969"/> <path
d="M6 0C2.69432 0 0 2.6826 0 5.97389C0 9.26518 2.69432 11.9478 6 11.9478V14L7.02604 13.3453C8.5166 12.3934 11.2347 10.3384 11.8659 7.22363C11.9523 6.82188 12 6.40385 12 5.97389C12 2.6826 9.30568 0 6 0Z" fill="#000"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 329 B

After

Width:  |  Height:  |  Size: 328 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18,14a4,4,0,0,0-3.08,1.48l-5.1-2.35a3.64,3.64,0,0,0,0-2.26l5.1-2.35A4,4,0,1,0,14,6a4.17,4.17,0,0,0,.07.71L8.79,9.14a4,4,0,1,0,0,5.72l5.28,2.43A4.17,4.17,0,0,0,14,18a4,4,0,1,0,4-4ZM18,4a2,2,0,1,1-2,2A2,2,0,0,1,18,4ZM6,14a2,2,0,1,1,2-2A2,2,0,0,1,6,14Zm12,6a2,2,0,1,1,2-2A2,2,0,0,1,18,20Z"/></svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16,2A14,14,0,1,0,30,16,14,14,0,0,0,16,2ZM10,26.39a6,6,0,0,1,11.94,0,11.87,11.87,0,0,1-11.94,0Zm13.74-1.26a8,8,0,0,0-15.54,0,12,12,0,1,1,15.54,0ZM16,8a5,5,0,1,0,5,5A5,5,0,0,0,16,8Zm0,8a3,3,0,1,1,3-3A3,3,0,0,1,16,16Z" data-name="13 User, Account, Circle, Person"/></svg>

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,201 @@
.comment {
background-color: #fff;
margin: 0 -2.4rem 1.5em;
padding: 0.8rem 2.4rem;
transition: background-color 0.3s;
&:hover {
background-color: #f6f6f6;
.commentControlReply,
.commentControlShare,
.commentControlDelete,
.commentControlEdit,
.commentControlComplain {
opacity: 1;
}
}
.shout-body {
@include font-size(1.5rem);
margin-bottom: 1em;
*:last-child {
margin-bottom: 0;
}
}
.author {
align-items: center;
margin-bottom: 1.4rem;
}
}
.commentLevel1 {
margin-left: 3.2rem;
}
.commentLevel2 {
margin-left: 6.4rem;
}
.commentLevel3 {
margin-left: 9.6rem;
}
.commentLevel4 {
margin-left: 12.8rem;
}
.commentLevel5 {
margin-left: 16rem;
}
.commentControls {
@include font-size(1.2rem);
margin-bottom: 0.5em;
}
.commentControlReply,
.commentControlShare,
.commentControlDelete,
.commentControlEdit,
.commentControlComplain {
@include media-breakpoint-up(md) {
opacity: 0;
transition: opacity 0.3s;
}
}
.commentControlReply,
.commentControlShare,
.commentControlDelete,
.commentControlEdit {
.icon {
line-height: 1.2;
width: 1.2rem;
}
}
.commentControl {
border: none;
color: #696969;
cursor: pointer;
display: inline-flex;
line-height: 1.2;
margin-right: 0.8rem;
padding: 0.2em 0.3em;
transition: opacity 0.2s, color 0.3s, background-color 0.3s;
vertical-align: top;
&:hover {
background: #000;
color: #fff;
.icon {
filter: invert(1);
opacity: 1;
}
}
.icon {
filter: invert(0);
margin-right: 0.3em;
opacity: 0.6;
transition: filter 0.3s, opacity 0.2s;
img {
margin-bottom: -0.1em;
}
}
}
.commentControlReply {
.icon {
height: 1.2em;
width: 1.2em;
}
}
.commentBody {
@include font-size(1.5rem);
line-height: 1.47;
}
.commentAuthor,
.commentDate,
.commentRating {
@include font-size(1.2rem);
}
.commentDate {
color: rgb(0 0 0 / 30%);
flex: 1;
@include media-breakpoint-down(md) {
margin-left: 1rem;
}
}
.commentDetails {
display: flex;
margin-bottom: 1.2rem;
}
.commentRating {
align-items: center;
display: flex;
font-weight: bold;
}
.commentRatingValue {
padding: 0 0.3em;
}
.commentRatingPositive {
color: #2bb452;
}
.commentRatingNegative {
color: #d00820;
}
.commentRatingControl {
border-left: 6px solid transparent;
border-right: 6px solid transparent;
height: 0;
width: 0;
}
.commentRatingControlUp {
border-bottom: 8px solid rgb(0 0 0 / 40%);
}
.commentRatingControlDown {
border-top: 8px solid rgb(0 0 0 / 40%);
}
.replyForm {
background: #fff;
border: 2px solid rgb(38 56 217 / 50%);
border-radius: 0.8rem;
margin-left: 2.4rem;
position: relative;
textarea {
border: none;
border-radius: 0.8rem;
padding-top: 1.2rem;
}
}
.replyFormControls {
padding: 0.5rem 1.6rem 1.6rem;
text-align: right;
button {
@include font-size(1.6rem);
margin-left: 1.2rem;
}
}

View File

@ -1,119 +0,0 @@
.comment {
background-color: #fff;
margin: 0 -2.4rem 1.5em;
padding: 0.8rem 2.4rem;
transition: background-color 0.3s;
&:hover {
background-color: #f6f6f6;
.comment-control--share,
.comment-control--delete,
.comment-control--edit,
.comment-control--complain {
opacity: 1;
}
}
.shout-body {
@include font-size(1.5rem);
margin-bottom: 1em;
*:last-child {
margin-bottom: 0;
}
}
.circlewrap {
position: absolute;
}
.author {
align-items: center;
margin-bottom: 1.4rem;
}
.author__name {
font-weight: bold;
@include font-size(1.2rem);
margin-bottom: 0;
}
.author__details {
margin-left: 4rem;
}
.shout-date {
@include font-size(1.2rem);
flex: 1;
color: rgb(0 0 0 / 30%);
}
}
.comment--level-1 {
margin-left: 2.4rem;
}
.comment--level-2 {
margin-left: 4.8rem;
}
.comment--level-3 {
margin-left: 7.2rem;
}
.comment--level-4 {
margin-left: 9.6rem;
}
.comment--level-5 {
margin-left: 12rem;
}
.shout-controls {
align-items: baseline;
display: flex;
justify-content: space-between;
padding-top: 0.8rem;
}
.comment-controls {
margin-bottom: 0.5em;
}
.comment-control--share,
.comment-control--delete,
.comment-control--edit,
.comment-control--complain {
opacity: 0;
transition: opacity 0.3s;
}
.comment-control {
background: rgb(0 0 0 / 5%);
border: none;
cursor: pointer;
display: inline-flex;
line-height: 1.2;
margin-right: 0.8rem;
padding: 0.2em 0.3em;
vertical-align: top;
.icon {
margin-right: 0.3em;
img {
margin-bottom: -0.1em;
}
}
}
.comment-control--reply {
.icon {
height: 1.2em;
width: 1.2em;
}
}

View File

@ -1,13 +1,17 @@
import './Comment.scss' import styles from './Comment.module.scss'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { AuthorCard } from '../Author/Card' import { AuthorCard } from '../Author/Card'
import { Show, createMemo } from 'solid-js' import { Show, createMemo, createSignal } from 'solid-js'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import type { Author, Reaction as Point } from '../../graphql/types.gen' import type { Author, Reaction as Point } from '../../graphql/types.gen'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
// import { createReaction, updateReaction, deleteReaction } from '../../stores/zine/reactions' // import { createReaction, updateReaction, deleteReaction } from '../../stores/zine/reactions'
import MD from './MD' import MD from './MD'
import { deleteReaction } from '../../stores/zine/reactions' import { deleteReaction } from '../../stores/zine/reactions'
import { formatDate } from '../../utils'
import { SharePopup } from './SharePopup'
import stylesHeader from '../Nav/Header.module.scss'
import Userpic from '../Author/Userpic'
export default (props: { export default (props: {
level?: number level?: number
@ -15,6 +19,8 @@ export default (props: {
canEdit?: boolean canEdit?: boolean
compact?: boolean compact?: boolean
}) => { }) => {
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
const comment = createMemo(() => props.comment) const comment = createMemo(() => props.comment)
const body = createMemo(() => (comment().body || '').trim()) const body = createMemo(() => (comment().body || '').trim())
const remove = () => { const remove = () => {
@ -23,81 +29,116 @@ export default (props: {
deleteReaction(comment().id) deleteReaction(comment().id)
} }
} }
const formattedDate = createMemo(() =>
formatDate(new Date(comment()?.createdAt), { hour: 'numeric', minute: 'numeric' })
)
return ( return (
<div class={clsx('comment', { [`comment--level-${props.level}`]: Boolean(props.level) })}> <div class={clsx(styles.comment, { [styles[`commentLevel${props.level}`]]: Boolean(props.level) })}>
<Show when={!!body()}> <Show when={!!body()}>
<div class="comment__content"> <div class={styles.commentContent}>
<Show <Show
when={!props.compact} when={!props.compact}
fallback={ fallback={
<div class="comment__details"> <div>
<a href={`/author/${comment()?.createdBy?.slug}`}> <Userpic user={comment().createdBy as Author} isBig={false} isAuthorsList={false} />
@{(comment()?.createdBy || { name: 'anonymous' }).name} <small class={styles.commentArticle}>
</a> <a href={`#comment-${comment()?.id}`}>{comment()?.shout.title || ''}</a>
<div class="comment__article"> </small>
<Icon name="reply-arrow" />
<a href={`#comment-${comment()?.id}`}>
#{(comment()?.shout || { title: 'Lorem ipsum titled' }).title}
</a>
</div>
</div> </div>
} }
> >
<div class="comment__details"> <div class={styles.commentDetails}>
<div class="comment-author"> <div class={styles.commentAuthor}>
<AuthorCard <AuthorCard
author={comment()?.createdBy as Author} author={comment()?.createdBy as Author}
hideDescription={true} hideDescription={true}
hideFollow={true} hideFollow={true}
isComments={true}
hasLink={true}
/> />
</div> </div>
<div class="comment-date">{comment()?.createdAt}</div> <div class={styles.commentDate}>{formattedDate()}</div>
<div class="comment-rating">{comment().stat.rating}</div> <div
class={styles.commentRating}
classList={{
[styles.commentRatingPositive]: comment().stat?.rating > 0,
[styles.commentRatingNegative]: comment().stat?.rating < 0
}}
>
<button class={clsx(styles.commentRatingControl, styles.commentRatingControlUp)} />
<div class={styles.commentRatingValue}>{comment().stat?.rating || 0}</div>
<button class={clsx(styles.commentRatingControl, styles.commentRatingControlDown)} />
</div>
</div> </div>
</Show> </Show>
<div class="comment-body" contenteditable={props.canEdit} id={'comment-' + (comment().id || '')}> <div
class={styles.commentBody}
contenteditable={props.canEdit}
id={'comment-' + (comment().id || '')}
>
<MD body={body()} /> <MD body={body()} />
</div> </div>
<Show when={!props.compact}> <Show when={!props.compact}>
<div class="comment-controls"> <div class={styles.commentControls}>
<button class="comment-control comment-control--reply"> <button
<Icon name="reply" /> class={clsx(styles.commentControl, styles.commentControlReply)}
onClick={() => setIsReplyVisible(!isReplyVisible())}
>
<Icon name="reply" class={styles.icon} />
{t('Reply')} {t('Reply')}
</button> </button>
<Show when={props.canEdit}> <Show when={props.canEdit}>
{/*FIXME implement edit comment modal*/} {/*FIXME implement edit comment modal*/}
{/*<button*/} {/*<button*/}
{/* class="comment-control comment-control--edit"*/} {/* class={clsx(styles.commentControl, styles.commentControlEdit)}*/}
{/* onClick={() => showModal('editComment')}*/} {/* onClick={() => showModal('editComment')}*/}
{/*>*/} {/*>*/}
{/* <Icon name="edit" />*/} {/* <Icon name="edit" class={styles.icon} />*/}
{/* {t('Edit')}*/} {/* {t('Edit')}*/}
{/*</button>*/} {/*</button>*/}
<button class="comment-control comment-control--delete" onClick={() => remove()}> <button
<Icon name="delete" /> class={clsx(styles.commentControl, styles.commentControlDelete)}
onClick={() => remove()}
>
<Icon name="delete" class={styles.icon} />
{t('Delete')} {t('Delete')}
</button> </button>
</Show> </Show>
{/*FIXME implement modals */} <SharePopup
containerCssClass={stylesHeader.control}
trigger={
<button class={clsx(styles.commentControl, styles.commentControlShare)}>
<Icon name="share" class={styles.icon} />
{t('Share')}
</button>
}
/>
{/*<button*/} {/*<button*/}
{/* class="comment-control comment-control--share"*/} {/* class={clsx(styles.commentControl, styles.commentControlComplain)}*/}
{/* onClick={() => showModal('shareComment')}*/}
{/*>*/}
{/* {t('Share')}*/}
{/*</button>*/}
{/*<button*/}
{/* class="comment-control comment-control--complain"*/}
{/* onClick={() => showModal('reportComment')}*/} {/* onClick={() => showModal('reportComment')}*/}
{/*>*/} {/*>*/}
{/* {t('Report')}*/} {/* {t('Report')}*/}
{/*</button>*/} {/*</button>*/}
</div> </div>
<Show when={isReplyVisible()}>
<form class={styles.replyForm}>
<textarea name="reply" id="reply" rows="5" />
<div class={styles.replyFormControls}>
<button class="button button--light" onClick={() => setIsReplyVisible(false)}>
{t('Cancel')}
</button>
<button class="button">{t('Send')}</button>
</div>
</form>
</Show>
</Show> </Show>
</div> </div>
</Show> </Show>

View File

@ -0,0 +1,127 @@
import { For, Show, createMemo, createSignal, onMount } from 'solid-js'
import { useSession } from '../../context/session'
import Comment from './Comment'
import { t } from '../../utils/intl'
import { showModal } from '../../stores/ui'
import styles from '../../styles/Article.module.scss'
import { useReactionsStore } from '../../stores/zine/reactions'
import type { Reaction } from '../../graphql/types.gen'
import { clsx } from 'clsx'
import { byCreated, byStat } from '../../utils/sortby'
import { Loading } from '../Loading'
const ARTICLE_COMMENTS_PAGE_SIZE = 50
const MAX_COMMENT_LEVEL = 6
export const CommentsTree = (props: { shoutSlug: string }) => {
const [getCommentsPage, setCommentsPage] = createSignal(0)
const [commentsOrder, setCommentsOrder] = createSignal<'rating' | 'createdAt'>('createdAt')
const [isCommentsLoading, setIsCommentsLoading] = createSignal(false)
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const { session } = useSession()
const { sortedReactions, loadReactionsBy } = useReactionsStore()
const reactions = createMemo<Reaction[]>(() =>
sortedReactions()
.sort(commentsOrder() === 'rating' ? byStat('rating') : byCreated)
.filter((r) => r.shout.slug === props.shoutSlug)
)
const loadMore = async () => {
try {
const page = getCommentsPage()
setIsCommentsLoading(true)
const { hasMore } = await loadReactionsBy({
by: { shout: props.shoutSlug, comment: true },
limit: ARTICLE_COMMENTS_PAGE_SIZE,
offset: page * ARTICLE_COMMENTS_PAGE_SIZE
})
setIsLoadMoreButtonVisible(hasMore)
} finally {
setIsCommentsLoading(false)
}
}
const getCommentById = (cid: number) => reactions().find((r: Reaction) => r.id === cid)
const getCommentLevel = (c: Reaction, level = 0) => {
if (c && c.replyTo && level < MAX_COMMENT_LEVEL) {
return getCommentLevel(getCommentById(c.replyTo), level + 1)
}
return level
}
onMount(async () => await loadMore())
return (
<>
<Show when={!isCommentsLoading()} fallback={<Loading />}>
<div class={styles.commentsHeaderWrapper}>
<h2 id="comments" class={styles.commentsHeader}>
{t('Comments')} {reactions().length.toString() || ''}
</h2>
<ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}>
<li classList={{ selected: commentsOrder() === 'createdAt' || !commentsOrder() }}>
<a
href="#"
onClick={(ev) => {
ev.preventDefault()
setCommentsOrder('createdAt')
}}
>
{t('By time')}
</a>
</li>
<li classList={{ selected: commentsOrder() === 'rating' }}>
<a
href="#"
onClick={(ev) => {
ev.preventDefault()
setCommentsOrder('rating')
}}
>
{t('By rating')}
</a>
</li>
</ul>
</div>
<For each={reactions().reverse()}>
{(reaction: Reaction) => (
<Comment
comment={reaction}
level={getCommentLevel(reaction)}
canEdit={reaction.createdBy?.slug === session()?.user?.slug}
/>
)}
</For>
<Show when={isLoadMoreButtonVisible()}>
<button onClick={loadMore}>{t('Load more')}</button>
</Show>
</Show>
<Show
when={!session()?.user?.slug}
fallback={
<form class={styles.commentForm}>
<div class="pretty-form__item">
<input type="text" id="new-comment" placeholder={t('Write comment')} />
<label for="new-comment">{t('Write comment')}</label>
</div>
</form>
}
>
<div class={styles.commentWarning} id="comments">
{t('To leave a comment you please')}
<a
href={''}
onClick={(evt) => {
evt.preventDefault()
showModal('auth')
}}
>
<i>{t('sign up or sign in')}</i>
</a>
</div>
</Show>
</>
)
}

View File

@ -1,63 +1,66 @@
import { capitalize } from '../../utils' import { capitalize, formatDate } from '../../utils'
import './Full.scss' import './Full.scss'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import ArticleComment from './Comment'
import { AuthorCard } from '../Author/Card' import { AuthorCard } from '../Author/Card'
import { createMemo, createSignal, For, onMount, Show } from 'solid-js' import { createMemo, For, Match, onMount, Show, Switch } from 'solid-js'
import type { Author, Reaction, Shout } from '../../graphql/types.gen' import type { Author, Shout } from '../../graphql/types.gen'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { showModal } from '../../stores/ui'
import MD from './MD' import MD from './MD'
import { SharePopup } from './SharePopup' import { SharePopup } from './SharePopup'
import { useSession } from '../../context/session'
import stylesHeader from '../Nav/Header.module.scss' import stylesHeader from '../Nav/Header.module.scss'
import styles from '../../styles/Article.module.scss' import styles from '../../styles/Article.module.scss'
import RatingControl from './RatingControl' import { RatingControl } from './RatingControl'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { CommentsTree } from './CommentsTree'
const MAX_COMMENT_LEVEL = 6 import { useSession } from '../../context/session'
import VideoPlayer from './VideoPlayer'
const getCommentLevel = (comment: Reaction, level = 0) => { import Slider from '../_shared/Slider'
if (comment && comment.replyTo && level < MAX_COMMENT_LEVEL) {
return 0 // FIXME: getCommentLevel(commentsById[c.replyTo], level + 1)
}
return level
}
interface ArticleProps { interface ArticleProps {
article: Shout article: Shout
reactions: Reaction[]
isCommentsLoading: boolean
} }
const formatDate = (date: Date) => { interface MediaItem {
return date url?: string
.toLocaleDateString('ru', { pic?: string
month: 'long', title?: string
day: 'numeric', body?: string
year: 'numeric' }
})
.replace(' г.', '') const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
return (
<>
<Switch fallback={<a href={props.media.url}>{t('Cannot show this media type')}</a>}>
<Match when={props.kind === 'audio'}>
<div>
<h5>{props.media.title}</h5>
<audio controls>
<source src={props.media.url} />
</audio>
<hr />
</div>
</Match>
<Match when={props.kind === 'video'}>
<VideoPlayer url={props.media.url} />
</Match>
</Switch>
</>
)
} }
export const FullArticle = (props: ArticleProps) => { export const FullArticle = (props: ArticleProps) => {
const { session } = useSession() const { session } = useSession()
const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt))) const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt)))
const [isSharePopupVisible, setIsSharePopupVisible] = createSignal(false)
const mainTopic = () => const mainTopic = createMemo(
(props.article.topics?.find((topic) => topic?.slug === props.article.mainTopic)?.title || '').replace( () =>
' ', props.article.topics?.find((topic) => topic?.slug === props.article.mainTopic) ||
'&nbsp;' props.article.topics[0]
) )
const mainTopicTitle = createMemo(() => mainTopic().title.replace(' ', '&nbsp;'))
onMount(() => { onMount(() => {
const script = document.createElement('script')
script.async = true
script.src = 'https://ackee.discours.io/increment.js'
script.dataset.ackeeServer = 'https://ackee.discours.io'
script.dataset.ackeeDomainId = '1004abeb-89b2-4e85-ad97-74f8d2c8ed2d'
document.body.appendChild(script)
const windowHash = window.location.hash const windowHash = window.location.hash
if (windowHash?.length > 0) { if (windowHash?.length > 0) {
const comments = document.querySelector(windowHash) const comments = document.querySelector(windowHash)
@ -70,12 +73,26 @@ export const FullArticle = (props: ArticleProps) => {
} }
}) })
const canEdit = () => props.article.authors?.some((a) => a.slug === session()?.user?.slug)
const bookmark = (ev) => {
// TODO: implement bookmark clicked
ev.preventDefault()
}
const body = createMemo(() => props.article.body)
const media = createMemo(() => {
const mi = JSON.parse(props.article.media || '[]')
console.debug(mi)
return mi
})
return ( return (
<div class="shout wide-container"> <div class="shout wide-container">
<article class="col-md-6 shift-content"> <article class="col-md-6 shift-content">
<div class={styles.shoutHeader}> <div class={styles.shoutHeader}>
<div class={styles.shoutTopic}> <div class={styles.shoutTopic}>
<a href={`/topic/${props.article.mainTopic}`} innerHTML={mainTopic() || ''} /> <a href={`/topic/${props.article.mainTopic}`} innerHTML={mainTopicTitle() || ''} />
</div> </div>
<h1>{props.article.title}</h1> <h1>{props.article.title}</h1>
@ -96,13 +113,38 @@ export const FullArticle = (props: ArticleProps) => {
<div class={styles.shoutCover} style={{ 'background-image': `url('${props.article.cover}')` }} /> <div class={styles.shoutCover} style={{ 'background-image': `url('${props.article.cover}')` }} />
</div> </div>
<Show when={Boolean(props.article.body)}>
<div class={styles.shoutBody}>
<Show <Show
when={!props.article.body.startsWith('<')} when={media() && props.article.layout !== 'image'}
fallback={<div innerHTML={props.article.body} />} fallback={
<Slider>
<For each={media() || []}>
{(m: MediaItem) => (
<>
<img src={m.url || m.pic} alt={m.title} />
<div innerHTML={m.body} />
</>
)}
</For>
</Slider>
}
> >
<MD body={props.article.body} /> <div class="media-items">
<For each={media() || []}>
{(m: MediaItem) => (
<div class={styles.shoutMediaBody}>
<MediaView media={m} kind={props.article.layout} />
<Show when={m?.body}>
<div innerHTML={m.body} />
</Show>
</div>
)}
</For>
</div>
</Show>
<Show when={body()}>
<div class={styles.shoutBody}>
<Show when={!body().startsWith('<')} fallback={<div innerHTML={body()} />}>
<MD body={body()} />
</Show> </Show>
</div> </div>
</Show> </Show>
@ -111,59 +153,54 @@ export const FullArticle = (props: ArticleProps) => {
<div class="col-md-8 shift-content"> <div class="col-md-8 shift-content">
<div class={styles.shoutStats}> <div class={styles.shoutStats}>
<div class={styles.shoutStatsItem}> <div class={styles.shoutStatsItem}>
<RatingControl rating={props.article.stat?.rating} /> <RatingControl rating={props.article.stat?.rating} class={styles.ratingControl} />
</div> </div>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemLikes)}> <Show when={props.article.stat?.viewed}>
<Icon name="like" class={styles.icon} /> <div class={clsx(styles.shoutStatsItem)}>
{props.article.stat?.rating || ''} <Icon name="eye" class={clsx(styles.icon, styles.iconEye)} />
{props.article.stat?.viewed}
</div> </div>
</Show>
<div class={styles.shoutStatsItem}> <div class={styles.shoutStatsItem}>
<Icon name="comment" class={styles.icon} /> <Icon name="comment" class={styles.icon} />
{props.article.stat?.commented || ''} {props.article.stat?.commented || ''}
</div> </div>
{/*FIXME*/}
{/*<div class={styles.shoutStatsItem}>*/}
{/* <a href="#bookmark" onClick={() => console.log(props.article.slug, 'articles')}>*/}
{/* <Icon name={'bookmark' + (bookmarked() ? '' : '-x')} />*/}
{/* </a>*/}
{/*</div>*/}
<div class={styles.shoutStatsItem}> <div class={styles.shoutStatsItem}>
<SharePopup <SharePopup
onVisibilityChange={(isVisible) => {
setIsSharePopupVisible(isVisible)
}}
containerCssClass={stylesHeader.control} containerCssClass={stylesHeader.control}
trigger={<Icon name="share" class={styles.icon} />} trigger={<Icon name="share-outline" class={styles.icon} />}
/> />
</div> </div>
<div class={styles.shoutStatsItem}>
<div class={styles.shoutStatsItem} onClick={bookmark}>
<Icon name="bookmark" class={styles.icon} /> <Icon name="bookmark" class={styles.icon} />
</div> </div>
{/*FIXME*/} <Show when={canEdit()}>
{/*<Show when={canEdit()}>*/} <div class={styles.shoutStatsItem}>
{/* <div class={styles.shoutStatsItem}>*/} <a href="/edit">
{/* <a href="/edit">*/} <Icon name="edit" />
{/* <Icon name="edit" />*/} {t('Edit')}
{/* {t('Edit')}*/} </a>
{/* </a>*/}
{/* </div>*/}
{/*</Show>*/}
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalData)}>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalDataItem)}>
{formattedDate}
</div>
<Show when={props.article.stat?.viewed}>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalDataItem)}>
<Icon name="view" class={styles.icon} />
{props.article.stat?.viewed}
</div> </div>
</Show> </Show>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalData)}>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalDataItem)}>
{formattedDate()}
</div> </div>
</div> </div>
</div>
<div class={styles.help}>
<Show when={session()?.token}>
<button class="button">{t('Cooperate')}</button>
</Show>
<Show when={canEdit()}>
<button class="button button--light">{t('Invite to collab')}</button>
</Show>
</div>
<div class={styles.topicsList}> <div class={styles.topicsList}>
<For each={props.article.topics}> <For each={props.article.topics}>
@ -181,45 +218,13 @@ export const FullArticle = (props: ArticleProps) => {
</Show> </Show>
<For each={props.article?.authors}> <For each={props.article?.authors}>
{(a: Author) => ( {(a: Author) => (
<div class="col-md-6"> <div class="col-xl-6">
<AuthorCard author={a} compact={false} hasLink={true} liteButtons={true} /> <AuthorCard author={a} compact={false} hasLink={true} liteButtons={true} />
</div> </div>
)} )}
</For> </For>
</div> </div>
<CommentsTree shoutSlug={props.article?.slug} />
<Show when={props.reactions?.length}>
<h2 id="comments">
{t('Comments')} {props.reactions?.length.toString() || ''}
</h2>
<For each={props.reactions?.filter((r) => r.body)}>
{(reaction) => (
<ArticleComment
comment={reaction}
level={getCommentLevel(reaction)}
canEdit={reaction.createdBy?.slug === session()?.user?.slug}
/>
)}
</For>
</Show>
<Show when={!session()?.user?.slug}>
<div class={styles.commentWarning} id="comments">
{t('To leave a comment you please')}
<a
href={''}
onClick={(evt) => {
evt.preventDefault()
showModal('auth')
}}
>
<i>{t('sign up or sign in')}</i>
</a>
</div>
</Show>
<Show when={session()?.user?.slug}>
<textarea class={styles.writeComment} rows="1" placeholder={t('Write comment')} />
</Show>
</div> </div>
</div> </div>
) )

View File

@ -1,5 +1,6 @@
import styles from './RatingControl.module.scss' import styles from './RatingControl.module.scss'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Icon } from '../_shared/Icon'
interface RatingControlProps { interface RatingControlProps {
rating?: number rating?: number
@ -15,5 +16,3 @@ export const RatingControl = (props: RatingControlProps) => {
</div> </div>
) )
} }
export default RatingControl

View File

@ -1,12 +1,24 @@
export default (props: { youtubeId?: string; vimeoId?: string; title?: string }) => { import { Show } from 'solid-js'
// TODO: styling
return ( export default (props: { url: string }) => (
<video <>
src={ <Show when={props.url.includes('youtube.com')}>
props.vimeoId <iframe
? `https://vimeo.com/${props.vimeoId}` id="ytplayer"
: `https://youtube.com/?watch=${props.youtubeId}` width="640"
} height="360"
src={`https://www.youtube.com/embed/${props.url.split('watch=').pop()}`}
allowfullscreen
/> />
</Show>
<Show when={props.url.includes('vimeo.com')}>
<iframe
src={'https://player.vimeo.com/video/' + props.url.split('video/').pop()}
width="420"
height="345"
allow="autoplay; fullscreen"
allowfullscreen
/>
</Show>
</>
) )
}

View File

@ -49,7 +49,10 @@
.authorSubscribe { .authorSubscribe {
align-items: center; align-items: center;
display: flex; display: flex;
@include media-breakpoint-up(sm) {
padding: 0 0 0 42px; padding: 0 0 0 42px;
}
a { a {
background: #f7f7f7; background: #f7f7f7;
@ -238,6 +241,8 @@
} }
.authorsListItem { .authorsListItem {
margin-bottom: 1em !important;
.authorName { .authorName {
@include font-size(2.2rem); @include font-size(2.2rem);
@ -256,3 +261,18 @@
display: block; display: block;
} }
} }
.authorComments {
.authorName {
@include font-size(1.2rem);
margin-bottom: 0;
}
.circlewrap {
margin-top: -0.6em;
}
}
.isSubscribing {
color: transparent;
}

View File

@ -2,13 +2,15 @@ import type { Author } from '../../graphql/types.gen'
import Userpic from './Userpic' import Userpic from './Userpic'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import styles from './Card.module.scss' import styles from './Card.module.scss'
import { createMemo, For, Show } from 'solid-js' import { createMemo, createSignal, For, Show } from 'solid-js'
import { translit } from '../../utils/ru2en' import { translit } from '../../utils/ru2en'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { follow, unfollow } from '../../stores/zine/common' import { follow, unfollow } from '../../stores/zine/common'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
import { StatMetrics } from '../_shared/StatMetrics'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { FollowingEntity } from '../../graphql/types.gen' import { FollowingEntity } from '../../graphql/types.gen'
import { router, useRouter } from '../../stores/router' import { router, useRouter } from '../../stores/router'
import { openPage } from '@nanostores/router' import { openPage } from '@nanostores/router'
@ -26,24 +28,47 @@ interface AuthorCardProps {
isAuthorsList?: boolean isAuthorsList?: boolean
truncateBio?: boolean truncateBio?: boolean
liteButtons?: boolean liteButtons?: boolean
isComments?: boolean
} }
export const AuthorCard = (props: AuthorCardProps) => { export const AuthorCard = (props: AuthorCardProps) => {
const { session } = useSession() const {
session,
isSessionLoaded,
actions: { loadSession }
} = useSession()
const [isSubscribing, setIsSubscribing] = createSignal(false)
const subscribed = createMemo<boolean>( const subscribed = createMemo<boolean>(
() => session()?.news?.authors?.some((u) => u === props.author.slug) || false () => session()?.news?.authors?.some((u) => u === props.author.slug) || false
) )
const subscribe = async (really = true) => {
setIsSubscribing(true)
await (really
? follow({ what: FollowingEntity.Author, slug: props.author.slug })
: unfollow({ what: FollowingEntity.Author, slug: props.author.slug }))
await loadSession()
setIsSubscribing(false)
}
const canFollow = createMemo(() => !props.hideFollow && session()?.user?.slug !== props.author.slug) const canFollow = createMemo(() => !props.hideFollow && session()?.user?.slug !== props.author.slug)
const bio = createMemo(() => {
return props.caption || props.author.bio || t('Our regular contributor') const name = createMemo(() => {
if (locale() !== 'ru') {
if (props.author.name === 'Дискурс') {
return 'Discours'
}
return translit(props.author.name)
}
return props.author.name
}) })
const name = () => {
return props.author.name === 'Дискурс' && locale() !== 'ru'
? 'Discours'
: translit(props.author.name || '', locale() || 'ru')
}
// TODO: reimplement AuthorCard // TODO: reimplement AuthorCard
const { changeSearchParam } = useRouter() const { changeSearchParam } = useRouter()
const initChat = () => { const initChat = () => {
@ -55,6 +80,7 @@ export const AuthorCard = (props: AuthorCardProps) => {
class={clsx(styles.author)} class={clsx(styles.author)}
classList={{ classList={{
[styles.authorPage]: props.isAuthorPage, [styles.authorPage]: props.isAuthorPage,
[styles.authorComments]: props.isComments,
[styles.authorsListItem]: props.isAuthorsList [styles.authorsListItem]: props.isAuthorsList
}} }}
> >
@ -63,6 +89,7 @@ export const AuthorCard = (props: AuthorCardProps) => {
hasLink={props.hasLink} hasLink={props.hasLink}
isBig={props.isAuthorPage} isBig={props.isAuthorPage}
isAuthorsList={props.isAuthorsList} isAuthorsList={props.isAuthorsList}
class={styles.circlewrap}
/> />
<div class={styles.authorDetails}> <div class={styles.authorDetails}>
@ -76,31 +103,37 @@ export const AuthorCard = (props: AuthorCardProps) => {
<div class={styles.authorName}>{name()}</div> <div class={styles.authorName}>{name()}</div>
</Show> </Show>
<Show when={!props.hideDescription}> <Show when={!props.hideDescription && props.author.bio}>
{props.isAuthorsList} {props.isAuthorsList}
<div <div
class={styles.authorAbout} class={styles.authorAbout}
classList={{ 'text-truncate': props.truncateBio }} classList={{ 'text-truncate': props.truncateBio }}
innerHTML={props.caption || bio()} innerHTML={props.author.bio}
></div> />
</Show>
<Show when={props.author.stat}>
<StatMetrics fields={['shouts', 'followers', 'comments']} stat={props.author.stat} />
</Show> </Show>
</div> </div>
<ShowOnlyOnClient>
<Show when={isSessionLoaded()}>
<Show when={canFollow()}> <Show when={canFollow()}>
<div class={styles.authorSubscribe}> <div class={styles.authorSubscribe}>
<Show <Show
when={subscribed()} when={subscribed()}
fallback={ fallback={
<button <button
// TODO: change button view reactivity onClick={() => subscribe(true)}
onclick={() => follow({ what: FollowingEntity.Author, slug: props.author.slug })}
class={clsx('button', styles.button)} class={clsx('button', styles.button)}
classList={{ classList={{
[styles.buttonSubscribe]: !props.isAuthorsList, [styles.buttonSubscribe]: !props.isAuthorsList,
'button--subscribe': !props.isAuthorsList, 'button--subscribe': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList, 'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.isAuthorsList [styles.buttonWrite]: props.isAuthorsList,
[styles.isSubscribing]: isSubscribing()
}} }}
disabled={isSubscribing()}
> >
<Show when={!props.isAuthorsList}> <Show when={!props.isAuthorsList}>
<Icon name="author-subscribe" class={styles.icon} /> <Icon name="author-subscribe" class={styles.icon} />
@ -110,13 +143,16 @@ export const AuthorCard = (props: AuthorCardProps) => {
} }
> >
<button <button
onclick={() => follow({ what: FollowingEntity.Author, slug: props.author.slug })} onClick={() => subscribe(false)}
class={clsx('button', styles.button)}
classList={{ classList={{
[styles.buttonSubscribe]: !props.isAuthorsList, [styles.buttonSubscribe]: !props.isAuthorsList,
'button--subscribe': !props.isAuthorsList, 'button--subscribe': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList, 'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.isAuthorsList [styles.buttonWrite]: props.isAuthorsList,
[styles.isSubscribing]: isSubscribing()
}} }}
disabled={isSubscribing()}
> >
<Show when={!props.isAuthorsList}> <Show when={!props.isAuthorsList}>
<Icon name="author-unsubscribe" class={styles.icon} /> <Icon name="author-unsubscribe" class={styles.icon} />
@ -146,6 +182,8 @@ export const AuthorCard = (props: AuthorCardProps) => {
</Show> </Show>
</div> </div>
</Show> </Show>
</Show>
</ShowOnlyOnClient>
</div> </div>
</div> </div>
) )

View File

@ -32,7 +32,7 @@ export default (props: UserpicProps) => {
when={props.user && props.user.userpic === ''} when={props.user && props.user.userpic === ''}
fallback={ fallback={
<img <img
src={props.user.userpic || '/icons/user-anonymous.svg'} src={props.user.userpic || '/icons/user-default.svg'}
alt={props.user.name || ''} alt={props.user.name || ''}
classList={{ anonymous: !props.user.userpic }} classList={{ anonymous: !props.user.userpic }}
/> />
@ -48,9 +48,10 @@ export default (props: UserpicProps) => {
when={props.user && props.user.userpic === ''} when={props.user && props.user.userpic === ''}
fallback={ fallback={
<img <img
src={props.user.userpic || '/icons/user-anonymous.svg'} src={props.user.userpic || '/icons/user-default.svg'}
alt={props.user.name || ''} alt={props.user.name || ''}
classList={{ anonymous: !props.user.userpic }} classList={{ anonymous: !props.user.userpic }}
loading="lazy"
/> />
} }
> >

View File

@ -0,0 +1,10 @@
.navigationHeader {
@include font-size(1.8rem);
font-weight: bold;
margin-top: 1.1em !important;
}
.navigation {
@include font-size(1.4rem);
}

View File

@ -0,0 +1,21 @@
import styles from './ProfileSettingsNavigation.module.scss'
import { clsx } from 'clsx'
export default () => {
return (
<>
<h4 class={styles.navigationHeader}>Настройки</h4>
<ul class={clsx(styles.navigation, 'nodash')}>
<li>
<a href="/profile/settings">Профиль</a>
</li>
<li>
<a href="/profile/subscriptions">Подписки</a>
</li>
<li>
<a href="/profile/security">Вход и&nbsp;безопасность</a>
</li>
</ul>
</>
)
}

View File

@ -59,7 +59,7 @@ export const Editor = () => {
const handleSaveButtonClick = () => { const handleSaveButtonClick = () => {
const article: ShoutInput = { const article: ShoutInput = {
body: getHtml(editorViewRef.current.state), body: getHtml(editorViewRef.current.state),
// community: 'discours', // ? Type 'string' is not assignable to type 'number'. community: 1, // 'discours' ?
slug: 'new-' + Math.floor(Math.random() * 1000000) slug: 'new-' + Math.floor(Math.random() * 1000000)
} }
createArticle({ article }) createArticle({ article })

View File

@ -20,6 +20,11 @@
a { a {
border: none; border: none;
} }
.icon {
height: 1.2em;
width: 100%;
}
} }
.shoutCardWithBorder { .shoutCardWithBorder {

View File

@ -7,8 +7,8 @@ import { Icon } from '../_shared/Icon'
import styles from './Card.module.scss' import styles from './Card.module.scss'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import CardTopic from './CardTopic' import { CardTopic } from './CardTopic'
import RatingControl from '../Article/RatingControl' import { RatingControl } from '../Article/RatingControl'
interface ArticleCardProps { interface ArticleCardProps {
settings?: { settings?: {
@ -56,9 +56,9 @@ const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string
} }
export const ArticleCard = (props: ArticleCardProps) => { export const ArticleCard = (props: ArticleCardProps) => {
const mainTopic = props.article.topics.find( const mainTopic =
(articleTopic) => articleTopic.slug === props.article.mainTopic props.article.topics.find((articleTopic) => articleTopic.slug === props.article.mainTopic) ||
) props.article.topics[0]
const formattedDate = createMemo<string>(() => { const formattedDate = createMemo<string>(() => {
return new Date(props.article.createdAt) return new Date(props.article.createdAt)
@ -107,7 +107,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
<Show when={!props.settings?.isGroup}> <Show when={!props.settings?.isGroup}>
<CardTopic <CardTopic
title={ title={
locale() === 'ru' && mainTopic.title ? mainTopic.title : mainTopic.slug.replace('-', ' ') locale() === 'ru' && mainTopic.title ? mainTopic.title : mainTopic?.slug?.replace('-', ' ')
} }
slug={mainTopic.slug} slug={mainTopic.slug}
isFloorImportant={props.settings?.isFloorImportant} isFloorImportant={props.settings?.isFloorImportant}
@ -134,10 +134,11 @@ export const ArticleCard = (props: ArticleCardProps) => {
<div class={styles.shoutAuthor}> <div class={styles.shoutAuthor}>
<For each={authors}> <For each={authors}>
{(author, index) => { {(author, index) => {
const name = let name = author.name
author.name === 'Дискурс' && locale() !== 'ru'
? 'Discours' if (locale() !== 'ru') {
: translit(author.name || '', locale() || 'ru') name = name === 'Дискурс' ? 'Discours' : translit(name)
}
return ( return (
<> <>

View File

@ -6,7 +6,7 @@ interface CardTopicProps {
isFloorImportant?: boolean isFloorImportant?: boolean
} }
export default (props: CardTopicProps) => { export const CardTopic = (props: CardTopicProps) => {
return ( return (
<div <div
class={style.shoutTopic} class={style.shoutTopic}

View File

@ -54,7 +54,7 @@ export const ForgotPasswordForm = () => {
setIsSubmitting(true) setIsSubmitting(true)
try { try {
await signSendLink({ email: email(), lang: locale() }) await signSendLink({ email: email(), lang: locale(), template: 'forgot_password' })
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.code === 'user_not_found') { if (error instanceof ApiError && error.code === 'user_not_found') {
setIsUserNotFound(true) setIsUserNotFound(true)

View File

@ -50,7 +50,7 @@ export const LoginForm = () => {
setIsEmailNotConfirmed(false) setIsEmailNotConfirmed(false)
setSubmitError('') setSubmitError('')
setIsLinkSent(true) setIsLinkSent(true)
const result = await signSendLink({ email: email(), lang: locale() }) const result = await signSendLink({ email: email(), lang: locale(), template: 'email_confirmation' })
if (result.error) setSubmitError(result.error) if (result.error) setSubmitError(result.error)
} }

View File

@ -61,14 +61,14 @@ export const RegisterForm = () => {
const newValidationErrors: ValidationErrors = {} const newValidationErrors: ValidationErrors = {}
const clearName = name().trim() const cleanName = name().trim()
const clearEmail = email().trim() const cleanEmail = email().trim()
if (!clearName) { if (!cleanName) {
newValidationErrors.name = t('Please enter a name to sign your comments and publication') newValidationErrors.name = t('Please enter a name to sign your comments and publication')
} }
if (!clearEmail) { if (!cleanEmail) {
newValidationErrors.email = t('Please enter email') newValidationErrors.email = t('Please enter email')
} else if (!isValidEmail(email())) { } else if (!isValidEmail(email())) {
newValidationErrors.email = t('Invalid email') newValidationErrors.email = t('Invalid email')
@ -80,7 +80,7 @@ export const RegisterForm = () => {
setValidationErrors(newValidationErrors) setValidationErrors(newValidationErrors)
const emailCheckResult = await checkEmail(clearEmail) const emailCheckResult = await checkEmail(cleanEmail)
const isValid = Object.keys(newValidationErrors).length === 0 && !emailCheckResult const isValid = Object.keys(newValidationErrors).length === 0 && !emailCheckResult
@ -92,8 +92,8 @@ export const RegisterForm = () => {
try { try {
await register({ await register({
name: clearName, name: cleanName,
email: clearEmail, email: cleanEmail,
password: password() password: password()
}) })
@ -123,13 +123,13 @@ export const RegisterForm = () => {
</Show> </Show>
<div class="pretty-form__item"> <div class="pretty-form__item">
<input <input
name="name" name="fullName"
type="text" type="text"
placeholder={t('Full name')} placeholder={t('Full name')}
autocomplete="" autocomplete=""
onInput={(event) => handleNameInput(event.currentTarget.value)} onInput={(event) => handleNameInput(event.currentTarget.value)}
/> />
<label for="name">{t('Full name')}</label> <label for="fullName">{t('Full name')}</label>
</div> </div>
<Show when={validationErrors().name}> <Show when={validationErrors().name}>
<div class={styles.validationError}>{validationErrors().name}</div> <div class={styles.validationError}>{validationErrors().name}</div>

View File

@ -13,6 +13,11 @@
} }
} }
.icon {
height: 1em;
width: 1em;
}
a:hover { a:hover {
.icon { .icon {
filter: invert(1); filter: invert(1);

View File

@ -132,7 +132,7 @@ export const Header = (props: Props) => {
<a href={getPagePath(router, 'inbox')} class={styles.control}> <a href={getPagePath(router, 'inbox')} class={styles.control}>
<Icon name="comments-outline" class={styles.icon} /> <Icon name="comments-outline" class={styles.icon} />
</a> </a>
<a href="#" class={styles.control} onClick={(event) => event.preventDefault()}> <a href={getPagePath(router, 'create')} class={styles.control}>
<Icon name="pencil-outline" class={styles.icon} /> <Icon name="pencil-outline" class={styles.icon} />
</a> </a>
<a href="#" class={styles.control} onClick={(event) => event.preventDefault()}> <a href="#" class={styles.control} onClick={(event) => event.preventDefault()}>

View File

@ -3,7 +3,7 @@ import { clsx } from 'clsx'
import { useRouter } from '../../stores/router' import { useRouter } from '../../stores/router'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { createSignal, Show } from 'solid-js' import { createEffect, createSignal, Show } from 'solid-js'
import Notifications from './Notifications' import Notifications from './Notifications'
import { ProfilePopup } from './ProfilePopup' import { ProfilePopup } from './ProfilePopup'
import Userpic from '../Author/Userpic' import Userpic from '../Author/Userpic'
@ -21,7 +21,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
const [visibleWarnings, setVisibleWarnings] = createSignal(false) const [visibleWarnings, setVisibleWarnings] = createSignal(false)
const { warnings } = useWarningsStore() const { warnings } = useWarningsStore()
const { session, isAuthenticated } = useSession() const { session, isSessionLoaded, isAuthenticated } = useSession()
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings()) const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
@ -38,7 +38,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
return ( return (
<ShowOnlyOnClient> <ShowOnlyOnClient>
<Show when={!session.loading}> <Show when={isSessionLoaded()}>
<div class={styles.usernav}> <div class={styles.usernav}>
<div class={clsx(styles.userControl, styles.userControl, 'col')}> <div class={clsx(styles.userControl, styles.userControl, 'col')}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}> <div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
@ -70,7 +70,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}> <div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
<a href="?modal=auth&mode=login"> <a href="?modal=auth&mode=login">
<span class={styles.textLabel}>{t('Enter')}</span> <span class={styles.textLabel}>{t('Enter')}</span>
<Icon name="user-anonymous" class={styles.icon} /> <Icon name="user-default" class={styles.icon} />
</a> </a>
</div> </div>
} }

View File

@ -23,15 +23,16 @@
position: absolute; position: absolute;
top: 1em; top: 1em;
cursor: pointer; cursor: pointer;
height: 0.8em; height: 18px;
width: 16px;
opacity: 1; opacity: 1;
padding: 0; padding: 0;
right: 0; right: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
width: 0.8em;
z-index: 1; z-index: 1;
svg { svg {
display: block;
pointer-events: none; pointer-events: none;
} }
@ -55,6 +56,7 @@
@media (min-width: 800px) and (max-width: 991px) { @media (min-width: 800px) and (max-width: 991px) {
width: 80%; width: 80%;
} }
.close { .close {
right: 12px; right: 12px;
top: 12px; top: 12px;

View File

@ -2,20 +2,23 @@ import { useSession } from '../../context/session'
import type { PopupProps } from '../_shared/Popup' import type { PopupProps } from '../_shared/Popup'
import { Popup } from '../_shared/Popup' import { Popup } from '../_shared/Popup'
import styles from '../_shared/Popup/Popup.module.scss' import styles from '../_shared/Popup/Popup.module.scss'
import { getPagePath } from '@nanostores/router'
import { router } from '../../stores/router'
type ProfilePopupProps = Omit<PopupProps, 'children'> type ProfilePopupProps = Omit<PopupProps, 'children'>
export const ProfilePopup = (props: ProfilePopupProps) => { export const ProfilePopup = (props: ProfilePopupProps) => {
const { const {
session, userSlug,
actions: { signOut } actions: { signOut }
} = useSession() } = useSession()
return ( return (
<Popup {...props} horizontalAnchor="right"> <Popup {...props} horizontalAnchor="right">
{/*TODO: l10n*/}
<ul class="nodash"> <ul class="nodash">
<li> <li>
<a href={`/author/${session().user?.slug}`}>Профиль</a> <a href={getPagePath(router, 'author', { slug: userSlug() })}>Профиль</a>
</li> </li>
<li> <li>
<a href="#">Черновики</a> <a href="#">Черновики</a>
@ -30,7 +33,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
<a href="#">Закладки</a> <a href="#">Закладки</a>
</li> </li>
<li> <li>
<a href="#">Настройки</a> <a href={getPagePath(router, 'profileSettings')}>Настройки</a>
</li> </li>
<li class={styles.topBorderItem}> <li class={styles.topBorderItem}>
<a <a

View File

@ -8,7 +8,7 @@ import { loadAuthor } from '../../stores/zine/authors'
import { Loading } from '../Loading' import { Loading } from '../Loading'
export const AuthorPage = (props: PageProps) => { export const AuthorPage = (props: PageProps) => {
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.shouts) && Boolean(props.author)) const [isLoaded, setIsLoaded] = createSignal(Boolean(props.authorShouts) && Boolean(props.author))
const slug = createMemo(() => { const slug = createMemo(() => {
const { page: getPage } = useRouter() const { page: getPage } = useRouter()
@ -38,7 +38,7 @@ export const AuthorPage = (props: PageProps) => {
return ( return (
<PageWrap> <PageWrap>
<Show when={isLoaded()} fallback={<Loading />}> <Show when={isLoaded()} fallback={<Loading />}>
<AuthorView author={props.author} shouts={props.shouts} authorSlug={slug()} /> <AuthorView author={props.author} shouts={props.authorShouts} authorSlug={slug()} />
</Show> </Show>
</PageWrap> </PageWrap>
) )

View File

@ -3,7 +3,7 @@ import { PageWrap } from '../_shared/PageWrap'
export const ConnectPage = () => { export const ConnectPage = () => {
return ( return (
<PageWrap> <PageWrap>
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-sm-10 col-md-8 col-lg-7 col-xl-6 shift-content"> <div class="col-sm-10 col-md-8 col-lg-7 col-xl-6 shift-content">
<h1> <h1>

View File

@ -8,7 +8,7 @@ import { Loading } from '../Loading'
import styles from './HomePage.module.scss' import styles from './HomePage.module.scss'
export const HomePage = (props: PageProps) => { export const HomePage = (props: PageProps) => {
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.shouts) && Boolean(props.randomTopics)) const [isLoaded, setIsLoaded] = createSignal(Boolean(props.homeShouts) && Boolean(props.randomTopics))
onMount(async () => { onMount(async () => {
if (isLoaded()) { if (isLoaded()) {
@ -26,7 +26,7 @@ export const HomePage = (props: PageProps) => {
return ( return (
<PageWrap class={styles.mainContent}> <PageWrap class={styles.mainContent}>
<Show when={isLoaded()} fallback={<Loading />}> <Show when={isLoaded()} fallback={<Loading />}>
<HomeView randomTopics={props.randomTopics} shouts={props.shouts || []} /> <HomeView randomTopics={props.randomTopics} shouts={props.homeShouts || []} />
</Show> </Show>
</PageWrap> </PageWrap>
) )

View File

@ -13,9 +13,10 @@ import { t } from '../../utils/intl'
import { Row3 } from '../Feed/Row3' import { Row3 } from '../Feed/Row3'
import { Row2 } from '../Feed/Row2' import { Row2 } from '../Feed/Row2'
import { Beside } from '../Feed/Beside' import { Beside } from '../Feed/Beside'
import Slider from '../Feed/Slider' import Slider from '../_shared/Slider'
import { Row1 } from '../Feed/Row1' import { Row1 } from '../Feed/Row1'
import styles from '../../styles/Topic.module.scss' import styles from '../../styles/Topic.module.scss'
import { ArticleCard } from '../Feed/Card'
export const PRERENDERED_ARTICLES_COUNT = 21 export const PRERENDERED_ARTICLES_COUNT = 21
const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3 const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3
@ -28,7 +29,7 @@ export const LayoutShoutsPage = (props: PageProps) => {
return page.params.layout as LayoutType return page.params.layout as LayoutType
}) })
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const { sortedLayoutShouts, loadLayoutShoutsBy } = useLayoutsStore(layout(), props.shouts) const { sortedLayoutShouts, loadLayoutShoutsBy } = useLayoutsStore(layout(), props.layoutShouts)
const sortedArticles = createMemo<Shout[]>(() => sortedLayoutShouts().get(layout()) || []) const sortedArticles = createMemo<Shout[]>(() => sortedLayoutShouts().get(layout()) || [])
const loadMoreLayout = async (kind: LayoutType) => { const loadMoreLayout = async (kind: LayoutType) => {
saveScrollPosition() saveScrollPosition()
@ -106,7 +107,21 @@ export const LayoutShoutsPage = (props: PageProps) => {
<ModeSwitcher /> <ModeSwitcher />
<Row1 article={sortedArticles()[0]} /> <Row1 article={sortedArticles()[0]} />
<Row2 articles={sortedArticles().slice(1, 3)} /> <Row2 articles={sortedArticles().slice(1, 3)} />
<Slider title={title()} articles={sortedArticles().slice(5, 11)} /> <Slider title={title()}>
<For each={sortedArticles().slice(5, 11)}>
{(a: Shout) => (
<ArticleCard
article={a}
settings={{
additionalClass: 'swiper-slide',
isFloorImportant: true,
isWithCover: true,
nodate: true
}}
/>
)}
</For>
</Slider>
<Beside <Beside
beside={sortedArticles()[12]} beside={sortedArticles()[12]}
title={t('Top viewed')} title={t('Top viewed')}

View File

@ -8,7 +8,7 @@ import { loadTopic } from '../../stores/zine/topics'
import { Loading } from '../Loading' import { Loading } from '../Loading'
export const TopicPage = (props: PageProps) => { export const TopicPage = (props: PageProps) => {
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.shouts) && Boolean(props.topic)) const [isLoaded, setIsLoaded] = createSignal(Boolean(props.topicShouts) && Boolean(props.topic))
const slug = createMemo(() => { const slug = createMemo(() => {
const { page: getPage } = useRouter() const { page: getPage } = useRouter()
@ -38,7 +38,7 @@ export const TopicPage = (props: PageProps) => {
return ( return (
<PageWrap> <PageWrap>
<Show when={isLoaded()} fallback={<Loading />}> <Show when={isLoaded()} fallback={<Loading />}>
<TopicView topic={props.topic} shouts={props.shouts} topicSlug={slug()} /> <TopicView topic={props.topic} shouts={props.topicShouts} topicSlug={slug()} />
</Show> </Show>
</PageWrap> </PageWrap>
) )

View File

@ -5,7 +5,7 @@ export const DiscussionRulesPage = () => {
const title = t('Discussion rules') const title = t('Discussion rules')
return ( return (
<PageWrap> <PageWrap>
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-7 shift-content order-md-first"> <div class="col-md-6 col-xl-7 shift-content order-md-first">
<h1> <h1>

View File

@ -5,7 +5,7 @@ import { PageWrap } from '../../_shared/PageWrap'
export const DogmaPage = () => { export const DogmaPage = () => {
return ( return (
<PageWrap> <PageWrap>
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-7 shift-content order-md-first"> <div class="col-md-6 col-xl-7 shift-content order-md-first">
<h4>Редакционные принципы</h4> <h4>Редакционные принципы</h4>

View File

@ -20,9 +20,9 @@ export const GuidePage = () => {
{/*<Meta property="og:image:width" content="1200" />*/} {/*<Meta property="og:image:width" content="1200" />*/}
{/*<Meta property="og:image:height" content="630" />*/} {/*<Meta property="og:image:height" content="630" />*/}
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-md-4 col-lg-3 order-md-last"> <div class="col-md-3 col-lg-2 order-md-last">
<button class="button button--content-index" onClick={toggleIndexExpanded}> <button class="button button--content-index" onClick={toggleIndexExpanded}>
<Show when={!indexExpanded()}> <Show when={!indexExpanded()}>
<Icon name="content-index-control" /> <Icon name="content-index-control" />

View File

@ -17,9 +17,9 @@ export const HelpPage = () => {
{/*<Modal name="thank">Благодарим!</Modal>*/} {/*<Modal name="thank">Благодарим!</Modal>*/}
<article class="container container--static-page discours-help"> <article class="wide-container container--static-page discours-help">
<div class="row"> <div class="row">
<div class="col-md-4 col-lg-3 order-md-last"> <div class="col-md-3 col-lg-2 order-md-last">
<button class="button button--content-index" onClick={toggleIndexExpanded}> <button class="button button--content-index" onClick={toggleIndexExpanded}>
<Show when={!indexExpanded()}> <Show when={!indexExpanded()}>
<Icon name="content-index-control" /> <Icon name="content-index-control" />

View File

@ -21,9 +21,9 @@ export const ManifestPage = () => {
<Modal variant="wide" name="subscribe"> <Modal variant="wide" name="subscribe">
<Subscribe /> <Subscribe />
</Modal> </Modal>
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-md-4 col-lg-3 order-md-last"> <div class="col-md-3 col-lg-2 order-md-last">
<button class="button button--content-index" onClick={toggleIndexExpanded}> <button class="button button--content-index" onClick={toggleIndexExpanded}>
<Show when={!indexExpanded()}> <Show when={!indexExpanded()}>
<Icon name="content-index-control" /> <Icon name="content-index-control" />

View File

@ -6,7 +6,7 @@ import { t } from '../../../utils/intl'
export const PartnersPage = () => { export const PartnersPage = () => {
return ( return (
<PageWrap> <PageWrap>
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-7 shift-content order-md-first"> <div class="col-md-6 col-xl-7 shift-content order-md-first">
<h1>{t('Partners')}</h1> <h1>{t('Partners')}</h1>

View File

@ -5,7 +5,7 @@ export const PrinciplesPage = () => {
const title = t('Principles') const title = t('Principles')
return ( return (
<PageWrap> <PageWrap>
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-7 shift-content order-md-first"> <div class="col-md-6 col-xl-7 shift-content order-md-first">
<h1> <h1>

View File

@ -6,7 +6,7 @@ import { t } from '../../../utils/intl'
export const ProjectsPage = () => { export const ProjectsPage = () => {
return ( return (
<PageWrap> <PageWrap>
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-7 shift-content order-md-first"> <div class="col-md-6 col-xl-7 shift-content order-md-first">
<h1>{t('Projects')}</h1> <h1>{t('Projects')}</h1>

View File

@ -15,9 +15,9 @@ export const TermsOfUsePage = () => {
{/*<Meta name="keywords" content={`Discours.io, ${t('Terms of use')}, ${t('Terms of use', 'en')}`} />*/} {/*<Meta name="keywords" content={`Discours.io, ${t('Terms of use')}, ${t('Terms of use', 'en')}`} />*/}
{/*<Meta property="og:title" content={title} />*/} {/*<Meta property="og:title" content={title} />*/}
{/*<Meta property="og:description" content={title} />*/} {/*<Meta property="og:description" content={title} />*/}
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-md-4 col-lg-3 order-md-last"> <div class="col-md-3 col-lg-2 order-md-last">
<button class="button button--content-index" onClick={toggleIndexExpanded}> <button class="button button--content-index" onClick={toggleIndexExpanded}>
<Show when={!indexExpanded()}> <Show when={!indexExpanded()}>
<Icon name="content-index-control" /> <Icon name="content-index-control" />

View File

@ -10,7 +10,7 @@ export const ThanksPage = () => {
{/*<Meta property="og:title" content={title} />*/} {/*<Meta property="og:title" content={title} />*/}
{/*<Meta property="og:description" content={title} />*/} {/*<Meta property="og:description" content={title} />*/}
<article class="container container--static-page"> <article class="wide-container container--static-page">
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-7 shift-content order-md-first"> <div class="col-md-6 col-xl-7 shift-content order-md-first">
<h1> <h1>

View File

@ -0,0 +1,135 @@
import { PageWrap } from '../../_shared/PageWrap'
import type { PageProps } from '../../types'
import styles from './Settings.module.scss'
import { Icon } from '../../_shared/Icon'
import { clsx } from 'clsx'
import ProfileSettingsNavigation from '../../Discours/ProfileSettingsNavigation'
export const ProfileSecurityPage = (props: PageProps) => {
return (
<PageWrap>
<div class="wide-container">
<div class="shift-content">
<div class="left-col">
<div class={clsx('left-navigation', styles.leftNavigation)}>
<ProfileSettingsNavigation />
</div>
</div>
<div class="row">
<div class="col-md-10 col-lg-9 col-xl-8">
<h1>Вход и&nbsp;безопасность</h1>
<p class="description">Настройки аккаунта, почты, пароля и&nbsp;способов входа.</p>
<form>
<h4>Почта</h4>
<div class="pretty-form__item">
<input type="text" name="email" id="email" placeholder="Почта" />
<label for="email">Почта</label>
</div>
<h4>Изменить пароль</h4>
<h5>Текущий пароль</h5>
<div class="pretty-form__item">
<input
type="text"
name="password-current"
id="password-current"
class={clsx(styles.passwordInput, 'nolabel')}
/>
<button type="button" class={styles.passwordToggleControl}>
<Icon name="password-hide" />
</button>
</div>
<h5>Новый пароль</h5>
<div class="pretty-form__item">
<input
type="password"
name="password-new"
id="password-new"
class={clsx(styles.passwordInput, 'nolabel')}
/>
<button type="button" class={styles.passwordToggleControl}>
<Icon name="password-open" />
</button>
</div>
<h5>Подтвердите новый пароль</h5>
<div class="pretty-form__item">
<input
type="password"
name="password-new-confirm"
id="password-new-confirm"
class={clsx(styles.passwordInput, 'nolabel')}
/>
<button type="button" class={styles.passwordToggleControl}>
<Icon name="password-open" />
</button>
</div>
<h4>Социальные сети</h4>
<h5>Google</h5>
<div class="pretty-form__item">
<p>
<button class={clsx('button button--light', styles.socialButton)} type="button">
<Icon name="google" class={styles.icon} />
Привязать
</button>
</p>
</div>
<h5>VK</h5>
<div class="pretty-form__item">
<p>
<button class={clsx(styles.socialButton, 'button button--light')} type="button">
<Icon name="vk" class={styles.icon} />
Привязать
</button>
</p>
</div>
<h5>Facebook</h5>
<div class="pretty-form__item">
<p>
<button class={clsx(styles.socialButton, 'button button--light')} type="button">
<Icon name="facebook" class={styles.icon} />
Привязать
</button>
</p>
</div>
<h5>Apple</h5>
<div class="pretty-form__item">
<p>
<button
class={clsx(
styles.socialButton,
styles.socialButtonApple,
'button' + ' button--light'
)}
type="button"
>
<Icon name="apple" class={styles.icon} />
Привязать
</button>
</p>
</div>
<br />
<p>
<button class="button button--submit" type="submit">
Сохранить настройки
</button>
</p>
</form>
</div>
</div>
</div>
</div>
</PageWrap>
)
}
// for lazy loading
export default ProfileSecurityPage

View File

@ -0,0 +1,186 @@
import { PageWrap } from '../../_shared/PageWrap'
import { t } from '../../../utils/intl'
import type { PageProps } from '../../types'
import { Icon } from '../../_shared/Icon'
import ProfileSettingsNavigation from '../../Discours/ProfileSettingsNavigation'
import { For, createSignal, Show } from 'solid-js'
import { clsx } from 'clsx'
import styles from './Settings.module.scss'
import { useProfileForm } from '../../../context/profile'
import { createFileUploader } from '@solid-primitives/upload'
export const ProfileSettingsPage = (props: PageProps) => {
const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false)
const { form, updateFormField, submit } = useProfileForm()
const handleChangeSocial = (value) => {
updateFormField('links', value)
setAddLinkForm(false)
}
const handleSubmit = (event: Event): void => {
event.preventDefault()
submit(form)
}
const { selectFiles: selectFilesAsync } = createFileUploader({ accept: 'image/*' })
const handleUpload = () => {
selectFilesAsync(async ([{ source, name, size, file }]) => {
try {
console.log({ source, name, size, file })
// DO UPLOAD STUFF HERE AND RETURN URL
} catch (error) {
console.log(error)
}
})
}
return (
<PageWrap>
<Show when={form}>
<div class="wide-container">
<div class="shift-content">
<div class="left-col">
<div class={clsx('left-navigation', styles.leftNavigation)}>
<ProfileSettingsNavigation />
</div>
</div>
<div class="row">
<div class="col-md-10 col-lg-9 col-xl-8">
<h1>{t('Profile settings')}</h1>
<p class="description">{t('Here you can customize your profile the way you want.')}</p>
<form onSubmit={handleSubmit}>
<h4>{t('Userpic')}</h4>
<div class="pretty-form__item">
<div class={styles.avatarContainer}>
<img class={styles.avatar} src={form.userpic} alt={form.name} />
<button type="button" class={styles.avatarInput} onClick={handleUpload} />
</div>
</div>
<h4>{t('Name')}</h4>
<p class="description">
{t(
'Your name will appear on your profile page and as your signature in publications, comments and responses.'
)}
</p>
<div class="pretty-form__item">
<input
type="text"
name="username"
id="username"
placeholder={t('Name')}
onChange={(event) => updateFormField('name', event.currentTarget.value)}
value={form.name}
/>
<label for="username">{t('Name')}</label>
</div>
<h4>{t('Address on Discourse')}</h4>
<div class="pretty-form__item">
<div class={styles.discoursName}>
<label for="user-address">https://new.discours.io/author/</label>
<div class={styles.discoursNameField}>
<input
type="text"
name="user-address"
id="user-address"
onChange={(event) => updateFormField('slug', event.currentTarget.value)}
value={form.slug}
class="nolabel"
/>
<p class="form-message form-message--error">
{t('Sorry, this address is already taken, please choose another one.')}
</p>
</div>
</div>
</div>
<h4>{t('Introduce')}</h4>
<div class="pretty-form__item">
<textarea name="presentation" id="presentation" placeholder={t('Introduce')}>
{form.bio}
</textarea>
<label for="presentation">{t('Introduce')}</label>
</div>
<h4>{t('About myself')}</h4>
<div class="pretty-form__item">
<textarea
name="about"
id="about"
placeholder={t('About myself')}
value={form.about}
onChange={(event) => updateFormField('about', event.currentTarget.value)}
/>
<label for="about">{t('About myself')}</label>
</div>
{/*Нет реализации полей на бэке*/}
{/*<h4>{t('How can I help/skills')}</h4>*/}
{/*<div class="pretty-form__item">*/}
{/* <input type="text" name="skills" id="skills" />*/}
{/*</div>*/}
{/*<h4>{t('Where')}</h4>*/}
{/*<div class="pretty-form__item">*/}
{/* <input type="text" name="location" id="location" placeholder="Откуда" />*/}
{/* <label for="location">{t('Where')}</label>*/}
{/*</div>*/}
{/*<h4>{t('Date of Birth')}</h4>*/}
{/*<div class="pretty-form__item">*/}
{/* <input*/}
{/* type="date"*/}
{/* name="birthdate"*/}
{/* id="birthdate"*/}
{/* placeholder="Дата рождения"*/}
{/* class="nolabel"*/}
{/* />*/}
{/*</div>*/}
<div class={clsx(styles.multipleControls, 'pretty-form__item')}>
<div class={styles.multipleControlsHeader}>
<h4>{t('Social networks')}</h4>
<button type="button" class="button" onClick={() => setAddLinkForm(!addLinkForm())}>
+
</button>
</div>
<Show when={addLinkForm()}>
<div class={styles.multipleControlsItem}>
<input
autofocus={true}
type="text"
name="link"
class="nolabel"
onChange={(event) => handleChangeSocial(event.currentTarget.value)}
/>
</div>
</Show>
<For each={form.links}>
{(link) => (
<div class={styles.multipleControlsItem}>
<input type="text" value={link} readonly={true} name="link" class="nolabel" />
<button type="button" onClick={() => updateFormField('links', link, true)}>
<Icon name="remove" class={styles.icon} />
</button>
</div>
)}
</For>
</div>
<br />
<p>
<button type="submit" class="button button--submit">
{t('Save settings')}
</button>
</p>
</form>
</div>
</div>
</div>
<pre>{JSON.stringify(form, null, 2)}</pre>
</div>
</Show>
</PageWrap>
)
}
// for lazy loading
export default ProfileSettingsPage

View File

@ -0,0 +1,137 @@
import { PageWrap } from '../../_shared/PageWrap'
import type { PageProps } from '../../types'
import styles from './Settings.module.scss'
import stylesSettings from '../../../styles/FeedSettings.module.scss'
import { clsx } from 'clsx'
import ProfileSettingsNavigation from '../../Discours/ProfileSettingsNavigation'
import { SearchField } from '../../_shared/SearchField'
export const ProfileSubscriptionsPage = (props: PageProps) => {
return (
<PageWrap>
<div class="wide-container">
<div class="shift-content">
<div class="left-col">
<div class={clsx('left-navigation', styles.leftNavigation)}>
<ProfileSettingsNavigation />
</div>
</div>
<div class="row">
<div class="col-md-10 col-lg-9 col-xl-8">
<h1>Подписки</h1>
<p class="description">Здесь можно управлять всеми своими подписками на&nbsp;сайте.</p>
<form>
<ul class="view-switcher">
<li class="selected">
<a href="#">Все</a>
</li>
<li>
<a href="#">Авторы</a>
</li>
<li>
<a href="#">Темы</a>
</li>
<li>
<a href="#">Сообщества</a>
</li>
<li>
<a href="#">Коллекции</a>
</li>
</ul>
<div class={clsx('pretty-form__item', styles.searchContainer)}>
<SearchField onChange={() => console.log('nothing')} class={styles.searchField} />
</div>
<div class={clsx(stylesSettings.settingsList, styles.topicsList)}>
<div class={stylesSettings.settingsListRow}>
<div class={clsx(stylesSettings.settingsListCell, styles.topicsListItem)}>
<input type="checkbox" name="checkbox1" id="checkbox1" />
<label for="checkbox1" />
</div>
<label for="checkbox1" class={styles.settingsListCell}>
Культура
</label>
</div>
<div class={stylesSettings.settingsListRow}>
<div class={clsx(stylesSettings.settingsListCell, styles.topicsListItem)}>
<input type="checkbox" name="checkbox2" id="checkbox2" />
<label for="checkbox2" />
</div>
<label for="checkbox2" class={styles.settingsListCell}>
Eto_ya sam
</label>
</div>
<div class={stylesSettings.settingsListRow}>
<div class={clsx(stylesSettings.settingsListCell, styles.topicsListItem)}>
<input type="checkbox" name="checkbox3" id="checkbox3" />
<label for="checkbox3" />
</div>
<label for="checkbox3" class={styles.settingsListCell}>
Технопарк
</label>
</div>
<div class={stylesSettings.settingsListRow}>
<div class={clsx(stylesSettings.settingsListCell, styles.topicsListItem)}>
<input type="checkbox" name="checkbox4" id="checkbox4" />
<label for="checkbox4" />
</div>
<label for="checkbox4" class={styles.settingsListCell}>
Лучшее
</label>
</div>
<div class={stylesSettings.settingsListRow}>
<div class={clsx(stylesSettings.settingsListCell, styles.topicsListItem)}>
<input type="checkbox" name="checkbox5" id="checkbox5" />
<label for="checkbox5" />
</div>
<label for="checkbox5" class={styles.settingsListCell}>
Реклама
</label>
</div>
<div class={stylesSettings.settingsListRow}>
<div class={clsx(stylesSettings.settingsListCell, styles.topicsListItem)}>
<input type="checkbox" name="checkbox6" id="checkbox6" />
<label for="checkbox6" />
</div>
<label for="checkbox6" class={styles.settingsListCell}>
Искусство
</label>
</div>
<div class={stylesSettings.settingsListRow}>
<div class={clsx(stylesSettings.settingsListCell, styles.topicsListItem)}>
<input type="checkbox" name="checkbox7" id="checkbox7" />
<label for="checkbox7" />
</div>
<label for="checkbox7" class={styles.settingsListCell}>
Общество
</label>
</div>
<div class={stylesSettings.settingsListRow}>
<div class={clsx(stylesSettings.settingsListCell, styles.topicsListItem)}>
<input type="checkbox" name="checkbox8" id="checkbox8" />
<label for="checkbox8" />
</div>
<label for="checkbox8" class={styles.settingsListCell}>
Личный опыт
</label>
</div>
</div>
<br />
<p>
<button class="button button--submit">Сохранить настройки</button>
</p>
</form>
</div>
</div>
</div>
</div>
</PageWrap>
)
}
// for lazy loading
export default ProfileSubscriptionsPage

View File

@ -0,0 +1,189 @@
h4,
h5 {
font-weight: 500;
}
h4 {
@include font-size(2.4rem);
}
h5 {
@include font-size(1.7rem);
margin: 0 0 0.8rem;
}
.avatarContainer {
border-radius: 100%;
overflow: hidden;
position: relative;
height: 18rem;
width: 18rem;
}
.avatar,
.avatarInput {
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
.avatar {
background: #ccc;
border: none;
object-fit: cover;
object-position: center;
}
.avatarInput {
border-radius: 100%;
cursor: pointer;
opacity: 0;
z-index: 1;
}
.multipleControls {
margin-top: 3rem;
}
.multipleControlsItem {
position: relative;
button {
background-color: #fff;
padding: 0.5em;
position: absolute;
right: 0.8em;
top: 50%;
transform: translateY(-50%);
transition: background-color 0.3s;
&:hover {
background-color: #000;
.icon {
filter: invert(1);
}
}
.icon {
filter: invert(0);
transition: filter 0.3s;
}
}
form & input {
padding-right: 5rem;
}
}
.multipleControlsHeader {
display: flex;
margin-bottom: 0.5em;
h4 {
flex: 1;
margin-bottom: 0;
}
button {
margin-left: 1em;
padding-bottom: 0.5rem;
padding-top: 0.5rem;
}
}
.discoursName {
display: flex;
@include media-breakpoint-down(sm) {
flex-wrap: wrap;
input {
min-width: 250px;
}
}
label {
margin: 0.6em 0.5em 0 0;
}
}
.discoursNameField {
flex: 1;
}
.leftNavigation {
top: 9rem !important;
}
.passwordToggleControl {
position: absolute;
right: 1em;
transform: translateY(-50%);
top: 50%;
}
.passwordInput {
padding-right: 3em !important;
}
.searchContainer {
margin-top: 2.4rem;
}
.searchField {
display: block;
label:first-child {
opacity: 0.5;
position: absolute;
right: 1em;
transform: translateY(-50%);
top: 50%;
}
}
.topicsList {
label {
@include font-size(1.7rem);
}
}
.topicsListItem {
padding-right: 1.5rem !important;
}
.socialButton {
color: #000;
display: flex;
padding: 0.8em 1em;
transition: background-color 0.3s, color 0.3s;
&:hover {
background: #000;
color: #fff;
}
img {
vertical-align: middle;
}
.icon {
margin-right: 1em;
}
}
.socialButtonApple {
&:hover {
.icon {
filter: invert(1);
}
}
.icon {
filter: invert(0);
transition: filter 0.3s;
}
}

View File

@ -32,6 +32,9 @@ import { ConnectPage } from './Pages/ConnectPage'
import { InboxPage } from './Pages/InboxPage' import { InboxPage } from './Pages/InboxPage'
import { LayoutShoutsPage } from './Pages/LayoutShoutsPage' import { LayoutShoutsPage } from './Pages/LayoutShoutsPage'
import { SessionProvider } from '../context/session' import { SessionProvider } from '../context/session'
import { ProfileSettingsPage } from './Pages/profile/ProfileSettingsPage'
import { ProfileSecurityPage } from './Pages/profile/ProfileSecurityPage'
import { ProfileSubscriptionsPage } from './Pages/profile/ProfileSubscriptionsPage'
// TODO: lazy load // TODO: lazy load
// const SomePage = lazy(() => import('./Pages/SomePage')) // const SomePage = lazy(() => import('./Pages/SomePage'))
@ -58,7 +61,10 @@ const pagesMap: Record<keyof Routes, Component<PageProps>> = {
partners: PartnersPage, partners: PartnersPage,
principles: PrinciplesPage, principles: PrinciplesPage,
termsOfUse: TermsOfUsePage, termsOfUse: TermsOfUsePage,
thanks: ThanksPage thanks: ThanksPage,
profileSettings: ProfileSettingsPage,
profileSecurity: ProfileSecurityPage,
profileSubscriptions: ProfileSubscriptionsPage
} }
export const Root = (props: PageProps) => { export const Root = (props: PageProps) => {

View File

@ -116,3 +116,7 @@
.buttonCompact { .buttonCompact {
margin-top: 0.6rem; margin-top: 0.6rem;
} }
.isSubscribing {
color: transparent;
}

View File

@ -1,6 +1,6 @@
import { capitalize } from '../../utils' import { capitalize } from '../../utils'
import styles from './Card.module.scss' import styles from './Card.module.scss'
import { createMemo, Show } from 'solid-js' import { createEffect, createMemo, createSignal, Show } from 'solid-js'
import type { Topic } from '../../graphql/types.gen' import type { Topic } from '../../graphql/types.gen'
import { FollowingEntity } from '../../graphql/types.gen' import { FollowingEntity } from '../../graphql/types.gen'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
@ -9,6 +9,7 @@ import { getLogger } from '../../utils/logger'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
import { StatMetrics } from '../_shared/StatMetrics' import { StatMetrics } from '../_shared/StatMetrics'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
const log = getLogger('TopicCard') const log = getLogger('TopicCard')
@ -25,7 +26,13 @@ interface TopicProps {
} }
export const TopicCard = (props: TopicProps) => { export const TopicCard = (props: TopicProps) => {
const { session } = useSession() const {
session,
isSessionLoaded,
actions: { loadSession }
} = useSession()
const [isSubscribing, setIsSubscribing] = createSignal(false)
const subscribed = createMemo(() => { const subscribed = createMemo(() => {
if (!session()?.user?.slug || !session()?.news?.topics) { if (!session()?.user?.slug || !session()?.news?.topics) {
@ -35,14 +42,17 @@ export const TopicCard = (props: TopicProps) => {
return session()?.news.topics.includes(props.topic.slug) return session()?.news.topics.includes(props.topic.slug)
}) })
// FIXME use store actions
const subscribe = async (really = true) => { const subscribe = async (really = true) => {
if (really) { setIsSubscribing(true)
follow({ what: FollowingEntity.Topic, slug: props.topic.slug })
} else { await (really
unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug }) ? follow({ what: FollowingEntity.Topic, slug: props.topic.slug })
} : unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug }))
await loadSession()
setIsSubscribing(false)
} }
return ( return (
<div <div
class={styles.topic} class={styles.topic}
@ -79,32 +89,30 @@ export const TopicCard = (props: TopicProps) => {
class={styles.controlContainer} class={styles.controlContainer}
classList={{ 'col-md-3': !props.compact && !props.subscribeButtonBottom }} classList={{ 'col-md-3': !props.compact && !props.subscribeButtonBottom }}
> >
<Show <ShowOnlyOnClient>
when={subscribed()} <Show when={isSessionLoaded()}>
fallback={
<button <button
onClick={() => subscribe(true)} onClick={() => subscribe(!subscribed())}
class="button--light button--subscribe-topic" class="button--light button--subscribe-topic"
classList={{ classList={{
[styles.buttonCompact]: props.compact [styles.buttonCompact]: props.compact,
[styles.isSubscribing]: isSubscribing()
}} }}
disabled={isSubscribing()}
> >
<Show when={props.iconButton}>+</Show> <Show when={props.iconButton}>
<Show when={!props.iconButton}>{t('Follow')}</Show> <Show when={subscribed()} fallback="+">
</button> -
} </Show>
> </Show>
<button <Show when={!props.iconButton}>
onClick={() => subscribe(false)} <Show when={subscribed()} fallback={t('Follow')}>
class="button--light button--subscribe-topic" {t('Unfollow')}
classList={{ </Show>
[styles.buttonCompact]: props.compact </Show>
}}
>
<Show when={props.iconButton}>-</Show>
<Show when={!props.iconButton}>{t('Unfollow')}</Show>
</button> </button>
</Show> </Show>
</ShowOnlyOnClient>
</div> </div>
</div> </div>
) )

View File

@ -1,14 +1,14 @@
import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js'
import type { Author } from '../../graphql/types.gen' import type { Author } from '../../graphql/types.gen'
import { AuthorCard } from '../Author/Card'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { useAuthorsStore, setAuthorsSort } from '../../stores/zine/authors' import { setAuthorsSort, useAuthorsStore } from '../../stores/zine/authors'
import { useRouter } from '../../stores/router' import { useRouter } from '../../stores/router'
import styles from '../../styles/AllTopics.module.scss' import { AuthorCard } from '../Author/Card'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { translit } from '../../utils/ru2en' import { translit } from '../../utils/ru2en'
import styles from '../../styles/AllTopics.module.scss'
import { SearchField } from '../_shared/SearchField' import { SearchField } from '../_shared/SearchField'
import { scrollHandler } from '../../utils/scroll' import { scrollHandler } from '../../utils/scroll'
import { StatMetrics } from '../_shared/StatMetrics' import { StatMetrics } from '../_shared/StatMetrics'
@ -17,41 +17,43 @@ type AllAuthorsPageSearchParams = {
by: '' | 'name' | 'shouts' | 'followers' by: '' | 'name' | 'shouts' | 'followers'
} }
type Props = { type AllAuthorsViewProps = {
authors: Author[] authors: Author[]
} }
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const ALPHABET = [...'@АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'] const ALPHABET = [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@']
export const AllAuthorsView = (props: Props) => { export const AllAuthorsView = (props: AllAuthorsViewProps) => {
const [limit, setLimit] = createSignal(PAGE_SIZE) const [limit, setLimit] = createSignal(PAGE_SIZE)
const { searchParams, changeSearchParam } = useRouter<AllAuthorsPageSearchParams>() const { searchParams, changeSearchParam } = useRouter<AllAuthorsPageSearchParams>()
const { sortedAuthors } = useAuthorsStore({ const { sortedAuthors } = useAuthorsStore({
authors: props.authors, authors: props.authors,
sortBy: searchParams().by || 'name' sortBy: searchParams().by || 'shouts'
}) })
const [searchQuery, setSearchQuery] = createSignal('')
const { session } = useSession() const { session } = useSession()
onMount(() => { onMount(() => {
if (!searchParams().by) { if (!searchParams().by) {
setAuthorsSort('name') changeSearchParam('by', 'shouts')
changeSearchParam('by', 'name')
} }
}) })
createEffect(() => {
setAuthorsSort(searchParams().by || 'name')
setLimit(PAGE_SIZE)
})
const subscribed = (s) => Boolean(session()?.news?.authors && session()?.news?.authors?.includes(s || '')) createEffect(() => {
setAuthorsSort(searchParams().by || 'shouts')
})
const byLetter = createMemo<{ [letter: string]: Author[] }>(() => { const byLetter = createMemo<{ [letter: string]: Author[] }>(() => {
return sortedAuthors().reduce((acc, author) => { return sortedAuthors().reduce((acc, author) => {
let letter = author.name.trim().split(' ').pop().at(0).toUpperCase() let letter = author.name.trim().split(' ').pop().at(0).toUpperCase()
if (!/[А-я]/i.test(letter) && locale() === 'ru') letter = '@'
if (/[^ËА-яё]/.test(letter) && locale() === 'ru') letter = '@'
if (!acc[letter]) acc[letter] = [] if (!acc[letter]) acc[letter] = []
acc[letter].push(author) acc[letter].push(author)
return acc return acc
}, {} as { [letter: string]: Author[] }) }, {} as { [letter: string]: Author[] })
@ -60,9 +62,35 @@ export const AllAuthorsView = (props: Props) => {
const sortedKeys = createMemo<string[]>(() => { const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetter()) const keys = Object.keys(byLetter())
keys.sort() keys.sort()
keys.push(keys.shift())
return keys return keys
}) })
const subscribed = (s) => Boolean(session()?.news?.authors && session()?.news?.authors?.includes(s || ''))
const filteredAuthors = createMemo(() => {
let q = searchQuery().toLowerCase()
if (q.length === 0) {
return sortedAuthors()
}
if (locale() === 'ru') q = translit(q)
return sortedAuthors().filter((author) => {
if (author.slug.split('-').some((w) => w.startsWith(q))) {
return true
}
let name = author.name.toLowerCase()
if (locale() === 'ru') {
name = translit(name)
}
return name.split(' ').some((word) => word.startsWith(q))
})
})
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE) const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
const AllAuthorsHead = () => ( const AllAuthorsHead = () => (
<div class="row"> <div class="row">
@ -71,57 +99,28 @@ export const AllAuthorsView = (props: Props) => {
<p>{t('Subscribe who you like to tune your personal feed')}</p> <p>{t('Subscribe who you like to tune your personal feed')}</p>
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}> <ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
<li classList={{ selected: searchParams().by === 'shouts' }}> <li classList={{ selected: !searchParams().by || searchParams().by === 'shouts' }}>
<a href="/authors?by=shouts">{t('By shouts')}</a> <a href="/authors?by=shouts">{t('By shouts')}</a>
</li> </li>
<li classList={{ selected: searchParams().by === 'followers' }}> <li classList={{ selected: searchParams().by === 'followers' }}>
<a href="/authors?by=followers">{t('By rating')}</a> <a href="/authors?by=followers">{t('By popularity')}</a>
</li> </li>
<li classList={{ selected: !searchParams().by || searchParams().by === 'name' }}> <li classList={{ selected: searchParams().by === 'name' }}>
<a href="/authors?by=name">{t('By name')}</a> <a href="/authors?by=name">{t('By name')}</a>
</li> </li>
<Show when={searchParams().by !== 'name'}>
<li class="view-switcher__search"> <li class="view-switcher__search">
<SearchField onChange={searchAuthors} /> <SearchField onChange={(value) => setSearchQuery(value)} />
</li> </li>
</Show>
</ul> </ul>
</div> </div>
</div> </div>
) )
const [searchResults, setSearchResults] = createSignal<Author[]>([])
// eslint-disable-next-line sonarjs/cognitive-complexity
const searchAuthors = (value) => {
/* very stupid search algorithm with no deps */
let q = value.toLowerCase()
if (q.length > 0) {
console.debug(q)
setSearchResults([])
if (locale() === 'ru') q = translit(q, 'ru')
const aaa: Author[] = []
sortedAuthors().forEach((a) => {
let flag = false
a.slug.split('-').forEach((w) => {
if (w.startsWith(q)) flag = true
})
if (!flag) {
let wrds: string = a.name.toLowerCase()
if (locale() === 'ru') wrds = translit(wrds, 'ru')
wrds.split(' ').forEach((w: string) => {
if (w.startsWith(q)) flag = true
})
}
if (flag && !aaa.includes(a)) aaa.push(a)
})
setSearchResults((sr: Author[]) => [...sr, ...aaa])
changeSearchParam('by', '')
}
}
return ( return (
<div class={clsx(styles.allTopicsPage, 'wide-container')}> <div class={clsx(styles.allTopicsPage, 'wide-container')}>
<Show when={sortedAuthors().length > 0 || searchResults().length > 0}> <Show when={sortedAuthors().length > 0}>
<div class="shift-content"> <div class="shift-content">
<AllAuthorsHead /> <AllAuthorsHead />
@ -130,12 +129,15 @@ export const AllAuthorsView = (props: Props) => {
<div class="col-lg-10 col-xl-9"> <div class="col-lg-10 col-xl-9">
<ul class={clsx('nodash', styles.alphabet)}> <ul class={clsx('nodash', styles.alphabet)}>
<For each={ALPHABET}> <For each={ALPHABET}>
{(letter: string, index) => ( {(letter, index) => (
<li> <li>
<Show when={letter in byLetter()} fallback={letter}> <Show when={letter in byLetter()} fallback={letter}>
<a <a
href={`/authors?by=name#letter-${index()}`} href={`/authors?by=name#letter-${index()}`}
onClick={() => scrollHandler(`letter-${index()}`)} onClick={(event) => {
event.preventDefault()
scrollHandler(`letter-${index()}`)
}}
> >
{letter} {letter}
</a> </a>
@ -148,9 +150,9 @@ export const AllAuthorsView = (props: Props) => {
</div> </div>
<For each={sortedKeys()}> <For each={sortedKeys()}>
{(letter, index) => ( {(letter) => (
<div class={clsx(styles.group, 'group')}> <div class={clsx(styles.group, 'group')}>
<h2 id={`letter-${index()}`}>{letter}</h2> <h2 id={`letter-${ALPHABET.indexOf(letter)}`}>{letter}</h2>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-lg-10"> <div class="col-lg-10">
@ -174,49 +176,26 @@ export const AllAuthorsView = (props: Props) => {
</For> </For>
</Show> </Show>
<Show when={searchResults().length > 0}>
<For each={searchResults().slice(0, limit())}>
{(author) => (
<>
<AuthorCard
author={author}
compact={false}
hasLink={true}
subscribed={subscribed(author.slug)}
noSocialButtons={true}
isAuthorsList={true}
truncateBio={true}
/>
<StatMetrics fields={['shouts', 'followers', 'comments']} stat={author.stat} />
</>
)}
</For>
</Show>
<Show when={searchParams().by && searchParams().by !== 'name'}> <Show when={searchParams().by && searchParams().by !== 'name'}>
<div class={clsx(styles.stats, 'row')}> <For each={filteredAuthors().slice(0, limit())}>
<div class="col-lg-10 col-xl-9">
<For each={sortedAuthors().slice(0, limit())}>
{(author) => ( {(author) => (
<> <div class="row">
<div class="col-lg-10 col-xl-9">
<AuthorCard <AuthorCard
author={author} author={author}
compact={false}
hasLink={true} hasLink={true}
subscribed={subscribed(author.slug)} subscribed={subscribed(author.slug)}
noSocialButtons={true} noSocialButtons={true}
isAuthorsList={true} isAuthorsList={true}
truncateBio={true} truncateBio={true}
/> />
<StatMetrics fields={['shouts', 'followers', 'comments']} stat={author.stat} /> </div>
</> </div>
)} )}
</For> </For>
</div>
</div>
</Show> </Show>
<Show when={searchParams().by !== 'name' && sortedAuthors().length > limit()}> <Show when={filteredAuthors().length > limit() && searchParams().by !== 'name'}>
<div class="row"> <div class="row">
<div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10')}> <div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10')}>
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}> <button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>

View File

@ -22,7 +22,7 @@ type AllTopicsViewProps = {
} }
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const ALPHABET = [...'#АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'] const ALPHABET = [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#']
export const AllTopicsView = (props: AllTopicsViewProps) => { export const AllTopicsView = (props: AllTopicsViewProps) => {
const { searchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>() const { searchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>()
@ -37,19 +37,18 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
onMount(() => { onMount(() => {
if (!searchParams().by) { if (!searchParams().by) {
setTopicsSort('shouts')
changeSearchParam('by', 'shouts') changeSearchParam('by', 'shouts')
} }
}) })
createEffect(() => { createEffect(() => {
setTopicsSort(searchParams().by || 'shouts') setTopicsSort(searchParams().by || 'shouts')
setLimit(PAGE_SIZE)
}) })
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => { const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
return sortedTopics().reduce((acc, topic) => { return sortedTopics().reduce((acc, topic) => {
let letter = topic.title[0].toUpperCase() let letter = topic.title[0].toUpperCase()
if (!/[А-я]/i.test(letter) && locale() === 'ru') letter = '#' if (/[Аё]/.test(letter) && locale() === 'ru') letter = '#'
if (!acc[letter]) acc[letter] = [] if (!acc[letter]) acc[letter] = []
acc[letter].push(topic) acc[letter].push(topic)
return acc return acc
@ -59,45 +58,41 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
const sortedKeys = createMemo<string[]>(() => { const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetter()) const keys = Object.keys(byLetter())
keys.sort() keys.sort()
keys.push(keys.shift())
return keys return keys
}) })
const subscribed = (s) => Boolean(session()?.news?.topics && session()?.news?.topics?.includes(s || '')) const subscribed = (s) => Boolean(session()?.news?.topics && session()?.news?.topics?.includes(s || ''))
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE) const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
const [searchResults, setSearchResults] = createSignal<Topic[]>([])
// eslint-disable-next-line sonarjs/cognitive-complexity
const searchTopics = (value) => {
/* very stupid search algorithm with no deps */
let q = value.toLowerCase()
if (q.length > 0) {
console.debug(q)
setSearchResults([])
if (locale() === 'ru') q = translit(q, 'ru') const [searchQuery, setSearchQuery] = createSignal('')
const ttt: Topic[] = []
sortedTopics().forEach((topic) => {
let flag = false
topic.slug.split('-').forEach((w) => {
if (w.startsWith(q)) flag = true
})
if (!flag) { const filteredResults = createMemo(() => {
let wrds: string = topic.title.toLowerCase() /* very stupid filter by string algorithm with no deps */
if (locale() === 'ru') wrds = translit(wrds, 'ru') let q = searchQuery().toLowerCase()
wrds.split(' ').forEach((w: string) => { if (q.length === 0) {
if (w.startsWith(q)) flag = true return sortedTopics()
})
} }
if (flag && !ttt.includes(topic)) ttt.push(topic) if (locale() === 'ru') {
}) q = translit(q)
}
setSearchResults((sr: Topic[]) => [...sr, ...ttt]) return sortedTopics().filter((topic) => {
changeSearchParam('by', '') if (topic.slug.split('-').some((w) => w.startsWith(q))) {
return true
} }
let title = topic.title.toLowerCase()
if (locale() === 'ru') {
title = translit(title)
} }
return title.split(' ').some((word) => word.startsWith(q))
})
})
const AllTopicsHead = () => ( const AllTopicsHead = () => (
<div class="row"> <div class="row">
<div class={clsx(styles.pageHeader, 'col-lg-10 col-xl-9')}> <div class={clsx(styles.pageHeader, 'col-lg-10 col-xl-9')}>
@ -114,9 +109,11 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
<li classList={{ selected: searchParams().by === 'title' }}> <li classList={{ selected: searchParams().by === 'title' }}>
<a href="/topics?by=title">{t('By title')}</a> <a href="/topics?by=title">{t('By title')}</a>
</li> </li>
<Show when={searchParams().by !== 'title'}>
<li class="view-switcher__search"> <li class="view-switcher__search">
<SearchField onChange={searchTopics} /> <SearchField onChange={(value) => setSearchQuery(value)} />
</li> </li>
</Show>
</ul> </ul>
</div> </div>
</div> </div>
@ -127,7 +124,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
<div class="shift-content"> <div class="shift-content">
<AllTopicsHead /> <AllTopicsHead />
<Show when={sortedTopics().length > 0 || searchResults().length > 0}> <Show when={filteredResults().length > 0}>
<Show when={searchParams().by === 'title'}> <Show when={searchParams().by === 'title'}>
<div class="col-lg-10 col-xl-9"> <div class="col-lg-10 col-xl-9">
<ul class={clsx('nodash', styles.alphabet)}> <ul class={clsx('nodash', styles.alphabet)}>
@ -137,7 +134,10 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
<Show when={letter in byLetter()} fallback={letter}> <Show when={letter in byLetter()} fallback={letter}>
<a <a
href={`/topics?by=title#letter-${index()}`} href={`/topics?by=title#letter-${index()}`}
onClick={() => scrollHandler(`letter-${index()}`)} onClick={(event) => {
event.preventDefault()
scrollHandler(`letter-${index()}`)
}}
> >
{letter} {letter}
</a> </a>
@ -149,9 +149,9 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</div> </div>
<For each={sortedKeys()}> <For each={sortedKeys()}>
{(letter, index) => ( {(letter) => (
<div class={clsx(styles.group, 'group')}> <div class={clsx(styles.group, 'group')}>
<h2 id={`letter-${index()}`}>{letter}</h2> <h2 id={`letter-${ALPHABET.indexOf(letter)}`}>{letter}</h2>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-lg-10"> <div class="col-lg-10">
@ -173,21 +173,8 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</For> </For>
</Show> </Show>
<Show when={searchResults().length > 1}>
<For each={searchResults().slice(0, limit())}>
{(topic) => (
<TopicCard
topic={topic}
compact={false}
subscribed={subscribed(topic.slug)}
showPublications={true}
/>
)}
</For>
</Show>
<Show when={searchParams().by && searchParams().by !== 'title'}> <Show when={searchParams().by && searchParams().by !== 'title'}>
<For each={sortedTopics().slice(0, limit())}> <For each={filteredResults().slice(0, limit())}>
{(topic) => ( {(topic) => (
<> <>
<TopicCard <TopicCard
@ -202,7 +189,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</For> </For>
</Show> </Show>
<Show when={sortedTopics().length > limit()}> <Show when={filteredResults().length > limit() && searchParams().by !== 'title'}>
<div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10 offset-md-1')}> <div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10 offset-md-1')}>
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}> <button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
{t('Load more')} {t('Load more')}

View File

@ -1,4 +1,4 @@
import { createEffect, createSignal, Show, Suspense } from 'solid-js' import { createEffect, createMemo, createSignal, onMount, Show, Suspense } from 'solid-js'
import { FullArticle } from '../Article/FullArticle' import { FullArticle } from '../Article/FullArticle'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import type { Shout, Reaction } from '../../graphql/types.gen' import type { Shout, Reaction } from '../../graphql/types.gen'
@ -9,34 +9,20 @@ interface ArticlePageProps {
reactions?: Reaction[] reactions?: Reaction[]
} }
const ARTICLE_COMMENTS_PAGE_SIZE = 50
export const ArticleView = (props: ArticlePageProps) => { export const ArticleView = (props: ArticlePageProps) => {
const [getCommentsPage] = createSignal(0) onMount(() => {
const [getIsCommentsLoading, setIsCommentsLoading] = createSignal(false) const script = document.createElement('script')
const { reactionsByShout, loadReactionsBy } = useReactionsStore({ reactions: props.reactions }) script.async = true
script.src = 'https://ackee.discours.io/increment.js'
createEffect(async () => { script.dataset.ackeeServer = 'https://ackee.discours.io'
try { script.dataset.ackeeDomainId = '1004abeb-89b2-4e85-ad97-74f8d2c8ed2d'
setIsCommentsLoading(true) document.body.appendChild(script)
await loadReactionsBy({
by: { shout: props.article.slug, comment: true },
limit: ARTICLE_COMMENTS_PAGE_SIZE,
offset: getCommentsPage() * ARTICLE_COMMENTS_PAGE_SIZE
})
} finally {
setIsCommentsLoading(false)
}
}) })
return ( return (
<Show fallback={<div class="center">{t('Loading')}</div>} when={props.article}> <Show fallback={<div class="center">{t('Loading')}</div>} when={props.article}>
<Suspense> <Suspense>
<FullArticle <FullArticle article={props.article} />
article={props.article}
reactions={reactionsByShout()[props.article.slug]}
isCommentsLoading={getIsCommentsLoading()}
/>
</Suspense> </Suspense>
</Show> </Show>
) )

View File

@ -105,7 +105,7 @@ export const AuthorView = (props: AuthorProps) => {
<Beside <Beside
title={t('Topics which supported by author')} title={t('Topics which supported by author')}
values={topicsByAuthor()[author().slug].slice(0, 5)} values={topicsByAuthor()[author().slug]?.slice(0, 5)}
beside={sortedArticles()[0]} beside={sortedArticles()[0]}
wrapper={'topic'} wrapper={'topic'}
topicShortDescription={true} topicShortDescription={true}

View File

@ -1,4 +1,4 @@
import { createSignal, For, onMount, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js'
import '../../styles/Feed.scss' import '../../styles/Feed.scss'
import stylesBeside from '../../components/Feed/Beside.module.scss' import stylesBeside from '../../components/Feed/Beside.module.scss'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
@ -14,7 +14,6 @@ import { useAuthorsStore } from '../../stores/zine/authors'
import { useTopicsStore } from '../../stores/zine/topics' import { useTopicsStore } from '../../stores/zine/topics'
import { useTopAuthorsStore } from '../../stores/zine/topAuthors' import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
import type { Shout } from '../../graphql/types.gen'
// const AUTHORSHIP_REACTIONS = [ // const AUTHORSHIP_REACTIONS = [
// ReactionKind.Accept, // ReactionKind.Accept,
@ -28,13 +27,31 @@ export const FEED_PAGE_SIZE = 20
export const FeedView = () => { export const FeedView = () => {
// state // state
const { sortedArticles } = useArticlesStore() const { sortedArticles } = useArticlesStore()
const { sortedReactions: topComments, loadReactionsBy } = useReactionsStore({}) const { sortedReactions: topComments, loadReactionsBy } = useReactionsStore()
const { sortedAuthors } = useAuthorsStore() const { sortedAuthors } = useAuthorsStore()
const { topTopics } = useTopicsStore() const { topTopics } = useTopicsStore()
const { topAuthors } = useTopAuthorsStore() const { topAuthors } = useTopAuthorsStore()
const { session } = useSession() const { session } = useSession()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const collaborativeShouts = createMemo(() =>
sortedArticles().filter((shout) => shout.visibility === 'authors')
)
createEffect(async () => {
if (collaborativeShouts()) {
// load reactions on collaborativeShouts
await loadReactionsBy({ by: { shouts: [...collaborativeShouts()] }, limit: 5 })
}
})
const userslug = createMemo(() => session()?.user?.slug)
createEffect(async () => {
if (userslug()) {
// load recent editing shouts ( visibility = authors )
await loadShouts({ filters: { author: userslug(), visibility: 'authors' }, limit: 15 })
}
})
const loadMore = async () => { const loadMore = async () => {
const { hasMore } = await loadShouts({ const { hasMore } = await loadShouts({
filters: { visibility: 'community' }, filters: { visibility: 'community' },
@ -50,16 +67,6 @@ export const FeedView = () => {
// load recent shouts not only published ( visibility = community ) // load recent shouts not only published ( visibility = community )
await loadMore() await loadMore()
// TODO: load collabs
// await loadCollabs()
// load recent editing shouts ( visibility = authors )
const userslug = session().user.slug
await loadShouts({ filters: { author: userslug, visibility: 'authors' }, limit: 15 })
const collaborativeShouts = sortedArticles().filter((shout) => shout.visibility === 'authors')
// load reactions on collaborativeShouts
await loadReactionsBy({ by: { shouts: [...collaborativeShouts] }, limit: 5 })
}) })
return ( return (

View File

@ -1,4 +1,4 @@
import '../../styles/FeedSettings.scss' import styles from '../../styles/FeedSettings.module.scss'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
// type FeedSettingsSearchParams = { // type FeedSettingsSearchParams = {
@ -27,20 +27,20 @@ export const FeedSettingsView = (_props) => {
</li> </li>
</ul> </ul>
<div class="settings-list"> <div class={styles.settingsList}>
<div class="settings-list__row"> <div class={styles.settingsListRow}>
<h2>Общее</h2> <h2>Общее</h2>
</div> </div>
<div class="settings-list__row"> <div class={styles.settingsListRow}>
<label for="checkbox1" class="settings-list__cell"> <label for="checkbox1" class={styles.settingsListCell}>
Комментарии к&nbsp;моим постам Комментарии к&nbsp;моим постам
</label> </label>
<div class="settings-list__cell"> <div class={styles.settingsListCell}>
<input type="checkbox" name="checkbox1" id="checkbox1" /> <input type="checkbox" name="checkbox1" id="checkbox1" />
<label for="checkbox1" /> <label for="checkbox1" />
</div> </div>
<div class="settings-list__cell"> <div class={styles.settingsListCell}>
<input <input
type="checkbox" type="checkbox"
name="checkbox1-notification" name="checkbox1-notification"
@ -51,15 +51,15 @@ export const FeedSettingsView = (_props) => {
</div> </div>
</div> </div>
<div class="settings-list__row"> <div class={styles.settingsListRow}>
<label for="checkbox2" class="settings-list__cell"> <label for="checkbox2" class={styles.settingsListCell}>
новые подписчики новые подписчики
</label> </label>
<div class="settings-list__cell"> <div class={styles.settingsListCell}>
<input type="checkbox" name="checkbox2" id="checkbox2" /> <input type="checkbox" name="checkbox2" id="checkbox2" />
<label for="checkbox2" /> <label for="checkbox2" />
</div> </div>
<div class="settings-list__cell"> <div class={styles.settingsListCell}>
<input <input
type="checkbox" type="checkbox"
name="checkbox2-notification" name="checkbox2-notification"
@ -70,15 +70,15 @@ export const FeedSettingsView = (_props) => {
</div> </div>
</div> </div>
<div class="settings-list__row"> <div class={styles.settingsListRow}>
<label for="checkbox3" class="settings-list__cell"> <label for="checkbox3" class={styles.settingsListCell}>
добавление моих текстов в&nbsp;коллекции добавление моих текстов в&nbsp;коллекции
</label> </label>
<div class="settings-list__cell"> <div class={styles.settingsListCell}>
<input type="checkbox" name="checkbox3" id="checkbox3" /> <input type="checkbox" name="checkbox3" id="checkbox3" />
<label for="checkbox3" /> <label for="checkbox3" />
</div> </div>
<div class="settings-list__cell"> <div class={styles.settingsListCell}>
<input <input
type="checkbox" type="checkbox"
name="checkbox3-notification" name="checkbox3-notification"
@ -89,19 +89,19 @@ export const FeedSettingsView = (_props) => {
</div> </div>
</div> </div>
<div class="settings-list__row"> <div class={styles.settingsListRow}>
<h2>Мои подписки</h2> <h2>Мои подписки</h2>
</div> </div>
<div class="settings-list__row"> <div class={styles.settingsListRow}>
<label for="checkbox4" class="settings-list__cell"> <label for="checkbox4" class={styles.settingsListCell}>
добавление моих текстов в&nbsp;коллекции добавление моих текстов в&nbsp;коллекции
</label> </label>
<div class="settings-list__cell"> <div class={styles.settingsListCell}>
<input type="checkbox" name="checkbox4" id="checkbox4" /> <input type="checkbox" name="checkbox4" id="checkbox4" />
<label for="checkbox4" /> <label for="checkbox4" />
</div> </div>
<div class="settings-list__cell"> <div class={styles.settingsListCell}>
<input <input
type="checkbox" type="checkbox"
name="checkbox4-notification" name="checkbox4-notification"

View File

@ -8,16 +8,22 @@ import { Row1 } from '../Feed/Row1'
import Hero from '../Discours/Hero' import Hero from '../Discours/Hero'
import { Beside } from '../Feed/Beside' import { Beside } from '../Feed/Beside'
import RowShort from '../Feed/RowShort' import RowShort from '../Feed/RowShort'
import Slider from '../Feed/Slider' import Slider from '../_shared/Slider'
import Group from '../Feed/Group' import Group from '../Feed/Group'
import type { Shout, Topic } from '../../graphql/types.gen' import type { Shout, Topic } from '../../graphql/types.gen'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { useTopicsStore } from '../../stores/zine/topics' import { useTopicsStore } from '../../stores/zine/topics'
import { loadShouts, useArticlesStore } from '../../stores/zine/articles' import {
loadShouts,
loadTopArticles,
loadTopMonthArticles,
useArticlesStore
} from '../../stores/zine/articles'
import { useTopAuthorsStore } from '../../stores/zine/topAuthors' import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll' import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages' import { splitToPages } from '../../utils/splitToPages'
import { ArticleCard } from '../Feed/Card'
type HomeProps = { type HomeProps = {
randomTopics: Topic[] randomTopics: Topic[]
@ -47,8 +53,11 @@ export const HomeView = (props: HomeProps) => {
const { topAuthors } = useTopAuthorsStore() const { topAuthors } = useTopAuthorsStore()
onMount(async () => { onMount(async () => {
loadTopArticles()
loadTopMonthArticles()
if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) { if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) {
const { hasMore } = await loadShouts({ const { hasMore } = await loadShouts({
filters: { visibility: 'public' },
limit: CLIENT_LOAD_ARTICLES_COUNT, limit: CLIENT_LOAD_ARTICLES_COUNT,
offset: sortedArticles().length offset: sortedArticles().length
}) })
@ -119,7 +128,21 @@ export const HomeView = (props: HomeProps) => {
wrapper={'author'} wrapper={'author'}
/> />
<Slider title={t('Top month articles')} articles={topMonthArticles()} /> <Slider title={t('Top month articles')}>
<For each={topMonthArticles()}>
{(a: Shout) => (
<ArticleCard
article={a}
settings={{
additionalClass: 'swiper-slide',
isFloorImportant: true,
isWithCover: true,
nodate: true
}}
/>
)}
</For>
</Slider>
<Row2 articles={sortedArticles().slice(10, 12)} /> <Row2 articles={sortedArticles().slice(10, 12)} />
@ -131,7 +154,21 @@ export const HomeView = (props: HomeProps) => {
{randomLayout()} {randomLayout()}
<Slider title={t('Favorite')} articles={topArticles()} /> <Slider title={t('Favorite')}>
<For each={topArticles()}>
{(a: Shout) => (
<ArticleCard
article={a}
settings={{
additionalClass: 'swiper-slide',
isFloorImportant: true,
isWithCover: true,
nodate: true
}}
/>
)}
</For>
</Slider>
<Beside <Beside
beside={sortedArticles()[20]} beside={sortedArticles()[20]}

View File

@ -13,8 +13,9 @@ import { useAuthorsStore } from '../../stores/zine/authors'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll' import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages' import { splitToPages } from '../../utils/splitToPages'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import Slider from '../Feed/Slider' import Slider from '../_shared/Slider'
import { Row1 } from '../Feed/Row1' import { Row1 } from '../Feed/Row1'
import { ArticleCard } from '../Feed/Card'
type TopicsPageSearchParams = { type TopicsPageSearchParams = {
by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented' by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented'
@ -122,7 +123,21 @@ export const TopicView = (props: TopicProps) => {
wrapper={'author'} wrapper={'author'}
/> />
<Slider title={title()} articles={sortedArticles().slice(5, 11)} /> <Slider title={title()}>
<For each={sortedArticles().slice(5, 11)}>
{(a: Shout) => (
<ArticleCard
article={a}
settings={{
additionalClass: 'swiper-slide',
isFloorImportant: true,
isWithCover: true,
nodate: true
}}
/>
)}
</For>
</Slider>
<Beside <Beside
beside={sortedArticles()[12]} beside={sortedArticles()[12]}
@ -134,15 +149,26 @@ export const TopicView = (props: TopicProps) => {
<Row2 articles={sortedArticles().slice(13, 15)} isEqual={true} /> <Row2 articles={sortedArticles().slice(13, 15)} isEqual={true} />
<Row1 article={sortedArticles()[15]} /> <Row1 article={sortedArticles()[15]} />
<Slider <Show when={sortedArticles().length > 15}>
title={title()} <Slider slidesPerView={3}>
articles={sortedArticles().slice(16, 22)} <For each={sortedArticles().slice(16, 22)}>
slidesPerView={3} {(a: Shout) => (
isCardsWithCover={false} <ArticleCard
article={a}
settings={{
additionalClass: 'swiper-slide',
isFloorImportant: true,
isWithCover: false,
nodate: true
}}
/> />
)}
</For>
</Slider>
<Row3 articles={sortedArticles().slice(23, 26)} /> <Row3 articles={sortedArticles().slice(23, 26)} />
<Row2 articles={sortedArticles().slice(26, 28)} /> <Row2 articles={sortedArticles().slice(26, 28)} />
</Show>
<For each={pages()}> <For each={pages()}>
{(page) => ( {(page) => (

View File

@ -5,9 +5,20 @@
input { input {
border: none; border: none;
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
box-shadow: 0 0 0 #ccc;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
outline: none;
transition: box-shadow 0.3s;
width: 100%; width: 100%;
&:focus {
box-shadow: 0 3px 0 #ccc;
}
+ label {
display: none;
}
} }
label { label {

View File

@ -1,16 +1,18 @@
import styles from './SearchField.module.scss' import styles from './SearchField.module.scss'
import { Icon } from './Icon' import { Icon } from './Icon'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import clsx from 'clsx'
type SearchFieldProps = { type SearchFieldProps = {
onChange: (value: string) => void onChange: (value: string) => void
class?: string
} }
export const SearchField = (props: SearchFieldProps) => { export const SearchField = (props: SearchFieldProps) => {
const handleInputChange = (event) => props.onChange(event.target.value.trim()) const handleInputChange = (event) => props.onChange(event.target.value.trim())
return ( return (
<div class={styles.searchField}> <div class={clsx(styles.searchField, props.class)}>
<label for="search-field"> <label for="search-field">
<Icon name="search" class={styles.icon} /> <Icon name="search" class={styles.icon} />
</label> </label>
@ -21,6 +23,7 @@ export const SearchField = (props: SearchFieldProps) => {
onInput={handleInputChange} onInput={handleInputChange}
placeholder={t('Search')} placeholder={t('Search')}
/> />
<label for="search-field">Поиск</label>
</div> </div>
) )
} }

View File

@ -1,19 +1,17 @@
import { ArticleCard } from './Card'
import { Swiper, Navigation, Pagination } from 'swiper' import { Swiper, Navigation, Pagination } from 'swiper'
import type { SwiperOptions } from 'swiper' import type { SwiperOptions } from 'swiper'
import 'swiper/scss' import 'swiper/scss'
import 'swiper/scss/navigation' import 'swiper/scss/navigation'
import 'swiper/scss/pagination' import 'swiper/scss/pagination'
import './Slider.scss' import './Slider.scss'
import type { Shout } from '../../graphql/types.gen' import { createEffect, createMemo, createSignal, Show, For, JSX } from 'solid-js'
import { createEffect, createMemo, createSignal, Show, For } from 'solid-js' import { Icon } from './Icon'
import { Icon } from '../_shared/Icon'
interface SliderProps { interface SliderProps {
title?: string title?: string
articles: Shout[]
slidesPerView?: number slidesPerView?: number
isCardsWithCover?: boolean isCardsWithCover?: boolean
children?: JSX.Element
} }
export default (props: SliderProps) => { export default (props: SliderProps) => {
@ -57,30 +55,14 @@ export default (props: SliderProps) => {
}, 500) }, 500)
} }
}) })
const articles = createMemo(() => props.articles)
return ( return (
<div class="floor floor--important"> <div class="floor floor--important">
<div class="wide-container"> <div class="wide-container">
<div class="row"> <div class="row">
<h2 class="col-12">{props.title}</h2> <h2 class="col-12">{props.title}</h2>
<Show when={!!articles()}>
<div class="swiper" classList={{ 'cards-with-cover': isCardsWithCover }} ref={el}> <div class="swiper" classList={{ 'cards-with-cover': isCardsWithCover }} ref={el}>
<div class="swiper-wrapper"> <div class="swiper-wrapper">{props.children}</div>
<For each={articles()}>
{(a: Shout) => (
<ArticleCard
article={a}
settings={{
additionalClass: 'swiper-slide',
isFloorImportant: true,
isWithCover: isCardsWithCover,
nodate: true
}}
/>
)}
</For>
</div>
<div class="slider-arrow-next" ref={nextEl} onClick={() => swiper()?.slideNext()}> <div class="slider-arrow-next" ref={nextEl} onClick={() => swiper()?.slideNext()}>
<Icon name="slider-arrow" class={'icon'} /> <Icon name="slider-arrow" class={'icon'} />
</div> </div>
@ -89,7 +71,6 @@ export default (props: SliderProps) => {
</div> </div>
<div class="slider-pagination" ref={pagEl} /> <div class="slider-pagination" ref={pagEl} />
</div> </div>
</Show>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
color: #9fa1a7; color: #9fa1a7;
display: flex; display: flex;
margin-bottom: 1em; margin: 0.5em 0 1em;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -6,7 +6,10 @@ import type { LayoutType } from '../stores/zine/layouts'
export type PageProps = { export type PageProps = {
randomTopics?: Topic[] randomTopics?: Topic[]
article?: Shout article?: Shout
shouts?: Shout[] layoutShouts?: Shout[]
authorShouts?: Shout[]
topicShouts?: Shout[]
homeShouts?: Shout[]
author?: Author author?: Author
allAuthors?: Author[] allAuthors?: Author[]
topic?: Topic topic?: Topic
@ -22,3 +25,10 @@ export type RootSearchParams = {
modal: string modal: string
lang: string lang: string
} }
export type UploadFile = {
source: string
name: string
size: number
file: File
}

68
src/context/profile.tsx Normal file
View File

@ -0,0 +1,68 @@
import { createEffect, createMemo } from 'solid-js'
import { createStore } from 'solid-js/store'
import { useSession } from './session'
import { loadAuthor, useAuthorsStore } from '../stores/zine/authors'
import { apiClient } from '../utils/apiClient'
import type { ProfileInput } from '../graphql/types.gen'
const submit = async (profile: ProfileInput) => {
try {
await apiClient.updateProfile(profile)
} catch (error) {
console.error(error)
}
}
const useProfileForm = () => {
const { session } = useSession()
const currentSlug = createMemo(() => session()?.user?.slug)
const { authorEntities } = useAuthorsStore({ authors: [] })
const currentAuthor = createMemo(() => authorEntities()[currentSlug()])
const [form, setForm] = createStore<ProfileInput>({
name: '',
bio: '',
about: '',
slug: '',
userpic: '',
links: []
})
createEffect(async () => {
if (!currentSlug()) return
try {
await loadAuthor({ slug: currentSlug() })
setForm({
name: currentAuthor()?.name,
slug: currentAuthor()?.name,
bio: currentAuthor()?.bio,
about: currentAuthor()?.about,
userpic: currentAuthor()?.userpic,
links: currentAuthor()?.links
})
} catch (error) {
console.error(error)
}
})
const updateFormField = (fieldName: string, value: string, remove?: boolean) => {
if (fieldName === 'links') {
if (remove) {
setForm(
'links',
form.links.filter((item) => item !== value)
)
} else {
setForm((prev) => ({ ...prev, links: [...prev.links, value] }))
}
} else {
setForm({
[fieldName]: value
})
}
}
return { form, submit, updateFormField }
}
export { useProfileForm }

View File

@ -1,14 +1,16 @@
import type { Accessor, InitializedResource, JSX } from 'solid-js' import type { Accessor, JSX, Resource } from 'solid-js'
import { createContext, createMemo, createResource, onMount, useContext } from 'solid-js' import { createContext, createMemo, createResource, createSignal, onMount, useContext } from 'solid-js'
import type { AuthResult } from '../graphql/types.gen' import type { AuthResult } from '../graphql/types.gen'
import { apiClient } from '../utils/apiClient' import { apiClient } from '../utils/apiClient'
import { resetToken, setToken } from '../graphql/privateGraphQLClient' import { resetToken, setToken } from '../graphql/privateGraphQLClient'
type SessionContextType = { type SessionContextType = {
session: InitializedResource<AuthResult> session: Resource<AuthResult>
isSessionLoaded: Accessor<boolean>
userSlug: Accessor<string>
isAuthenticated: Accessor<boolean> isAuthenticated: Accessor<boolean>
actions: { actions: {
getSession: () => AuthResult | Promise<AuthResult> loadSession: () => AuthResult | Promise<AuthResult>
signIn: ({ email, password }: { email: string; password: string }) => Promise<void> signIn: ({ email, password }: { email: string; password: string }) => Promise<void>
signOut: () => Promise<void> signOut: () => Promise<void>
confirmEmail: (token: string) => Promise<void> confirmEmail: (token: string) => Promise<void>
@ -17,6 +19,13 @@ type SessionContextType = {
const SessionContext = createContext<SessionContextType>() const SessionContext = createContext<SessionContextType>()
export function useSession() {
return useContext(SessionContext)
}
export const SessionProvider = (props: { children: JSX.Element }) => {
const [isSessionLoaded, setIsSessionLoaded] = createSignal(false)
const getSession = async (): Promise<AuthResult> => { const getSession = async (): Promise<AuthResult> => {
try { try {
const authResult = await apiClient.getSession() const authResult = await apiClient.getSession()
@ -26,22 +35,21 @@ const getSession = async (): Promise<AuthResult> => {
setToken(authResult.token) setToken(authResult.token)
return authResult return authResult
} catch (error) { } catch (error) {
console.error('renewSession error:', error) console.error('getSession error:', error)
resetToken() resetToken()
return null return null
} finally {
setIsSessionLoaded(true)
} }
} }
export function useSession() { const [session, { refetch: loadSession, mutate }] = createResource<AuthResult>(getSession, {
return useContext(SessionContext)
}
export const SessionProvider = (props: { children: JSX.Element }) => {
const [session, { refetch: refetchSession, mutate }] = createResource<AuthResult>(getSession, {
ssrLoadFrom: 'initial', ssrLoadFrom: 'initial',
initialValue: null initialValue: null
}) })
const userSlug = createMemo(() => session()?.user?.slug)
const isAuthenticated = createMemo(() => Boolean(session()?.user?.slug)) const isAuthenticated = createMemo(() => Boolean(session()?.user?.slug))
const signIn = async ({ email, password }: { email: string; password: string }) => { const signIn = async ({ email, password }: { email: string; password: string }) => {
@ -65,16 +73,16 @@ export const SessionProvider = (props: { children: JSX.Element }) => {
} }
const actions = { const actions = {
getSession: refetchSession, loadSession,
signIn, signIn,
signOut, signOut,
confirmEmail confirmEmail
} }
const value: SessionContextType = { session, isAuthenticated, actions } const value: SessionContextType = { session, isSessionLoaded, userSlug, isAuthenticated, actions }
onMount(() => { onMount(() => {
refetchSession() loadSession()
}) })
return <SessionContext.Provider value={value}>{props.children}</SessionContext.Provider> return <SessionContext.Provider value={value}>{props.children}</SessionContext.Provider>

View File

@ -11,12 +11,12 @@ export default gql`
subtitle subtitle
body body
topics { topics {
_id: slug # id
title title
slug slug
} }
authors { authors {
_id: slug id
name name
slug slug
userpic userpic

View File

@ -12,13 +12,13 @@ export default gql`
image image
body body
topics { topics {
_id: slug # id
title title
slug slug
image image
} }
authors { authors {
_id: slug id
name name
slug slug
userpic userpic

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
mutation SendLinkQuery($email: String!, $lang: String) { mutation SendLinkQuery($email: String!, $lang: String, $template: String) {
sendLink(email: $email, lang: $lang) { sendLink(email: $email, lang: $lang, template: $template) {
error error
} }
} }

View File

@ -1,19 +0,0 @@
import { gql } from '@urql/core'
// WARNING: need Auth header
export default gql`
mutation ProfileUpdateMutation($user: User!) {
profileUpdate(user: $user) {
error
token
user {
_id: slug
name
slug
userpic
bio
# links
}
}
}
`

View File

@ -1,7 +1,7 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
mutation FollowQuery($what: FollowingEntity!, $slug: String!) { mutation FollowMutation($what: FollowingEntity!, $slug: String!) {
follow(what: $what, slug: $slug) { follow(what: $what, slug: $slug) {
error error
} }

View File

@ -1,7 +1,7 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
mutation UnfollowQuery($what: FollowingEntity!, $slug: String!) { mutation UnfollowMutation($what: FollowingEntity!, $slug: String!) {
unfollow(what: $what, slug: $slug) { unfollow(what: $what, slug: $slug) {
error error
} }

View File

@ -0,0 +1,13 @@
import { gql } from '@urql/core'
// WARNING: need Auth header
export default gql`
mutation ProfileUpdateMutation($profile: ProfileInput!) {
updateProfile(profile: $profile) {
error
author {
name
}
}
}
`

View File

@ -25,6 +25,10 @@ export const getToken = (): string => {
} }
export const setToken = (token: string) => { export const setToken = (token: string) => {
if (!token) {
console.error('[privateGraphQLClient] setToken: token is null!')
}
localStorage.setItem(TOKEN_LOCAL_STORAGE_KEY, token) localStorage.setItem(TOKEN_LOCAL_STORAGE_KEY, token)
} }
@ -37,11 +41,12 @@ const options: ClientOptions = {
maskTypename: true, maskTypename: true,
requestPolicy: 'cache-and-network', requestPolicy: 'cache-and-network',
fetchOptions: () => { fetchOptions: () => {
// пока источником правды для значения токена будет локальное хранилище // localStorage is the source of truth for now
// меняем через setToken, например при получении значения с сервера // to change token call setToken, for example after login
// скорее всего придумаем что-нибудь получше со временем
const token = localStorage.getItem(TOKEN_LOCAL_STORAGE_KEY) const token = localStorage.getItem(TOKEN_LOCAL_STORAGE_KEY)
if (token === null) alert('token is null') if (!token) {
console.error('[privateGraphQLClient] fetchOptions: token is null!')
}
const headers = { Authorization: token } const headers = { Authorization: token }
return { headers } return { headers }
}, },

View File

@ -10,9 +10,11 @@ export default gql`
layout layout
cover cover
body body
media
# community # community
mainTopic mainTopic
topics { topics {
# id
title title
body body
slug slug
@ -24,7 +26,7 @@ export default gql`
} }
} }
authors { authors {
_id: slug id
name name
slug slug
userpic userpic

View File

@ -12,6 +12,7 @@ export default gql`
# community # community
mainTopic mainTopic
topics { topics {
# id
title title
body body
slug slug
@ -23,7 +24,7 @@ export default gql`
} }
} }
authors { authors {
_id: slug id
name name
slug slug
userpic userpic

View File

@ -14,6 +14,7 @@ export default gql`
# community # community
mainTopic mainTopic
topics { topics {
# id
title title
body body
slug slug
@ -25,7 +26,7 @@ export default gql`
} }
} }
authors { authors {
_id: slug id
name name
slug slug
userpic userpic

View File

@ -4,6 +4,7 @@ export default gql`
query GetChatsQuery($limit: Int, $offset: Int) { query GetChatsQuery($limit: Int, $offset: Int) {
loadRecipients(limit: $limit, offset: $offset) { loadRecipients(limit: $limit, offset: $offset) {
members { members {
id
name name
id id
slug slug

View File

@ -4,6 +4,7 @@ export default gql`
query GetCollabsQuery { query GetCollabsQuery {
getCollabs { getCollabs {
authors { authors {
id
slug slug
name name
pic pic

Some files were not shown because too many files have changed in this diff Show More