Merge branch 'dev' into prepare-inbox
17
.eslintrc.js
|
@ -50,16 +50,8 @@ module.exports = {
|
||||||
},
|
},
|
||||||
globals: {},
|
globals: {},
|
||||||
rules: {
|
rules: {
|
||||||
// FIXME
|
// Solid
|
||||||
'unicorn/prefer-dom-node-append': 'off',
|
'solid/reactivity': 'off', // FIXME
|
||||||
|
|
||||||
// TEMP
|
|
||||||
// FIXME
|
|
||||||
'solid/reactivity': 'off',
|
|
||||||
|
|
||||||
// Should be enabled
|
|
||||||
// 'promise/catch-or-return': 'off',
|
|
||||||
|
|
||||||
'solid/no-innerhtml': 'off',
|
'solid/no-innerhtml': 'off',
|
||||||
|
|
||||||
/** Unicorn **/
|
/** Unicorn **/
|
||||||
|
@ -73,8 +65,13 @@ module.exports = {
|
||||||
'unicorn/import-style': 'off',
|
'unicorn/import-style': 'off',
|
||||||
'unicorn/numeric-separators-style': 'off',
|
'unicorn/numeric-separators-style': 'off',
|
||||||
'unicorn/prefer-node-protocol': 'off',
|
'unicorn/prefer-node-protocol': 'off',
|
||||||
|
'unicorn/prefer-dom-node-append': 'off', // FIXME
|
||||||
|
'unicorn/prefer-top-level-await': 'warn',
|
||||||
'unicorn/consistent-function-scoping': 'warn',
|
'unicorn/consistent-function-scoping': 'warn',
|
||||||
|
'sonarjs/no-duplicate-string': 'warn',
|
||||||
|
|
||||||
|
// Promise
|
||||||
|
// 'promise/catch-or-return': 'off', // Should be enabled
|
||||||
'promise/always-return': 'off',
|
'promise/always-return': 'off',
|
||||||
|
|
||||||
eqeqeq: 'error',
|
eqeqeq: 'error',
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
[0.7.0]
|
||||||
|
[+] inbox: context provider, chats
|
||||||
|
[+] comments: show
|
||||||
|
[+] session: context provider
|
||||||
|
[+] views tracker: counting for shouts
|
||||||
|
|
||||||
[0.6.1]
|
[0.6.1]
|
||||||
[+] auth ver. 0.9
|
[+] auth ver. 0.9
|
||||||
[+] load-by interfaces for shouts, authors and messages
|
[+] load-by interfaces for shouts, authors and messages
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
|
# How to start
|
||||||
|
|
||||||
|
If you use yarn
|
||||||
|
|
||||||
```
|
```
|
||||||
yarn install
|
yarn
|
||||||
npm start
|
PUBLIC_API_URL=https://v2.discours.io yarn dev
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "discoursio-webapp",
|
"name": "discoursio-webapp",
|
||||||
"version": "0.6.1",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -26,6 +26,7 @@
|
||||||
"server": "node server/server.mjs",
|
"server": "node server/server.mjs",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"start:local": "cross-env PUBLIC_API_URL=http://127.0.0.1:8080 astro dev",
|
"start:local": "cross-env PUBLIC_API_URL=http://127.0.0.1:8080 astro dev",
|
||||||
|
"start:production": "cross-env PUBLIC_API_URL=https://v2.discours.io astro dev",
|
||||||
"start:staging": "cross-env PUBLIC_API_URL=https://testapi.discours.io astro dev",
|
"start:staging": "cross-env PUBLIC_API_URL=https://testapi.discours.io astro dev",
|
||||||
"typecheck": "astro check && tsc --noEmit",
|
"typecheck": "astro check && tsc --noEmit",
|
||||||
"typecheck:watch": "tsc --noEmit --watch",
|
"typecheck:watch": "tsc --noEmit --watch",
|
||||||
|
@ -34,6 +35,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.216.0",
|
"@aws-sdk/client-s3": "^3.216.0",
|
||||||
"@aws-sdk/s3-presigned-post": "^3.216.0",
|
"@aws-sdk/s3-presigned-post": "^3.216.0",
|
||||||
|
"@aws-sdk/signature-v4-multi-region": "^3.215.0",
|
||||||
|
"@aws-sdk/util-user-agent-node": "^3.215.0",
|
||||||
"mailgun.js": "^8.0.2"
|
"mailgun.js": "^8.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -54,6 +57,7 @@
|
||||||
"@solid-devtools/logger": "^0.5.0",
|
"@solid-devtools/logger": "^0.5.0",
|
||||||
"@solid-primitives/memo": "^1.1.2",
|
"@solid-primitives/memo": "^1.1.2",
|
||||||
"@solid-primitives/storage": "^1.3.3",
|
"@solid-primitives/storage": "^1.3.3",
|
||||||
|
"@solid-primitives/upload": "^0.0.105",
|
||||||
"@types/express": "^4.17.14",
|
"@types/express": "^4.17.14",
|
||||||
"@types/node": "^18.11.9",
|
"@types/node": "^18.11.9",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
|
|
3
public/icons/apple.svg
Normal file
|
@ -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 |
3
public/icons/arrow-circle-left.svg
Normal 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 |
3
public/icons/arrow-circle-right.svg
Normal 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 |
4
public/icons/circle-plus.svg
Normal 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 |
|
@ -1,3 +1,3 @@
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M1 1L6 6M6 6L1 11M6 6L11 1M6 6L11 11" stroke="#696969" stroke-width="2"/>
|
<path d="M1 1L6 6M6 6L1 11M6 6L11 1M6 6L11 11" stroke="#000" stroke-width="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 186 B After Width: | Height: | Size: 183 B |
|
@ -1,4 +1 @@
|
||||||
<svg width="18" height="18" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19,2H6.27A3,3,0,0,0,3.32,4.46l-1.27,7A3,3,0,0,0,5,15H9.56L9,16.43A4.13,4.13,0,0,0,12.89,22a1,1,0,0,0,.91-.59L16.65,15H19a3,3,0,0,0,3-3V5A3,3,0,0,0,19,2ZM15,13.79l-2.72,6.12a2.13,2.13,0,0,1-1.38-2.78l.53-1.43A2,2,0,0,0,9.56,13H5a1,1,0,0,1-.77-.36A1,1,0,0,1,4,11.82l1.27-7a1,1,0,0,1,1-.82H15ZM20,12a1,1,0,0,1-1,1H17V4h2a1,1,0,0,1,1,1Z"/></svg>
|
||||||
<path transform="rotate(180 9 9)" d="M0 8H3V18H0V8Z" fill="#141414"/>
|
|
||||||
<path transform="rotate(180 9 9)" d="M4 18V8H5.50049C5.81525 8 6.11164 7.85181 6.30049 7.6L8.87932 4.16155C8.95929 4.05493 9.01714 3.93339 9.04947 3.80409L9.93965 0.243377C9.9754 0.100343 10.1039 0 10.2514 0C11.4935 0 12.5005 1.00697 12.5005 2.24913V4.5L12.1636 6.85858C12.0775 7.46101 12.545 8 13.1535 8H19.0005C19.5528 8 20.0005 8.44771 20.0005 9V9.3702C20.0005 9.93081 19.7652 10.4657 19.3519 10.8445L19.2854 10.9055C18.8721 11.2843 18.6369 11.8192 18.6369 12.3798V13.1202C18.6369 13.6808 18.4016 14.2157 17.9883 14.5945L17.651 14.9037C17.4031 15.1309 17.2166 15.4169 17.1085 15.7353L16.256 18.2473C16.1064 18.6879 15.6726 18.9672 15.2095 18.9209L6.00049 18H4Z" fill="#141414"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 855 B After Width: | Height: | Size: 411 B |
1
public/icons/edit-2.svg
Normal file
|
@ -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 |
|
@ -1,4 +1,6 @@
|
||||||
<svg width="20" height="13" viewBox="0 0 20 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" width="512px" height="512px" viewBox="0 0 512 512">
|
||||||
<path d="M13 6C13 7.65685 11.6569 9 10 9C8.34315 9 7 7.65685 7 6C7 4.34315 8.34315 3 10 3C11.6569 3 13 4.34315 13 6Z" fill="#141414"/>
|
<path
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.7784 6.5C16.6611 8.44191 13.9671 11 10 11C6.03294 11 3.33893 8.44191 2.22163 6.5C3.33893 4.55809 6.03294 2 10 2C13.9671 2 16.6611 4.55809 17.7784 6.5ZM10 13C15.5228 13 19 9 20 6.5C19 4 15.5228 0 10 0C4.47715 0 1 4 0 6.5C1 9 4.47715 13 10 13Z" fill="#141414"/>
|
d="M255.66,112c-77.94,0-157.89,45.11-220.83,135.33a16,16,0,0,0-.27,17.77C82.92,340.8,161.8,400,255.66,400,348.5,400,429,340.62,477.45,264.75a16.14,16.14,0,0,0,0-17.47C428.89,172.28,347.8,112,255.66,112Z"
|
||||||
|
style="fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;stroke-width:32px" />
|
||||||
|
<circle cx="256" cy="256" r="80" style="fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:32px" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 551 B After Width: | Height: | Size: 532 B |
1
public/icons/favorite.svg
Normal 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
|
@ -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
|
@ -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 |
1
public/icons/internet.svg
Normal 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 |
|
@ -1,4 +1 @@
|
||||||
<svg width="20" height="19" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21.3,10.08A3,3,0,0,0,19,9H14.44L15,7.57A4.13,4.13,0,0,0,11.11,2a1,1,0,0,0-.91.59L7.35,9H5a3,3,0,0,0-3,3v7a3,3,0,0,0,3,3H17.73a3,3,0,0,0,2.95-2.46l1.27-7A3,3,0,0,0,21.3,10.08ZM7,20H5a1,1,0,0,1-1-1V12a1,1,0,0,1,1-1H7Zm13-7.82-1.27,7a1,1,0,0,1-1,.82H9V10.21l2.72-6.12A2.11,2.11,0,0,1,13.1,6.87L12.57,8.3A2,2,0,0,0,14.44,11H19a1,1,0,0,1,.77.36A1,1,0,0,1,20,12.18Z"/></svg>
|
||||||
<path d="M0 8H3V18H0V8Z" fill="#141414"/>
|
|
||||||
<path d="M4 18V8H5.50049C5.81525 8 6.11164 7.85181 6.30049 7.6L8.87932 4.16155C8.95929 4.05493 9.01714 3.93339 9.04947 3.80409L9.93965 0.243377C9.9754 0.100343 10.1039 0 10.2514 0C11.4935 0 12.5005 1.00697 12.5005 2.24913V4.5L12.1636 6.85858C12.0775 7.46101 12.545 8 13.1535 8H19.0005C19.5528 8 20.0005 8.44771 20.0005 9V9.3702C20.0005 9.93081 19.7652 10.4657 19.3519 10.8445L19.2854 10.9055C18.8721 11.2843 18.6369 11.8192 18.6369 12.3798V13.1202C18.6369 13.6808 18.4016 14.2157 17.9883 14.5945L17.651 14.9037C17.4031 15.1309 17.2166 15.4169 17.1085 15.7353L16.256 18.2473C16.1064 18.6879 15.6726 18.9672 15.2095 18.9209L6.00049 18H4Z" fill="#141414"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 799 B After Width: | Height: | Size: 438 B |
1
public/icons/link.svg
Normal file
|
@ -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 |
3
public/icons/password-hide.svg
Normal 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 |
3
public/icons/password-open.svg
Normal 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
|
@ -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 |
|
@ -1,3 +1,4 @@
|
||||||
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M6 0C2.69432 0 0 2.6826 0 5.97389C0 9.26518 2.69432 11.9478 6 11.9478V14L7.02604 13.3453C8.5166 12.3934 11.2347 10.3384 11.8659 7.22363C11.9523 6.82188 12 6.40385 12 5.97389C12 2.6826 9.30568 0 6 0Z" fill="#696969"/>
|
<path
|
||||||
|
d="M6 0C2.69432 0 0 2.6826 0 5.97389C0 9.26518 2.69432 11.9478 6 11.9478V14L7.02604 13.3453C8.5166 12.3934 11.2347 10.3384 11.8659 7.22363C11.9523 6.82188 12 6.40385 12 5.97389C12 2.6826 9.30568 0 6 0Z" fill="#000"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 329 B After Width: | Height: | Size: 328 B |
1
public/icons/share-new.svg
Normal 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 |
1
public/icons/user-default.svg
Normal 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 |
BIN
public/logo.png
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.4 KiB |
201
src/components/Article/Comment.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +1,17 @@
|
||||||
import './Comment.scss'
|
import styles from './Comment.module.scss'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { AuthorCard } from '../Author/Card'
|
import { AuthorCard } from '../Author/Card'
|
||||||
import { Show, createMemo } from 'solid-js'
|
import { Show, createMemo, createSignal } from 'solid-js'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import type { Author, Reaction as Point } from '../../graphql/types.gen'
|
import type { Author, Reaction as Point } from '../../graphql/types.gen'
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
// import { createReaction, updateReaction, deleteReaction } from '../../stores/zine/reactions'
|
// import { createReaction, updateReaction, deleteReaction } from '../../stores/zine/reactions'
|
||||||
import MD from './MD'
|
import MD from './MD'
|
||||||
import { deleteReaction } from '../../stores/zine/reactions'
|
import { deleteReaction } from '../../stores/zine/reactions'
|
||||||
|
import { formatDate } from '../../utils'
|
||||||
|
import { SharePopup } from './SharePopup'
|
||||||
|
import stylesHeader from '../Nav/Header.module.scss'
|
||||||
|
import Userpic from '../Author/Userpic'
|
||||||
|
|
||||||
export default (props: {
|
export default (props: {
|
||||||
level?: number
|
level?: number
|
||||||
|
@ -15,6 +19,8 @@ export default (props: {
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
|
const [isReplyVisible, setIsReplyVisible] = createSignal(false)
|
||||||
|
|
||||||
const comment = createMemo(() => props.comment)
|
const comment = createMemo(() => props.comment)
|
||||||
const body = createMemo(() => (comment().body || '').trim())
|
const body = createMemo(() => (comment().body || '').trim())
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
|
@ -23,81 +29,116 @@ export default (props: {
|
||||||
deleteReaction(comment().id)
|
deleteReaction(comment().id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const formattedDate = createMemo(() =>
|
||||||
|
formatDate(new Date(comment()?.createdAt), { hour: 'numeric', minute: 'numeric' })
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={clsx('comment', { [`comment--level-${props.level}`]: Boolean(props.level) })}>
|
<div class={clsx(styles.comment, { [styles[`commentLevel${props.level}`]]: Boolean(props.level) })}>
|
||||||
<Show when={!!body()}>
|
<Show when={!!body()}>
|
||||||
<div class="comment__content">
|
<div class={styles.commentContent}>
|
||||||
<Show
|
<Show
|
||||||
when={!props.compact}
|
when={!props.compact}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="comment__details">
|
<div>
|
||||||
<a href={`/author/${comment()?.createdBy?.slug}`}>
|
<Userpic user={comment().createdBy as Author} isBig={false} isAuthorsList={false} />
|
||||||
@{(comment()?.createdBy || { name: 'anonymous' }).name}
|
<small class={styles.commentArticle}>
|
||||||
</a>
|
<a href={`#comment-${comment()?.id}`}>{comment()?.shout.title || ''}</a>
|
||||||
<div class="comment__article">
|
</small>
|
||||||
<Icon name="reply-arrow" />
|
|
||||||
<a href={`#comment-${comment()?.id}`}>
|
|
||||||
#{(comment()?.shout || { title: 'Lorem ipsum titled' }).title}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="comment__details">
|
<div class={styles.commentDetails}>
|
||||||
<div class="comment-author">
|
<div class={styles.commentAuthor}>
|
||||||
<AuthorCard
|
<AuthorCard
|
||||||
author={comment()?.createdBy as Author}
|
author={comment()?.createdBy as Author}
|
||||||
hideDescription={true}
|
hideDescription={true}
|
||||||
hideFollow={true}
|
hideFollow={true}
|
||||||
|
isComments={true}
|
||||||
|
hasLink={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="comment-date">{comment()?.createdAt}</div>
|
<div class={styles.commentDate}>{formattedDate()}</div>
|
||||||
<div class="comment-rating">{comment().stat.rating}</div>
|
<div
|
||||||
|
class={styles.commentRating}
|
||||||
|
classList={{
|
||||||
|
[styles.commentRatingPositive]: comment().stat?.rating > 0,
|
||||||
|
[styles.commentRatingNegative]: comment().stat?.rating < 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button class={clsx(styles.commentRatingControl, styles.commentRatingControlUp)} />
|
||||||
|
<div class={styles.commentRatingValue}>{comment().stat?.rating || 0}</div>
|
||||||
|
<button class={clsx(styles.commentRatingControl, styles.commentRatingControlDown)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="comment-body" contenteditable={props.canEdit} id={'comment-' + (comment().id || '')}>
|
<div
|
||||||
|
class={styles.commentBody}
|
||||||
|
contenteditable={props.canEdit}
|
||||||
|
id={'comment-' + (comment().id || '')}
|
||||||
|
>
|
||||||
<MD body={body()} />
|
<MD body={body()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!props.compact}>
|
<Show when={!props.compact}>
|
||||||
<div class="comment-controls">
|
<div class={styles.commentControls}>
|
||||||
<button class="comment-control comment-control--reply">
|
<button
|
||||||
<Icon name="reply" />
|
class={clsx(styles.commentControl, styles.commentControlReply)}
|
||||||
|
onClick={() => setIsReplyVisible(!isReplyVisible())}
|
||||||
|
>
|
||||||
|
<Icon name="reply" class={styles.icon} />
|
||||||
{t('Reply')}
|
{t('Reply')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={props.canEdit}>
|
<Show when={props.canEdit}>
|
||||||
{/*FIXME implement edit comment modal*/}
|
{/*FIXME implement edit comment modal*/}
|
||||||
{/*<button*/}
|
{/*<button*/}
|
||||||
{/* class="comment-control comment-control--edit"*/}
|
{/* class={clsx(styles.commentControl, styles.commentControlEdit)}*/}
|
||||||
{/* onClick={() => showModal('editComment')}*/}
|
{/* onClick={() => showModal('editComment')}*/}
|
||||||
{/*>*/}
|
{/*>*/}
|
||||||
{/* <Icon name="edit" />*/}
|
{/* <Icon name="edit" class={styles.icon} />*/}
|
||||||
{/* {t('Edit')}*/}
|
{/* {t('Edit')}*/}
|
||||||
{/*</button>*/}
|
{/*</button>*/}
|
||||||
<button class="comment-control comment-control--delete" onClick={() => remove()}>
|
<button
|
||||||
<Icon name="delete" />
|
class={clsx(styles.commentControl, styles.commentControlDelete)}
|
||||||
|
onClick={() => remove()}
|
||||||
|
>
|
||||||
|
<Icon name="delete" class={styles.icon} />
|
||||||
{t('Delete')}
|
{t('Delete')}
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/*FIXME implement modals */}
|
<SharePopup
|
||||||
|
containerCssClass={stylesHeader.control}
|
||||||
|
trigger={
|
||||||
|
<button class={clsx(styles.commentControl, styles.commentControlShare)}>
|
||||||
|
<Icon name="share" class={styles.icon} />
|
||||||
|
{t('Share')}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/*<button*/}
|
{/*<button*/}
|
||||||
{/* class="comment-control comment-control--share"*/}
|
{/* class={clsx(styles.commentControl, styles.commentControlComplain)}*/}
|
||||||
{/* onClick={() => showModal('shareComment')}*/}
|
|
||||||
{/*>*/}
|
|
||||||
{/* {t('Share')}*/}
|
|
||||||
{/*</button>*/}
|
|
||||||
{/*<button*/}
|
|
||||||
{/* class="comment-control comment-control--complain"*/}
|
|
||||||
{/* onClick={() => showModal('reportComment')}*/}
|
{/* onClick={() => showModal('reportComment')}*/}
|
||||||
{/*>*/}
|
{/*>*/}
|
||||||
{/* {t('Report')}*/}
|
{/* {t('Report')}*/}
|
||||||
{/*</button>*/}
|
{/*</button>*/}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={isReplyVisible()}>
|
||||||
|
<form class={styles.replyForm}>
|
||||||
|
<textarea name="reply" id="reply" rows="5" />
|
||||||
|
<div class={styles.replyFormControls}>
|
||||||
|
<button class="button button--light" onClick={() => setIsReplyVisible(false)}>
|
||||||
|
{t('Cancel')}
|
||||||
|
</button>
|
||||||
|
<button class="button">{t('Send')}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
127
src/components/Article/CommentsTree.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,63 +1,66 @@
|
||||||
import { capitalize } from '../../utils'
|
import { capitalize, formatDate } from '../../utils'
|
||||||
import './Full.scss'
|
import './Full.scss'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import ArticleComment from './Comment'
|
|
||||||
import { AuthorCard } from '../Author/Card'
|
import { AuthorCard } from '../Author/Card'
|
||||||
import { createMemo, createSignal, For, onMount, Show } from 'solid-js'
|
import { createMemo, For, Match, onMount, Show, Switch } from 'solid-js'
|
||||||
import type { Author, Reaction, Shout } from '../../graphql/types.gen'
|
import type { Author, Shout } from '../../graphql/types.gen'
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
import { showModal } from '../../stores/ui'
|
|
||||||
import MD from './MD'
|
import MD from './MD'
|
||||||
import { SharePopup } from './SharePopup'
|
import { SharePopup } from './SharePopup'
|
||||||
import { useSession } from '../../context/session'
|
|
||||||
import stylesHeader from '../Nav/Header.module.scss'
|
import stylesHeader from '../Nav/Header.module.scss'
|
||||||
import styles from '../../styles/Article.module.scss'
|
import styles from '../../styles/Article.module.scss'
|
||||||
import RatingControl from './RatingControl'
|
import { RatingControl } from './RatingControl'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import { CommentsTree } from './CommentsTree'
|
||||||
const MAX_COMMENT_LEVEL = 6
|
import { useSession } from '../../context/session'
|
||||||
|
import VideoPlayer from './VideoPlayer'
|
||||||
const getCommentLevel = (comment: Reaction, level = 0) => {
|
import Slider from '../_shared/Slider'
|
||||||
if (comment && comment.replyTo && level < MAX_COMMENT_LEVEL) {
|
|
||||||
return 0 // FIXME: getCommentLevel(commentsById[c.replyTo], level + 1)
|
|
||||||
}
|
|
||||||
return level
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArticleProps {
|
interface ArticleProps {
|
||||||
article: Shout
|
article: Shout
|
||||||
reactions: Reaction[]
|
|
||||||
isCommentsLoading: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
interface MediaItem {
|
||||||
return date
|
url?: string
|
||||||
.toLocaleDateString('ru', {
|
pic?: string
|
||||||
month: 'long',
|
title?: string
|
||||||
day: 'numeric',
|
body?: string
|
||||||
year: 'numeric'
|
}
|
||||||
})
|
|
||||||
.replace(' г.', '')
|
const MediaView = (props: { media: MediaItem; kind: Shout['layout'] }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Switch fallback={<a href={props.media.url}>{t('Cannot show this media type')}</a>}>
|
||||||
|
<Match when={props.kind === 'audio'}>
|
||||||
|
<div>
|
||||||
|
<h5>{props.media.title}</h5>
|
||||||
|
<audio controls>
|
||||||
|
<source src={props.media.url} />
|
||||||
|
</audio>
|
||||||
|
<hr />
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.kind === 'video'}>
|
||||||
|
<VideoPlayer url={props.media.url} />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FullArticle = (props: ArticleProps) => {
|
export const FullArticle = (props: ArticleProps) => {
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt)))
|
const formattedDate = createMemo(() => formatDate(new Date(props.article.createdAt)))
|
||||||
const [isSharePopupVisible, setIsSharePopupVisible] = createSignal(false)
|
|
||||||
|
|
||||||
const mainTopic = () =>
|
const mainTopic = createMemo(
|
||||||
(props.article.topics?.find((topic) => topic?.slug === props.article.mainTopic)?.title || '').replace(
|
() =>
|
||||||
' ',
|
props.article.topics?.find((topic) => topic?.slug === props.article.mainTopic) ||
|
||||||
' '
|
props.article.topics[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const mainTopicTitle = createMemo(() => mainTopic().title.replace(' ', ' '))
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const script = document.createElement('script')
|
|
||||||
script.async = true
|
|
||||||
script.src = 'https://ackee.discours.io/increment.js'
|
|
||||||
script.dataset.ackeeServer = 'https://ackee.discours.io'
|
|
||||||
script.dataset.ackeeDomainId = '1004abeb-89b2-4e85-ad97-74f8d2c8ed2d'
|
|
||||||
document.body.appendChild(script)
|
|
||||||
const windowHash = window.location.hash
|
const windowHash = window.location.hash
|
||||||
if (windowHash?.length > 0) {
|
if (windowHash?.length > 0) {
|
||||||
const comments = document.querySelector(windowHash)
|
const comments = document.querySelector(windowHash)
|
||||||
|
@ -70,12 +73,26 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const canEdit = () => props.article.authors?.some((a) => a.slug === session()?.user?.slug)
|
||||||
|
|
||||||
|
const bookmark = (ev) => {
|
||||||
|
// TODO: implement bookmark clicked
|
||||||
|
ev.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = createMemo(() => props.article.body)
|
||||||
|
const media = createMemo(() => {
|
||||||
|
const mi = JSON.parse(props.article.media || '[]')
|
||||||
|
console.debug(mi)
|
||||||
|
return mi
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="shout wide-container">
|
<div class="shout wide-container">
|
||||||
<article class="col-md-6 shift-content">
|
<article class="col-md-6 shift-content">
|
||||||
<div class={styles.shoutHeader}>
|
<div class={styles.shoutHeader}>
|
||||||
<div class={styles.shoutTopic}>
|
<div class={styles.shoutTopic}>
|
||||||
<a href={`/topic/${props.article.mainTopic}`} innerHTML={mainTopic() || ''} />
|
<a href={`/topic/${props.article.mainTopic}`} innerHTML={mainTopicTitle() || ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>{props.article.title}</h1>
|
<h1>{props.article.title}</h1>
|
||||||
|
@ -96,13 +113,38 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
<div class={styles.shoutCover} style={{ 'background-image': `url('${props.article.cover}')` }} />
|
<div class={styles.shoutCover} style={{ 'background-image': `url('${props.article.cover}')` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={Boolean(props.article.body)}>
|
|
||||||
<div class={styles.shoutBody}>
|
|
||||||
<Show
|
<Show
|
||||||
when={!props.article.body.startsWith('<')}
|
when={media() && props.article.layout !== 'image'}
|
||||||
fallback={<div innerHTML={props.article.body} />}
|
fallback={
|
||||||
|
<Slider>
|
||||||
|
<For each={media() || []}>
|
||||||
|
{(m: MediaItem) => (
|
||||||
|
<>
|
||||||
|
<img src={m.url || m.pic} alt={m.title} />
|
||||||
|
<div innerHTML={m.body} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Slider>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<MD body={props.article.body} />
|
<div class="media-items">
|
||||||
|
<For each={media() || []}>
|
||||||
|
{(m: MediaItem) => (
|
||||||
|
<div class={styles.shoutMediaBody}>
|
||||||
|
<MediaView media={m} kind={props.article.layout} />
|
||||||
|
<Show when={m?.body}>
|
||||||
|
<div innerHTML={m.body} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={body()}>
|
||||||
|
<div class={styles.shoutBody}>
|
||||||
|
<Show when={!body().startsWith('<')} fallback={<div innerHTML={body()} />}>
|
||||||
|
<MD body={body()} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
@ -111,59 +153,54 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
<div class="col-md-8 shift-content">
|
<div class="col-md-8 shift-content">
|
||||||
<div class={styles.shoutStats}>
|
<div class={styles.shoutStats}>
|
||||||
<div class={styles.shoutStatsItem}>
|
<div class={styles.shoutStatsItem}>
|
||||||
<RatingControl rating={props.article.stat?.rating} />
|
<RatingControl rating={props.article.stat?.rating} class={styles.ratingControl} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemLikes)}>
|
<Show when={props.article.stat?.viewed}>
|
||||||
<Icon name="like" class={styles.icon} />
|
<div class={clsx(styles.shoutStatsItem)}>
|
||||||
{props.article.stat?.rating || ''}
|
<Icon name="eye" class={clsx(styles.icon, styles.iconEye)} />
|
||||||
|
{props.article.stat?.viewed}
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class={styles.shoutStatsItem}>
|
<div class={styles.shoutStatsItem}>
|
||||||
<Icon name="comment" class={styles.icon} />
|
<Icon name="comment" class={styles.icon} />
|
||||||
{props.article.stat?.commented || ''}
|
{props.article.stat?.commented || ''}
|
||||||
</div>
|
</div>
|
||||||
{/*FIXME*/}
|
|
||||||
{/*<div class={styles.shoutStatsItem}>*/}
|
|
||||||
{/* <a href="#bookmark" onClick={() => console.log(props.article.slug, 'articles')}>*/}
|
|
||||||
{/* <Icon name={'bookmark' + (bookmarked() ? '' : '-x')} />*/}
|
|
||||||
{/* </a>*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
<div class={styles.shoutStatsItem}>
|
<div class={styles.shoutStatsItem}>
|
||||||
<SharePopup
|
<SharePopup
|
||||||
onVisibilityChange={(isVisible) => {
|
|
||||||
setIsSharePopupVisible(isVisible)
|
|
||||||
}}
|
|
||||||
containerCssClass={stylesHeader.control}
|
containerCssClass={stylesHeader.control}
|
||||||
trigger={<Icon name="share" class={styles.icon} />}
|
trigger={<Icon name="share-outline" class={styles.icon} />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.shoutStatsItem}>
|
|
||||||
|
<div class={styles.shoutStatsItem} onClick={bookmark}>
|
||||||
<Icon name="bookmark" class={styles.icon} />
|
<Icon name="bookmark" class={styles.icon} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*FIXME*/}
|
<Show when={canEdit()}>
|
||||||
{/*<Show when={canEdit()}>*/}
|
<div class={styles.shoutStatsItem}>
|
||||||
{/* <div class={styles.shoutStatsItem}>*/}
|
<a href="/edit">
|
||||||
{/* <a href="/edit">*/}
|
<Icon name="edit" />
|
||||||
{/* <Icon name="edit" />*/}
|
{t('Edit')}
|
||||||
{/* {t('Edit')}*/}
|
</a>
|
||||||
{/* </a>*/}
|
|
||||||
{/* </div>*/}
|
|
||||||
{/*</Show>*/}
|
|
||||||
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalData)}>
|
|
||||||
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalDataItem)}>
|
|
||||||
{formattedDate}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={props.article.stat?.viewed}>
|
|
||||||
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalDataItem)}>
|
|
||||||
<Icon name="view" class={styles.icon} />
|
|
||||||
{props.article.stat?.viewed}
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalData)}>
|
||||||
|
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemAdditionalDataItem)}>
|
||||||
|
{formattedDate()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class={styles.help}>
|
||||||
|
<Show when={session()?.token}>
|
||||||
|
<button class="button">{t('Cooperate')}</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={canEdit()}>
|
||||||
|
<button class="button button--light">{t('Invite to collab')}</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class={styles.topicsList}>
|
<div class={styles.topicsList}>
|
||||||
<For each={props.article.topics}>
|
<For each={props.article.topics}>
|
||||||
|
@ -181,45 +218,13 @@ export const FullArticle = (props: ArticleProps) => {
|
||||||
</Show>
|
</Show>
|
||||||
<For each={props.article?.authors}>
|
<For each={props.article?.authors}>
|
||||||
{(a: Author) => (
|
{(a: Author) => (
|
||||||
<div class="col-md-6">
|
<div class="col-xl-6">
|
||||||
<AuthorCard author={a} compact={false} hasLink={true} liteButtons={true} />
|
<AuthorCard author={a} compact={false} hasLink={true} liteButtons={true} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
<CommentsTree shoutSlug={props.article?.slug} />
|
||||||
<Show when={props.reactions?.length}>
|
|
||||||
<h2 id="comments">
|
|
||||||
{t('Comments')} {props.reactions?.length.toString() || ''}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<For each={props.reactions?.filter((r) => r.body)}>
|
|
||||||
{(reaction) => (
|
|
||||||
<ArticleComment
|
|
||||||
comment={reaction}
|
|
||||||
level={getCommentLevel(reaction)}
|
|
||||||
canEdit={reaction.createdBy?.slug === session()?.user?.slug}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
<Show when={!session()?.user?.slug}>
|
|
||||||
<div class={styles.commentWarning} id="comments">
|
|
||||||
{t('To leave a comment you please')}
|
|
||||||
<a
|
|
||||||
href={''}
|
|
||||||
onClick={(evt) => {
|
|
||||||
evt.preventDefault()
|
|
||||||
showModal('auth')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i>{t('sign up or sign in')}</i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={session()?.user?.slug}>
|
|
||||||
<textarea class={styles.writeComment} rows="1" placeholder={t('Write comment')} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import styles from './RatingControl.module.scss'
|
import styles from './RatingControl.module.scss'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
import { Icon } from '../_shared/Icon'
|
||||||
|
|
||||||
interface RatingControlProps {
|
interface RatingControlProps {
|
||||||
rating?: number
|
rating?: number
|
||||||
|
@ -15,5 +16,3 @@ export const RatingControl = (props: RatingControlProps) => {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RatingControl
|
|
||||||
|
|
|
@ -1,12 +1,24 @@
|
||||||
export default (props: { youtubeId?: string; vimeoId?: string; title?: string }) => {
|
import { Show } from 'solid-js'
|
||||||
// TODO: styling
|
|
||||||
return (
|
export default (props: { url: string }) => (
|
||||||
<video
|
<>
|
||||||
src={
|
<Show when={props.url.includes('youtube.com')}>
|
||||||
props.vimeoId
|
<iframe
|
||||||
? `https://vimeo.com/${props.vimeoId}`
|
id="ytplayer"
|
||||||
: `https://youtube.com/?watch=${props.youtubeId}`
|
width="640"
|
||||||
}
|
height="360"
|
||||||
|
src={`https://www.youtube.com/embed/${props.url.split('watch=').pop()}`}
|
||||||
|
allowfullscreen
|
||||||
/>
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.url.includes('vimeo.com')}>
|
||||||
|
<iframe
|
||||||
|
src={'https://player.vimeo.com/video/' + props.url.split('video/').pop()}
|
||||||
|
width="420"
|
||||||
|
height="345"
|
||||||
|
allow="autoplay; fullscreen"
|
||||||
|
allowfullscreen
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
|
@ -49,7 +49,10 @@
|
||||||
.authorSubscribe {
|
.authorSubscribe {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
|
@include media-breakpoint-up(sm) {
|
||||||
padding: 0 0 0 42px;
|
padding: 0 0 0 42px;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
background: #f7f7f7;
|
background: #f7f7f7;
|
||||||
|
@ -238,6 +241,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.authorsListItem {
|
.authorsListItem {
|
||||||
|
margin-bottom: 1em !important;
|
||||||
|
|
||||||
.authorName {
|
.authorName {
|
||||||
@include font-size(2.2rem);
|
@include font-size(2.2rem);
|
||||||
|
|
||||||
|
@ -256,3 +261,18 @@
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authorComments {
|
||||||
|
.authorName {
|
||||||
|
@include font-size(1.2rem);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circlewrap {
|
||||||
|
margin-top: -0.6em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.isSubscribing {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
|
@ -2,13 +2,15 @@ import type { Author } from '../../graphql/types.gen'
|
||||||
import Userpic from './Userpic'
|
import Userpic from './Userpic'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import styles from './Card.module.scss'
|
import styles from './Card.module.scss'
|
||||||
import { createMemo, For, Show } from 'solid-js'
|
import { createMemo, createSignal, For, Show } from 'solid-js'
|
||||||
import { translit } from '../../utils/ru2en'
|
import { translit } from '../../utils/ru2en'
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
import { locale } from '../../stores/ui'
|
import { locale } from '../../stores/ui'
|
||||||
import { follow, unfollow } from '../../stores/zine/common'
|
import { follow, unfollow } from '../../stores/zine/common'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
|
import { StatMetrics } from '../_shared/StatMetrics'
|
||||||
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
import { FollowingEntity } from '../../graphql/types.gen'
|
import { FollowingEntity } from '../../graphql/types.gen'
|
||||||
import { router, useRouter } from '../../stores/router'
|
import { router, useRouter } from '../../stores/router'
|
||||||
import { openPage } from '@nanostores/router'
|
import { openPage } from '@nanostores/router'
|
||||||
|
@ -26,24 +28,47 @@ interface AuthorCardProps {
|
||||||
isAuthorsList?: boolean
|
isAuthorsList?: boolean
|
||||||
truncateBio?: boolean
|
truncateBio?: boolean
|
||||||
liteButtons?: boolean
|
liteButtons?: boolean
|
||||||
|
isComments?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthorCard = (props: AuthorCardProps) => {
|
export const AuthorCard = (props: AuthorCardProps) => {
|
||||||
const { session } = useSession()
|
const {
|
||||||
|
session,
|
||||||
|
isSessionLoaded,
|
||||||
|
actions: { loadSession }
|
||||||
|
} = useSession()
|
||||||
|
|
||||||
|
const [isSubscribing, setIsSubscribing] = createSignal(false)
|
||||||
|
|
||||||
const subscribed = createMemo<boolean>(
|
const subscribed = createMemo<boolean>(
|
||||||
() => session()?.news?.authors?.some((u) => u === props.author.slug) || false
|
() => session()?.news?.authors?.some((u) => u === props.author.slug) || false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const subscribe = async (really = true) => {
|
||||||
|
setIsSubscribing(true)
|
||||||
|
|
||||||
|
await (really
|
||||||
|
? follow({ what: FollowingEntity.Author, slug: props.author.slug })
|
||||||
|
: unfollow({ what: FollowingEntity.Author, slug: props.author.slug }))
|
||||||
|
|
||||||
|
await loadSession()
|
||||||
|
setIsSubscribing(false)
|
||||||
|
}
|
||||||
|
|
||||||
const canFollow = createMemo(() => !props.hideFollow && session()?.user?.slug !== props.author.slug)
|
const canFollow = createMemo(() => !props.hideFollow && session()?.user?.slug !== props.author.slug)
|
||||||
const bio = createMemo(() => {
|
|
||||||
return props.caption || props.author.bio || t('Our regular contributor')
|
const name = createMemo(() => {
|
||||||
|
if (locale() !== 'ru') {
|
||||||
|
if (props.author.name === 'Дискурс') {
|
||||||
|
return 'Discours'
|
||||||
|
}
|
||||||
|
|
||||||
|
return translit(props.author.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.author.name
|
||||||
})
|
})
|
||||||
|
|
||||||
const name = () => {
|
|
||||||
return props.author.name === 'Дискурс' && locale() !== 'ru'
|
|
||||||
? 'Discours'
|
|
||||||
: translit(props.author.name || '', locale() || 'ru')
|
|
||||||
}
|
|
||||||
// TODO: reimplement AuthorCard
|
// TODO: reimplement AuthorCard
|
||||||
const { changeSearchParam } = useRouter()
|
const { changeSearchParam } = useRouter()
|
||||||
const initChat = () => {
|
const initChat = () => {
|
||||||
|
@ -55,6 +80,7 @@ export const AuthorCard = (props: AuthorCardProps) => {
|
||||||
class={clsx(styles.author)}
|
class={clsx(styles.author)}
|
||||||
classList={{
|
classList={{
|
||||||
[styles.authorPage]: props.isAuthorPage,
|
[styles.authorPage]: props.isAuthorPage,
|
||||||
|
[styles.authorComments]: props.isComments,
|
||||||
[styles.authorsListItem]: props.isAuthorsList
|
[styles.authorsListItem]: props.isAuthorsList
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -63,6 +89,7 @@ export const AuthorCard = (props: AuthorCardProps) => {
|
||||||
hasLink={props.hasLink}
|
hasLink={props.hasLink}
|
||||||
isBig={props.isAuthorPage}
|
isBig={props.isAuthorPage}
|
||||||
isAuthorsList={props.isAuthorsList}
|
isAuthorsList={props.isAuthorsList}
|
||||||
|
class={styles.circlewrap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class={styles.authorDetails}>
|
<div class={styles.authorDetails}>
|
||||||
|
@ -76,31 +103,37 @@ export const AuthorCard = (props: AuthorCardProps) => {
|
||||||
<div class={styles.authorName}>{name()}</div>
|
<div class={styles.authorName}>{name()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!props.hideDescription}>
|
<Show when={!props.hideDescription && props.author.bio}>
|
||||||
{props.isAuthorsList}
|
{props.isAuthorsList}
|
||||||
<div
|
<div
|
||||||
class={styles.authorAbout}
|
class={styles.authorAbout}
|
||||||
classList={{ 'text-truncate': props.truncateBio }}
|
classList={{ 'text-truncate': props.truncateBio }}
|
||||||
innerHTML={props.caption || bio()}
|
innerHTML={props.author.bio}
|
||||||
></div>
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={props.author.stat}>
|
||||||
|
<StatMetrics fields={['shouts', 'followers', 'comments']} stat={props.author.stat} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<ShowOnlyOnClient>
|
||||||
|
<Show when={isSessionLoaded()}>
|
||||||
<Show when={canFollow()}>
|
<Show when={canFollow()}>
|
||||||
<div class={styles.authorSubscribe}>
|
<div class={styles.authorSubscribe}>
|
||||||
<Show
|
<Show
|
||||||
when={subscribed()}
|
when={subscribed()}
|
||||||
fallback={
|
fallback={
|
||||||
<button
|
<button
|
||||||
// TODO: change button view reactivity
|
onClick={() => subscribe(true)}
|
||||||
onclick={() => follow({ what: FollowingEntity.Author, slug: props.author.slug })}
|
|
||||||
class={clsx('button', styles.button)}
|
class={clsx('button', styles.button)}
|
||||||
classList={{
|
classList={{
|
||||||
[styles.buttonSubscribe]: !props.isAuthorsList,
|
[styles.buttonSubscribe]: !props.isAuthorsList,
|
||||||
'button--subscribe': !props.isAuthorsList,
|
'button--subscribe': !props.isAuthorsList,
|
||||||
'button--subscribe-topic': props.isAuthorsList,
|
'button--subscribe-topic': props.isAuthorsList,
|
||||||
[styles.buttonWrite]: props.isAuthorsList
|
[styles.buttonWrite]: props.isAuthorsList,
|
||||||
|
[styles.isSubscribing]: isSubscribing()
|
||||||
}}
|
}}
|
||||||
|
disabled={isSubscribing()}
|
||||||
>
|
>
|
||||||
<Show when={!props.isAuthorsList}>
|
<Show when={!props.isAuthorsList}>
|
||||||
<Icon name="author-subscribe" class={styles.icon} />
|
<Icon name="author-subscribe" class={styles.icon} />
|
||||||
|
@ -110,13 +143,16 @@ export const AuthorCard = (props: AuthorCardProps) => {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onclick={() => follow({ what: FollowingEntity.Author, slug: props.author.slug })}
|
onClick={() => subscribe(false)}
|
||||||
|
class={clsx('button', styles.button)}
|
||||||
classList={{
|
classList={{
|
||||||
[styles.buttonSubscribe]: !props.isAuthorsList,
|
[styles.buttonSubscribe]: !props.isAuthorsList,
|
||||||
'button--subscribe': !props.isAuthorsList,
|
'button--subscribe': !props.isAuthorsList,
|
||||||
'button--subscribe-topic': props.isAuthorsList,
|
'button--subscribe-topic': props.isAuthorsList,
|
||||||
[styles.buttonWrite]: props.isAuthorsList
|
[styles.buttonWrite]: props.isAuthorsList,
|
||||||
|
[styles.isSubscribing]: isSubscribing()
|
||||||
}}
|
}}
|
||||||
|
disabled={isSubscribing()}
|
||||||
>
|
>
|
||||||
<Show when={!props.isAuthorsList}>
|
<Show when={!props.isAuthorsList}>
|
||||||
<Icon name="author-unsubscribe" class={styles.icon} />
|
<Icon name="author-unsubscribe" class={styles.icon} />
|
||||||
|
@ -146,6 +182,8 @@ export const AuthorCard = (props: AuthorCardProps) => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</ShowOnlyOnClient>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -32,7 +32,7 @@ export default (props: UserpicProps) => {
|
||||||
when={props.user && props.user.userpic === ''}
|
when={props.user && props.user.userpic === ''}
|
||||||
fallback={
|
fallback={
|
||||||
<img
|
<img
|
||||||
src={props.user.userpic || '/icons/user-anonymous.svg'}
|
src={props.user.userpic || '/icons/user-default.svg'}
|
||||||
alt={props.user.name || ''}
|
alt={props.user.name || ''}
|
||||||
classList={{ anonymous: !props.user.userpic }}
|
classList={{ anonymous: !props.user.userpic }}
|
||||||
/>
|
/>
|
||||||
|
@ -48,9 +48,10 @@ export default (props: UserpicProps) => {
|
||||||
when={props.user && props.user.userpic === ''}
|
when={props.user && props.user.userpic === ''}
|
||||||
fallback={
|
fallback={
|
||||||
<img
|
<img
|
||||||
src={props.user.userpic || '/icons/user-anonymous.svg'}
|
src={props.user.userpic || '/icons/user-default.svg'}
|
||||||
alt={props.user.name || ''}
|
alt={props.user.name || ''}
|
||||||
classList={{ anonymous: !props.user.userpic }}
|
classList={{ anonymous: !props.user.userpic }}
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
.navigationHeader {
|
||||||
|
@include font-size(1.8rem);
|
||||||
|
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1.1em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
@include font-size(1.4rem);
|
||||||
|
}
|
21
src/components/Discours/ProfileSettingsNavigation.tsx
Normal 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">Вход и безопасность</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -59,7 +59,7 @@ export const Editor = () => {
|
||||||
const handleSaveButtonClick = () => {
|
const handleSaveButtonClick = () => {
|
||||||
const article: ShoutInput = {
|
const article: ShoutInput = {
|
||||||
body: getHtml(editorViewRef.current.state),
|
body: getHtml(editorViewRef.current.state),
|
||||||
// community: 'discours', // ? Type 'string' is not assignable to type 'number'.
|
community: 1, // 'discours' ?
|
||||||
slug: 'new-' + Math.floor(Math.random() * 1000000)
|
slug: 'new-' + Math.floor(Math.random() * 1000000)
|
||||||
}
|
}
|
||||||
createArticle({ article })
|
createArticle({ article })
|
||||||
|
|
|
@ -20,6 +20,11 @@
|
||||||
a {
|
a {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 1.2em;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shoutCardWithBorder {
|
.shoutCardWithBorder {
|
||||||
|
|
|
@ -7,8 +7,8 @@ import { Icon } from '../_shared/Icon'
|
||||||
import styles from './Card.module.scss'
|
import styles from './Card.module.scss'
|
||||||
import { locale } from '../../stores/ui'
|
import { locale } from '../../stores/ui'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import CardTopic from './CardTopic'
|
import { CardTopic } from './CardTopic'
|
||||||
import RatingControl from '../Article/RatingControl'
|
import { RatingControl } from '../Article/RatingControl'
|
||||||
|
|
||||||
interface ArticleCardProps {
|
interface ArticleCardProps {
|
||||||
settings?: {
|
settings?: {
|
||||||
|
@ -56,9 +56,9 @@ const getTitleAndSubtitle = (article: Shout): { title: string; subtitle: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ArticleCard = (props: ArticleCardProps) => {
|
export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
const mainTopic = props.article.topics.find(
|
const mainTopic =
|
||||||
(articleTopic) => articleTopic.slug === props.article.mainTopic
|
props.article.topics.find((articleTopic) => articleTopic.slug === props.article.mainTopic) ||
|
||||||
)
|
props.article.topics[0]
|
||||||
|
|
||||||
const formattedDate = createMemo<string>(() => {
|
const formattedDate = createMemo<string>(() => {
|
||||||
return new Date(props.article.createdAt)
|
return new Date(props.article.createdAt)
|
||||||
|
@ -107,7 +107,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
<Show when={!props.settings?.isGroup}>
|
<Show when={!props.settings?.isGroup}>
|
||||||
<CardTopic
|
<CardTopic
|
||||||
title={
|
title={
|
||||||
locale() === 'ru' && mainTopic.title ? mainTopic.title : mainTopic.slug.replace('-', ' ')
|
locale() === 'ru' && mainTopic.title ? mainTopic.title : mainTopic?.slug?.replace('-', ' ')
|
||||||
}
|
}
|
||||||
slug={mainTopic.slug}
|
slug={mainTopic.slug}
|
||||||
isFloorImportant={props.settings?.isFloorImportant}
|
isFloorImportant={props.settings?.isFloorImportant}
|
||||||
|
@ -134,10 +134,11 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
||||||
<div class={styles.shoutAuthor}>
|
<div class={styles.shoutAuthor}>
|
||||||
<For each={authors}>
|
<For each={authors}>
|
||||||
{(author, index) => {
|
{(author, index) => {
|
||||||
const name =
|
let name = author.name
|
||||||
author.name === 'Дискурс' && locale() !== 'ru'
|
|
||||||
? 'Discours'
|
if (locale() !== 'ru') {
|
||||||
: translit(author.name || '', locale() || 'ru')
|
name = name === 'Дискурс' ? 'Discours' : translit(name)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -6,7 +6,7 @@ interface CardTopicProps {
|
||||||
isFloorImportant?: boolean
|
isFloorImportant?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props: CardTopicProps) => {
|
export const CardTopic = (props: CardTopicProps) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={style.shoutTopic}
|
class={style.shoutTopic}
|
||||||
|
|
|
@ -54,7 +54,7 @@ export const ForgotPasswordForm = () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signSendLink({ email: email(), lang: locale() })
|
await signSendLink({ email: email(), lang: locale(), template: 'forgot_password' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiError && error.code === 'user_not_found') {
|
if (error instanceof ApiError && error.code === 'user_not_found') {
|
||||||
setIsUserNotFound(true)
|
setIsUserNotFound(true)
|
||||||
|
|
|
@ -50,7 +50,7 @@ export const LoginForm = () => {
|
||||||
setIsEmailNotConfirmed(false)
|
setIsEmailNotConfirmed(false)
|
||||||
setSubmitError('')
|
setSubmitError('')
|
||||||
setIsLinkSent(true)
|
setIsLinkSent(true)
|
||||||
const result = await signSendLink({ email: email(), lang: locale() })
|
const result = await signSendLink({ email: email(), lang: locale(), template: 'email_confirmation' })
|
||||||
if (result.error) setSubmitError(result.error)
|
if (result.error) setSubmitError(result.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -61,14 +61,14 @@ export const RegisterForm = () => {
|
||||||
|
|
||||||
const newValidationErrors: ValidationErrors = {}
|
const newValidationErrors: ValidationErrors = {}
|
||||||
|
|
||||||
const clearName = name().trim()
|
const cleanName = name().trim()
|
||||||
const clearEmail = email().trim()
|
const cleanEmail = email().trim()
|
||||||
|
|
||||||
if (!clearName) {
|
if (!cleanName) {
|
||||||
newValidationErrors.name = t('Please enter a name to sign your comments and publication')
|
newValidationErrors.name = t('Please enter a name to sign your comments and publication')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!clearEmail) {
|
if (!cleanEmail) {
|
||||||
newValidationErrors.email = t('Please enter email')
|
newValidationErrors.email = t('Please enter email')
|
||||||
} else if (!isValidEmail(email())) {
|
} else if (!isValidEmail(email())) {
|
||||||
newValidationErrors.email = t('Invalid email')
|
newValidationErrors.email = t('Invalid email')
|
||||||
|
@ -80,7 +80,7 @@ export const RegisterForm = () => {
|
||||||
|
|
||||||
setValidationErrors(newValidationErrors)
|
setValidationErrors(newValidationErrors)
|
||||||
|
|
||||||
const emailCheckResult = await checkEmail(clearEmail)
|
const emailCheckResult = await checkEmail(cleanEmail)
|
||||||
|
|
||||||
const isValid = Object.keys(newValidationErrors).length === 0 && !emailCheckResult
|
const isValid = Object.keys(newValidationErrors).length === 0 && !emailCheckResult
|
||||||
|
|
||||||
|
@ -92,8 +92,8 @@ export const RegisterForm = () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register({
|
await register({
|
||||||
name: clearName,
|
name: cleanName,
|
||||||
email: clearEmail,
|
email: cleanEmail,
|
||||||
password: password()
|
password: password()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -123,13 +123,13 @@ export const RegisterForm = () => {
|
||||||
</Show>
|
</Show>
|
||||||
<div class="pretty-form__item">
|
<div class="pretty-form__item">
|
||||||
<input
|
<input
|
||||||
name="name"
|
name="fullName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('Full name')}
|
placeholder={t('Full name')}
|
||||||
autocomplete=""
|
autocomplete=""
|
||||||
onInput={(event) => handleNameInput(event.currentTarget.value)}
|
onInput={(event) => handleNameInput(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<label for="name">{t('Full name')}</label>
|
<label for="fullName">{t('Full name')}</label>
|
||||||
</div>
|
</div>
|
||||||
<Show when={validationErrors().name}>
|
<Show when={validationErrors().name}>
|
||||||
<div class={styles.validationError}>{validationErrors().name}</div>
|
<div class={styles.validationError}>{validationErrors().name}</div>
|
||||||
|
|
|
@ -13,6 +13,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
a:hover {
|
a:hover {
|
||||||
.icon {
|
.icon {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
|
|
|
@ -132,7 +132,7 @@ export const Header = (props: Props) => {
|
||||||
<a href={getPagePath(router, 'inbox')} class={styles.control}>
|
<a href={getPagePath(router, 'inbox')} class={styles.control}>
|
||||||
<Icon name="comments-outline" class={styles.icon} />
|
<Icon name="comments-outline" class={styles.icon} />
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class={styles.control} onClick={(event) => event.preventDefault()}>
|
<a href={getPagePath(router, 'create')} class={styles.control}>
|
||||||
<Icon name="pencil-outline" class={styles.icon} />
|
<Icon name="pencil-outline" class={styles.icon} />
|
||||||
</a>
|
</a>
|
||||||
<a href="#" class={styles.control} onClick={(event) => event.preventDefault()}>
|
<a href="#" class={styles.control} onClick={(event) => event.preventDefault()}>
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { clsx } from 'clsx'
|
||||||
import { useRouter } from '../../stores/router'
|
import { useRouter } from '../../stores/router'
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { createSignal, Show } from 'solid-js'
|
import { createEffect, createSignal, Show } from 'solid-js'
|
||||||
import Notifications from './Notifications'
|
import Notifications from './Notifications'
|
||||||
import { ProfilePopup } from './ProfilePopup'
|
import { ProfilePopup } from './ProfilePopup'
|
||||||
import Userpic from '../Author/Userpic'
|
import Userpic from '../Author/Userpic'
|
||||||
|
@ -21,7 +21,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
const [visibleWarnings, setVisibleWarnings] = createSignal(false)
|
const [visibleWarnings, setVisibleWarnings] = createSignal(false)
|
||||||
const { warnings } = useWarningsStore()
|
const { warnings } = useWarningsStore()
|
||||||
|
|
||||||
const { session, isAuthenticated } = useSession()
|
const { session, isSessionLoaded, isAuthenticated } = useSession()
|
||||||
|
|
||||||
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
|
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShowOnlyOnClient>
|
<ShowOnlyOnClient>
|
||||||
<Show when={!session.loading}>
|
<Show when={isSessionLoaded()}>
|
||||||
<div class={styles.usernav}>
|
<div class={styles.usernav}>
|
||||||
<div class={clsx(styles.userControl, styles.userControl, 'col')}>
|
<div class={clsx(styles.userControl, styles.userControl, 'col')}>
|
||||||
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
|
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>
|
||||||
|
@ -70,7 +70,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
|
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose, 'loginbtn')}>
|
||||||
<a href="?modal=auth&mode=login">
|
<a href="?modal=auth&mode=login">
|
||||||
<span class={styles.textLabel}>{t('Enter')}</span>
|
<span class={styles.textLabel}>{t('Enter')}</span>
|
||||||
<Icon name="user-anonymous" class={styles.icon} />
|
<Icon name="user-default" class={styles.icon} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,15 +23,16 @@
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1em;
|
top: 1em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 0.8em;
|
height: 18px;
|
||||||
|
width: 16px;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
width: 0.8em;
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
|
display: block;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,6 +56,7 @@
|
||||||
@media (min-width: 800px) and (max-width: 991px) {
|
@media (min-width: 800px) and (max-width: 991px) {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
right: 12px;
|
right: 12px;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
|
|
|
@ -2,20 +2,23 @@ import { useSession } from '../../context/session'
|
||||||
import type { PopupProps } from '../_shared/Popup'
|
import type { PopupProps } from '../_shared/Popup'
|
||||||
import { Popup } from '../_shared/Popup'
|
import { Popup } from '../_shared/Popup'
|
||||||
import styles from '../_shared/Popup/Popup.module.scss'
|
import styles from '../_shared/Popup/Popup.module.scss'
|
||||||
|
import { getPagePath } from '@nanostores/router'
|
||||||
|
import { router } from '../../stores/router'
|
||||||
|
|
||||||
type ProfilePopupProps = Omit<PopupProps, 'children'>
|
type ProfilePopupProps = Omit<PopupProps, 'children'>
|
||||||
|
|
||||||
export const ProfilePopup = (props: ProfilePopupProps) => {
|
export const ProfilePopup = (props: ProfilePopupProps) => {
|
||||||
const {
|
const {
|
||||||
session,
|
userSlug,
|
||||||
actions: { signOut }
|
actions: { signOut }
|
||||||
} = useSession()
|
} = useSession()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup {...props} horizontalAnchor="right">
|
<Popup {...props} horizontalAnchor="right">
|
||||||
|
{/*TODO: l10n*/}
|
||||||
<ul class="nodash">
|
<ul class="nodash">
|
||||||
<li>
|
<li>
|
||||||
<a href={`/author/${session().user?.slug}`}>Профиль</a>
|
<a href={getPagePath(router, 'author', { slug: userSlug() })}>Профиль</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">Черновики</a>
|
<a href="#">Черновики</a>
|
||||||
|
@ -30,7 +33,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => {
|
||||||
<a href="#">Закладки</a>
|
<a href="#">Закладки</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">Настройки</a>
|
<a href={getPagePath(router, 'profileSettings')}>Настройки</a>
|
||||||
</li>
|
</li>
|
||||||
<li class={styles.topBorderItem}>
|
<li class={styles.topBorderItem}>
|
||||||
<a
|
<a
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { loadAuthor } from '../../stores/zine/authors'
|
||||||
import { Loading } from '../Loading'
|
import { Loading } from '../Loading'
|
||||||
|
|
||||||
export const AuthorPage = (props: PageProps) => {
|
export const AuthorPage = (props: PageProps) => {
|
||||||
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.shouts) && Boolean(props.author))
|
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.authorShouts) && Boolean(props.author))
|
||||||
|
|
||||||
const slug = createMemo(() => {
|
const slug = createMemo(() => {
|
||||||
const { page: getPage } = useRouter()
|
const { page: getPage } = useRouter()
|
||||||
|
@ -38,7 +38,7 @@ export const AuthorPage = (props: PageProps) => {
|
||||||
return (
|
return (
|
||||||
<PageWrap>
|
<PageWrap>
|
||||||
<Show when={isLoaded()} fallback={<Loading />}>
|
<Show when={isLoaded()} fallback={<Loading />}>
|
||||||
<AuthorView author={props.author} shouts={props.shouts} authorSlug={slug()} />
|
<AuthorView author={props.author} shouts={props.authorShouts} authorSlug={slug()} />
|
||||||
</Show>
|
</Show>
|
||||||
</PageWrap>
|
</PageWrap>
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { PageWrap } from '../_shared/PageWrap'
|
||||||
export const ConnectPage = () => {
|
export const ConnectPage = () => {
|
||||||
return (
|
return (
|
||||||
<PageWrap>
|
<PageWrap>
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-10 col-md-8 col-lg-7 col-xl-6 shift-content">
|
<div class="col-sm-10 col-md-8 col-lg-7 col-xl-6 shift-content">
|
||||||
<h1>
|
<h1>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { Loading } from '../Loading'
|
||||||
import styles from './HomePage.module.scss'
|
import styles from './HomePage.module.scss'
|
||||||
|
|
||||||
export const HomePage = (props: PageProps) => {
|
export const HomePage = (props: PageProps) => {
|
||||||
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.shouts) && Boolean(props.randomTopics))
|
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.homeShouts) && Boolean(props.randomTopics))
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (isLoaded()) {
|
if (isLoaded()) {
|
||||||
|
@ -26,7 +26,7 @@ export const HomePage = (props: PageProps) => {
|
||||||
return (
|
return (
|
||||||
<PageWrap class={styles.mainContent}>
|
<PageWrap class={styles.mainContent}>
|
||||||
<Show when={isLoaded()} fallback={<Loading />}>
|
<Show when={isLoaded()} fallback={<Loading />}>
|
||||||
<HomeView randomTopics={props.randomTopics} shouts={props.shouts || []} />
|
<HomeView randomTopics={props.randomTopics} shouts={props.homeShouts || []} />
|
||||||
</Show>
|
</Show>
|
||||||
</PageWrap>
|
</PageWrap>
|
||||||
)
|
)
|
||||||
|
|
|
@ -13,9 +13,10 @@ import { t } from '../../utils/intl'
|
||||||
import { Row3 } from '../Feed/Row3'
|
import { Row3 } from '../Feed/Row3'
|
||||||
import { Row2 } from '../Feed/Row2'
|
import { Row2 } from '../Feed/Row2'
|
||||||
import { Beside } from '../Feed/Beside'
|
import { Beside } from '../Feed/Beside'
|
||||||
import Slider from '../Feed/Slider'
|
import Slider from '../_shared/Slider'
|
||||||
import { Row1 } from '../Feed/Row1'
|
import { Row1 } from '../Feed/Row1'
|
||||||
import styles from '../../styles/Topic.module.scss'
|
import styles from '../../styles/Topic.module.scss'
|
||||||
|
import { ArticleCard } from '../Feed/Card'
|
||||||
|
|
||||||
export const PRERENDERED_ARTICLES_COUNT = 21
|
export const PRERENDERED_ARTICLES_COUNT = 21
|
||||||
const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3
|
const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3
|
||||||
|
@ -28,7 +29,7 @@ export const LayoutShoutsPage = (props: PageProps) => {
|
||||||
return page.params.layout as LayoutType
|
return page.params.layout as LayoutType
|
||||||
})
|
})
|
||||||
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
||||||
const { sortedLayoutShouts, loadLayoutShoutsBy } = useLayoutsStore(layout(), props.shouts)
|
const { sortedLayoutShouts, loadLayoutShoutsBy } = useLayoutsStore(layout(), props.layoutShouts)
|
||||||
const sortedArticles = createMemo<Shout[]>(() => sortedLayoutShouts().get(layout()) || [])
|
const sortedArticles = createMemo<Shout[]>(() => sortedLayoutShouts().get(layout()) || [])
|
||||||
const loadMoreLayout = async (kind: LayoutType) => {
|
const loadMoreLayout = async (kind: LayoutType) => {
|
||||||
saveScrollPosition()
|
saveScrollPosition()
|
||||||
|
@ -106,7 +107,21 @@ export const LayoutShoutsPage = (props: PageProps) => {
|
||||||
<ModeSwitcher />
|
<ModeSwitcher />
|
||||||
<Row1 article={sortedArticles()[0]} />
|
<Row1 article={sortedArticles()[0]} />
|
||||||
<Row2 articles={sortedArticles().slice(1, 3)} />
|
<Row2 articles={sortedArticles().slice(1, 3)} />
|
||||||
<Slider title={title()} articles={sortedArticles().slice(5, 11)} />
|
<Slider title={title()}>
|
||||||
|
<For each={sortedArticles().slice(5, 11)}>
|
||||||
|
{(a: Shout) => (
|
||||||
|
<ArticleCard
|
||||||
|
article={a}
|
||||||
|
settings={{
|
||||||
|
additionalClass: 'swiper-slide',
|
||||||
|
isFloorImportant: true,
|
||||||
|
isWithCover: true,
|
||||||
|
nodate: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Slider>
|
||||||
<Beside
|
<Beside
|
||||||
beside={sortedArticles()[12]}
|
beside={sortedArticles()[12]}
|
||||||
title={t('Top viewed')}
|
title={t('Top viewed')}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { loadTopic } from '../../stores/zine/topics'
|
||||||
import { Loading } from '../Loading'
|
import { Loading } from '../Loading'
|
||||||
|
|
||||||
export const TopicPage = (props: PageProps) => {
|
export const TopicPage = (props: PageProps) => {
|
||||||
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.shouts) && Boolean(props.topic))
|
const [isLoaded, setIsLoaded] = createSignal(Boolean(props.topicShouts) && Boolean(props.topic))
|
||||||
|
|
||||||
const slug = createMemo(() => {
|
const slug = createMemo(() => {
|
||||||
const { page: getPage } = useRouter()
|
const { page: getPage } = useRouter()
|
||||||
|
@ -38,7 +38,7 @@ export const TopicPage = (props: PageProps) => {
|
||||||
return (
|
return (
|
||||||
<PageWrap>
|
<PageWrap>
|
||||||
<Show when={isLoaded()} fallback={<Loading />}>
|
<Show when={isLoaded()} fallback={<Loading />}>
|
||||||
<TopicView topic={props.topic} shouts={props.shouts} topicSlug={slug()} />
|
<TopicView topic={props.topic} shouts={props.topicShouts} topicSlug={slug()} />
|
||||||
</Show>
|
</Show>
|
||||||
</PageWrap>
|
</PageWrap>
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,7 +5,7 @@ export const DiscussionRulesPage = () => {
|
||||||
const title = t('Discussion rules')
|
const title = t('Discussion rules')
|
||||||
return (
|
return (
|
||||||
<PageWrap>
|
<PageWrap>
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
||||||
<h1>
|
<h1>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { PageWrap } from '../../_shared/PageWrap'
|
||||||
export const DogmaPage = () => {
|
export const DogmaPage = () => {
|
||||||
return (
|
return (
|
||||||
<PageWrap>
|
<PageWrap>
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
||||||
<h4>Редакционные принципы</h4>
|
<h4>Редакционные принципы</h4>
|
||||||
|
|
|
@ -20,9 +20,9 @@ export const GuidePage = () => {
|
||||||
{/*<Meta property="og:image:width" content="1200" />*/}
|
{/*<Meta property="og:image:width" content="1200" />*/}
|
||||||
{/*<Meta property="og:image:height" content="630" />*/}
|
{/*<Meta property="og:image:height" content="630" />*/}
|
||||||
|
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 col-lg-3 order-md-last">
|
<div class="col-md-3 col-lg-2 order-md-last">
|
||||||
<button class="button button--content-index" onClick={toggleIndexExpanded}>
|
<button class="button button--content-index" onClick={toggleIndexExpanded}>
|
||||||
<Show when={!indexExpanded()}>
|
<Show when={!indexExpanded()}>
|
||||||
<Icon name="content-index-control" />
|
<Icon name="content-index-control" />
|
||||||
|
|
|
@ -17,9 +17,9 @@ export const HelpPage = () => {
|
||||||
|
|
||||||
{/*<Modal name="thank">Благодарим!</Modal>*/}
|
{/*<Modal name="thank">Благодарим!</Modal>*/}
|
||||||
|
|
||||||
<article class="container container--static-page discours-help">
|
<article class="wide-container container--static-page discours-help">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 col-lg-3 order-md-last">
|
<div class="col-md-3 col-lg-2 order-md-last">
|
||||||
<button class="button button--content-index" onClick={toggleIndexExpanded}>
|
<button class="button button--content-index" onClick={toggleIndexExpanded}>
|
||||||
<Show when={!indexExpanded()}>
|
<Show when={!indexExpanded()}>
|
||||||
<Icon name="content-index-control" />
|
<Icon name="content-index-control" />
|
||||||
|
|
|
@ -21,9 +21,9 @@ export const ManifestPage = () => {
|
||||||
<Modal variant="wide" name="subscribe">
|
<Modal variant="wide" name="subscribe">
|
||||||
<Subscribe />
|
<Subscribe />
|
||||||
</Modal>
|
</Modal>
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 col-lg-3 order-md-last">
|
<div class="col-md-3 col-lg-2 order-md-last">
|
||||||
<button class="button button--content-index" onClick={toggleIndexExpanded}>
|
<button class="button button--content-index" onClick={toggleIndexExpanded}>
|
||||||
<Show when={!indexExpanded()}>
|
<Show when={!indexExpanded()}>
|
||||||
<Icon name="content-index-control" />
|
<Icon name="content-index-control" />
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { t } from '../../../utils/intl'
|
||||||
export const PartnersPage = () => {
|
export const PartnersPage = () => {
|
||||||
return (
|
return (
|
||||||
<PageWrap>
|
<PageWrap>
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
||||||
<h1>{t('Partners')}</h1>
|
<h1>{t('Partners')}</h1>
|
||||||
|
|
|
@ -5,7 +5,7 @@ export const PrinciplesPage = () => {
|
||||||
const title = t('Principles')
|
const title = t('Principles')
|
||||||
return (
|
return (
|
||||||
<PageWrap>
|
<PageWrap>
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
||||||
<h1>
|
<h1>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { t } from '../../../utils/intl'
|
||||||
export const ProjectsPage = () => {
|
export const ProjectsPage = () => {
|
||||||
return (
|
return (
|
||||||
<PageWrap>
|
<PageWrap>
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
||||||
<h1>{t('Projects')}</h1>
|
<h1>{t('Projects')}</h1>
|
||||||
|
|
|
@ -15,9 +15,9 @@ export const TermsOfUsePage = () => {
|
||||||
{/*<Meta name="keywords" content={`Discours.io, ${t('Terms of use')}, ${t('Terms of use', 'en')}`} />*/}
|
{/*<Meta name="keywords" content={`Discours.io, ${t('Terms of use')}, ${t('Terms of use', 'en')}`} />*/}
|
||||||
{/*<Meta property="og:title" content={title} />*/}
|
{/*<Meta property="og:title" content={title} />*/}
|
||||||
{/*<Meta property="og:description" content={title} />*/}
|
{/*<Meta property="og:description" content={title} />*/}
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 col-lg-3 order-md-last">
|
<div class="col-md-3 col-lg-2 order-md-last">
|
||||||
<button class="button button--content-index" onClick={toggleIndexExpanded}>
|
<button class="button button--content-index" onClick={toggleIndexExpanded}>
|
||||||
<Show when={!indexExpanded()}>
|
<Show when={!indexExpanded()}>
|
||||||
<Icon name="content-index-control" />
|
<Icon name="content-index-control" />
|
||||||
|
|
|
@ -10,7 +10,7 @@ export const ThanksPage = () => {
|
||||||
{/*<Meta property="og:title" content={title} />*/}
|
{/*<Meta property="og:title" content={title} />*/}
|
||||||
{/*<Meta property="og:description" content={title} />*/}
|
{/*<Meta property="og:description" content={title} />*/}
|
||||||
|
|
||||||
<article class="container container--static-page">
|
<article class="wide-container container--static-page">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
<div class="col-md-6 col-xl-7 shift-content order-md-first">
|
||||||
<h1>
|
<h1>
|
||||||
|
|
135
src/components/Pages/profile/ProfileSecurityPage.tsx
Normal 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>Вход и безопасность</h1>
|
||||||
|
<p class="description">Настройки аккаунта, почты, пароля и способов входа.</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
|
186
src/components/Pages/profile/ProfileSettingsPage.tsx
Normal 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
|
137
src/components/Pages/profile/ProfileSubscriptionsPage.tsx
Normal 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">Здесь можно управлять всеми своими подписками на сайте.</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
|
189
src/components/Pages/profile/Settings.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,6 +32,9 @@ import { ConnectPage } from './Pages/ConnectPage'
|
||||||
import { InboxPage } from './Pages/InboxPage'
|
import { InboxPage } from './Pages/InboxPage'
|
||||||
import { LayoutShoutsPage } from './Pages/LayoutShoutsPage'
|
import { LayoutShoutsPage } from './Pages/LayoutShoutsPage'
|
||||||
import { SessionProvider } from '../context/session'
|
import { SessionProvider } from '../context/session'
|
||||||
|
import { ProfileSettingsPage } from './Pages/profile/ProfileSettingsPage'
|
||||||
|
import { ProfileSecurityPage } from './Pages/profile/ProfileSecurityPage'
|
||||||
|
import { ProfileSubscriptionsPage } from './Pages/profile/ProfileSubscriptionsPage'
|
||||||
|
|
||||||
// TODO: lazy load
|
// TODO: lazy load
|
||||||
// const SomePage = lazy(() => import('./Pages/SomePage'))
|
// const SomePage = lazy(() => import('./Pages/SomePage'))
|
||||||
|
@ -58,7 +61,10 @@ const pagesMap: Record<keyof Routes, Component<PageProps>> = {
|
||||||
partners: PartnersPage,
|
partners: PartnersPage,
|
||||||
principles: PrinciplesPage,
|
principles: PrinciplesPage,
|
||||||
termsOfUse: TermsOfUsePage,
|
termsOfUse: TermsOfUsePage,
|
||||||
thanks: ThanksPage
|
thanks: ThanksPage,
|
||||||
|
profileSettings: ProfileSettingsPage,
|
||||||
|
profileSecurity: ProfileSecurityPage,
|
||||||
|
profileSubscriptions: ProfileSubscriptionsPage
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Root = (props: PageProps) => {
|
export const Root = (props: PageProps) => {
|
||||||
|
|
|
@ -116,3 +116,7 @@
|
||||||
.buttonCompact {
|
.buttonCompact {
|
||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.isSubscribing {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { capitalize } from '../../utils'
|
import { capitalize } from '../../utils'
|
||||||
import styles from './Card.module.scss'
|
import styles from './Card.module.scss'
|
||||||
import { createMemo, Show } from 'solid-js'
|
import { createEffect, createMemo, createSignal, Show } from 'solid-js'
|
||||||
import type { Topic } from '../../graphql/types.gen'
|
import type { Topic } from '../../graphql/types.gen'
|
||||||
import { FollowingEntity } from '../../graphql/types.gen'
|
import { FollowingEntity } from '../../graphql/types.gen'
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
|
@ -9,6 +9,7 @@ import { getLogger } from '../../utils/logger'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { StatMetrics } from '../_shared/StatMetrics'
|
import { StatMetrics } from '../_shared/StatMetrics'
|
||||||
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
|
|
||||||
const log = getLogger('TopicCard')
|
const log = getLogger('TopicCard')
|
||||||
|
|
||||||
|
@ -25,7 +26,13 @@ interface TopicProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopicCard = (props: TopicProps) => {
|
export const TopicCard = (props: TopicProps) => {
|
||||||
const { session } = useSession()
|
const {
|
||||||
|
session,
|
||||||
|
isSessionLoaded,
|
||||||
|
actions: { loadSession }
|
||||||
|
} = useSession()
|
||||||
|
|
||||||
|
const [isSubscribing, setIsSubscribing] = createSignal(false)
|
||||||
|
|
||||||
const subscribed = createMemo(() => {
|
const subscribed = createMemo(() => {
|
||||||
if (!session()?.user?.slug || !session()?.news?.topics) {
|
if (!session()?.user?.slug || !session()?.news?.topics) {
|
||||||
|
@ -35,14 +42,17 @@ export const TopicCard = (props: TopicProps) => {
|
||||||
return session()?.news.topics.includes(props.topic.slug)
|
return session()?.news.topics.includes(props.topic.slug)
|
||||||
})
|
})
|
||||||
|
|
||||||
// FIXME use store actions
|
|
||||||
const subscribe = async (really = true) => {
|
const subscribe = async (really = true) => {
|
||||||
if (really) {
|
setIsSubscribing(true)
|
||||||
follow({ what: FollowingEntity.Topic, slug: props.topic.slug })
|
|
||||||
} else {
|
await (really
|
||||||
unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug })
|
? follow({ what: FollowingEntity.Topic, slug: props.topic.slug })
|
||||||
}
|
: unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug }))
|
||||||
|
|
||||||
|
await loadSession()
|
||||||
|
setIsSubscribing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={styles.topic}
|
class={styles.topic}
|
||||||
|
@ -79,32 +89,30 @@ export const TopicCard = (props: TopicProps) => {
|
||||||
class={styles.controlContainer}
|
class={styles.controlContainer}
|
||||||
classList={{ 'col-md-3': !props.compact && !props.subscribeButtonBottom }}
|
classList={{ 'col-md-3': !props.compact && !props.subscribeButtonBottom }}
|
||||||
>
|
>
|
||||||
<Show
|
<ShowOnlyOnClient>
|
||||||
when={subscribed()}
|
<Show when={isSessionLoaded()}>
|
||||||
fallback={
|
|
||||||
<button
|
<button
|
||||||
onClick={() => subscribe(true)}
|
onClick={() => subscribe(!subscribed())}
|
||||||
class="button--light button--subscribe-topic"
|
class="button--light button--subscribe-topic"
|
||||||
classList={{
|
classList={{
|
||||||
[styles.buttonCompact]: props.compact
|
[styles.buttonCompact]: props.compact,
|
||||||
|
[styles.isSubscribing]: isSubscribing()
|
||||||
}}
|
}}
|
||||||
|
disabled={isSubscribing()}
|
||||||
>
|
>
|
||||||
<Show when={props.iconButton}>+</Show>
|
<Show when={props.iconButton}>
|
||||||
<Show when={!props.iconButton}>{t('Follow')}</Show>
|
<Show when={subscribed()} fallback="+">
|
||||||
</button>
|
-
|
||||||
}
|
</Show>
|
||||||
>
|
</Show>
|
||||||
<button
|
<Show when={!props.iconButton}>
|
||||||
onClick={() => subscribe(false)}
|
<Show when={subscribed()} fallback={t('Follow')}>
|
||||||
class="button--light button--subscribe-topic"
|
{t('Unfollow')}
|
||||||
classList={{
|
</Show>
|
||||||
[styles.buttonCompact]: props.compact
|
</Show>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={props.iconButton}>-</Show>
|
|
||||||
<Show when={!props.iconButton}>{t('Unfollow')}</Show>
|
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
|
</ShowOnlyOnClient>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js'
|
import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js'
|
||||||
import type { Author } from '../../graphql/types.gen'
|
import type { Author } from '../../graphql/types.gen'
|
||||||
import { AuthorCard } from '../Author/Card'
|
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
import { useAuthorsStore, setAuthorsSort } from '../../stores/zine/authors'
|
import { setAuthorsSort, useAuthorsStore } from '../../stores/zine/authors'
|
||||||
import { useRouter } from '../../stores/router'
|
import { useRouter } from '../../stores/router'
|
||||||
import styles from '../../styles/AllTopics.module.scss'
|
import { AuthorCard } from '../Author/Card'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import { locale } from '../../stores/ui'
|
import { locale } from '../../stores/ui'
|
||||||
import { translit } from '../../utils/ru2en'
|
import { translit } from '../../utils/ru2en'
|
||||||
|
import styles from '../../styles/AllTopics.module.scss'
|
||||||
import { SearchField } from '../_shared/SearchField'
|
import { SearchField } from '../_shared/SearchField'
|
||||||
import { scrollHandler } from '../../utils/scroll'
|
import { scrollHandler } from '../../utils/scroll'
|
||||||
import { StatMetrics } from '../_shared/StatMetrics'
|
import { StatMetrics } from '../_shared/StatMetrics'
|
||||||
|
@ -17,41 +17,43 @@ type AllAuthorsPageSearchParams = {
|
||||||
by: '' | 'name' | 'shouts' | 'followers'
|
by: '' | 'name' | 'shouts' | 'followers'
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type AllAuthorsViewProps = {
|
||||||
authors: Author[]
|
authors: Author[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
const ALPHABET = [...'@АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ']
|
const ALPHABET = [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@']
|
||||||
|
|
||||||
export const AllAuthorsView = (props: Props) => {
|
export const AllAuthorsView = (props: AllAuthorsViewProps) => {
|
||||||
const [limit, setLimit] = createSignal(PAGE_SIZE)
|
const [limit, setLimit] = createSignal(PAGE_SIZE)
|
||||||
const { searchParams, changeSearchParam } = useRouter<AllAuthorsPageSearchParams>()
|
const { searchParams, changeSearchParam } = useRouter<AllAuthorsPageSearchParams>()
|
||||||
const { sortedAuthors } = useAuthorsStore({
|
const { sortedAuthors } = useAuthorsStore({
|
||||||
authors: props.authors,
|
authors: props.authors,
|
||||||
sortBy: searchParams().by || 'name'
|
sortBy: searchParams().by || 'shouts'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
|
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!searchParams().by) {
|
if (!searchParams().by) {
|
||||||
setAuthorsSort('name')
|
changeSearchParam('by', 'shouts')
|
||||||
changeSearchParam('by', 'name')
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
createEffect(() => {
|
|
||||||
setAuthorsSort(searchParams().by || 'name')
|
|
||||||
setLimit(PAGE_SIZE)
|
|
||||||
})
|
|
||||||
|
|
||||||
const subscribed = (s) => Boolean(session()?.news?.authors && session()?.news?.authors?.includes(s || ''))
|
createEffect(() => {
|
||||||
|
setAuthorsSort(searchParams().by || 'shouts')
|
||||||
|
})
|
||||||
|
|
||||||
const byLetter = createMemo<{ [letter: string]: Author[] }>(() => {
|
const byLetter = createMemo<{ [letter: string]: Author[] }>(() => {
|
||||||
return sortedAuthors().reduce((acc, author) => {
|
return sortedAuthors().reduce((acc, author) => {
|
||||||
let letter = author.name.trim().split(' ').pop().at(0).toUpperCase()
|
let letter = author.name.trim().split(' ').pop().at(0).toUpperCase()
|
||||||
if (!/[А-я]/i.test(letter) && locale() === 'ru') letter = '@'
|
|
||||||
|
if (/[^ËА-яё]/.test(letter) && locale() === 'ru') letter = '@'
|
||||||
|
|
||||||
if (!acc[letter]) acc[letter] = []
|
if (!acc[letter]) acc[letter] = []
|
||||||
|
|
||||||
acc[letter].push(author)
|
acc[letter].push(author)
|
||||||
return acc
|
return acc
|
||||||
}, {} as { [letter: string]: Author[] })
|
}, {} as { [letter: string]: Author[] })
|
||||||
|
@ -60,9 +62,35 @@ export const AllAuthorsView = (props: Props) => {
|
||||||
const sortedKeys = createMemo<string[]>(() => {
|
const sortedKeys = createMemo<string[]>(() => {
|
||||||
const keys = Object.keys(byLetter())
|
const keys = Object.keys(byLetter())
|
||||||
keys.sort()
|
keys.sort()
|
||||||
|
keys.push(keys.shift())
|
||||||
return keys
|
return keys
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const subscribed = (s) => Boolean(session()?.news?.authors && session()?.news?.authors?.includes(s || ''))
|
||||||
|
|
||||||
|
const filteredAuthors = createMemo(() => {
|
||||||
|
let q = searchQuery().toLowerCase()
|
||||||
|
|
||||||
|
if (q.length === 0) {
|
||||||
|
return sortedAuthors()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale() === 'ru') q = translit(q)
|
||||||
|
|
||||||
|
return sortedAuthors().filter((author) => {
|
||||||
|
if (author.slug.split('-').some((w) => w.startsWith(q))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = author.name.toLowerCase()
|
||||||
|
if (locale() === 'ru') {
|
||||||
|
name = translit(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return name.split(' ').some((word) => word.startsWith(q))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
|
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
|
||||||
const AllAuthorsHead = () => (
|
const AllAuthorsHead = () => (
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@ -71,57 +99,28 @@ export const AllAuthorsView = (props: Props) => {
|
||||||
<p>{t('Subscribe who you like to tune your personal feed')}</p>
|
<p>{t('Subscribe who you like to tune your personal feed')}</p>
|
||||||
|
|
||||||
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
|
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
|
||||||
<li classList={{ selected: searchParams().by === 'shouts' }}>
|
<li classList={{ selected: !searchParams().by || searchParams().by === 'shouts' }}>
|
||||||
<a href="/authors?by=shouts">{t('By shouts')}</a>
|
<a href="/authors?by=shouts">{t('By shouts')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li classList={{ selected: searchParams().by === 'followers' }}>
|
<li classList={{ selected: searchParams().by === 'followers' }}>
|
||||||
<a href="/authors?by=followers">{t('By rating')}</a>
|
<a href="/authors?by=followers">{t('By popularity')}</a>
|
||||||
</li>
|
</li>
|
||||||
<li classList={{ selected: !searchParams().by || searchParams().by === 'name' }}>
|
<li classList={{ selected: searchParams().by === 'name' }}>
|
||||||
<a href="/authors?by=name">{t('By name')}</a>
|
<a href="/authors?by=name">{t('By name')}</a>
|
||||||
</li>
|
</li>
|
||||||
|
<Show when={searchParams().by !== 'name'}>
|
||||||
<li class="view-switcher__search">
|
<li class="view-switcher__search">
|
||||||
<SearchField onChange={searchAuthors} />
|
<SearchField onChange={(value) => setSearchQuery(value)} />
|
||||||
</li>
|
</li>
|
||||||
|
</Show>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
const [searchResults, setSearchResults] = createSignal<Author[]>([])
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
const searchAuthors = (value) => {
|
|
||||||
/* very stupid search algorithm with no deps */
|
|
||||||
let q = value.toLowerCase()
|
|
||||||
if (q.length > 0) {
|
|
||||||
console.debug(q)
|
|
||||||
setSearchResults([])
|
|
||||||
|
|
||||||
if (locale() === 'ru') q = translit(q, 'ru')
|
|
||||||
const aaa: Author[] = []
|
|
||||||
sortedAuthors().forEach((a) => {
|
|
||||||
let flag = false
|
|
||||||
a.slug.split('-').forEach((w) => {
|
|
||||||
if (w.startsWith(q)) flag = true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!flag) {
|
|
||||||
let wrds: string = a.name.toLowerCase()
|
|
||||||
if (locale() === 'ru') wrds = translit(wrds, 'ru')
|
|
||||||
wrds.split(' ').forEach((w: string) => {
|
|
||||||
if (w.startsWith(q)) flag = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flag && !aaa.includes(a)) aaa.push(a)
|
|
||||||
})
|
|
||||||
|
|
||||||
setSearchResults((sr: Author[]) => [...sr, ...aaa])
|
|
||||||
changeSearchParam('by', '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div class={clsx(styles.allTopicsPage, 'wide-container')}>
|
<div class={clsx(styles.allTopicsPage, 'wide-container')}>
|
||||||
<Show when={sortedAuthors().length > 0 || searchResults().length > 0}>
|
<Show when={sortedAuthors().length > 0}>
|
||||||
<div class="shift-content">
|
<div class="shift-content">
|
||||||
<AllAuthorsHead />
|
<AllAuthorsHead />
|
||||||
|
|
||||||
|
@ -130,12 +129,15 @@ export const AllAuthorsView = (props: Props) => {
|
||||||
<div class="col-lg-10 col-xl-9">
|
<div class="col-lg-10 col-xl-9">
|
||||||
<ul class={clsx('nodash', styles.alphabet)}>
|
<ul class={clsx('nodash', styles.alphabet)}>
|
||||||
<For each={ALPHABET}>
|
<For each={ALPHABET}>
|
||||||
{(letter: string, index) => (
|
{(letter, index) => (
|
||||||
<li>
|
<li>
|
||||||
<Show when={letter in byLetter()} fallback={letter}>
|
<Show when={letter in byLetter()} fallback={letter}>
|
||||||
<a
|
<a
|
||||||
href={`/authors?by=name#letter-${index()}`}
|
href={`/authors?by=name#letter-${index()}`}
|
||||||
onClick={() => scrollHandler(`letter-${index()}`)}
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollHandler(`letter-${index()}`)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{letter}
|
{letter}
|
||||||
</a>
|
</a>
|
||||||
|
@ -148,9 +150,9 @@ export const AllAuthorsView = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<For each={sortedKeys()}>
|
<For each={sortedKeys()}>
|
||||||
{(letter, index) => (
|
{(letter) => (
|
||||||
<div class={clsx(styles.group, 'group')}>
|
<div class={clsx(styles.group, 'group')}>
|
||||||
<h2 id={`letter-${index()}`}>{letter}</h2>
|
<h2 id={`letter-${ALPHABET.indexOf(letter)}`}>{letter}</h2>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-10">
|
<div class="col-lg-10">
|
||||||
|
@ -174,49 +176,26 @@ export const AllAuthorsView = (props: Props) => {
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={searchResults().length > 0}>
|
|
||||||
<For each={searchResults().slice(0, limit())}>
|
|
||||||
{(author) => (
|
|
||||||
<>
|
|
||||||
<AuthorCard
|
|
||||||
author={author}
|
|
||||||
compact={false}
|
|
||||||
hasLink={true}
|
|
||||||
subscribed={subscribed(author.slug)}
|
|
||||||
noSocialButtons={true}
|
|
||||||
isAuthorsList={true}
|
|
||||||
truncateBio={true}
|
|
||||||
/>
|
|
||||||
<StatMetrics fields={['shouts', 'followers', 'comments']} stat={author.stat} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={searchParams().by && searchParams().by !== 'name'}>
|
<Show when={searchParams().by && searchParams().by !== 'name'}>
|
||||||
<div class={clsx(styles.stats, 'row')}>
|
<For each={filteredAuthors().slice(0, limit())}>
|
||||||
<div class="col-lg-10 col-xl-9">
|
|
||||||
<For each={sortedAuthors().slice(0, limit())}>
|
|
||||||
{(author) => (
|
{(author) => (
|
||||||
<>
|
<div class="row">
|
||||||
|
<div class="col-lg-10 col-xl-9">
|
||||||
<AuthorCard
|
<AuthorCard
|
||||||
author={author}
|
author={author}
|
||||||
compact={false}
|
|
||||||
hasLink={true}
|
hasLink={true}
|
||||||
subscribed={subscribed(author.slug)}
|
subscribed={subscribed(author.slug)}
|
||||||
noSocialButtons={true}
|
noSocialButtons={true}
|
||||||
isAuthorsList={true}
|
isAuthorsList={true}
|
||||||
truncateBio={true}
|
truncateBio={true}
|
||||||
/>
|
/>
|
||||||
<StatMetrics fields={['shouts', 'followers', 'comments']} stat={author.stat} />
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={searchParams().by !== 'name' && sortedAuthors().length > limit()}>
|
<Show when={filteredAuthors().length > limit() && searchParams().by !== 'name'}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10')}>
|
<div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10')}>
|
||||||
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
|
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
|
||||||
|
|
|
@ -22,7 +22,7 @@ type AllTopicsViewProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
const ALPHABET = [...'#АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ']
|
const ALPHABET = [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#']
|
||||||
|
|
||||||
export const AllTopicsView = (props: AllTopicsViewProps) => {
|
export const AllTopicsView = (props: AllTopicsViewProps) => {
|
||||||
const { searchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>()
|
const { searchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>()
|
||||||
|
@ -37,19 +37,18 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!searchParams().by) {
|
if (!searchParams().by) {
|
||||||
setTopicsSort('shouts')
|
|
||||||
changeSearchParam('by', 'shouts')
|
changeSearchParam('by', 'shouts')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setTopicsSort(searchParams().by || 'shouts')
|
setTopicsSort(searchParams().by || 'shouts')
|
||||||
setLimit(PAGE_SIZE)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
|
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
|
||||||
return sortedTopics().reduce((acc, topic) => {
|
return sortedTopics().reduce((acc, topic) => {
|
||||||
let letter = topic.title[0].toUpperCase()
|
let letter = topic.title[0].toUpperCase()
|
||||||
if (!/[А-я]/i.test(letter) && locale() === 'ru') letter = '#'
|
if (/[^ËА-яё]/.test(letter) && locale() === 'ru') letter = '#'
|
||||||
if (!acc[letter]) acc[letter] = []
|
if (!acc[letter]) acc[letter] = []
|
||||||
acc[letter].push(topic)
|
acc[letter].push(topic)
|
||||||
return acc
|
return acc
|
||||||
|
@ -59,45 +58,41 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
|
||||||
const sortedKeys = createMemo<string[]>(() => {
|
const sortedKeys = createMemo<string[]>(() => {
|
||||||
const keys = Object.keys(byLetter())
|
const keys = Object.keys(byLetter())
|
||||||
keys.sort()
|
keys.sort()
|
||||||
|
keys.push(keys.shift())
|
||||||
return keys
|
return keys
|
||||||
})
|
})
|
||||||
|
|
||||||
const subscribed = (s) => Boolean(session()?.news?.topics && session()?.news?.topics?.includes(s || ''))
|
const subscribed = (s) => Boolean(session()?.news?.topics && session()?.news?.topics?.includes(s || ''))
|
||||||
|
|
||||||
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
|
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
|
||||||
const [searchResults, setSearchResults] = createSignal<Topic[]>([])
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
||||||
const searchTopics = (value) => {
|
|
||||||
/* very stupid search algorithm with no deps */
|
|
||||||
let q = value.toLowerCase()
|
|
||||||
if (q.length > 0) {
|
|
||||||
console.debug(q)
|
|
||||||
setSearchResults([])
|
|
||||||
|
|
||||||
if (locale() === 'ru') q = translit(q, 'ru')
|
const [searchQuery, setSearchQuery] = createSignal('')
|
||||||
const ttt: Topic[] = []
|
|
||||||
sortedTopics().forEach((topic) => {
|
|
||||||
let flag = false
|
|
||||||
topic.slug.split('-').forEach((w) => {
|
|
||||||
if (w.startsWith(q)) flag = true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!flag) {
|
const filteredResults = createMemo(() => {
|
||||||
let wrds: string = topic.title.toLowerCase()
|
/* very stupid filter by string algorithm with no deps */
|
||||||
if (locale() === 'ru') wrds = translit(wrds, 'ru')
|
let q = searchQuery().toLowerCase()
|
||||||
wrds.split(' ').forEach((w: string) => {
|
if (q.length === 0) {
|
||||||
if (w.startsWith(q)) flag = true
|
return sortedTopics()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flag && !ttt.includes(topic)) ttt.push(topic)
|
if (locale() === 'ru') {
|
||||||
})
|
q = translit(q)
|
||||||
|
}
|
||||||
|
|
||||||
setSearchResults((sr: Topic[]) => [...sr, ...ttt])
|
return sortedTopics().filter((topic) => {
|
||||||
changeSearchParam('by', '')
|
if (topic.slug.split('-').some((w) => w.startsWith(q))) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let title = topic.title.toLowerCase()
|
||||||
|
if (locale() === 'ru') {
|
||||||
|
title = translit(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return title.split(' ').some((word) => word.startsWith(q))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const AllTopicsHead = () => (
|
const AllTopicsHead = () => (
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class={clsx(styles.pageHeader, 'col-lg-10 col-xl-9')}>
|
<div class={clsx(styles.pageHeader, 'col-lg-10 col-xl-9')}>
|
||||||
|
@ -114,9 +109,11 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
|
||||||
<li classList={{ selected: searchParams().by === 'title' }}>
|
<li classList={{ selected: searchParams().by === 'title' }}>
|
||||||
<a href="/topics?by=title">{t('By title')}</a>
|
<a href="/topics?by=title">{t('By title')}</a>
|
||||||
</li>
|
</li>
|
||||||
|
<Show when={searchParams().by !== 'title'}>
|
||||||
<li class="view-switcher__search">
|
<li class="view-switcher__search">
|
||||||
<SearchField onChange={searchTopics} />
|
<SearchField onChange={(value) => setSearchQuery(value)} />
|
||||||
</li>
|
</li>
|
||||||
|
</Show>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -127,7 +124,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
|
||||||
<div class="shift-content">
|
<div class="shift-content">
|
||||||
<AllTopicsHead />
|
<AllTopicsHead />
|
||||||
|
|
||||||
<Show when={sortedTopics().length > 0 || searchResults().length > 0}>
|
<Show when={filteredResults().length > 0}>
|
||||||
<Show when={searchParams().by === 'title'}>
|
<Show when={searchParams().by === 'title'}>
|
||||||
<div class="col-lg-10 col-xl-9">
|
<div class="col-lg-10 col-xl-9">
|
||||||
<ul class={clsx('nodash', styles.alphabet)}>
|
<ul class={clsx('nodash', styles.alphabet)}>
|
||||||
|
@ -137,7 +134,10 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
|
||||||
<Show when={letter in byLetter()} fallback={letter}>
|
<Show when={letter in byLetter()} fallback={letter}>
|
||||||
<a
|
<a
|
||||||
href={`/topics?by=title#letter-${index()}`}
|
href={`/topics?by=title#letter-${index()}`}
|
||||||
onClick={() => scrollHandler(`letter-${index()}`)}
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollHandler(`letter-${index()}`)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{letter}
|
{letter}
|
||||||
</a>
|
</a>
|
||||||
|
@ -149,9 +149,9 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<For each={sortedKeys()}>
|
<For each={sortedKeys()}>
|
||||||
{(letter, index) => (
|
{(letter) => (
|
||||||
<div class={clsx(styles.group, 'group')}>
|
<div class={clsx(styles.group, 'group')}>
|
||||||
<h2 id={`letter-${index()}`}>{letter}</h2>
|
<h2 id={`letter-${ALPHABET.indexOf(letter)}`}>{letter}</h2>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-10">
|
<div class="col-lg-10">
|
||||||
|
@ -173,21 +173,8 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={searchResults().length > 1}>
|
|
||||||
<For each={searchResults().slice(0, limit())}>
|
|
||||||
{(topic) => (
|
|
||||||
<TopicCard
|
|
||||||
topic={topic}
|
|
||||||
compact={false}
|
|
||||||
subscribed={subscribed(topic.slug)}
|
|
||||||
showPublications={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={searchParams().by && searchParams().by !== 'title'}>
|
<Show when={searchParams().by && searchParams().by !== 'title'}>
|
||||||
<For each={sortedTopics().slice(0, limit())}>
|
<For each={filteredResults().slice(0, limit())}>
|
||||||
{(topic) => (
|
{(topic) => (
|
||||||
<>
|
<>
|
||||||
<TopicCard
|
<TopicCard
|
||||||
|
@ -202,7 +189,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={sortedTopics().length > limit()}>
|
<Show when={filteredResults().length > limit() && searchParams().by !== 'title'}>
|
||||||
<div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10 offset-md-1')}>
|
<div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10 offset-md-1')}>
|
||||||
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
|
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
|
||||||
{t('Load more')}
|
{t('Load more')}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { createEffect, createSignal, Show, Suspense } from 'solid-js'
|
import { createEffect, createMemo, createSignal, onMount, Show, Suspense } from 'solid-js'
|
||||||
import { FullArticle } from '../Article/FullArticle'
|
import { FullArticle } from '../Article/FullArticle'
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
import type { Shout, Reaction } from '../../graphql/types.gen'
|
import type { Shout, Reaction } from '../../graphql/types.gen'
|
||||||
|
@ -9,34 +9,20 @@ interface ArticlePageProps {
|
||||||
reactions?: Reaction[]
|
reactions?: Reaction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARTICLE_COMMENTS_PAGE_SIZE = 50
|
|
||||||
|
|
||||||
export const ArticleView = (props: ArticlePageProps) => {
|
export const ArticleView = (props: ArticlePageProps) => {
|
||||||
const [getCommentsPage] = createSignal(0)
|
onMount(() => {
|
||||||
const [getIsCommentsLoading, setIsCommentsLoading] = createSignal(false)
|
const script = document.createElement('script')
|
||||||
const { reactionsByShout, loadReactionsBy } = useReactionsStore({ reactions: props.reactions })
|
script.async = true
|
||||||
|
script.src = 'https://ackee.discours.io/increment.js'
|
||||||
createEffect(async () => {
|
script.dataset.ackeeServer = 'https://ackee.discours.io'
|
||||||
try {
|
script.dataset.ackeeDomainId = '1004abeb-89b2-4e85-ad97-74f8d2c8ed2d'
|
||||||
setIsCommentsLoading(true)
|
document.body.appendChild(script)
|
||||||
await loadReactionsBy({
|
|
||||||
by: { shout: props.article.slug, comment: true },
|
|
||||||
limit: ARTICLE_COMMENTS_PAGE_SIZE,
|
|
||||||
offset: getCommentsPage() * ARTICLE_COMMENTS_PAGE_SIZE
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsCommentsLoading(false)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show fallback={<div class="center">{t('Loading')}</div>} when={props.article}>
|
<Show fallback={<div class="center">{t('Loading')}</div>} when={props.article}>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<FullArticle
|
<FullArticle article={props.article} />
|
||||||
article={props.article}
|
|
||||||
reactions={reactionsByShout()[props.article.slug]}
|
|
||||||
isCommentsLoading={getIsCommentsLoading()}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
|
@ -105,7 +105,7 @@ export const AuthorView = (props: AuthorProps) => {
|
||||||
|
|
||||||
<Beside
|
<Beside
|
||||||
title={t('Topics which supported by author')}
|
title={t('Topics which supported by author')}
|
||||||
values={topicsByAuthor()[author().slug].slice(0, 5)}
|
values={topicsByAuthor()[author().slug]?.slice(0, 5)}
|
||||||
beside={sortedArticles()[0]}
|
beside={sortedArticles()[0]}
|
||||||
wrapper={'topic'}
|
wrapper={'topic'}
|
||||||
topicShortDescription={true}
|
topicShortDescription={true}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { createSignal, For, onMount, Show } from 'solid-js'
|
import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js'
|
||||||
import '../../styles/Feed.scss'
|
import '../../styles/Feed.scss'
|
||||||
import stylesBeside from '../../components/Feed/Beside.module.scss'
|
import stylesBeside from '../../components/Feed/Beside.module.scss'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
|
@ -14,7 +14,6 @@ import { useAuthorsStore } from '../../stores/zine/authors'
|
||||||
import { useTopicsStore } from '../../stores/zine/topics'
|
import { useTopicsStore } from '../../stores/zine/topics'
|
||||||
import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
|
import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
|
||||||
import { useSession } from '../../context/session'
|
import { useSession } from '../../context/session'
|
||||||
import type { Shout } from '../../graphql/types.gen'
|
|
||||||
|
|
||||||
// const AUTHORSHIP_REACTIONS = [
|
// const AUTHORSHIP_REACTIONS = [
|
||||||
// ReactionKind.Accept,
|
// ReactionKind.Accept,
|
||||||
|
@ -28,13 +27,31 @@ export const FEED_PAGE_SIZE = 20
|
||||||
export const FeedView = () => {
|
export const FeedView = () => {
|
||||||
// state
|
// state
|
||||||
const { sortedArticles } = useArticlesStore()
|
const { sortedArticles } = useArticlesStore()
|
||||||
const { sortedReactions: topComments, loadReactionsBy } = useReactionsStore({})
|
const { sortedReactions: topComments, loadReactionsBy } = useReactionsStore()
|
||||||
const { sortedAuthors } = useAuthorsStore()
|
const { sortedAuthors } = useAuthorsStore()
|
||||||
const { topTopics } = useTopicsStore()
|
const { topTopics } = useTopicsStore()
|
||||||
const { topAuthors } = useTopAuthorsStore()
|
const { topAuthors } = useTopAuthorsStore()
|
||||||
const { session } = useSession()
|
const { session } = useSession()
|
||||||
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
|
||||||
|
|
||||||
|
const collaborativeShouts = createMemo(() =>
|
||||||
|
sortedArticles().filter((shout) => shout.visibility === 'authors')
|
||||||
|
)
|
||||||
|
createEffect(async () => {
|
||||||
|
if (collaborativeShouts()) {
|
||||||
|
// load reactions on collaborativeShouts
|
||||||
|
await loadReactionsBy({ by: { shouts: [...collaborativeShouts()] }, limit: 5 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const userslug = createMemo(() => session()?.user?.slug)
|
||||||
|
createEffect(async () => {
|
||||||
|
if (userslug()) {
|
||||||
|
// load recent editing shouts ( visibility = authors )
|
||||||
|
await loadShouts({ filters: { author: userslug(), visibility: 'authors' }, limit: 15 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
const { hasMore } = await loadShouts({
|
const { hasMore } = await loadShouts({
|
||||||
filters: { visibility: 'community' },
|
filters: { visibility: 'community' },
|
||||||
|
@ -50,16 +67,6 @@ export const FeedView = () => {
|
||||||
|
|
||||||
// load recent shouts not only published ( visibility = community )
|
// load recent shouts not only published ( visibility = community )
|
||||||
await loadMore()
|
await loadMore()
|
||||||
|
|
||||||
// TODO: load collabs
|
|
||||||
// await loadCollabs()
|
|
||||||
|
|
||||||
// load recent editing shouts ( visibility = authors )
|
|
||||||
const userslug = session().user.slug
|
|
||||||
await loadShouts({ filters: { author: userslug, visibility: 'authors' }, limit: 15 })
|
|
||||||
const collaborativeShouts = sortedArticles().filter((shout) => shout.visibility === 'authors')
|
|
||||||
// load reactions on collaborativeShouts
|
|
||||||
await loadReactionsBy({ by: { shouts: [...collaborativeShouts] }, limit: 5 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import '../../styles/FeedSettings.scss'
|
import styles from '../../styles/FeedSettings.module.scss'
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
|
|
||||||
// type FeedSettingsSearchParams = {
|
// type FeedSettingsSearchParams = {
|
||||||
|
@ -27,20 +27,20 @@ export const FeedSettingsView = (_props) => {
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="settings-list">
|
<div class={styles.settingsList}>
|
||||||
<div class="settings-list__row">
|
<div class={styles.settingsListRow}>
|
||||||
<h2>Общее</h2>
|
<h2>Общее</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-list__row">
|
<div class={styles.settingsListRow}>
|
||||||
<label for="checkbox1" class="settings-list__cell">
|
<label for="checkbox1" class={styles.settingsListCell}>
|
||||||
Комментарии к моим постам
|
Комментарии к моим постам
|
||||||
</label>
|
</label>
|
||||||
<div class="settings-list__cell">
|
<div class={styles.settingsListCell}>
|
||||||
<input type="checkbox" name="checkbox1" id="checkbox1" />
|
<input type="checkbox" name="checkbox1" id="checkbox1" />
|
||||||
<label for="checkbox1" />
|
<label for="checkbox1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-list__cell">
|
<div class={styles.settingsListCell}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="checkbox1-notification"
|
name="checkbox1-notification"
|
||||||
|
@ -51,15 +51,15 @@ export const FeedSettingsView = (_props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-list__row">
|
<div class={styles.settingsListRow}>
|
||||||
<label for="checkbox2" class="settings-list__cell">
|
<label for="checkbox2" class={styles.settingsListCell}>
|
||||||
новые подписчики
|
новые подписчики
|
||||||
</label>
|
</label>
|
||||||
<div class="settings-list__cell">
|
<div class={styles.settingsListCell}>
|
||||||
<input type="checkbox" name="checkbox2" id="checkbox2" />
|
<input type="checkbox" name="checkbox2" id="checkbox2" />
|
||||||
<label for="checkbox2" />
|
<label for="checkbox2" />
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-list__cell">
|
<div class={styles.settingsListCell}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="checkbox2-notification"
|
name="checkbox2-notification"
|
||||||
|
@ -70,15 +70,15 @@ export const FeedSettingsView = (_props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-list__row">
|
<div class={styles.settingsListRow}>
|
||||||
<label for="checkbox3" class="settings-list__cell">
|
<label for="checkbox3" class={styles.settingsListCell}>
|
||||||
добавление моих текстов в коллекции
|
добавление моих текстов в коллекции
|
||||||
</label>
|
</label>
|
||||||
<div class="settings-list__cell">
|
<div class={styles.settingsListCell}>
|
||||||
<input type="checkbox" name="checkbox3" id="checkbox3" />
|
<input type="checkbox" name="checkbox3" id="checkbox3" />
|
||||||
<label for="checkbox3" />
|
<label for="checkbox3" />
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-list__cell">
|
<div class={styles.settingsListCell}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="checkbox3-notification"
|
name="checkbox3-notification"
|
||||||
|
@ -89,19 +89,19 @@ export const FeedSettingsView = (_props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-list__row">
|
<div class={styles.settingsListRow}>
|
||||||
<h2>Мои подписки</h2>
|
<h2>Мои подписки</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-list__row">
|
<div class={styles.settingsListRow}>
|
||||||
<label for="checkbox4" class="settings-list__cell">
|
<label for="checkbox4" class={styles.settingsListCell}>
|
||||||
добавление моих текстов в коллекции
|
добавление моих текстов в коллекции
|
||||||
</label>
|
</label>
|
||||||
<div class="settings-list__cell">
|
<div class={styles.settingsListCell}>
|
||||||
<input type="checkbox" name="checkbox4" id="checkbox4" />
|
<input type="checkbox" name="checkbox4" id="checkbox4" />
|
||||||
<label for="checkbox4" />
|
<label for="checkbox4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-list__cell">
|
<div class={styles.settingsListCell}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="checkbox4-notification"
|
name="checkbox4-notification"
|
||||||
|
|
|
@ -8,16 +8,22 @@ import { Row1 } from '../Feed/Row1'
|
||||||
import Hero from '../Discours/Hero'
|
import Hero from '../Discours/Hero'
|
||||||
import { Beside } from '../Feed/Beside'
|
import { Beside } from '../Feed/Beside'
|
||||||
import RowShort from '../Feed/RowShort'
|
import RowShort from '../Feed/RowShort'
|
||||||
import Slider from '../Feed/Slider'
|
import Slider from '../_shared/Slider'
|
||||||
import Group from '../Feed/Group'
|
import Group from '../Feed/Group'
|
||||||
import type { Shout, Topic } from '../../graphql/types.gen'
|
import type { Shout, Topic } from '../../graphql/types.gen'
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
import { useTopicsStore } from '../../stores/zine/topics'
|
import { useTopicsStore } from '../../stores/zine/topics'
|
||||||
import { loadShouts, useArticlesStore } from '../../stores/zine/articles'
|
import {
|
||||||
|
loadShouts,
|
||||||
|
loadTopArticles,
|
||||||
|
loadTopMonthArticles,
|
||||||
|
useArticlesStore
|
||||||
|
} from '../../stores/zine/articles'
|
||||||
import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
|
import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
|
||||||
import { locale } from '../../stores/ui'
|
import { locale } from '../../stores/ui'
|
||||||
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
|
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
|
||||||
import { splitToPages } from '../../utils/splitToPages'
|
import { splitToPages } from '../../utils/splitToPages'
|
||||||
|
import { ArticleCard } from '../Feed/Card'
|
||||||
|
|
||||||
type HomeProps = {
|
type HomeProps = {
|
||||||
randomTopics: Topic[]
|
randomTopics: Topic[]
|
||||||
|
@ -47,8 +53,11 @@ export const HomeView = (props: HomeProps) => {
|
||||||
const { topAuthors } = useTopAuthorsStore()
|
const { topAuthors } = useTopAuthorsStore()
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
loadTopArticles()
|
||||||
|
loadTopMonthArticles()
|
||||||
if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) {
|
if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) {
|
||||||
const { hasMore } = await loadShouts({
|
const { hasMore } = await loadShouts({
|
||||||
|
filters: { visibility: 'public' },
|
||||||
limit: CLIENT_LOAD_ARTICLES_COUNT,
|
limit: CLIENT_LOAD_ARTICLES_COUNT,
|
||||||
offset: sortedArticles().length
|
offset: sortedArticles().length
|
||||||
})
|
})
|
||||||
|
@ -119,7 +128,21 @@ export const HomeView = (props: HomeProps) => {
|
||||||
wrapper={'author'}
|
wrapper={'author'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Slider title={t('Top month articles')} articles={topMonthArticles()} />
|
<Slider title={t('Top month articles')}>
|
||||||
|
<For each={topMonthArticles()}>
|
||||||
|
{(a: Shout) => (
|
||||||
|
<ArticleCard
|
||||||
|
article={a}
|
||||||
|
settings={{
|
||||||
|
additionalClass: 'swiper-slide',
|
||||||
|
isFloorImportant: true,
|
||||||
|
isWithCover: true,
|
||||||
|
nodate: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Slider>
|
||||||
|
|
||||||
<Row2 articles={sortedArticles().slice(10, 12)} />
|
<Row2 articles={sortedArticles().slice(10, 12)} />
|
||||||
|
|
||||||
|
@ -131,7 +154,21 @@ export const HomeView = (props: HomeProps) => {
|
||||||
|
|
||||||
{randomLayout()}
|
{randomLayout()}
|
||||||
|
|
||||||
<Slider title={t('Favorite')} articles={topArticles()} />
|
<Slider title={t('Favorite')}>
|
||||||
|
<For each={topArticles()}>
|
||||||
|
{(a: Shout) => (
|
||||||
|
<ArticleCard
|
||||||
|
article={a}
|
||||||
|
settings={{
|
||||||
|
additionalClass: 'swiper-slide',
|
||||||
|
isFloorImportant: true,
|
||||||
|
isWithCover: true,
|
||||||
|
nodate: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Slider>
|
||||||
|
|
||||||
<Beside
|
<Beside
|
||||||
beside={sortedArticles()[20]}
|
beside={sortedArticles()[20]}
|
||||||
|
|
|
@ -13,8 +13,9 @@ import { useAuthorsStore } from '../../stores/zine/authors'
|
||||||
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
|
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
|
||||||
import { splitToPages } from '../../utils/splitToPages'
|
import { splitToPages } from '../../utils/splitToPages'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import Slider from '../Feed/Slider'
|
import Slider from '../_shared/Slider'
|
||||||
import { Row1 } from '../Feed/Row1'
|
import { Row1 } from '../Feed/Row1'
|
||||||
|
import { ArticleCard } from '../Feed/Card'
|
||||||
|
|
||||||
type TopicsPageSearchParams = {
|
type TopicsPageSearchParams = {
|
||||||
by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented'
|
by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented'
|
||||||
|
@ -122,7 +123,21 @@ export const TopicView = (props: TopicProps) => {
|
||||||
wrapper={'author'}
|
wrapper={'author'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Slider title={title()} articles={sortedArticles().slice(5, 11)} />
|
<Slider title={title()}>
|
||||||
|
<For each={sortedArticles().slice(5, 11)}>
|
||||||
|
{(a: Shout) => (
|
||||||
|
<ArticleCard
|
||||||
|
article={a}
|
||||||
|
settings={{
|
||||||
|
additionalClass: 'swiper-slide',
|
||||||
|
isFloorImportant: true,
|
||||||
|
isWithCover: true,
|
||||||
|
nodate: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Slider>
|
||||||
|
|
||||||
<Beside
|
<Beside
|
||||||
beside={sortedArticles()[12]}
|
beside={sortedArticles()[12]}
|
||||||
|
@ -134,15 +149,26 @@ export const TopicView = (props: TopicProps) => {
|
||||||
<Row2 articles={sortedArticles().slice(13, 15)} isEqual={true} />
|
<Row2 articles={sortedArticles().slice(13, 15)} isEqual={true} />
|
||||||
<Row1 article={sortedArticles()[15]} />
|
<Row1 article={sortedArticles()[15]} />
|
||||||
|
|
||||||
<Slider
|
<Show when={sortedArticles().length > 15}>
|
||||||
title={title()}
|
<Slider slidesPerView={3}>
|
||||||
articles={sortedArticles().slice(16, 22)}
|
<For each={sortedArticles().slice(16, 22)}>
|
||||||
slidesPerView={3}
|
{(a: Shout) => (
|
||||||
isCardsWithCover={false}
|
<ArticleCard
|
||||||
|
article={a}
|
||||||
|
settings={{
|
||||||
|
additionalClass: 'swiper-slide',
|
||||||
|
isFloorImportant: true,
|
||||||
|
isWithCover: false,
|
||||||
|
nodate: true
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Slider>
|
||||||
|
|
||||||
<Row3 articles={sortedArticles().slice(23, 26)} />
|
<Row3 articles={sortedArticles().slice(23, 26)} />
|
||||||
<Row2 articles={sortedArticles().slice(26, 28)} />
|
<Row2 articles={sortedArticles().slice(26, 28)} />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<For each={pages()}>
|
<For each={pages()}>
|
||||||
{(page) => (
|
{(page) => (
|
||||||
|
|
|
@ -5,9 +5,20 @@
|
||||||
input {
|
input {
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
|
box-shadow: 0 0 0 #ccc;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0 3px 0 #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
+ label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
import styles from './SearchField.module.scss'
|
import styles from './SearchField.module.scss'
|
||||||
import { Icon } from './Icon'
|
import { Icon } from './Icon'
|
||||||
import { t } from '../../utils/intl'
|
import { t } from '../../utils/intl'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
type SearchFieldProps = {
|
type SearchFieldProps = {
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
|
class?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SearchField = (props: SearchFieldProps) => {
|
export const SearchField = (props: SearchFieldProps) => {
|
||||||
const handleInputChange = (event) => props.onChange(event.target.value.trim())
|
const handleInputChange = (event) => props.onChange(event.target.value.trim())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={styles.searchField}>
|
<div class={clsx(styles.searchField, props.class)}>
|
||||||
<label for="search-field">
|
<label for="search-field">
|
||||||
<Icon name="search" class={styles.icon} />
|
<Icon name="search" class={styles.icon} />
|
||||||
</label>
|
</label>
|
||||||
|
@ -21,6 +23,7 @@ export const SearchField = (props: SearchFieldProps) => {
|
||||||
onInput={handleInputChange}
|
onInput={handleInputChange}
|
||||||
placeholder={t('Search')}
|
placeholder={t('Search')}
|
||||||
/>
|
/>
|
||||||
|
<label for="search-field">Поиск</label>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
import { ArticleCard } from './Card'
|
|
||||||
import { Swiper, Navigation, Pagination } from 'swiper'
|
import { Swiper, Navigation, Pagination } from 'swiper'
|
||||||
import type { SwiperOptions } from 'swiper'
|
import type { SwiperOptions } from 'swiper'
|
||||||
import 'swiper/scss'
|
import 'swiper/scss'
|
||||||
import 'swiper/scss/navigation'
|
import 'swiper/scss/navigation'
|
||||||
import 'swiper/scss/pagination'
|
import 'swiper/scss/pagination'
|
||||||
import './Slider.scss'
|
import './Slider.scss'
|
||||||
import type { Shout } from '../../graphql/types.gen'
|
import { createEffect, createMemo, createSignal, Show, For, JSX } from 'solid-js'
|
||||||
import { createEffect, createMemo, createSignal, Show, For } from 'solid-js'
|
import { Icon } from './Icon'
|
||||||
import { Icon } from '../_shared/Icon'
|
|
||||||
|
|
||||||
interface SliderProps {
|
interface SliderProps {
|
||||||
title?: string
|
title?: string
|
||||||
articles: Shout[]
|
|
||||||
slidesPerView?: number
|
slidesPerView?: number
|
||||||
isCardsWithCover?: boolean
|
isCardsWithCover?: boolean
|
||||||
|
children?: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props: SliderProps) => {
|
export default (props: SliderProps) => {
|
||||||
|
@ -57,30 +55,14 @@ export default (props: SliderProps) => {
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const articles = createMemo(() => props.articles)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="floor floor--important">
|
<div class="floor floor--important">
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2 class="col-12">{props.title}</h2>
|
<h2 class="col-12">{props.title}</h2>
|
||||||
<Show when={!!articles()}>
|
|
||||||
<div class="swiper" classList={{ 'cards-with-cover': isCardsWithCover }} ref={el}>
|
<div class="swiper" classList={{ 'cards-with-cover': isCardsWithCover }} ref={el}>
|
||||||
<div class="swiper-wrapper">
|
<div class="swiper-wrapper">{props.children}</div>
|
||||||
<For each={articles()}>
|
|
||||||
{(a: Shout) => (
|
|
||||||
<ArticleCard
|
|
||||||
article={a}
|
|
||||||
settings={{
|
|
||||||
additionalClass: 'swiper-slide',
|
|
||||||
isFloorImportant: true,
|
|
||||||
isWithCover: isCardsWithCover,
|
|
||||||
nodate: true
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<div class="slider-arrow-next" ref={nextEl} onClick={() => swiper()?.slideNext()}>
|
<div class="slider-arrow-next" ref={nextEl} onClick={() => swiper()?.slideNext()}>
|
||||||
<Icon name="slider-arrow" class={'icon'} />
|
<Icon name="slider-arrow" class={'icon'} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -89,7 +71,6 @@ export default (props: SliderProps) => {
|
||||||
</div>
|
</div>
|
||||||
<div class="slider-pagination" ref={pagEl} />
|
<div class="slider-pagination" ref={pagEl} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
color: #9fa1a7;
|
color: #9fa1a7;
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 1em;
|
margin: 0.5em 0 1em;
|
||||||
|
|
||||||
@include media-breakpoint-down(md) {
|
@include media-breakpoint-down(md) {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
@ -6,7 +6,10 @@ import type { LayoutType } from '../stores/zine/layouts'
|
||||||
export type PageProps = {
|
export type PageProps = {
|
||||||
randomTopics?: Topic[]
|
randomTopics?: Topic[]
|
||||||
article?: Shout
|
article?: Shout
|
||||||
shouts?: Shout[]
|
layoutShouts?: Shout[]
|
||||||
|
authorShouts?: Shout[]
|
||||||
|
topicShouts?: Shout[]
|
||||||
|
homeShouts?: Shout[]
|
||||||
author?: Author
|
author?: Author
|
||||||
allAuthors?: Author[]
|
allAuthors?: Author[]
|
||||||
topic?: Topic
|
topic?: Topic
|
||||||
|
@ -22,3 +25,10 @@ export type RootSearchParams = {
|
||||||
modal: string
|
modal: string
|
||||||
lang: string
|
lang: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UploadFile = {
|
||||||
|
source: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
file: File
|
||||||
|
}
|
||||||
|
|
68
src/context/profile.tsx
Normal file
|
@ -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 }
|
|
@ -1,14 +1,16 @@
|
||||||
import type { Accessor, InitializedResource, JSX } from 'solid-js'
|
import type { Accessor, JSX, Resource } from 'solid-js'
|
||||||
import { createContext, createMemo, createResource, onMount, useContext } from 'solid-js'
|
import { createContext, createMemo, createResource, createSignal, onMount, useContext } from 'solid-js'
|
||||||
import type { AuthResult } from '../graphql/types.gen'
|
import type { AuthResult } from '../graphql/types.gen'
|
||||||
import { apiClient } from '../utils/apiClient'
|
import { apiClient } from '../utils/apiClient'
|
||||||
import { resetToken, setToken } from '../graphql/privateGraphQLClient'
|
import { resetToken, setToken } from '../graphql/privateGraphQLClient'
|
||||||
|
|
||||||
type SessionContextType = {
|
type SessionContextType = {
|
||||||
session: InitializedResource<AuthResult>
|
session: Resource<AuthResult>
|
||||||
|
isSessionLoaded: Accessor<boolean>
|
||||||
|
userSlug: Accessor<string>
|
||||||
isAuthenticated: Accessor<boolean>
|
isAuthenticated: Accessor<boolean>
|
||||||
actions: {
|
actions: {
|
||||||
getSession: () => AuthResult | Promise<AuthResult>
|
loadSession: () => AuthResult | Promise<AuthResult>
|
||||||
signIn: ({ email, password }: { email: string; password: string }) => Promise<void>
|
signIn: ({ email, password }: { email: string; password: string }) => Promise<void>
|
||||||
signOut: () => Promise<void>
|
signOut: () => Promise<void>
|
||||||
confirmEmail: (token: string) => Promise<void>
|
confirmEmail: (token: string) => Promise<void>
|
||||||
|
@ -17,6 +19,13 @@ type SessionContextType = {
|
||||||
|
|
||||||
const SessionContext = createContext<SessionContextType>()
|
const SessionContext = createContext<SessionContextType>()
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
return useContext(SessionContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SessionProvider = (props: { children: JSX.Element }) => {
|
||||||
|
const [isSessionLoaded, setIsSessionLoaded] = createSignal(false)
|
||||||
|
|
||||||
const getSession = async (): Promise<AuthResult> => {
|
const getSession = async (): Promise<AuthResult> => {
|
||||||
try {
|
try {
|
||||||
const authResult = await apiClient.getSession()
|
const authResult = await apiClient.getSession()
|
||||||
|
@ -26,22 +35,21 @@ const getSession = async (): Promise<AuthResult> => {
|
||||||
setToken(authResult.token)
|
setToken(authResult.token)
|
||||||
return authResult
|
return authResult
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('renewSession error:', error)
|
console.error('getSession error:', error)
|
||||||
resetToken()
|
resetToken()
|
||||||
return null
|
return null
|
||||||
|
} finally {
|
||||||
|
setIsSessionLoaded(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSession() {
|
const [session, { refetch: loadSession, mutate }] = createResource<AuthResult>(getSession, {
|
||||||
return useContext(SessionContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SessionProvider = (props: { children: JSX.Element }) => {
|
|
||||||
const [session, { refetch: refetchSession, mutate }] = createResource<AuthResult>(getSession, {
|
|
||||||
ssrLoadFrom: 'initial',
|
ssrLoadFrom: 'initial',
|
||||||
initialValue: null
|
initialValue: null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const userSlug = createMemo(() => session()?.user?.slug)
|
||||||
|
|
||||||
const isAuthenticated = createMemo(() => Boolean(session()?.user?.slug))
|
const isAuthenticated = createMemo(() => Boolean(session()?.user?.slug))
|
||||||
|
|
||||||
const signIn = async ({ email, password }: { email: string; password: string }) => {
|
const signIn = async ({ email, password }: { email: string; password: string }) => {
|
||||||
|
@ -65,16 +73,16 @@ export const SessionProvider = (props: { children: JSX.Element }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
getSession: refetchSession,
|
loadSession,
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
confirmEmail
|
confirmEmail
|
||||||
}
|
}
|
||||||
|
|
||||||
const value: SessionContextType = { session, isAuthenticated, actions }
|
const value: SessionContextType = { session, isSessionLoaded, userSlug, isAuthenticated, actions }
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
refetchSession()
|
loadSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
return <SessionContext.Provider value={value}>{props.children}</SessionContext.Provider>
|
return <SessionContext.Provider value={value}>{props.children}</SessionContext.Provider>
|
||||||
|
|
|
@ -11,12 +11,12 @@ export default gql`
|
||||||
subtitle
|
subtitle
|
||||||
body
|
body
|
||||||
topics {
|
topics {
|
||||||
_id: slug
|
# id
|
||||||
title
|
title
|
||||||
slug
|
slug
|
||||||
}
|
}
|
||||||
authors {
|
authors {
|
||||||
_id: slug
|
id
|
||||||
name
|
name
|
||||||
slug
|
slug
|
||||||
userpic
|
userpic
|
||||||
|
|
|
@ -12,13 +12,13 @@ export default gql`
|
||||||
image
|
image
|
||||||
body
|
body
|
||||||
topics {
|
topics {
|
||||||
_id: slug
|
# id
|
||||||
title
|
title
|
||||||
slug
|
slug
|
||||||
image
|
image
|
||||||
}
|
}
|
||||||
authors {
|
authors {
|
||||||
_id: slug
|
id
|
||||||
name
|
name
|
||||||
slug
|
slug
|
||||||
userpic
|
userpic
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { gql } from '@urql/core'
|
import { gql } from '@urql/core'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
mutation SendLinkQuery($email: String!, $lang: String) {
|
mutation SendLinkQuery($email: String!, $lang: String, $template: String) {
|
||||||
sendLink(email: $email, lang: $lang) {
|
sendLink(email: $email, lang: $lang, template: $template) {
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { gql } from '@urql/core'
|
import { gql } from '@urql/core'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
mutation FollowQuery($what: FollowingEntity!, $slug: String!) {
|
mutation FollowMutation($what: FollowingEntity!, $slug: String!) {
|
||||||
follow(what: $what, slug: $slug) {
|
follow(what: $what, slug: $slug) {
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { gql } from '@urql/core'
|
import { gql } from '@urql/core'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
mutation UnfollowQuery($what: FollowingEntity!, $slug: String!) {
|
mutation UnfollowMutation($what: FollowingEntity!, $slug: String!) {
|
||||||
unfollow(what: $what, slug: $slug) {
|
unfollow(what: $what, slug: $slug) {
|
||||||
error
|
error
|
||||||
}
|
}
|
||||||
|
|
13
src/graphql/mutation/update-profile.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
|
@ -25,6 +25,10 @@ export const getToken = (): string => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setToken = (token: string) => {
|
export const setToken = (token: string) => {
|
||||||
|
if (!token) {
|
||||||
|
console.error('[privateGraphQLClient] setToken: token is null!')
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_LOCAL_STORAGE_KEY, token)
|
localStorage.setItem(TOKEN_LOCAL_STORAGE_KEY, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,11 +41,12 @@ const options: ClientOptions = {
|
||||||
maskTypename: true,
|
maskTypename: true,
|
||||||
requestPolicy: 'cache-and-network',
|
requestPolicy: 'cache-and-network',
|
||||||
fetchOptions: () => {
|
fetchOptions: () => {
|
||||||
// пока источником правды для значения токена будет локальное хранилище
|
// localStorage is the source of truth for now
|
||||||
// меняем через setToken, например при получении значения с сервера
|
// to change token call setToken, for example after login
|
||||||
// скорее всего придумаем что-нибудь получше со временем
|
|
||||||
const token = localStorage.getItem(TOKEN_LOCAL_STORAGE_KEY)
|
const token = localStorage.getItem(TOKEN_LOCAL_STORAGE_KEY)
|
||||||
if (token === null) alert('token is null')
|
if (!token) {
|
||||||
|
console.error('[privateGraphQLClient] fetchOptions: token is null!')
|
||||||
|
}
|
||||||
const headers = { Authorization: token }
|
const headers = { Authorization: token }
|
||||||
return { headers }
|
return { headers }
|
||||||
},
|
},
|
||||||
|
|
|
@ -10,9 +10,11 @@ export default gql`
|
||||||
layout
|
layout
|
||||||
cover
|
cover
|
||||||
body
|
body
|
||||||
|
media
|
||||||
# community
|
# community
|
||||||
mainTopic
|
mainTopic
|
||||||
topics {
|
topics {
|
||||||
|
# id
|
||||||
title
|
title
|
||||||
body
|
body
|
||||||
slug
|
slug
|
||||||
|
@ -24,7 +26,7 @@ export default gql`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
authors {
|
authors {
|
||||||
_id: slug
|
id
|
||||||
name
|
name
|
||||||
slug
|
slug
|
||||||
userpic
|
userpic
|
|
@ -12,6 +12,7 @@ export default gql`
|
||||||
# community
|
# community
|
||||||
mainTopic
|
mainTopic
|
||||||
topics {
|
topics {
|
||||||
|
# id
|
||||||
title
|
title
|
||||||
body
|
body
|
||||||
slug
|
slug
|
||||||
|
@ -23,7 +24,7 @@ export default gql`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
authors {
|
authors {
|
||||||
_id: slug
|
id
|
||||||
name
|
name
|
||||||
slug
|
slug
|
||||||
userpic
|
userpic
|
||||||
|
|
|
@ -14,6 +14,7 @@ export default gql`
|
||||||
# community
|
# community
|
||||||
mainTopic
|
mainTopic
|
||||||
topics {
|
topics {
|
||||||
|
# id
|
||||||
title
|
title
|
||||||
body
|
body
|
||||||
slug
|
slug
|
||||||
|
@ -25,7 +26,7 @@ export default gql`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
authors {
|
authors {
|
||||||
_id: slug
|
id
|
||||||
name
|
name
|
||||||
slug
|
slug
|
||||||
userpic
|
userpic
|
||||||
|
|
|
@ -4,6 +4,7 @@ export default gql`
|
||||||
query GetChatsQuery($limit: Int, $offset: Int) {
|
query GetChatsQuery($limit: Int, $offset: Int) {
|
||||||
loadRecipients(limit: $limit, offset: $offset) {
|
loadRecipients(limit: $limit, offset: $offset) {
|
||||||
members {
|
members {
|
||||||
|
id
|
||||||
name
|
name
|
||||||
id
|
id
|
||||||
slug
|
slug
|
||||||
|
|
|
@ -4,6 +4,7 @@ export default gql`
|
||||||
query GetCollabsQuery {
|
query GetCollabsQuery {
|
||||||
getCollabs {
|
getCollabs {
|
||||||
authors {
|
authors {
|
||||||
|
id
|
||||||
slug
|
slug
|
||||||
name
|
name
|
||||||
pic
|
pic
|
||||||
|
|