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: {},
rules: {
// FIXME
'unicorn/prefer-dom-node-append': 'off',
// TEMP
// FIXME
'solid/reactivity': 'off',
// Should be enabled
// 'promise/catch-or-return': 'off',
// Solid
'solid/reactivity': 'off', // FIXME
'solid/no-innerhtml': 'off',
/** Unicorn **/
@ -73,8 +65,13 @@ module.exports = {
'unicorn/import-style': 'off',
'unicorn/numeric-separators-style': 'off',
'unicorn/prefer-node-protocol': 'off',
'unicorn/prefer-dom-node-append': 'off', // FIXME
'unicorn/prefer-top-level-await': 'warn',
'unicorn/consistent-function-scoping': 'warn',
'sonarjs/no-duplicate-string': 'warn',
// Promise
// 'promise/catch-or-return': 'off', // Should be enabled
'promise/always-return': 'off',
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]
[+] auth ver. 0.9
[+] load-by interfaces for shouts, authors and messages

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "discoursio-webapp",
"version": "0.6.1",
"version": "0.7.0",
"private": true,
"license": "MIT",
"scripts": {
@ -26,6 +26,7 @@
"server": "node server/server.mjs",
"start": "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",
"typecheck": "astro check && tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch",
@ -34,6 +35,8 @@
"dependencies": {
"@aws-sdk/client-s3": "^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"
},
"devDependencies": {
@ -54,6 +57,7 @@
"@solid-devtools/logger": "^0.5.0",
"@solid-primitives/memo": "^1.1.2",
"@solid-primitives/storage": "^1.3.3",
"@solid-primitives/upload": "^0.0.105",
"@types/express": "^4.17.14",
"@types/node": "^18.11.9",
"@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">
<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>

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">
<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>
<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>

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">
<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 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"/>
<svg xmlns="http://www.w3.org/2000/svg" width="512px" height="512px" viewBox="0 0 512 512">
<path
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>

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">
<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>
<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>

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">
<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>

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 { AuthorCard } from '../Author/Card'
import { Show, createMemo } from 'solid-js'
import { Show, createMemo, createSignal } from 'solid-js'
import { clsx } from 'clsx'
import type { Author, Reaction as Point } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
// import { createReaction, updateReaction, deleteReaction } from '../../stores/zine/reactions'
import MD from './MD'
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: {
level?: number
@ -15,6 +19,8 @@ export default (props: {
canEdit?: boolean
compact?: boolean
}) => {
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
const comment = createMemo(() => props.comment)
const body = createMemo(() => (comment().body || '').trim())
const remove = () => {
@ -23,81 +29,116 @@ export default (props: {
deleteReaction(comment().id)
}
}
const formattedDate = createMemo(() =>
formatDate(new Date(comment()?.createdAt), { hour: 'numeric', minute: 'numeric' })
)
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()}>
<div class="comment__content">
<div class={styles.commentContent}>
<Show
when={!props.compact}
fallback={
<div class="comment__details">
<a href={`/author/${comment()?.createdBy?.slug}`}>
@{(comment()?.createdBy || { name: 'anonymous' }).name}
</a>
<div class="comment__article">
<Icon name="reply-arrow" />
<a href={`#comment-${comment()?.id}`}>
#{(comment()?.shout || { title: 'Lorem ipsum titled' }).title}
</a>
</div>
<div>
<Userpic user={comment().createdBy as Author} isBig={false} isAuthorsList={false} />
<small class={styles.commentArticle}>
<a href={`#comment-${comment()?.id}`}>{comment()?.shout.title || ''}</a>
</small>
</div>
}
>
<div class="comment__details">
<div class="comment-author">
<div class={styles.commentDetails}>
<div class={styles.commentAuthor}>
<AuthorCard
author={comment()?.createdBy as Author}
hideDescription={true}
hideFollow={true}
isComments={true}
hasLink={true}
/>
</div>
<div class="comment-date">{comment()?.createdAt}</div>
<div class="comment-rating">{comment().stat.rating}</div>
<div class={styles.commentDate}>{formattedDate()}</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>
</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()} />
</div>
<Show when={!props.compact}>
<div class="comment-controls">
<button class="comment-control comment-control--reply">
<Icon name="reply" />
<div class={styles.commentControls}>
<button
class={clsx(styles.commentControl, styles.commentControlReply)}
onClick={() => setIsReplyVisible(!isReplyVisible())}
>
<Icon name="reply" class={styles.icon} />
{t('Reply')}
</button>
<Show when={props.canEdit}>
{/*FIXME implement edit comment modal*/}
{/*<button*/}
{/* class="comment-control comment-control--edit"*/}
{/* class={clsx(styles.commentControl, styles.commentControlEdit)}*/}
{/* onClick={() => showModal('editComment')}*/}
{/*>*/}
{/* <Icon name="edit" />*/}
{/* <Icon name="edit" class={styles.icon} />*/}
{/* {t('Edit')}*/}
{/*</button>*/}
<button class="comment-control comment-control--delete" onClick={() => remove()}>
<Icon name="delete" />
<button
class={clsx(styles.commentControl, styles.commentControlDelete)}
onClick={() => remove()}
>
<Icon name="delete" class={styles.icon} />
{t('Delete')}
</button>
</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*/}
{/* class="comment-control comment-control--share"*/}
{/* onClick={() => showModal('shareComment')}*/}
{/*>*/}
{/* {t('Share')}*/}
{/*</button>*/}
{/*<button*/}
{/* class="comment-control comment-control--complain"*/}
{/* class={clsx(styles.commentControl, styles.commentControlComplain)}*/}
{/* onClick={() => showModal('reportComment')}*/}
{/*>*/}
{/* {t('Report')}*/}
{/*</button>*/}
</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>
</div>
</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 { Icon } from '../_shared/Icon'
import ArticleComment from './Comment'
import { AuthorCard } from '../Author/Card'
import { createMemo, createSignal, For, onMount, Show } from 'solid-js'
import type { Author, Reaction, Shout } from '../../graphql/types.gen'
import { createMemo, For, Match, onMount, Show, Switch } from 'solid-js'
import type { Author, Shout } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
import { showModal } from '../../stores/ui'
import MD from './MD'
import { SharePopup } from './SharePopup'
import { useSession } from '../../context/session'
import stylesHeader from '../Nav/Header.module.scss'
import styles from '../../styles/Article.module.scss'
import RatingControl from './RatingControl'
import { RatingControl } from './RatingControl'
import { clsx } from 'clsx'
const MAX_COMMENT_LEVEL = 6
const getCommentLevel = (comment: Reaction, level = 0) => {
if (comment && comment.replyTo && level < MAX_COMMENT_LEVEL) {
return 0 // FIXME: getCommentLevel(commentsById[c.replyTo], level + 1)
}
return level
}
import { CommentsTree } from './CommentsTree'
import { useSession } from '../../context/session'
import VideoPlayer from './VideoPlayer'
import Slider from '../_shared/Slider'
interface ArticleProps {
article: Shout
reactions: Reaction[]
isCommentsLoading: boolean
}
const formatDate = (date: Date) => {
return date
.toLocaleDateString('ru', {
month: 'long',
day: 'numeric',
year: 'numeric'
})
.replace(' г.', '')
interface MediaItem {
url?: string
pic?: string
title?: string
body?: string
}
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) => {
const { session } = useSession()
const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt)))
const [isSharePopupVisible, setIsSharePopupVisible] = createSignal(false)
const mainTopic = () =>
(props.article.topics?.find((topic) => topic?.slug === props.article.mainTopic)?.title || '').replace(
' ',
'&nbsp;'
const mainTopic = createMemo(
() =>
props.article.topics?.find((topic) => topic?.slug === props.article.mainTopic) ||
props.article.topics[0]
)
const mainTopicTitle = createMemo(() => mainTopic().title.replace(' ', '&nbsp;'))
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
if (windowHash?.length > 0) {
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 (
<div class="shout wide-container">
<article class="col-md-6 shift-content">
<div class={styles.shoutHeader}>
<div class={styles.shoutTopic}>
<a href={`/topic/${props.article.mainTopic}`} innerHTML={mainTopic() || ''} />
<a href={`/topic/${props.article.mainTopic}`} innerHTML={mainTopicTitle() || ''} />
</div>
<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>
<Show when={Boolean(props.article.body)}>
<div class={styles.shoutBody}>
<Show
when={!props.article.body.startsWith('<')}
fallback={<div innerHTML={props.article.body} />}
when={media() && props.article.layout !== 'image'}
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>
</div>
</Show>
@ -111,59 +153,54 @@ export const FullArticle = (props: ArticleProps) => {
<div class="col-md-8 shift-content">
<div class={styles.shoutStats}>
<div class={styles.shoutStatsItem}>
<RatingControl rating={props.article.stat?.rating} />
<RatingControl rating={props.article.stat?.rating} class={styles.ratingControl} />
</div>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemLikes)}>
<Icon name="like" class={styles.icon} />
{props.article.stat?.rating || ''}
<Show when={props.article.stat?.viewed}>
<div class={clsx(styles.shoutStatsItem)}>
<Icon name="eye" class={clsx(styles.icon, styles.iconEye)} />
{props.article.stat?.viewed}
</div>
</Show>
<div class={styles.shoutStatsItem}>
<Icon name="comment" class={styles.icon} />
{props.article.stat?.commented || ''}
</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}>
<SharePopup
onVisibilityChange={(isVisible) => {
setIsSharePopupVisible(isVisible)
}}
containerCssClass={stylesHeader.control}
trigger={<Icon name="share" class={styles.icon} />}
trigger={<Icon name="share-outline" class={styles.icon} />}
/>
</div>
<div class={styles.shoutStatsItem}>
<div class={styles.shoutStatsItem} onClick={bookmark}>
<Icon name="bookmark" class={styles.icon} />
</div>
{/*FIXME*/}
{/*<Show when={canEdit()}>*/}
{/* <div class={styles.shoutStatsItem}>*/}
{/* <a href="/edit">*/}
{/* <Icon name="edit" />*/}
{/* {t('Edit')}*/}
{/* </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}
<Show when={canEdit()}>
<div class={styles.shoutStatsItem}>
<a href="/edit">
<Icon name="edit" />
{t('Edit')}
</a>
</div>
</Show>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalData)}>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalDataItem)}>
{formattedDate()}
</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}>
<For each={props.article.topics}>
@ -181,45 +218,13 @@ export const FullArticle = (props: ArticleProps) => {
</Show>
<For each={props.article?.authors}>
{(a: Author) => (
<div class="col-md-6">
<div class="col-xl-6">
<AuthorCard author={a} compact={false} hasLink={true} liteButtons={true} />
</div>
)}
</For>
</div>
<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>
<CommentsTree shoutSlug={props.article?.slug} />
</div>
</div>
)

View File

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

View File

@ -1,12 +1,24 @@
export default (props: { youtubeId?: string; vimeoId?: string; title?: string }) => {
// TODO: styling
return (
<video
src={
props.vimeoId
? `https://vimeo.com/${props.vimeoId}`
: `https://youtube.com/?watch=${props.youtubeId}`
}
import { Show } from 'solid-js'
export default (props: { url: string }) => (
<>
<Show when={props.url.includes('youtube.com')}>
<iframe
id="ytplayer"
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 {
align-items: center;
display: flex;
@include media-breakpoint-up(sm) {
padding: 0 0 0 42px;
}
a {
background: #f7f7f7;
@ -238,6 +241,8 @@
}
.authorsListItem {
margin-bottom: 1em !important;
.authorName {
@include font-size(2.2rem);
@ -256,3 +261,18 @@
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 { Icon } from '../_shared/Icon'
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 { t } from '../../utils/intl'
import { locale } from '../../stores/ui'
import { follow, unfollow } from '../../stores/zine/common'
import { clsx } from 'clsx'
import { useSession } from '../../context/session'
import { StatMetrics } from '../_shared/StatMetrics'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { FollowingEntity } from '../../graphql/types.gen'
import { router, useRouter } from '../../stores/router'
import { openPage } from '@nanostores/router'
@ -26,24 +28,47 @@ interface AuthorCardProps {
isAuthorsList?: boolean
truncateBio?: boolean
liteButtons?: boolean
isComments?: boolean
}
export const AuthorCard = (props: AuthorCardProps) => {
const { session } = useSession()
const {
session,
isSessionLoaded,
actions: { loadSession }
} = useSession()
const [isSubscribing, setIsSubscribing] = createSignal(false)
const subscribed = createMemo<boolean>(
() => 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 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
const { changeSearchParam } = useRouter()
const initChat = () => {
@ -55,6 +80,7 @@ export const AuthorCard = (props: AuthorCardProps) => {
class={clsx(styles.author)}
classList={{
[styles.authorPage]: props.isAuthorPage,
[styles.authorComments]: props.isComments,
[styles.authorsListItem]: props.isAuthorsList
}}
>
@ -63,6 +89,7 @@ export const AuthorCard = (props: AuthorCardProps) => {
hasLink={props.hasLink}
isBig={props.isAuthorPage}
isAuthorsList={props.isAuthorsList}
class={styles.circlewrap}
/>
<div class={styles.authorDetails}>
@ -76,31 +103,37 @@ export const AuthorCard = (props: AuthorCardProps) => {
<div class={styles.authorName}>{name()}</div>
</Show>
<Show when={!props.hideDescription}>
<Show when={!props.hideDescription && props.author.bio}>
{props.isAuthorsList}
<div
class={styles.authorAbout}
classList={{ 'text-truncate': props.truncateBio }}
innerHTML={props.caption || bio()}
></div>
innerHTML={props.author.bio}
/>
</Show>
<Show when={props.author.stat}>
<StatMetrics fields={['shouts', 'followers', 'comments']} stat={props.author.stat} />
</Show>
</div>
<ShowOnlyOnClient>
<Show when={isSessionLoaded()}>
<Show when={canFollow()}>
<div class={styles.authorSubscribe}>
<Show
when={subscribed()}
fallback={
<button
// TODO: change button view reactivity
onclick={() => follow({ what: FollowingEntity.Author, slug: props.author.slug })}
onClick={() => subscribe(true)}
class={clsx('button', styles.button)}
classList={{
[styles.buttonSubscribe]: !props.isAuthorsList,
'button--subscribe': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.isAuthorsList
[styles.buttonWrite]: props.isAuthorsList,
[styles.isSubscribing]: isSubscribing()
}}
disabled={isSubscribing()}
>
<Show when={!props.isAuthorsList}>
<Icon name="author-subscribe" class={styles.icon} />
@ -110,13 +143,16 @@ export const AuthorCard = (props: AuthorCardProps) => {
}
>
<button
onclick={() => follow({ what: FollowingEntity.Author, slug: props.author.slug })}
onClick={() => subscribe(false)}
class={clsx('button', styles.button)}
classList={{
[styles.buttonSubscribe]: !props.isAuthorsList,
'button--subscribe': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.isAuthorsList
[styles.buttonWrite]: props.isAuthorsList,
[styles.isSubscribing]: isSubscribing()
}}
disabled={isSubscribing()}
>
<Show when={!props.isAuthorsList}>
<Icon name="author-unsubscribe" class={styles.icon} />
@ -146,6 +182,8 @@ export const AuthorCard = (props: AuthorCardProps) => {
</Show>
</div>
</Show>
</Show>
</ShowOnlyOnClient>
</div>
</div>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,7 +50,7 @@ export const LoginForm = () => {
setIsEmailNotConfirmed(false)
setSubmitError('')
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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,9 +13,10 @@ import { t } from '../../utils/intl'
import { Row3 } from '../Feed/Row3'
import { Row2 } from '../Feed/Row2'
import { Beside } from '../Feed/Beside'
import Slider from '../Feed/Slider'
import Slider from '../_shared/Slider'
import { Row1 } from '../Feed/Row1'
import styles from '../../styles/Topic.module.scss'
import { ArticleCard } from '../Feed/Card'
export const PRERENDERED_ARTICLES_COUNT = 21
const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3
@ -28,7 +29,7 @@ export const LayoutShoutsPage = (props: PageProps) => {
return page.params.layout as LayoutType
})
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 loadMoreLayout = async (kind: LayoutType) => {
saveScrollPosition()
@ -106,7 +107,21 @@ export const LayoutShoutsPage = (props: PageProps) => {
<ModeSwitcher />
<Row1 article={sortedArticles()[0]} />
<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={sortedArticles()[12]}
title={t('Top viewed')}

View File

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

View File

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

View File

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

View File

@ -20,9 +20,9 @@ export const GuidePage = () => {
{/*<Meta property="og:image:width" content="1200" />*/}
{/*<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="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}>
<Show when={!indexExpanded()}>
<Icon name="content-index-control" />

View File

@ -17,9 +17,9 @@ export const HelpPage = () => {
{/*<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="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}>
<Show when={!indexExpanded()}>
<Icon name="content-index-control" />

View File

@ -21,9 +21,9 @@ export const ManifestPage = () => {
<Modal variant="wide" name="subscribe">
<Subscribe />
</Modal>
<article class="container container--static-page">
<article class="wide-container container--static-page">
<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}>
<Show when={!indexExpanded()}>
<Icon name="content-index-control" />

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import { t } from '../../../utils/intl'
export const ProjectsPage = () => {
return (
<PageWrap>
<article class="container container--static-page">
<article class="wide-container container--static-page">
<div class="row">
<div class="col-md-6 col-xl-7 shift-content order-md-first">
<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 property="og:title" 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="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}>
<Show when={!indexExpanded()}>
<Icon name="content-index-control" />

View File

@ -10,7 +10,7 @@ export const ThanksPage = () => {
{/*<Meta property="og:title" 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="col-md-6 col-xl-7 shift-content order-md-first">
<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 { LayoutShoutsPage } from './Pages/LayoutShoutsPage'
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
// const SomePage = lazy(() => import('./Pages/SomePage'))
@ -58,7 +61,10 @@ const pagesMap: Record<keyof Routes, Component<PageProps>> = {
partners: PartnersPage,
principles: PrinciplesPage,
termsOfUse: TermsOfUsePage,
thanks: ThanksPage
thanks: ThanksPage,
profileSettings: ProfileSettingsPage,
profileSecurity: ProfileSecurityPage,
profileSubscriptions: ProfileSubscriptionsPage
}
export const Root = (props: PageProps) => {

View File

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

View File

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

View File

@ -1,14 +1,14 @@
import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js'
import type { Author } from '../../graphql/types.gen'
import { AuthorCard } from '../Author/Card'
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 styles from '../../styles/AllTopics.module.scss'
import { AuthorCard } from '../Author/Card'
import { clsx } from 'clsx'
import { useSession } from '../../context/session'
import { locale } from '../../stores/ui'
import { translit } from '../../utils/ru2en'
import styles from '../../styles/AllTopics.module.scss'
import { SearchField } from '../_shared/SearchField'
import { scrollHandler } from '../../utils/scroll'
import { StatMetrics } from '../_shared/StatMetrics'
@ -17,41 +17,43 @@ type AllAuthorsPageSearchParams = {
by: '' | 'name' | 'shouts' | 'followers'
}
type Props = {
type AllAuthorsViewProps = {
authors: Author[]
}
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 { searchParams, changeSearchParam } = useRouter<AllAuthorsPageSearchParams>()
const { sortedAuthors } = useAuthorsStore({
authors: props.authors,
sortBy: searchParams().by || 'name'
sortBy: searchParams().by || 'shouts'
})
const [searchQuery, setSearchQuery] = createSignal('')
const { session } = useSession()
onMount(() => {
if (!searchParams().by) {
setAuthorsSort('name')
changeSearchParam('by', 'name')
changeSearchParam('by', 'shouts')
}
})
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[] }>(() => {
return sortedAuthors().reduce((acc, author) => {
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] = []
acc[letter].push(author)
return acc
}, {} as { [letter: string]: Author[] })
@ -60,9 +62,35 @@ export const AllAuthorsView = (props: Props) => {
const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetter())
keys.sort()
keys.push(keys.shift())
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 AllAuthorsHead = () => (
<div class="row">
@ -71,57 +99,28 @@ export const AllAuthorsView = (props: Props) => {
<p>{t('Subscribe who you like to tune your personal feed')}</p>
<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>
</li>
<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 classList={{ selected: !searchParams().by || searchParams().by === 'name' }}>
<li classList={{ selected: searchParams().by === 'name' }}>
<a href="/authors?by=name">{t('By name')}</a>
</li>
<Show when={searchParams().by !== 'name'}>
<li class="view-switcher__search">
<SearchField onChange={searchAuthors} />
<SearchField onChange={(value) => setSearchQuery(value)} />
</li>
</Show>
</ul>
</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 (
<div class={clsx(styles.allTopicsPage, 'wide-container')}>
<Show when={sortedAuthors().length > 0 || searchResults().length > 0}>
<Show when={sortedAuthors().length > 0}>
<div class="shift-content">
<AllAuthorsHead />
@ -130,12 +129,15 @@ export const AllAuthorsView = (props: Props) => {
<div class="col-lg-10 col-xl-9">
<ul class={clsx('nodash', styles.alphabet)}>
<For each={ALPHABET}>
{(letter: string, index) => (
{(letter, index) => (
<li>
<Show when={letter in byLetter()} fallback={letter}>
<a
href={`/authors?by=name#letter-${index()}`}
onClick={() => scrollHandler(`letter-${index()}`)}
onClick={(event) => {
event.preventDefault()
scrollHandler(`letter-${index()}`)
}}
>
{letter}
</a>
@ -148,9 +150,9 @@ export const AllAuthorsView = (props: Props) => {
</div>
<For each={sortedKeys()}>
{(letter, index) => (
{(letter) => (
<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="row">
<div class="col-lg-10">
@ -174,49 +176,26 @@ export const AllAuthorsView = (props: Props) => {
</For>
</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'}>
<div class={clsx(styles.stats, 'row')}>
<div class="col-lg-10 col-xl-9">
<For each={sortedAuthors().slice(0, limit())}>
<For each={filteredAuthors().slice(0, limit())}>
{(author) => (
<>
<div class="row">
<div class="col-lg-10 col-xl-9">
<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} />
</>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<Show when={searchParams().by !== 'name' && sortedAuthors().length > limit()}>
<Show when={filteredAuthors().length > limit() && searchParams().by !== 'name'}>
<div class="row">
<div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10')}>
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>

View File

@ -22,7 +22,7 @@ type AllTopicsViewProps = {
}
const PAGE_SIZE = 20
const ALPHABET = [...'#АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ']
const ALPHABET = [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#']
export const AllTopicsView = (props: AllTopicsViewProps) => {
const { searchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>()
@ -37,19 +37,18 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
onMount(() => {
if (!searchParams().by) {
setTopicsSort('shouts')
changeSearchParam('by', 'shouts')
}
})
createEffect(() => {
setTopicsSort(searchParams().by || 'shouts')
setLimit(PAGE_SIZE)
})
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
return sortedTopics().reduce((acc, topic) => {
let letter = topic.title[0].toUpperCase()
if (!/[А-я]/i.test(letter) && locale() === 'ru') letter = '#'
if (/[Аё]/.test(letter) && locale() === 'ru') letter = '#'
if (!acc[letter]) acc[letter] = []
acc[letter].push(topic)
return acc
@ -59,45 +58,41 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetter())
keys.sort()
keys.push(keys.shift())
return keys
})
const subscribed = (s) => Boolean(session()?.news?.topics && session()?.news?.topics?.includes(s || ''))
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 ttt: Topic[] = []
sortedTopics().forEach((topic) => {
let flag = false
topic.slug.split('-').forEach((w) => {
if (w.startsWith(q)) flag = true
})
const [searchQuery, setSearchQuery] = createSignal('')
if (!flag) {
let wrds: string = topic.title.toLowerCase()
if (locale() === 'ru') wrds = translit(wrds, 'ru')
wrds.split(' ').forEach((w: string) => {
if (w.startsWith(q)) flag = true
})
const filteredResults = createMemo(() => {
/* very stupid filter by string algorithm with no deps */
let q = searchQuery().toLowerCase()
if (q.length === 0) {
return sortedTopics()
}
if (flag && !ttt.includes(topic)) ttt.push(topic)
})
if (locale() === 'ru') {
q = translit(q)
}
setSearchResults((sr: Topic[]) => [...sr, ...ttt])
changeSearchParam('by', '')
return sortedTopics().filter((topic) => {
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 = () => (
<div class="row">
<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' }}>
<a href="/topics?by=title">{t('By title')}</a>
</li>
<Show when={searchParams().by !== 'title'}>
<li class="view-switcher__search">
<SearchField onChange={searchTopics} />
<SearchField onChange={(value) => setSearchQuery(value)} />
</li>
</Show>
</ul>
</div>
</div>
@ -127,7 +124,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
<div class="shift-content">
<AllTopicsHead />
<Show when={sortedTopics().length > 0 || searchResults().length > 0}>
<Show when={filteredResults().length > 0}>
<Show when={searchParams().by === 'title'}>
<div class="col-lg-10 col-xl-9">
<ul class={clsx('nodash', styles.alphabet)}>
@ -137,7 +134,10 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
<Show when={letter in byLetter()} fallback={letter}>
<a
href={`/topics?by=title#letter-${index()}`}
onClick={() => scrollHandler(`letter-${index()}`)}
onClick={(event) => {
event.preventDefault()
scrollHandler(`letter-${index()}`)
}}
>
{letter}
</a>
@ -149,9 +149,9 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</div>
<For each={sortedKeys()}>
{(letter, index) => (
{(letter) => (
<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="row">
<div class="col-lg-10">
@ -173,21 +173,8 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</For>
</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'}>
<For each={sortedTopics().slice(0, limit())}>
<For each={filteredResults().slice(0, limit())}>
{(topic) => (
<>
<TopicCard
@ -202,7 +189,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</For>
</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')}>
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
{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 { t } from '../../utils/intl'
import type { Shout, Reaction } from '../../graphql/types.gen'
@ -9,34 +9,20 @@ interface ArticlePageProps {
reactions?: Reaction[]
}
const ARTICLE_COMMENTS_PAGE_SIZE = 50
export const ArticleView = (props: ArticlePageProps) => {
const [getCommentsPage] = createSignal(0)
const [getIsCommentsLoading, setIsCommentsLoading] = createSignal(false)
const { reactionsByShout, loadReactionsBy } = useReactionsStore({ reactions: props.reactions })
createEffect(async () => {
try {
setIsCommentsLoading(true)
await loadReactionsBy({
by: { shout: props.article.slug, comment: true },
limit: ARTICLE_COMMENTS_PAGE_SIZE,
offset: getCommentsPage() * ARTICLE_COMMENTS_PAGE_SIZE
})
} finally {
setIsCommentsLoading(false)
}
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)
})
return (
<Show fallback={<div class="center">{t('Loading')}</div>} when={props.article}>
<Suspense>
<FullArticle
article={props.article}
reactions={reactionsByShout()[props.article.slug]}
isCommentsLoading={getIsCommentsLoading()}
/>
<FullArticle article={props.article} />
</Suspense>
</Show>
)

View File

@ -105,7 +105,7 @@ export const AuthorView = (props: AuthorProps) => {
<Beside
title={t('Topics which supported by author')}
values={topicsByAuthor()[author().slug].slice(0, 5)}
values={topicsByAuthor()[author().slug]?.slice(0, 5)}
beside={sortedArticles()[0]}
wrapper={'topic'}
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 stylesBeside from '../../components/Feed/Beside.module.scss'
import { Icon } from '../_shared/Icon'
@ -14,7 +14,6 @@ import { useAuthorsStore } from '../../stores/zine/authors'
import { useTopicsStore } from '../../stores/zine/topics'
import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
import { useSession } from '../../context/session'
import type { Shout } from '../../graphql/types.gen'
// const AUTHORSHIP_REACTIONS = [
// ReactionKind.Accept,
@ -28,13 +27,31 @@ export const FEED_PAGE_SIZE = 20
export const FeedView = () => {
// state
const { sortedArticles } = useArticlesStore()
const { sortedReactions: topComments, loadReactionsBy } = useReactionsStore({})
const { sortedReactions: topComments, loadReactionsBy } = useReactionsStore()
const { sortedAuthors } = useAuthorsStore()
const { topTopics } = useTopicsStore()
const { topAuthors } = useTopAuthorsStore()
const { session } = useSession()
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 { hasMore } = await loadShouts({
filters: { visibility: 'community' },
@ -50,16 +67,6 @@ export const FeedView = () => {
// load recent shouts not only published ( visibility = community )
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 (

View File

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

View File

@ -8,16 +8,22 @@ import { Row1 } from '../Feed/Row1'
import Hero from '../Discours/Hero'
import { Beside } from '../Feed/Beside'
import RowShort from '../Feed/RowShort'
import Slider from '../Feed/Slider'
import Slider from '../_shared/Slider'
import Group from '../Feed/Group'
import type { Shout, Topic } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
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 { locale } from '../../stores/ui'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages'
import { ArticleCard } from '../Feed/Card'
type HomeProps = {
randomTopics: Topic[]
@ -47,8 +53,11 @@ export const HomeView = (props: HomeProps) => {
const { topAuthors } = useTopAuthorsStore()
onMount(async () => {
loadTopArticles()
loadTopMonthArticles()
if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) {
const { hasMore } = await loadShouts({
filters: { visibility: 'public' },
limit: CLIENT_LOAD_ARTICLES_COUNT,
offset: sortedArticles().length
})
@ -119,7 +128,21 @@ export const HomeView = (props: HomeProps) => {
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)} />
@ -131,7 +154,21 @@ export const HomeView = (props: HomeProps) => {
{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={sortedArticles()[20]}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,10 @@ import type { LayoutType } from '../stores/zine/layouts'
export type PageProps = {
randomTopics?: Topic[]
article?: Shout
shouts?: Shout[]
layoutShouts?: Shout[]
authorShouts?: Shout[]
topicShouts?: Shout[]
homeShouts?: Shout[]
author?: Author
allAuthors?: Author[]
topic?: Topic
@ -22,3 +25,10 @@ export type RootSearchParams = {
modal: 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 { createContext, createMemo, createResource, onMount, useContext } from 'solid-js'
import type { Accessor, JSX, Resource } from 'solid-js'
import { createContext, createMemo, createResource, createSignal, onMount, useContext } from 'solid-js'
import type { AuthResult } from '../graphql/types.gen'
import { apiClient } from '../utils/apiClient'
import { resetToken, setToken } from '../graphql/privateGraphQLClient'
type SessionContextType = {
session: InitializedResource<AuthResult>
session: Resource<AuthResult>
isSessionLoaded: Accessor<boolean>
userSlug: Accessor<string>
isAuthenticated: Accessor<boolean>
actions: {
getSession: () => AuthResult | Promise<AuthResult>
loadSession: () => AuthResult | Promise<AuthResult>
signIn: ({ email, password }: { email: string; password: string }) => Promise<void>
signOut: () => Promise<void>
confirmEmail: (token: string) => Promise<void>
@ -17,7 +19,14 @@ type SessionContextType = {
const SessionContext = createContext<SessionContextType>()
const getSession = async (): Promise<AuthResult> => {
export function useSession() {
return useContext(SessionContext)
}
export const SessionProvider = (props: { children: JSX.Element }) => {
const [isSessionLoaded, setIsSessionLoaded] = createSignal(false)
const getSession = async (): Promise<AuthResult> => {
try {
const authResult = await apiClient.getSession()
if (!authResult) {
@ -26,22 +35,21 @@ const getSession = async (): Promise<AuthResult> => {
setToken(authResult.token)
return authResult
} catch (error) {
console.error('renewSession error:', error)
console.error('getSession error:', error)
resetToken()
return null
} finally {
setIsSessionLoaded(true)
}
}
}
export function useSession() {
return useContext(SessionContext)
}
export const SessionProvider = (props: { children: JSX.Element }) => {
const [session, { refetch: refetchSession, mutate }] = createResource<AuthResult>(getSession, {
const [session, { refetch: loadSession, mutate }] = createResource<AuthResult>(getSession, {
ssrLoadFrom: 'initial',
initialValue: null
})
const userSlug = createMemo(() => session()?.user?.slug)
const isAuthenticated = createMemo(() => Boolean(session()?.user?.slug))
const signIn = async ({ email, password }: { email: string; password: string }) => {
@ -65,16 +73,16 @@ export const SessionProvider = (props: { children: JSX.Element }) => {
}
const actions = {
getSession: refetchSession,
loadSession,
signIn,
signOut,
confirmEmail
}
const value: SessionContextType = { session, isAuthenticated, actions }
const value: SessionContextType = { session, isSessionLoaded, userSlug, isAuthenticated, actions }
onMount(() => {
refetchSession()
loadSession()
})
return <SessionContext.Provider value={value}>{props.children}</SessionContext.Provider>

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core'
export default gql`
mutation SendLinkQuery($email: String!, $lang: String) {
sendLink(email: $email, lang: $lang) {
mutation SendLinkQuery($email: String!, $lang: String, $template: String) {
sendLink(email: $email, lang: $lang, template: $template) {
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'
export default gql`
mutation FollowQuery($what: FollowingEntity!, $slug: String!) {
mutation FollowMutation($what: FollowingEntity!, $slug: String!) {
follow(what: $what, slug: $slug) {
error
}

View File

@ -1,7 +1,7 @@
import { gql } from '@urql/core'
export default gql`
mutation UnfollowQuery($what: FollowingEntity!, $slug: String!) {
mutation UnfollowMutation($what: FollowingEntity!, $slug: String!) {
unfollow(what: $what, slug: $slug) {
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) => {
if (!token) {
console.error('[privateGraphQLClient] setToken: token is null!')
}
localStorage.setItem(TOKEN_LOCAL_STORAGE_KEY, token)
}
@ -37,11 +41,12 @@ const options: ClientOptions = {
maskTypename: true,
requestPolicy: 'cache-and-network',
fetchOptions: () => {
// пока источником правды для значения токена будет локальное хранилище
// меняем через setToken, например при получении значения с сервера
// скорее всего придумаем что-нибудь получше со временем
// localStorage is the source of truth for now
// to change token call setToken, for example after login
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 }
return { headers }
},

View File

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

View File

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

View File

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

View File

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

View File

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

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