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