Compare commits

...

522 Commits

Author SHA1 Message Date
a95c78fd60 unused-rm
Some checks failed
deploy / test (push) Failing after 6m54s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-03 00:01:01 +03:00
4e050198c5 pathfix2
Some checks failed
deploy / test (push) Failing after 6m52s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-02 23:33:58 +03:00
36810d688c fmt2
Some checks failed
deploy / test (push) Failing after 1m13s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-02 23:24:32 +03:00
abc1f184a1 fmt
Some checks failed
deploy / test (push) Failing after 1m10s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-02 23:21:35 +03:00
244c91fb02 pathfix
Some checks failed
deploy / test (push) Failing after 43s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-02 23:17:12 +03:00
6bd919f16b views-refactored
Some checks failed
deploy / test (push) Failing after 1m9s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-02 23:12:14 +03:00
Tony
04878bec0b
Merge pull request #487 from Discours/hotfix/sv-artical-component
Fix slug updating in ArticlePage component by managing currentSlug signal
2024-10-02 23:10:32 +03:00
b791de3d4e fmt
Some checks failed
deploy / test (push) Failing after 1m7s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-02 21:29:51 +03:00
6f26e09bef drafts-view-refactoring+fix
Some checks failed
deploy / Update templates on Mailgun (push) Waiting to run
deploy / test (push) Has been cancelled
2024-10-02 21:29:32 +03:00
Stepan Vladovskiy
55e2b34466 feat: fix slug updating in ArticlePage component by managing curentSlug signal 2024-10-01 20:50:01 +00:00
217c027044 ..
Some checks failed
deploy / test (push) Failing after 1m12s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-01 22:52:45 +03:00
8106bae0c2 editor-refactored
Some checks failed
deploy / test (push) Failing after 1m15s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-01 22:39:17 +03:00
ae1a93469b bubble-menu-used
Some checks failed
deploy / test (push) Failing after 6m42s
deploy / Update templates on Mailgun (push) Has been skipped
2024-10-01 21:11:07 +03:00
67e8c80d9a fmt 2024-10-01 20:19:40 +03:00
abdc419aa8 mini+micro-fix 2024-10-01 20:18:27 +03:00
Stepan Vladovskiy
32ebcff5fe feat: improve component structure and addded signal managment. Extract Article page Content into a separate block. Added createSignal and CreateEffect to track slug dynamically. Replace ifelse with Switch and Match. 2024-09-30 16:35:15 +00:00
Stepan Vladovskiy
b91a1be989 Merge branch 'dev' into hotfix/sv-author-empty 2024-09-30 14:30:21 +00:00
21b3903062 minor-fix 2024-09-30 14:00:02 +03:00
fab8a5ed53 minor
Some checks failed
deploy / test (push) Failing after 1m6s
deploy / Update templates on Mailgun (push) Has been skipped
2024-09-30 13:38:44 +03:00
845490d5db Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into dev 2024-09-27 21:12:07 +03:00
30de1ddb3e editor-refactored-2 2024-09-27 21:09:50 +03:00
90cd3988a1 editor-toolbar 2024-09-27 21:00:45 +03:00
258b579d05 editor-toolbar 2024-09-27 20:57:49 +03:00
76dea4341d toolbar-appear-fix 2024-09-27 20:21:52 +03:00
595e2b8a4b editor-refactoring 2024-09-27 19:31:54 +03:00
b393810f7a cleanup-stories 2024-09-27 17:28:50 +03:00
90057a2d0e fmt 2024-09-27 17:27:01 +03:00
7aa01d6152 MiniEditor-fix 2024-09-27 17:26:40 +03:00
962140e755 microeditor-wip 2024-09-27 16:46:43 +03:00
0c61445293 config-fix
Some checks failed
deploy / test (push) Failing after 48s
deploy / Update templates on Mailgun (push) Has been skipped
2024-09-27 10:51:51 +03:00
29dbd67c27 debugL 2024-09-25 21:15:17 -03:00
a144d7051b expo-fixes 2024-09-26 01:48:30 +03:00
c165742fbf debug: replace createAsync for slugloader to createSignal in slug/tab for trying avoid complex repeating of async functions 2024-09-25 19:16:13 -03:00
bbab1e4cb8 debug: Added logic for robust reload data forslug before render 2024-09-25 17:25:08 -03:00
dc2b6a5ab1 debug: with reset data if slug is changes in slug/tab articalpage 2024-09-25 17:04:08 -03:00
91d8fdf746 debug: add logs in dev variant of slug/tab 2024-09-25 16:20:42 -03:00
db8b53f9bd debug: back to dev version. Investigate UserPage Artcicle Render 2024-09-25 10:21:08 -03:00
aa11c8d8b8 debug: With load topics and logs in slug tab to see why it is not loading topic from user page 2024-09-25 09:59:12 -03:00
6dc25260bb debug: break everythig, rename Artile Page and add logs 2024-09-24 22:25:56 -03:00
a805493b27 debug: used version only with useParam and with logging for slug tab 2024-09-24 22:14:46 -03:00
996153bbc2 debug: more pres logs for slug tab.tsx 2024-09-24 22:03:57 -03:00
c26f960d9b debug: slug with logging to async and useParam for fetching data and slugs 2024-09-24 21:47:19 -03:00
ef7ceb5d33 debug: backed slug to dev code just rename ArticlePage inner function 2024-09-24 20:51:32 -03:00
22575cc7fa expo-showup 2024-09-24 14:56:16 +03:00
a52ee5a90f Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into dev 2024-09-24 14:26:58 +03:00
2fef053029 canedit-fix 2024-09-24 14:24:53 +03:00
5e2b4a7ae6 canedit-fix 2024-09-24 13:09:24 +03:00
6e3871cd5a link-fix1 2024-09-24 12:38:53 +03:00
a451a6bf4e Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into dev 2024-09-24 12:19:40 +03:00
317d4a004c graphql-client-unmemo
Some checks failed
deploy / test (push) Failing after 6m50s
deploy / Update templates on Mailgun (push) Has been skipped
2024-09-24 12:15:50 +03:00
29d1661993 new-shout-desc-fix
Some checks failed
deploy / test (push) Failing after 6m49s
deploy / Update templates on Mailgun (push) Has been skipped
2024-09-24 11:03:40 +03:00
7288725480 new-draft-store-fix 2024-09-24 10:59:54 +03:00
c959a2bba4 fmt
Some checks failed
deploy / test (push) Failing after 7m1s
deploy / Update templates on Mailgun (push) Has been skipped
2024-09-24 09:51:58 +03:00
d7a5a188ff editor-showup+fixes
Some checks failed
deploy / Update templates on Mailgun (push) Waiting to run
deploy / test (push) Has been cancelled
2024-09-24 09:48:39 +03:00
b3b8e51d2d devruntime-fix 2024-09-24 06:50:44 +03:00
343b71defd feat: renamed ArticalPage to component, added useParam for slug and reactivity 2024-09-24 00:40:57 -03:00
Tony
545728ac84
Merge pull request #485 from Discours/hotfix/sv-author-empty
Author Profile when logged in infinity load resolve bugFix
2024-09-23 14:17:46 +03:00
fe76bb46e6 Merge branch 'dev' into hotfix/sv-author-empty 2024-09-23 10:17:24 +03:00
1f0e91d2b9 bump 2024-09-23 10:14:33 +03:00
Stepan Vladovskiy
76366a01d9 feat: with setFollowingsLoaded(true) in Author View component. It is need if user already loaded, then after changeFollowing for unsetting Loading and show page 2024-09-20 23:56:25 +00:00
d496760fdb sbtest 2024-09-19 20:26:58 +03:00
3da600dacc packages-update 2024-09-19 20:24:11 +03:00
f9ed111d8e stylefixes 2024-09-19 20:08:22 +03:00
392581f50d linted 2024-09-19 19:58:23 +03:00
c40a357815 Merge branch 'storybook' of https://github.com/discours/discoursio-webapp into storybook 2024-09-19 19:57:55 +03:00
6ca29a351f editor-wip 2024-09-19 19:51:56 +03:00
d6c6545726 Merge branch 'dev' of https://github.com/discours/discoursio-webapp into dev 2024-09-19 19:15:57 +03:00
1db4224827 editor-wip 2024-09-19 19:15:51 +03:00
98f03c2296 linted 2024-09-19 19:15:05 +03:00
Tony
9491f7c504
Merge pull request #483 from Discours/hotfix/sv-author-empty
Author empty for logged in user (user profile)
2024-09-19 18:12:14 +03:00
Raksana Karlova
fac7451fc4 added styles for button in SB 2024-09-17 19:52:57 +04:00
64224720f5 build-tuning 2024-09-16 16:50:44 +03:00
d1ff340e0e makePersisted-update 2024-09-16 16:35:48 +03:00
92ec0da0bc Merge branch 'dev' of https://github.com/discours/discoursio-webapp into dev 2024-09-16 15:57:24 +03:00
d3cd027910 build-fix+const-fix+icon-fix+expo-fix 2024-09-16 15:57:00 +03:00
Tony
146f41e167
Merge pull request #480 from Discours/fix/style
Style fixes
2024-09-16 14:28:07 +03:00
08cbfa1d57 vite535 2024-09-16 14:07:30 +03:00
8345d858ed vite535 2024-09-16 13:52:33 +03:00
5e73863679 local-build-ok 2024-09-16 13:45:30 +03:00
ff541b4456 freezefix 2024-09-16 13:34:17 +03:00
d210587b74 bun-ci 2024-09-16 13:21:45 +03:00
64658f5175 versions... 2024-09-16 13:14:08 +03:00
b201163ab9 buildable 2024-09-16 13:02:34 +03:00
11c94da20d lockfix 2024-09-16 12:39:55 +03:00
63e159822a build-fix 2024-09-16 12:31:12 +03:00
d5c5eaf57b packages-revert-fix 2024-09-16 12:25:28 +03:00
6a48b53216 patch-fix 2024-09-16 12:03:43 +03:00
20e7588019 force-use-dart 2024-09-16 11:56:22 +03:00
80dbe410ab try-patch-sass 2024-09-16 11:53:14 +03:00
d00084183d ci-failok 2024-09-16 11:09:27 +03:00
bf09277822 sass-embedded-downgrade 2024-09-16 10:50:47 +03:00
35febd9ac4 noapi 2024-09-16 04:06:17 +03:00
35cae9415d scss-fixes 2024-09-16 03:42:55 +03:00
74725df0ff editor-storybook-fix 2024-09-16 03:09:07 +03:00
5eded9f143 lock-restored 2024-09-16 03:03:25 +03:00
afa69fc86f editor-fix 2024-09-16 02:53:49 +03:00
6e0a830168 abc-sort-fix 2024-09-16 02:41:48 +03:00
433a74a58a linted 2024-09-15 23:17:21 +03:00
53299fc183 simplified-story-fixes 2024-09-15 23:17:02 +03:00
ebed7f38c3 sass-fixes+minieditor-storybooked 2024-09-15 22:39:39 +03:00
199f845610 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into dev 2024-09-15 21:54:40 +03:00
2fad5b8db9 bad update 2 2024-09-15 21:47:21 +03:00
344f716d1d bad update 2024-09-15 21:47:14 +03:00
ba55780246 linted-update 2024-09-15 21:44:10 +03:00
512c65aeef Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into dev 2024-09-15 19:41:05 +03:00
ad4bda3c24 prestorybook 2024-09-15 19:41:02 +03:00
Tony
7d895aa343
Merge pull request #481 from Discours/storybook
Storybook
2024-09-15 19:38:22 +03:00
Stepan Vladovskiy
49001bb63c style: with small readme at top, and small coment in code 2024-09-15 15:37:27 +00:00
Stepan Vladovskiy
bef13a9bde feat: router/author/slug with err_ in console , and Placeholder handle undefinde in slug for users with empty slug 2024-09-14 16:30:47 +00:00
09243925b4 vite-conf-connection-fix 2024-09-12 09:56:53 +03:00
Raksana Karlova
1da37e7a52 config 2024-09-11 16:50:28 +04:00
479a4ea852 Fixed paddings on the feed page 2024-09-10 22:49:09 +03:00
Raksana Karlova
e4a1679052 Storybook update 2024-09-10 17:58:19 +04:00
8824fbab2f reactions-store-fix 2024-09-06 08:33:44 +03:00
6ec271fe7c reactions-store-fix 2024-09-06 08:27:48 +03:00
260b95f692 scrollto+shoutreaction 2024-09-06 08:13:24 +03:00
1ec368eae7 minor fixes 2024-09-06 07:55:57 +03:00
e03971193e url-backward-compat 2024-09-06 07:52:39 +03:00
6e11e19f0a Style fixes 2024-09-04 22:48:18 +03:00
d003df96f2 preload-trans 2024-09-03 20:12:46 +03:00
dd02c46174 typefix 2024-09-03 20:07:46 +03:00
9319c8d526 locnochunk 2024-09-03 19:59:53 +03:00
7a2043f223 comments-view-fix 2024-09-03 19:50:26 +03:00
a2999e3851 chunk-merged 2024-09-03 19:24:50 +03:00
23e9ca9838 fmt 2024-09-03 19:22:52 +03:00
b868c7282d postmerge 2024-09-03 19:22:33 +03:00
124ae3dece Merge remote-tracking branch 'origin/dev' into dev 2024-09-03 19:20:56 +03:00
a4e5d8f332 revert-manual-chunks 2024-09-03 19:19:24 +03:00
7fa17cee3c manual-chunked 2024-09-03 18:37:57 +03:00
6bfaa3fb51 topic-feed-revised 2024-09-03 14:36:33 +03:00
30ff30d099 fmt 2024-09-03 13:29:01 +03:00
7714977391 commentsByAuthor 2024-09-03 13:21:59 +03:00
e176544e36 author-shouts-loadmore 2024-09-03 11:07:32 +03:00
33a81d8ee7 storybook 2024-09-03 10:06:39 +03:00
7c614c66d9 minor-ref 2024-08-30 16:45:17 +03:00
cd436dd34d about-title-fix 2024-08-29 18:34:13 +03:00
0698900f8d fmt 2024-08-29 17:40:31 +03:00
090295327f profile-settings-fix 2024-08-29 16:11:51 +03:00
f808bd2394 topic+author-loadmore-fix 2024-08-28 16:10:00 +03:00
22f44ee0ec tab-navigation-fix 2024-08-28 12:50:04 +03:00
7ca7acc487 loadmore+viewed-fix 2024-08-28 11:53:40 +03:00
3d2125d99f undefer-fix 2024-08-28 08:12:44 +03:00
e695c7847f loading-error-fix 2024-08-28 01:02:55 +03:00
95198e9791 topic-page-fix 2024-08-28 00:51:17 +03:00
71772ae0d6 all-authors-revert 2024-08-27 20:17:24 +03:00
28f3d6619d fix from 'origin/demo-fix3' 2024-08-26 21:22:27 +03:00
Igor Lobanov
5bf8340f13 topic articles fix 2024-08-26 14:55:01 +02:00
tony
1c2b53cfe8 topics-fmt-fix 2024-08-26 15:26:00 +03:00
tony
0748aa342c header-auth-nav-fix 2024-08-26 15:23:51 +03:00
e360cdf1ba header-links-fix 2024-08-22 16:27:09 +03:00
17750a7630 fixd 2024-08-22 15:56:12 +03:00
b80a497e4d bump-deploy 2024-08-22 15:45:36 +03:00
ae3473312a nitro-timing-log 2024-08-14 15:50:01 +03:00
a186dc4d3f vercel-edge-deploy-test 2024-08-12 14:42:31 +03:00
e6e19d74cd netlify-runtime 2024-08-12 01:21:01 +03:00
6a105b24fc api-up 2024-08-07 13:39:39 +03:00
50d98deadf api-revert
Some checks failed
deploy / test (push) Failing after 14s
deploy / Update templates on Mailgun (push) Has been skipped
2024-08-06 18:19:38 +03:00
582a80d34d apifix 2024-08-06 15:08:04 +03:00
c6ea1eb8b7 e2e-testing 2024-08-05 15:09:57 +03:00
e797e8ca96 lock+fmt 2024-08-05 15:02:13 +03:00
e07ace8741 all-topics-cache-fix 2024-08-05 14:03:30 +03:00
b53f83947c inbox-route 2024-08-02 00:32:52 +03:00
a39190e2b1 editor fixed 2024-08-01 00:41:42 +03:00
e875212ae7 editor-fixed 2024-07-31 02:20:54 +03:00
ca629e8c26 tests 2024-07-31 01:39:24 +03:00
8db9f4c93f signup-e2e 2024-07-31 00:52:25 +03:00
ae4ec7b6b7 e2e-wip 2024-07-30 22:44:53 +03:00
6db49c2296 e2e 2024-07-30 22:28:28 +03:00
a5bb138fc7 editor-wip 2024-07-30 22:18:16 +03:00
142c5bfb41 graphql-fix 2024-07-30 22:06:17 +03:00
81ed60a52f Merge branch 'dev' of github.com:Discours/discoursio-webapp into dev 2024-07-30 20:59:30 +03:00
418572af88 author-profile-preload-fix 2024-07-30 20:59:12 +03:00
Tony
4902096e48
Merge pull request #478 from Discours/router-upgrade
update router workflow
2024-07-30 06:04:14 +03:00
5846d24c10 alltopics-title-fix 2024-07-30 05:55:12 +03:00
4cb888262d restored-all-authors 2024-07-30 05:35:27 +03:00
1e4138e40e reset1 2024-07-26 18:49:15 +03:00
219e3e2325 :Merge branch 'dev' of github.com:Discours/discoursio-webapp into dev 2024-07-26 01:19:05 +03:00
3e25997d2a version 2024-07-26 01:17:24 +03:00
38f6cbbdca
Debug: cleaning, back to last configs 2024-07-25 09:33:32 -03:00
15233db9a5
Debug: trying to follower read me file and place config.ts in utils 2024-07-24 22:20:38 -03:00
87dd66ad4b biomepass 2024-07-23 17:00:33 +03:00
0de087d999 comments-tree-fix 2024-07-23 16:58:57 +03:00
42d8e7598c empty-files-removed 2024-07-22 17:40:26 +03:00
b5e25e0d6d fixtest 2024-07-22 17:15:47 +03:00
1f58385b95 noauthor-vercelbuildtest 2024-07-22 17:11:43 +03:00
9733eff104 postmerge 2024-07-22 17:07:04 +03:00
607d986293 Merge branch 'router-upgrade' of github.com:Discours/discoursio-webapp into router-upgrade 2024-07-22 15:31:18 +03:00
Stepan Vladovskiy
e6aa3a881f feat: page AllAuthors show sorted list and ABC nav is working. But pagination not yet 2024-07-22 11:43:56 +00:00
b61b19a119 fmt 2024-07-22 14:29:33 +03:00
01e7dec615 author-feed-debug 2024-07-22 14:24:36 +03:00
1a9529d9fc debug... 2024-07-22 09:57:48 +03:00
85e0a92b31 editor... 2024-07-22 09:29:57 +03:00
751157b421 clued 2024-07-22 08:41:23 +03:00
0061b68257 postmerge: loadmorewrappers 2024-07-21 17:26:03 +03:00
87d08dcb75 Merge branch 'router-upgrade' into feature/rating 2024-07-21 15:48:38 +03:00
1d540f28b7 bump 2024-07-21 15:43:45 +03:00
cad695dc59 struct 2024-07-21 05:17:42 +03:00
77d8ca352a upd
Some checks failed
deploy / test (push) Failing after 5m41s
deploy / Update templates on Mailgun (push) Has been skipped
2024-07-18 15:29:23 +03:00
a8d778b2e4 buildfix2
Some checks failed
deploy / test (push) Failing after 13s
deploy / Update templates on Mailgun (push) Has been skipped
2024-07-18 14:26:57 +03:00
8f9dea9bfb buildfix 2024-07-18 14:22:59 +03:00
1eb9c57f0d loadmore-main-fix 2024-07-18 13:22:58 +03:00
7573c6334c mainpage-loadmore-hide 2024-07-18 12:22:28 +03:00
ab05a9e539 typefix 2024-07-18 07:34:55 +03:00
8c5ef2fd54 mainpage-deploy 2024-07-18 07:33:23 +03:00
8fbc85615c author-feed+comments-paginate 2024-07-16 03:14:08 +03:00
82904bd1da @-patch
Some checks failed
deploy / test (push) Failing after 14s
deploy / Update templates on Mailgun (push) Has been skipped
2024-07-16 02:11:01 +03:00
19bae3b2dd edit-new-wip 2024-07-16 01:57:44 +03:00
c79b0451cb linking-fix 2024-07-16 01:13:19 +03:00
8cce8d897e loadmore+feed+my 2024-07-15 23:56:40 +03:00
4fe2768329 load-more-main-ok 2024-07-15 23:35:33 +03:00
f6f012449d load-wrapper-debug 2024-07-15 19:51:52 +03:00
789a7497a3 load-more-wrapper-wip 2024-07-15 17:28:08 +03:00
2b7a825bc5 sortfn-type 2024-07-13 20:22:32 +03:00
4efec31fec https 2024-07-13 20:15:41 +03:00
03787196a9 minor-configs 2024-07-13 19:29:17 +03:00
35c5a0ebcf getThumbUrl 2024-07-13 15:25:56 +03:00
7c9c155f5b sort 2024-07-13 14:47:31 +03:00
2d7fbc42a8 utils-refactored 2024-07-13 14:42:53 +03:00
95612eb7b8 topic-outing+fixes 2024-07-13 13:33:49 +03:00
24e594138f topic-!-routing 2024-07-13 13:32:27 +03:00
17d2600142 filerouter-import-workaround 2024-07-13 12:45:10 +03:00
fde2335a02 @-routing-fix 2024-07-13 12:36:23 +03:00
ef1408327f isolate-utils-authors-with-@ 2024-07-13 12:06:49 +03:00
645e65751b typecheck-fix 2024-07-13 10:53:35 +03:00
f916e9f9ae nostat 2024-07-13 10:44:51 +03:00
e5a3788f71 vercel-fix2 2024-07-13 10:44:07 +03:00
98461b8d09 vercel-fix 2024-07-13 10:34:21 +03:00
0192acb8a4 author-page-view 2024-07-13 10:02:05 +03:00
b7e775eeea author-page-wip 2024-07-13 10:01:41 +03:00
25d217389b sass-depr-suppres 2024-07-12 19:00:30 +03:00
e68741efa1 stylelint-order-fmt 2024-07-12 16:19:49 +03:00
3041ee2fd6 some-reverts 2024-07-12 15:10:22 +03:00
41f989024c lock-fix 2024-07-12 14:38:54 +03:00
c2b56ed745 biomed2 2024-07-12 14:20:31 +03:00
7623f719a5 biomed 2024-07-12 14:19:58 +03:00
65798a7f60 Merge branch 'router-upgrade' of github.com:Discours/discoursio-webapp into router-upgrade 2024-07-12 14:18:28 +03:00
27653a4db2 stylelint-fix 2024-07-12 14:18:10 +03:00
Stepan Vladovskiy
d9a0badedd feat: app.tsx now with AutorsPaginator wrap, AllAuthors with logs and some dont needed changes, working on it. authors witrh minor changes 2024-07-12 06:46:12 +00:00
Stepan Vladovskiy
b677cb3493 feat: step to all authors, with debug level an dsome minor changes in memo 2024-07-11 23:00:00 +00:00
Stepan Vladovskiy
59885486eb feat: local server starts on https://localhost = true 2024-07-09 20:53:28 +00:00
edbd7ec3b2 :topic-page-fix
Some checks failed
deploy / test (push) Failing after 6m0s
deploy / Update templates on Mailgun (push) Has been skipped
2024-07-09 20:41:50 +03:00
481e4292b5 e2e-title-check-fix 2024-07-09 17:32:48 +03:00
6602f48693 one-article-fix 2024-07-09 17:13:36 +03:00
d255f6f0b1 one-article-fixes 2024-07-09 17:12:13 +03:00
bfc78d9df3 fmt 2024-07-09 15:27:50 +03:00
d4deef9bb6 does not provide reactions where not needed 2024-07-09 15:26:24 +03:00
ec1aacc010 404-fix 2024-07-09 14:58:38 +03:00
878f0036ab styles+error-new-fix 2024-07-09 13:35:57 +03:00
0c000b8b6e fmt 2024-07-09 12:57:40 +03:00
7e3499fbb3 all-topics-styling-fix 2024-07-09 12:56:56 +03:00
e3ac3cc406 meta-refactored 2024-07-09 12:13:13 +03:00
b204204a31 prep 2024-07-08 17:51:55 +03:00
327cd514d6 Merge branch 'router-upgrade' of github.com:Discours/discoursio-webapp into router-upgrade 2024-07-08 17:41:29 +03:00
0abb85ee28 fix-translation 2024-07-08 17:41:04 +03:00
Stepan Vladovskiy
c6816b9271 feat: With e2e tests on CI github worflow. README+. After tuned page need to move auth tests to main test dir 2024-07-08 10:37:56 +00:00
Stepan Vladovskiy
3bfa4adf68 fix: playwright config with baseURL env bar. 2024-07-08 10:24:07 +00:00
Stepan Vladovskiy
32519d5ee9 fix: without auth playwright tests, they are in tests-with-auth dir. testing 2024-07-08 10:10:35 +00:00
Stepan Vladovskiy
9686619243 feat: e2e_tests job is started in case if ci all jobs are successeful. In output return github.event.depolyment ststus for debug. (not a prod mode) 2024-07-08 09:52:32 +00:00
e7bcb4c6d4 fmt 2024-07-07 17:07:11 +03:00
f4f4e80816 authors-all-fix+slug-404 2024-07-07 16:48:53 +03:00
d64f68579c window-fix2 2024-07-06 09:51:25 +03:00
e627bae06c window-fix 2024-07-06 09:41:16 +03:00
fa79a0cd5d localstorage-fix 2024-07-06 09:24:37 +03:00
47622f996b lsusage-fix 2024-07-06 04:35:03 +03:00
c623356893 editpage-fix 2024-07-06 04:29:59 +03:00
90691aa650 profile-view-refactored 2024-07-06 04:25:10 +03:00
6d3629592a vercel-asks 2024-07-06 04:08:16 +03:00
d8e9dd62ce typofix 2024-07-06 04:04:55 +03:00
6ba51ad83e showup-2 2024-07-06 04:03:00 +03:00
2d89f62864 showup 2024-07-06 03:59:01 +03:00
6104079f09 fmt 2024-07-06 01:46:07 +03:00
0323c913b3 home-fetch-all-topics 2024-07-06 01:45:42 +03:00
35f39da99e topics-render-fix2 2024-07-06 01:24:56 +03:00
2d5e9877ee topics-render-fix 2024-07-06 01:24:22 +03:00
de29d435ec intl-rev-2 2024-07-06 00:40:33 +03:00
6fc2d107e9 intl-improve 2024-07-06 00:09:56 +03:00
28c66a564a preprocess-text-fix 2024-07-05 22:40:54 +03:00
a5d15f2808 router fixes 2024-07-05 20:23:07 +03:00
587c2a961a .. 2024-07-05 18:10:19 +03:00
7a499ae2e5 404-route 2024-07-05 17:22:49 +03:00
7c2d97053b utils-refactored 2024-07-05 17:08:12 +03:00
546d1bd743 minor 2024-07-05 16:37:13 +03:00
a767ce7fd1 fmt 2024-07-05 16:35:13 +03:00
bff5de0c8e lang-fix 2024-07-05 16:34:19 +03:00
22f45d8bd9 fmt 2024-07-05 11:12:17 +03:00
3433ba0aac page-titles 2024-07-05 11:11:57 +03:00
6354bb8d47 footer-translation-fix 2024-07-05 10:38:53 +03:00
cc60bb99ce fmt 2024-07-05 10:32:15 +03:00
545c25d305 Merge branch 'router-upgrade' of github.com:Discours/discoursio-webapp into router-upgrade 2024-07-05 10:26:07 +03:00
b5c63fbbb0 .. 2024-07-05 10:26:02 +03:00
e83fd35b85 static-routes-fixes 2024-07-05 10:23:59 +03:00
Stepan Vladovskiy
35e282fb18 fix: merge github workflows in one file, trying github deploy status triger, drink tea and eat cookies 2024-07-04 21:36:20 +00:00
Stepan Vladovskiy
a7e1a1763c feat: AGAIN changed metology of starting e2e tests, now after deployment-status is changed and url is active. No more certs in tests dir, aliluya, and all for github only. Gitea CI was made not by dufok, so let it be 2024-07-04 21:12:34 +00:00
Stepan Vladovskiy
7383433b58 feat: changed metology of starting e2e tests, now after deployment-status is changed and url is active. No more certs in tests dir, aliluya, and all for github only. Gitea CI was made not by dufok, so let it be 2024-07-04 21:09:15 +00:00
Stepan Vladovskiy
7d921cee2f feat: e2e tests are tuned for using CI script not only with localhost. But CI logic ssr not used in CI, need to generate frontend 2024-07-04 12:02:36 +00:00
874654f0eb merged 2024-07-04 10:52:37 +03:00
8b773e5fed refactored-folder-structure+imports-alias 2024-07-04 10:51:15 +03:00
e5950417ea lock-upgrade 2024-07-04 10:33:11 +03:00
Stepan Vladovskiy
82e8d0d00e fix: change output build directory to output/public 2024-07-04 01:22:10 +00:00
Stepan Vladovskiy
1cac091a1c style: refactor tests code to make it clean. But maybe this is can solve the problem in 404 2024-07-04 01:11:10 +00:00
Stepan Vladovskiy
fd4233f907 fix: in package var BASE url for e2e tests ci is correted to https 2024-07-04 00:58:40 +00:00
Stepan Vladovskiy
d321cb21b7 fix: add certs 2024-07-04 00:54:33 +00:00
Stepan Vladovskiy
105b286b37 fix: crzy dev, not use this, certs in tests/certs dir for https deploy e2e server 2024-07-04 00:50:41 +00:00
Stepan Vladovskiy
49d6f17779 feat: playwright tests withability tio test running or not server not lonly in localhost mode 2024-07-04 00:29:23 +00:00
Stepan Vladovskiy
bd4aa9c0ac feat: if start e2e script is has CI var set to any, than playwright not checks local http is run and dont truing to run local dev on 2024-07-04 00:17:48 +00:00
Stepan Vladovskiy
272d9caa67 fix: default value changed to actual ip:port config in packges 2024-07-03 23:49:30 +00:00
Stepan Vladovskiy
f7294c96d1 fix: separated e2e tests in mode of local testing and CI testing 2024-07-03 23:46:46 +00:00
Stepan Vladovskiy
b36983b583 fix: with not global http-serv install. Fingercross and last try' 2024-07-03 23:38:53 +00:00
Stepan Vladovskiy
0e5cc90648 fix: with http-server start on github actions 2024-07-03 23:33:49 +00:00
Stepan Vladovskiy
24f4a22110 fix: need to deploy before e2e. Right now without e2e 2024-07-03 23:18:21 +00:00
Stepan Vladovskiy
9106f45f53 feat: with e2e tests in github workflow node-ci 2024-07-03 22:48:37 +00:00
3e8efdcae5 e2e-merged 2024-07-04 01:33:50 +03:00
0a997bdcae Merge branch 'feature/e2e-tests' into router-upgrade 2024-07-04 01:25:07 +03:00
f7b39687da import-fix 2024-07-04 00:39:29 +03:00
d8144c4a4d fmt 2024-07-04 00:35:53 +03:00
b909f83411 links-minor-fixes 2024-07-04 00:35:34 +03:00
a2514735f9 links-fix 2024-07-04 00:31:39 +03:00
0f8744dc5d thanks-page+route-fix 2024-07-04 00:27:58 +03:00
c9a8c1aa8e routing-structure-fin 2024-07-04 00:25:03 +03:00
3fa235cd16 feed-wip 2024-07-03 20:38:43 +03:00
020768f5b1 guide-static 2024-07-03 10:23:36 +03:00
5711b26644 navheader-fix 2024-07-03 10:03:48 +03:00
e1275b76ea navheader-fix 2024-07-03 10:02:46 +03:00
e4390d83e6 header-links-fix 2024-07-01 18:30:45 +03:00
d2771ac2a4 vercel-adapt 2024-07-01 16:57:30 +03:00
c8ab5ef4f6 solid-start-deploy 2024-07-01 16:57:03 +03:00
2f2c4af161 Merge branch 'router-upgrade' into feature/rating 2024-07-01 16:54:31 +03:00
f55fad3d76 deploy.sh 2024-07-01 16:54:01 +03:00
c1d6b4498a Merge branch 'router-upgrade' into feature/rating 2024-07-01 16:46:49 +03:00
b855e1c11f all-authors-dummy 2024-07-01 16:41:22 +03:00
a8d7a28297 draft abstract topic slug page 2024-07-01 16:27:45 +03:00
deeb7c58cf linted 2024-06-28 18:26:00 +03:00
4e631f5f91 home-fix+topics-wip 2024-06-28 18:05:45 +03:00
a616f97fe4 topics-context-fix 2024-06-28 14:45:25 +03:00
ed145ad447 undouble-main-layout-2 2024-06-28 13:48:11 +03:00
c0c379a918 undouble-main-layout 2024-06-28 13:45:44 +03:00
6837bd3d48 pkg-bump 2024-06-28 13:35:03 +03:00
305bd23f9f home-tops 2024-06-28 10:47:38 +03:00
ae207a02f2 preload-only-body 2024-06-26 14:42:35 +03:00
fcc7d19f59 search-fix2 2024-06-26 11:25:37 +03:00
2bc87b5e99 search-fix 2024-06-26 11:23:21 +03:00
e38fce68c6 ga-integration 2024-06-26 11:22:05 +03:00
a5eaeab5cd langswitch-fix 2024-06-26 01:52:46 +03:00
4c7839aaff apiurl-fix+modal-fmt 2024-06-25 22:56:42 +03:00
c6ae893403 modal-fix, media-query-fix 2024-06-25 22:10:00 +03:00
1d38c12509 connect-page 2024-06-25 20:36:45 +03:00
81a85f7da7 styles-fix 2024-06-25 19:43:02 +03:00
0a193d340a footer-loc-fix 2024-06-25 18:30:45 +03:00
35ab9abe06 Merge branch 'feature/e2e-tests' of github.com:Discours/discoursio-webapp into feature/e2e-tests 2024-06-25 18:17:07 +03:00
9eb30da07c Merge branch 'dev' into feature/e2e-tests 2024-06-25 18:16:26 +03:00
d002a494e8 ftr-fix 2024-06-25 18:14:48 +03:00
71e38d233a fns-back 2024-06-25 17:32:32 +03:00
1f326c7611 try-no-api 2024-06-25 17:22:28 +03:00
876e342f4c edge-try 2024-06-25 17:17:02 +03:00
7ceb9db77f ssr-fix 2024-06-25 16:47:28 +03:00
e697f041ff trig-deploy-2 2024-06-25 15:59:58 +03:00
2f73152829 trig-deploy 2024-06-25 14:52:21 +03:00
f3681a2b1b ci-lint-fix 2024-06-25 14:31:05 +03:00
bc52dcc653 tsc-ok 2024-06-25 14:25:20 +03:00
bbff52019b lockfix-2 2024-06-25 14:09:26 +03:00
7591c3d40f pkgcmd 2024-06-24 22:16:49 +03:00
b2bf8335ca engine 2024-06-24 21:57:24 +03:00
4043b83aca addlock 2024-06-24 21:03:28 +03:00
8d39c74242 gigantic-wip 2024-06-24 20:50:27 +03:00
Stepan Vladovskiy
c1bd614845 style: Readme with worker 1 info 2024-06-19 01:10:27 +00:00
Stepan Vladovskiy
d5e95eb7bd feat: added in gitignore devcontainers 2024-06-19 01:06:59 +00:00
Stepan Vladovskiy
94520bc57b feat: Playwright now can start all from comand line, tests are placed in separate files for use workers, webServer is starts before all workers 2024-06-19 01:06:34 +00:00
Stepan Vladovskiy
6fcd0105de feat: with two script now, instead of one. e2e 2024-06-19 01:03:31 +00:00
Stepan Vladovskiy
250947a8d1 style: with info in README 2024-06-19 01:02:05 +00:00
3e214d0352 nosentry-hotfix-2
Some checks failed
deploy / test (push) Failing after 3m39s
deploy / Update templates on Mailgun (push) Has been skipped
2024-06-17 17:23:49 +03:00
4901a761b3 nosentry-hotfix 2024-06-17 17:23:21 +03:00
9c530c4180 .. 2024-06-17 14:45:58 +03:00
Stepan Vladovskiy
86c091f366 feat: With auth e2e test scenarios, not fully done. With debug mode playwright in main yml for gitea 2024-06-13 08:38:43 -03:00
914f9a6a7b bad-creds-show 2024-06-11 23:06:02 +03:00
8b5b5a609f posts-string-fix 2024-06-09 12:36:47 +03:00
95232f8358 meta-context-fix+counter-strings-fix 2024-06-09 12:08:59 +03:00
7fc67c07a4 client-id-session 2024-06-09 08:58:09 +03:00
Tony
ab61c1e35a
Merge pull request #466 from Discours/hotfix/following
hotfix following status update
2024-06-06 17:50:01 +03:00
2a3fe3b89e Merge branch 'dev' into hotfix/following 2024-06-06 17:46:35 +03:00
Tony
1ce6c9cd63
Merge pull request #463 from Discours/hotfix/expo
Expo fixes
2024-06-06 17:42:27 +03:00
dog
e40df167d8 fix all expo path 2024-06-06 13:25:39 +03:00
1a2d197e0c profile-hotfix 2024-06-06 13:20:10 +03:00
9ba7bfada8 Fixed code style 2024-06-06 13:20:10 +03:00
0e8e8f5d20 Add icons to the feed context popup 2024-06-06 13:20:10 +03:00
fa54d89d53 Table of contents minor fixes 2024-06-06 13:20:10 +03:00
fd76d98d96 editor-autosave-fix 2024-06-06 13:20:10 +03:00
f288cc3388 multierror 2024-06-06 13:20:10 +03:00
2952e15591 login-validations-fixes 2024-06-06 13:20:10 +03:00
8a36b21e6e handle-auth-errors 2024-06-06 13:20:10 +03:00
2cbd54d440 defer-fix 2024-06-06 13:15:54 +03:00
fc8d48436a topic-profile-fixed 2024-06-06 12:27:49 +03:00
c8ebb699e2 render-sync 2024-06-06 12:04:01 +03:00
89a55f7d15 topic-fix 2024-06-06 11:36:07 +03:00
f1b705633c minor 2024-06-06 11:01:56 +03:00
121a71873b domain-fix 2024-06-06 10:59:36 +03:00
9dd9bc7cab debug-session 2024-06-06 10:06:36 +03:00
85e21b0fc3 domains-prepare 2024-06-06 09:59:10 +03:00
79d8835c89 subscounter-fix 2024-06-06 09:34:30 +03:00
d338510454 subscounter-fix 2024-06-06 09:32:35 +03:00
c4be770375 ci-fix-2 2024-06-06 09:18:23 +03:00
e286aee46c ci-fix 2024-06-06 09:16:45 +03:00
4e71a2e748 try-e2e 2024-06-06 09:14:48 +03:00
3ec7420eb8 webkit2e2 2024-06-06 09:10:36 +03:00
d249cb050d e2e tests fine 2024-06-06 09:05:23 +03:00
cada98f135 refactoring: subscribe-term separated to newsletter and following 2024-06-06 08:44:59 +03:00
35cb6c1a44 Merge branch 'fix/topic-header' into hotfix/following 2024-06-06 07:57:18 +03:00
6b0fa86c21 Merge branch 'fix/topic-header' of github.com:Discours/discoursio-webapp into fix/topic-header 2024-06-06 07:56:04 +03:00
48a8e88b15 linted 2024-06-05 20:54:56 +03:00
823ff181c6 Merge branch 'feature/empty-feed' into hotfix/following 2024-06-05 20:49:31 +03:00
2ff29b9e0f profile-fix 2024-06-05 19:31:31 +03:00
603ebbb4a5 session-patch 2024-06-05 19:11:48 +03:00
409e64ddaf lock 2024-06-05 18:11:09 +03:00
2a80dce98a splice-stab-patch 2024-06-02 13:37:54 +03:00
b9705ab8ba merged-empty-header 2024-05-31 15:18:14 +03:00
e6a4db2eb5 Merge remote-tracking branch 'hub/fix/topic-header' into hotfix/following 2024-05-30 21:58:52 +03:00
4fe0d8e841 linted-fix 2024-05-30 21:35:51 +03:00
83656fedb5 warnfix 2024-05-30 21:33:54 +03:00
aa0fc4ad95 About-fix 2024-05-30 13:37:14 +03:00
fc25522516 profile-hotfix 2024-05-27 22:13:55 +03:00
7ba6bb2f97 Merge branch 'feature/empty-feed' of https://github.com/Discours/discoursio-webapp into feature/empty-feed 2024-05-25 19:58:23 +03:00
3f8f3492c8 Fixed placeholder image size 2024-05-25 19:57:14 +03:00
309b07c596 Fixed code style 2024-05-25 19:35:02 +03:00
65d7645374 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into feature/empty-feed 2024-05-25 19:30:35 +03:00
3319bfe973 Fixed subscribers style 2024-05-25 19:27:15 +03:00
8caef5b1f0 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into fix/topic-header 2024-05-24 21:38:33 +03:00
4e7cfa9241 profile-fixes 2024-05-24 18:07:27 +03:00
ac9fd8d313 Merge branch 'dev' into feature/empty-feed 2024-05-24 18:00:41 +03:00
5f194c7a3b Merge branch 'feature/empty-feed' of github.com:Discours/discoursio-webapp into feature/empty-feed 2024-05-24 18:00:30 +03:00
b7ce071ee9 Merge branch 'dev' of github.com:Discours/discoursio-webapp into hotfix/following 2024-05-24 17:59:29 +03:00
9a42086f08 auth-minor-fixes 2024-05-24 17:59:15 +03:00
Tony
ba6d29ef59
Merge pull request #474 from Discours/hotfix/context-popup-icons
Add icons to the feed context popup
2024-05-22 14:23:31 +03:00
b53aa85337 hardcoded-config-fix 2024-05-21 16:51:36 +03:00
144db5c0e8 hardcoded-config 2024-05-21 16:51:13 +03:00
36ca02dc1b hardcoded-apiurl 2024-05-21 14:13:37 +03:00
e327238678 result-fix6 2024-05-21 04:49:49 +03:00
7b546ecbc8 stab 2024-05-21 04:20:08 +03:00
46b80a3182 rlbk 2024-05-21 04:11:47 +03:00
068fadd19b testdomains 2024-05-21 04:10:34 +03:00
0b01f41edc redeploy 2024-05-21 04:03:29 +03:00
79f94876f0 subs-refactoring 2024-05-21 02:15:52 +03:00
be4d16b1a5 Merge branch 'fix/topic-header' of https://github.com/Discours/discoursio-webapp into fix/topic-header 2024-05-21 00:36:00 +03:00
27d3496423 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into fix/topic-header 2024-05-21 00:31:38 +03:00
2e6e1abad7 Fixed banners style 2024-05-21 00:26:55 +03:00
0631ecf8c9 Fixed line breaks 2024-05-20 23:51:33 +03:00
417c57e338 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into feature/empty-feed 2024-05-20 23:41:56 +03:00
8397facc5e Fixed code style 2024-05-20 23:39:59 +03:00
5bde0bc7d0 Add icons to the feed context popup 2024-05-20 23:37:56 +03:00
bec333f7c3 packaging-upgrade 2024-05-20 18:53:48 +03:00
652d0b647a refactoring:following 2024-05-20 14:16:54 +03:00
a25d50d99b sorted, subscribe -> follow 2024-05-20 13:48:38 +03:00
0a5e5eca95 tiptap deprecated warning fix 2024-05-20 11:31:55 +03:00
58ec520c3e Merge branch 'dev' into hotfix/following 2024-05-20 11:17:06 +03:00
Tony
fe2db946b4
Merge pull request #473 from Discours/hotfix/contents
Table of contents minor fixes
2024-05-20 10:51:04 +03:00
9b76a52430 editor-autosave-fix 2024-05-19 02:22:19 +03:00
8f330ab914 multierror 2024-05-19 01:41:50 +03:00
319136474e Table of contents minor fixes 2024-05-19 01:38:56 +03:00
7e2f2d5192 Fixed article cover animation 2024-05-19 01:26:07 +03:00
38899ad8cb login-validations-fixes 2024-05-19 01:18:55 +03:00
59eaf3837d handle-auth-errors 2024-05-19 01:14:28 +03:00
e4f7675606 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into hotfix/expo 2024-05-19 01:04:49 +03:00
22f0c9052d auth-errors-fix 2024-05-19 01:04:18 +03:00
f22d10a535 Placeholders fixes 2024-05-19 01:03:06 +03:00
135e0d215f error-catch 2024-05-19 00:55:30 +03:00
4a55271a79 edit-hotfix 2024-05-19 00:48:58 +03:00
18a23ee0f8 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into feature/empty-feed 2024-05-18 23:54:08 +03:00
60664581bd logfix 2024-05-18 20:50:27 +03:00
95c4d777b2 connect-fox 2024-05-18 20:43:20 +03:00
c8517c85c6 edit-hotfix 2024-05-18 20:36:22 +03:00
18bd07291d edit-effect 2024-05-18 20:36:06 +03:00
0b06e41670 authors added 2024-05-18 20:16:45 +03:00
af806590fb postmerge 2024-05-18 19:55:24 +03:00
2e82eec82c Merge branch 'feature/empty-feed' into fix/topic-header 2024-05-18 19:47:01 +03:00
74f7469c7d topic followers + shouts counter 2024-05-18 19:45:36 +03:00
0ecbf07ef6 Merge branch 'dev' into feature/empty-feed 2024-05-18 17:45:51 +03:00
40b2c80b54 Merge branch 'dev' into fix/topic-header 2024-05-18 17:17:22 +03:00
Tony
6c99aa1adf
Merge pull request #469 from Discours/fix/all-topics-page
Fix/all topics page
2024-05-18 17:15:37 +03:00
de7afc2691 Merge branch 'dev' into fix/all-topics-page 2024-05-18 17:13:04 +03:00
Tony
8adff4629f
Merge pull request #468 from Discours/fix/popups
Enlarge profile popup
2024-05-18 17:09:44 +03:00
f14ba82a29 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into feature/empty-feed 2024-05-18 17:07:53 +03:00
Tony
2c5ba6edab
Merge pull request #470 from Discours/hotfix/header-navigation-style
Hotfix/header navigation style
2024-05-18 17:04:22 +03:00
Tony
0fa881dd6e
Merge pull request #471 from Discours/hotfix/scroll-to-comments
Hotfix/scroll to comments
2024-05-18 17:02:24 +03:00
412360f87f Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into hotfix/scroll-to-comments 2024-05-18 16:59:50 +03:00
39d555c490 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into hotfix/header-navigation-style
 Conflicts:
	src/components/Nav/HeaderAuth.tsx
2024-05-18 16:58:10 +03:00
Tony
e16f20db81
Merge pull request #467 from Discours/hotfix/snackbar
Fixed snackbar position
2024-05-18 16:52:01 +03:00
c61ad86234 sse 2024-05-18 16:44:57 +03:00
3f96850948 connect-logs-fix 2024-05-18 16:44:57 +03:00
150903ecbd update-token-fix 2024-05-18 16:44:57 +03:00
aa8d5973ee swiper-fix 2024-05-18 16:44:57 +03:00
ed2b4ebfbf stab-hotfix 2024-05-18 16:44:57 +03:00
Ilya Y
cb5c78790b Feature/profile settings page (#452)
* Init change password form
2024-05-18 16:44:57 +03:00
0bb4465e87 Merge branch 'dev' into hotfix/following 2024-05-18 16:38:16 +03:00
e7f17c3cc9 Merge branch 'dev' into hotfix/following 2024-05-18 15:10:30 +03:00
5bbd29ddd2 Fixed style on hovered article card 2024-05-17 23:45:53 +03:00
49ff3ccbe3 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into hotfix/expo 2024-05-17 23:28:57 +03:00
c261c8cad0 nopublic 2024-05-16 17:22:26 +03:00
399deaec86 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into feature/empty-feed 2024-05-11 20:34:47 +03:00
7d69c55963 Code style fixes 2024-05-11 20:33:40 +03:00
0664b5c933 Placeholders on the user profile page 2024-05-11 20:27:57 +03:00
90c47a0177 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into fix/topic-header 2024-05-10 20:17:59 +03:00
9542fd0209 Fixes props type in the Placeholder.tsx 2024-05-10 20:14:51 +03:00
be7e31dbd2 Feed page fixes 2024-05-10 20:09:37 +03:00
e53181ef2d Links in the placeholders for authorized users 2024-05-10 19:57:34 +03:00
6a789d4a0e Feed placeholders 2024-05-10 19:52:13 +03:00
9ec36168ae Topic header fixes 2024-05-10 17:14:06 +03:00
1e33d18c54 Expo fixes 2024-05-10 15:59:21 +03:00
7d490c91aa Fixed snackbar position 2024-05-09 12:15:39 +03:00
2fa5a98933 Enlarge profile popup 2024-05-09 12:04:55 +03:00
0cbd3aedba following-debug 2024-05-05 20:04:47 +03:00
3bb0044f6e Code style fixes 2024-05-03 00:06:03 +03:00
f0b2ef0ef9 Fixed header icons 2024-05-03 00:02:48 +03:00
ilya-bkv
004baba591 Merge remote-tracking branch 'origin/fix/all-topics-page' into fix/all-topics-page 2024-05-02 17:02:09 +03:00
ilya-bkv
3539ab500e Merge remote-tracking branch 'origin/dev' into fix/all-topics-page 2024-05-02 16:58:34 +03:00
11b932c30e Fixed scrolling to comments 2024-04-30 18:43:38 +03:00
12ca2b9a97 Fixed author badge in the following modal 2024-04-30 18:14:10 +03:00
e1484e0aa9 Revert styles for user descriptions 2024-04-30 16:56:39 +03:00
4c787fe49c Topics list fixes 2024-04-30 15:26:53 +03:00
ece5f2505f Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into fix/all-topics-page 2024-04-26 09:19:33 +03:00
7c9ecd1e3a Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into fix/all-topics-page 2024-04-25 23:38:04 +03:00
3174c9d83c Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into fix/all-topics-page 2024-04-24 22:31:11 +03:00
24cd1c54a8 Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into fix/all-topics-page 2024-04-01 23:32:43 +03:00
6d30964a8b linted 2024-04-01 23:31:16 +03:00
a95686d12b Fixed topics page 2024-03-27 23:10:49 +03:00
589 changed files with 32915 additions and 18643 deletions

View File

@ -18,10 +18,10 @@ jobs:
run: npm install --global --save-exact @biomejs/biome run: npm install --global --save-exact @biomejs/biome
- name: Lint with Biome - name: Lint with Biome
run: npx biome ci . run: npx @biomejs/biome ci
- name: Lint styles - name: Lint styles
run: npm run lint:styles run: npx stylelint **/*.{scss,css}
- name: Check types - name: Check types
run: npm run typecheck run: npm run typecheck
@ -29,6 +29,15 @@ jobs:
- name: Test production build - name: Test production build
run: npm run build run: npm run build
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npm run e2e
env:
BASE_URL: ${{ github.event.deployment_status.target_url }}
DEBUG: pw:api
email-templates: email-templates:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Update templates on Mailgun name: Update templates on Mailgun

View File

@ -1,42 +1,58 @@
name: "deploy" name: "CI and E2E Tests"
on: [push] on:
push:
deployment_status:
types: [success]
jobs: jobs:
test: ci:
if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
- name: Install dependencies - name: Install dependencies
run: npm i
- name: Install CI checks
run: npm ci run: npm ci
- name: Check types - name: Check types
run: npm run typecheck run: npm run typecheck
- name: Lint with Biome - name: Lint with Biome
run: npm run check:code run: npx @biomejs/biome check src/.
- name: Lint styles - name: Lint styles
run: npm run lint:styles run: npx stylelint **/*.{scss,css}
- name: Test production build - name: Test production build
run: npm run build run: npm run build
e2e: e2e_tests:
timeout-minutes: 60 needs: ci
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event.deployment_status.state == 'success'
steps: steps:
- name: Debug event info
run: |
echo "Event Name: ${{ github.event_name }}"
echo "Deployment Status: ${{ github.event.deployment_status.state }}"
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm install
- name: Install Playwright - name: Wait for deployment to be live
run: npx playwright install --with-deps run: |
echo "Waiting for Vercel deployment to be live..."
until curl -sSf https://testing.discours.io > /dev/null; do
printf '.'
sleep 10
done
- name: Install Playwright and dependencies
run: npm run e2e:install
- name: Run Playwright tests - name: Run Playwright tests
run: npx playwright test run: npm run e2e:tests:ci
env: env:
BASE_URL: ${{ github.event.deployment_status.target_url }} BASE_URL: https://testing.discours.io
continue-on-error: true
- name: Report test result if failed
if: failure()
run: echo "E2E tests failed. Please review the logs."

11
.gitignore vendored
View File

@ -1,8 +1,9 @@
.devcontainer
.pnpm-store
dist/ dist/
node_modules/ node_modules/
npm-debug.log* npm-debug.log*
pnpm-debug.log* pnpm-debug.log*
.vscode
.env .env
.env.production .env.production
.DS_Store .DS_Store
@ -23,4 +24,10 @@ bun.lockb
/playwright/.cache/ /playwright/.cache/
/plawright-report/ /plawright-report/
target target
.venv .github/dependabot.yml
.output
.vinxi
*.pem
edge.*
.vscode/settings.json
storybook-static

View File

@ -1,5 +0,0 @@
{
"*.{js,ts,cjs,mjs,d.mts,jsx,tsx,json,jsonc}": [
"npx @biomejs/biome check ./src && tsc"
]
}

49
.storybook/main.ts Normal file
View File

@ -0,0 +1,49 @@
import type { FrameworkOptions, StorybookConfig } from 'storybook-solidjs-vite'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
'@storybook/addon-themes',
'storybook-addon-sass-postcss'
],
framework: {
name: 'storybook-solidjs-vite',
options: {
builder: {
viteConfigPath: './vite.config.ts'
}
} as FrameworkOptions
},
docs: {
autodocs: 'tag'
},
viteFinal: (config) => {
if (config.build) {
config.build.sourcemap = true
config.build.minify = process.env.NODE_ENV === 'production'
}
if (config.css) {
config.css.preprocessorOptions = {
scss: {
silenceDeprecations: ['mixed-decls'],
additionalData: '@import "~/styles/imports";\n',
includePaths: ['./public', './src/styles', './node_modules']
}
}
}
return config
},
previewHead: (head) => `
${head}
<style>
body {
transition: none !important;
}
</style>
`
}
export default config

34
.storybook/preview.ts Normal file
View File

@ -0,0 +1,34 @@
import { withThemeByClassName } from '@storybook/addon-themes'
import '../src/styles/app.scss'
const preview = {
parameters: {
themes: {
default: 'light',
list: [
{ name: 'light', class: '', color: '#f8fafc' },
{ name: 'dark', class: 'dark', color: '#0f172a' }
]
},
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/
}
}
}
}
export default preview
export const decorators = [
withThemeByClassName({
themes: {
light: '',
dark: 'dark'
},
defaultTheme: 'light',
parentSelector: 'body'
})
]

23
.storybook/test-runner.ts Normal file
View File

@ -0,0 +1,23 @@
import type { Page } from '@playwright/test'
import type { TestRunnerConfig } from '@storybook/test-runner'
import { checkA11y, injectAxe } from 'axe-playwright'
/*
* See https://storybook.js.org/docs/react/writing-tests/test-runner#test-hook-api-experimental
* to learn more about the test-runner hooks API.
*/
const a11yConfig = {
async preRender(page: Page) {
await injectAxe(page)
},
async postRender(page: Page) {
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: {
html: true
}
})
}
} as TestRunnerConfig
module.exports = a11yConfig

View File

@ -1,2 +1,6 @@
.vercel/ node_modules
dist/ dist/
storybook-static
.output
.vinxi
.vercel

View File

@ -1,34 +1,73 @@
{ {
"extends": ["stylelint-config-standard-scss"], "defaultSeverity": "warning",
"extends": ["stylelint-config-standard-scss", "stylelint-config-recommended"],
"plugins": ["stylelint-order", "stylelint-scss"], "plugins": ["stylelint-order", "stylelint-scss"],
"rules": { "rules": {
"keyframes-name-pattern": null, "annotation-no-unknown": [
"declaration-block-no-redundant-longhand-properties": null,
"selector-class-pattern": null,
"no-descending-specificity": null,
"scss/function-no-unknown": null,
"scss/no-global-function-names": null,
"function-url-quotes": null,
"font-family-no-missing-generic-family-keyword": null,
"order/order": ["custom-properties", "declarations"],
"scss/dollar-variable-pattern": [
"^[a-z][a-zA-Z]+$",
{
"ignore": "global"
}
],
"selector-pseudo-class-no-unknown": [
true, true,
{ {
"ignorePseudoClasses": ["global", "export"] "ignoreAnnotations": ["default"]
} }
], ],
"at-rule-no-unknown": null,
"declaration-block-no-redundant-longhand-properties": null,
"font-family-no-missing-generic-family-keyword": null,
"function-no-unknown": [
true,
{
"ignoreFunctions": ["divide", "transparentize"]
}
],
"function-url-quotes": null,
"keyframes-name-pattern": null,
"no-descending-specificity": null,
"order/order": [
{
"type": "at-rule",
"name": "include"
},
"custom-properties",
"declarations",
"rules"
],
"property-no-vendor-prefix": [ "property-no-vendor-prefix": [
true, true,
{ {
"ignoreProperties": ["box-decoration-break"] "ignoreProperties": ["box-decoration-break"]
} }
],
"scss/at-function-pattern": null,
"scss/at-mixin-pattern": null,
"scss/dollar-variable-colon-space-after": "always-single-line",
"scss/dollar-variable-colon-space-before": "never",
"scss/dollar-variable-pattern": [
"^[a-z][a-zA-Z]+$",
{
"ignore": "global"
}
],
"scss/double-slash-comment-empty-line-before": [
"always",
{
"except": ["first-nested"],
"ignore": ["between-comments", "stylelint-commands"]
}
],
"scss/double-slash-comment-whitespace-inside": "always",
"scss/function-no-unknown": null,
"scss/no-duplicate-dollar-variables": null,
"scss/no-duplicate-mixins": null,
"scss/no-global-function-names": null,
"scss/operator-no-newline-after": null,
"scss/operator-no-newline-before": null,
"scss/operator-no-unspaced": null,
"scss/percent-placeholder-pattern": null,
"selector-class-pattern": null,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global", "export"]
}
] ]
}, }
"defaultSeverity": "warning"
} }

3
.vscode/extension.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome", "stylelint.vscode-stylelint", "wayou.vscode-todo-highlight"]
}

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "always"
}
}

57
README.en.md Normal file
View File

@ -0,0 +1,57 @@
## Development setup recommendations
### How to start
Use `bun i`, `npm i`, `pnpm i` or `yarn` to install packages.
### Config of variables
- Use `.env` file to setup your own development environment
- Env vars with prefix `PUBLIC_` are widely used in `/src/utils/config.ts`
### Useful commands
run checks, fix styles, imports, formatting and autofixable linting errors:
```
bun run typecheck
bun run fix
```
## End-to-End (E2E) Tests
This directory contains end-to-end tests. These tests are written using [Playwright](https://playwright.dev/)
### Structure
- `/tests/*`: This directory contains the test files.
- `/playwright.config.ts`: This is the configuration file for Playwright.
### Getting Started
Follow these steps:
1. **Install dependencies**: Run `npm run e2e:install` to install the necessary dependencies for running the tests.
2. **Run the tests**: After using `npm run e2e:tests`.
### Additional Information
If workers is no needed use:
- `npx playwright test --project=webkit --workers 4`
For more information on how to write tests using Playwright - [Playwright documentation](https://playwright.dev/docs/intro).
### 🚀 Tests in CI Mode
Tests are executed within a GitHub workflow. We organize our tests into two main directories:
- `tests`: Contains tests that do not require authentication.
- `tests-with-auth`: Houses tests that interact with authenticated parts of the application.
🔧 **Configuration:**
Playwright is configured to utilize the `BASE_URL` environment variable. Ensure this is properly set in your CI configuration to point to the correct environment.
📝 **Note:**
After pages have been adjusted to work with authentication, all tests should be moved to the `tests` directory to streamline the testing process.

View File

@ -1,31 +1,57 @@
## How to start [English](README.en.md)
```
npm install
npm start
```
## Useful commands ## Рекомендации по настройке разработки
run checks with your favorite package manager: npm, yarn, pnpm or bun ### Как начать
Используйте `bun i`, `npm i`, `pnpm i` или `yarn`, чтобы установить пакеты.
### Настройка переменных
- Используйте файл `.env` для настройки переменных собственной среды разработки.
- Переменные окружения с префиксом `PUBLIC_` широко используются в `/src/utils/config.ts`.
### Полезные команды
Запуск проверки соответствия типов и автоматически исправить ошибки стилей, порядок импорта, форматирование:
``` ```
npm run check bun run typecheck
bun run fix
``` ```
fix styles, imports, formatting and autofixable linting errors: ## End-to-End (E2E) тесты
``` End-to-end тесты написаны с использованием [Playwright](https://playwright.dev/).
npm run fix
```
## Code generation ### Структура
generate new SolidJS component: - `/tests/*`: содержит файлы тестов
``` - `/playwright.config.ts`: конфиг для Playwright
npm run hygen component new NewComponentName
```
generate new SolidJS context: ### Начало работы
```
npm run hygen context new NewContextName Следуйте этим шагам:
```
1. **Установите зависимости**: Запустите `npm run e2e:install`, чтобы установить необходимые зависимости для выполнения тестов.
2. **Запустите тесты**: После установки зависимостей используйте `npm run e2e:tests`.
### Дополнительная информация
Для параллельного исполнения:
- `npx playwright test --project=webkit --workers 4`
Для получения дополнительной информации о написании тестов с использованием Playwright - [Документация Playwright](https://playwright.dev/docs/intro).
### 🚀 Тесты в режиме CI
Тесты выполняются в рамках GitHub workflow из папки `tests`
🔧 **Конфигурация:**
Playwright настроен на использование переменной окружения `BASE_URL`. Убедитесь, что она правильно установлена в вашей конфигурации CI для указания на правильную среду.
📝 **Примечание:**
После того как страницы были настроены для работы с аутентификацией, все тесты должны быть перемещены в директорию `tests` для упрощения процесса тестирования.

View File

@ -1,32 +0,0 @@
import { renderPage } from 'vike/server'
export const config = {
runtime: 'edge',
}
export default async function handler(request, _response) {
const { url, cookies } = request
const pageContext = await renderPage({ urlOriginal: url, cookies })
const { httpResponse, errorWhileRendering, is404 } = pageContext
if (errorWhileRendering && !is404) {
console.error(errorWhileRendering)
return new Response('', { status: 500 })
}
if (!httpResponse) {
return new Response()
}
const { body, statusCode, headers: headersArray } = httpResponse
const headers = headersArray.reduce((acc, [name, value]) => {
acc[name] = value
return acc
}, {})
headers['Cache-Control'] = 's-maxage=1, stale-while-revalidate'
return new Response(body, { status: statusCode, headers })
}

View File

@ -1,10 +1,8 @@
const formData = require('form-data') import FormData from 'form-data'
const Mailgun = require('mailgun.js') import Mailgun from 'mailgun.js'
const mailgun = new Mailgun(formData) const mailgun = new Mailgun(FormData)
const mg = mailgun.client({ username: 'discoursio', key: process.env.MAILGUN_API_KEY })
const { MAILGUN_API_KEY, MAILGUN_DOMAIN } = process.env
const mg = mailgun.client({ username: 'discoursio', key: MAILGUN_API_KEY })
export default async function handler(req, res) { export default async function handler(req, res) {
const { contact, subject, message } = req.body const { contact, subject, message } = req.body
@ -15,11 +13,11 @@ export default async function handler(req, res) {
from: 'Discours Feedback Robot <robot@discours.io>', from: 'Discours Feedback Robot <robot@discours.io>',
to: 'welcome@discours.io', to: 'welcome@discours.io',
subject, subject,
text, text
} }
try { try {
const response = await mg.messages.create(MAILGUN_DOMAIN, data) const response = await mg.messages.create('discours.io', data)
console.log('Email sent successfully!', response) console.log('Email sent successfully!', response)
res.status(200).json({ result: 'great success' }) res.status(200).json({ result: 'great success' })
} catch (error) { } catch (error) {

View File

@ -1,10 +1,8 @@
const formData = require('form-data') import FormData from 'form-data'
const Mailgun = require('mailgun.js') import Mailgun from 'mailgun.js'
const mailgun = new Mailgun(formData) const mailgun = new Mailgun(FormData)
const mg = mailgun.client({ username: 'discoursio', key: process.env.MAILGUN_API_KEY })
const { MAILGUN_API_KEY } = process.env
const mg = mailgun.client({ username: 'discoursio', key: MAILGUN_API_KEY })
export default async (req, res) => { export default async (req, res) => {
const { email } = req.body const { email } = req.body
@ -13,18 +11,18 @@ export default async (req, res) => {
const response = await mg.lists.members.createMember('newsletter@discours.io', { const response = await mg.lists.members.createMember('newsletter@discours.io', {
address: email, address: email,
subscribed: true, subscribed: true,
upsert: 'yes', upsert: 'yes'
}) })
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
message: 'Email was added to newsletter list', message: 'Email was added to newsletter list',
response: JSON.stringify(response), response: JSON.stringify(response)
}) })
} catch (error) { } catch (error) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: error.message, message: error.message
}) })
} }
} }

23
app.config.ts Normal file
View File

@ -0,0 +1,23 @@
import { SolidStartInlineConfig, defineConfig } from '@solidjs/start/config'
import viteConfig, { isDev } from './vite.config'
const isVercel = Boolean(process.env.VERCEL)
const isNetlify = Boolean(process.env.NETLIFY)
const isBun = Boolean(process.env.BUN)
const preset = isNetlify ? 'netlify' : isVercel ? 'vercel_edge' : isBun ? 'bun' : 'node'
console.info(`[app.config] solid-start preset {> ${preset} <}`)
export default defineConfig({
nitro: {
timing: true
},
ssr: true,
server: {
preset,
port: 3000,
https: true
},
devOverlay: isDev,
vite: viteConfig
} as SolidStartInlineConfig)

View File

@ -1,16 +1,18 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json",
"files": { "files": {
"include": ["*.tsx", "*.ts", "*.js", "*.json"], "include": ["*.tsx", "*.ts", "*.js", "*.json"],
"ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"] "ignore": ["./dist", "./node_modules", ".husky", "docs", "gen", "*.gen.ts", "*.d.ts"]
}, },
"vcs": { "vcs": {
"defaultBranch": "dev", "defaultBranch": "dev",
"useIgnoreFile": true "useIgnoreFile": true,
"enabled": true,
"clientKind": "git"
}, },
"organizeImports": { "organizeImports": {
"enabled": true, "enabled": true,
"ignore": ["./api", "./gen"] "ignore": ["./gen"]
}, },
"formatter": { "formatter": {
"indentStyle": "space", "indentStyle": "space",
@ -22,10 +24,10 @@
"formatter": { "formatter": {
"semicolons": "asNeeded", "semicolons": "asNeeded",
"quoteStyle": "single", "quoteStyle": "single",
"trailingComma": "all",
"enabled": true, "enabled": true,
"jsxQuoteStyle": "double", "jsxQuoteStyle": "double",
"arrowParentheses": "always" "arrowParentheses": "always",
"trailingCommas": "none"
} }
}, },
"linter": { "linter": {
@ -40,7 +42,9 @@
"noExcessiveCognitiveComplexity": "off" "noExcessiveCognitiveComplexity": "off"
}, },
"correctness": { "correctness": {
"useHookAtTopLevel": "off" "useHookAtTopLevel": "off",
"useImportExtensions": "off",
"noUndeclaredDependencies": "off"
}, },
"a11y": { "a11y": {
"useHeadingContent": "off", "useHeadingContent": "off",
@ -52,7 +56,8 @@
"useAltText": "off", "useAltText": "off",
"useButtonType": "off", "useButtonType": "off",
"noRedundantAlt": "off", "noRedundantAlt": "off",
"noSvgWithoutTitle": "off" "noSvgWithoutTitle": "off",
"noLabelWithoutControl": "off"
}, },
"nursery": { "nursery": {
"useImportRestrictions": "off" "useImportRestrictions": "off"
@ -61,14 +66,18 @@
"noBarrelFile": "off" "noBarrelFile": "off"
}, },
"style": { "style": {
"noNonNullAssertion": "off",
"noNamespaceImport": "warn",
"useBlockStatements": "off", "useBlockStatements": "off",
"noImplicitBoolean": "off", "noImplicitBoolean": "off",
"useNamingConvention": "off", "useNamingConvention": "off",
"useImportType": "off", "useImportType": "off",
"noDefaultExport": "off", "noDefaultExport": "off",
"useFilenamingConvention": "off" "useFilenamingConvention": "off",
"useExplicitLengthCheck": "off"
}, },
"suspicious": { "suspicious": {
"noConsole": "off",
"noConsoleLog": "off", "noConsoleLog": "off",
"noAssignInExpressions": "off" "noAssignInExpressions": "off"
} }

View File

@ -11,7 +11,7 @@ generates:
skipTypename: true skipTypename: true
useTypeImports: true useTypeImports: true
outputPath: './src/graphql/types/chat.gen.ts' outputPath: './src/graphql/types/chat.gen.ts'
# namingConvention: lodash#pascalCase # namingConvention: change-case#CamelCase # for generated types
# Generate types for core # Generate types for core
src/graphql/schema/core.gen.ts: src/graphql/schema/core.gen.ts:
@ -24,7 +24,4 @@ generates:
skipTypename: true skipTypename: true
useTypeImports: true useTypeImports: true
outputPath: './src/graphql/types/core.gen.ts' outputPath: './src/graphql/types/core.gen.ts'
# namingConvention: lodash#pascalCase # namingConvention: change-case#CamelCase # for generated types
hooks:
afterAllFileWrite:
- prettier --ignore-path .gitignore --write --plugin-search-dir=. src/graphql/schema/*.gen.ts

View File

@ -1,66 +0,0 @@
@startuml
actor User
participant Browser
participant Vercel
participant article.page.server.ts
participant Solid
participant Store
User -> Browser: discours.io
activate Browser
Browser -> Vercel: GET <slug>
activate Vercel
Vercel -> article.page.server.ts: render
activate article.page.server.ts
article.page.server.ts -> apiClient: getArticle({ slug })
activate apiClient
apiClient -> DB: query: articleBySlug
activate DB
DB --> apiClient: response
deactivate DB
apiClient --> article.page.server.ts: article data
deactivate apiClient
article.page.server.ts -> Solid: render <ArticlePage article={article} />
activate Solid
Solid -> Store: useCurrentArticleStore(article)
activate Store
Store -> Store: create store with initial data (server)
Store --> Solid: currentArticle
deactivate Store
Solid -> Solid: render component
Solid --> article.page.server.ts: rendered component
deactivate Solid
article.page.server.ts --> Vercel: rendered page
Vercel -> Vercel: save rendered page to CDN
deactivate article.page.server.ts
Vercel --> Browser: rendered page
deactivate Vercel
Browser --> User: rendered page
deactivate Browser
Browser -> Browser: load client scripts
Browser -> Solid: render <ArticlePage article={article} />
Solid -> Store: useCurrentArticleStore(article)
activate Store
Store -> Store: create store with initial data (client)
Store --> Solid: currentArticle
deactivate Store
Solid -> Solid: render component (no changes)
Solid -> Solid: onMount
Solid -> Store: loadArticleComments
activate Store
Store -> apiClient: getArticleComments
activate apiClient
apiClient -> DB: query: getReactions
activate DB
DB --> apiClient: response
deactivate DB
apiClient --> Store: comments data
deactivate apiClient
Store -> Store: update store
Store --> Solid: store updated
deactivate Store
Solid -> Solid: render comments
Solid --> Browser: rendered comments
Browser --> User: comments
@enduml

View File

@ -1,40 +0,0 @@
@startuml
actor User
participant Browser
participant Server
User -> Browser: discours.io
activate Browser
Browser -> Server: GET\nquery { lng }\ncookies { lng }
opt lng in query
Server -> Server: lng = lng from query
else no lng in query
opt lng in cookies
Server -> Server: lng = lng from cookies
else no lng in cookies
Server -> Server: lng = 'ru'
end opt
end opt
note right
_dafault.page.server.ts render
end note
opt i18next is not initialized
Server -> Server: initialize i18next with lng
else i18next not initialized
Server -> Server: change i18next language to lng
end opt
note right
all resources loaded synchronously
end note
Server --> Browser: pageContext { lng }
Browser -> Browser: init client side i18next with http backend
activate Browser
Browser -> Server: get translations for current language
Server --> Browser: translations JSON
deactivate Browser
Browser -> Browser: render page
Browser --> User: rendered page
deactivate Browser
@enduml

View File

@ -1,24 +0,0 @@
@startuml
actor User
participant Browser
participant Server
User -> Browser: discours.io
activate Browser
Browser -> Server: GET
activate Server
Server -> Server: resolve route
note right
based on routes from
*.page.route.ts files
end note
Server -> Server: some.page.server.ts onBeforeRender
Server -> Server: _default.page.server.tsx render
Server --> Browser: pageContent
deactivate Server
Browser -> Browser: _default.page.client.tsx render(pageContext)
Browser --> User: rendered page
deactivate Browser
@enduml

View File

@ -1,18 +0,0 @@
---
to: src/components/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.tsx
---
import { clsx } from 'clsx'
import styles from './<%= h.changeCase.pascal(name) %>.module.scss'
type Props = {
class?: string
}
export const <%= h.changeCase.pascal(name) %> = (props: Props) => {
return (
<div class={clsx(styles.<%= h.changeCase.pascal(name) %>, props.class)}>
<%= h.changeCase.pascal(name) %>
</div>
)
}

View File

@ -1,4 +0,0 @@
---
to: src/components/<%= h.changeCase.pascal(name) %>/index.ts
---
export { <%= h.changeCase.pascal(name) %> } from './<%= h.changeCase.pascal(name) %>'

View File

@ -1,7 +0,0 @@
---
to: src/components/<%= h.changeCase.pascal(name) %>/<%= h.changeCase.pascal(name) %>.module.scss
---
.<%= h.changeCase.pascal(name) %> {
display: block;
}

View File

@ -1,24 +0,0 @@
---
to: src/context/<%= h.changeCase.camel(name) %>.tsx
---
import type { Accessor, JSX } from 'solid-js'
import { createContext, createSignal, useContext } from 'solid-js'
type <%= h.changeCase.pascal(name) %>ContextType = {
}
const <%= h.changeCase.pascal(name) %>Context = createContext<<%= h.changeCase.pascal(name) %>ContextType>()
export function use<%= h.changeCase.pascal(name) %>() {
return useContext(<%= h.changeCase.pascal(name) %>Context)
}
export const <%= h.changeCase.pascal(name) %>Provider = (props: { children: JSX.Element }) => {
const actions = {
}
const value: <%= h.changeCase.pascal(name) %>ContextType = { ...actions }
return <<%= h.changeCase.pascal(name) %>Context.Provider value={value}>{props.children}</<%= h.changeCase.pascal(name) %>Context.Provider>
}

View File

@ -1,5 +0,0 @@
---
message: |
hygen {bold generator new} --name [NAME] --action [ACTION]
hygen {bold generator with-prompt} --name [NAME] --action [ACTION]
---

View File

@ -1,16 +0,0 @@
---
to: gen/<%= name %>/<%= action || 'new' %>/hello.ejs.t
---
---
to: app/hello.js
---
const hello = ```
Hello!
This is your first hygen template.
Learn what it can do here:
https://github.com/jondot/hygen
```
console.log(hello)

View File

@ -1,16 +0,0 @@
---
to: gen/<%= name %>/<%= action || 'new' %>/hello.ejs.t
---
---
to: app/hello.js
---
const hello = ```
Hello!
This is your first prompt based hygen template.
Learn what it can do here:
https://github.com/jondot/hygen
```
console.log(hello)

View File

@ -1,14 +0,0 @@
---
to: gen/<%= name %>/<%= action || 'new' %>/prompt.js
---
// see types of prompts:
// https://github.com/enquirer/enquirer/tree/master/examples
//
module.exports = [
{
type: 'input',
name: 'message',
message: "What's your message?"
}
]

View File

@ -1,4 +0,0 @@
---
setup: <%= name %>
force: true # this is because mostly, people init into existing folders is safe
---

21000
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,144 +1,151 @@
{ {
"name": "discoursio-webapp", "name": "discoursio-webapp",
"version": "0.9.2",
"private": true, "private": true,
"license": "MIT", "version": "0.9.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "vite build", "dev": "vinxi dev",
"check": "npm run lint && npm run typecheck", "build": "vinxi build",
"start": "vinxi start",
"codegen": "graphql-codegen", "codegen": "graphql-codegen",
"deploy": "graphql-codegen && npm run typecheck && vite build && vercel", "e2e": "E2E=1 npm run e2e:tests",
"dev": "vite", "e2e:tests": "npx playwright test --project=webkit",
"e2e": "npx playwright test --project=chromium", "e2e:tests:ci": "CI=true npx playwright test --project=webkit",
"fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix", "e2e:install": "npx playwright install webkit && npx playwright install-deps ",
"fix": "npx @biomejs/biome check . --fix && stylelint **/*.{scss,css} --fix",
"format": "npx @biomejs/biome format src/. --write", "format": "npx @biomejs/biome format src/. --write",
"hygen": "HYGEN_TMPLS=gen hygen",
"postinstall": "npm run codegen && npx patch-package", "postinstall": "npm run codegen && npx patch-package",
"check:code": "npx @biomejs/biome check src --log-kind=compact --verbose",
"check:code:fix": "npx @biomejs/biome check . --apply",
"lint": "npm run lint:code && stylelint **/*.{scss,css}",
"lint:code": "npx @biomejs/biome lint src --log-kind=compact --verbose",
"lint:code:fix": "npx @biomejs/biome lint src --apply-unsafe --log-kind=compact --verbose",
"lint:styles": "stylelint **/*.{scss,css}",
"lint:styles:fix": "stylelint **/*.{scss,css} --fix",
"preview": "vite preview",
"start": "vite",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch" "storybook": "storybook dev -p 6006",
}, "storybook:test": "test-storybook",
"dependencies": { "build-storybook": "storybook build"
"form-data": "4.0.0",
"idb": "8.0.0",
"mailgun.js": "10.1.0"
}, },
"devDependencies": { "devDependencies": {
"@authorizerdev/authorizer-js": "2.0.0", "@authorizerdev/authorizer-js": "^2.0.3",
"@babel/core": "7.23.3", "@biomejs/biome": "^1.9.3",
"@biomejs/biome": "^1.7.2", "@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/cli": "^5.0.0", "@graphql-codegen/typescript": "^4.0.9",
"@graphql-codegen/typescript": "^4.0.1", "@graphql-codegen/typescript-operations": "^4.2.3",
"@graphql-codegen/typescript-operations": "^4.0.1",
"@graphql-codegen/typescript-urql": "^4.0.0", "@graphql-codegen/typescript-urql": "^4.0.0",
"@graphql-tools/url-loader": "8.0.1", "@hocuspocus/provider": "^2.13.6",
"@hocuspocus/provider": "2.11.0", "@playwright/test": "^1.47.2",
"@microsoft/fetch-event-source": "^2.0.1", "@popperjs/core": "^2.11.8",
"@nanostores/router": "0.13.0", "@solid-primitives/media": "^2.2.9",
"@nanostores/solid": "0.4.2", "@solid-primitives/memo": "^1.3.9",
"@playwright/test": "1.41.2", "@solid-primitives/pagination": "^0.3.0",
"@popperjs/core": "2.11.8", "@solid-primitives/script-loader": "^2.2.0",
"@sentry/browser": "^7.113.0", "@solid-primitives/share": "^2.0.6",
"@solid-primitives/media": "2.2.3", "@solid-primitives/storage": "^4.2.1",
"@solid-primitives/memo": "1.2.4", "@solid-primitives/upload": "^0.0.117",
"@solid-primitives/pagination": "0.2.10", "@solidjs/meta": "^0.29.4",
"@solid-primitives/share": "2.0.4", "@solidjs/router": "^0.14.7",
"@solid-primitives/storage": "^3.5.0", "@solidjs/start": "^1.0.8",
"@solid-primitives/upload": "0.0.115", "@storybook/addon-a11y": "^8.3.4",
"@thisbeyond/solid-select": "0.14.0", "@storybook/addon-actions": "^8.3.4",
"@tiptap/core": "2.2.3", "@storybook/addon-controls": "^8.3.4",
"@tiptap/extension-blockquote": "2.2.3", "@storybook/addon-essentials": "^8.3.4",
"@tiptap/extension-bold": "2.2.3", "@storybook/addon-interactions": "^8.3.4",
"@tiptap/extension-bubble-menu": "2.2.3", "@storybook/addon-links": "^8.3.4",
"@tiptap/extension-bullet-list": "2.2.3", "@storybook/addon-themes": "^8.3.4",
"@tiptap/extension-character-count": "2.2.3", "@storybook/addon-viewport": "^8.3.4",
"@tiptap/extension-collaboration": "2.2.3", "@storybook/builder-vite": "^8.3.4",
"@tiptap/extension-collaboration-cursor": "2.2.3", "@storybook/docs-tools": "^8.3.4",
"@tiptap/extension-document": "2.2.3", "@storybook/test": "^8.3.4",
"@tiptap/extension-dropcursor": "2.2.3", "@storybook/test-runner": "^0.19.1",
"@tiptap/extension-floating-menu": "2.2.3", "@tiptap/core": "^2.8.0",
"@tiptap/extension-focus": "2.2.3", "@tiptap/extension-blockquote": "^2.8.0",
"@tiptap/extension-gapcursor": "2.2.3", "@tiptap/extension-bold": "^2.8.0",
"@tiptap/extension-hard-break": "2.2.3", "@tiptap/extension-bubble-menu": "^2.8.0",
"@tiptap/extension-heading": "2.2.3", "@tiptap/extension-bullet-list": "^2.8.0",
"@tiptap/extension-highlight": "2.2.3", "@tiptap/extension-character-count": "^2.8.0",
"@tiptap/extension-history": "2.2.3", "@tiptap/extension-collaboration": "^2.8.0",
"@tiptap/extension-horizontal-rule": "2.2.3", "@tiptap/extension-collaboration-cursor": "^2.8.0",
"@tiptap/extension-image": "2.2.3", "@tiptap/extension-document": "^2.8.0",
"@tiptap/extension-italic": "2.2.3", "@tiptap/extension-dropcursor": "^2.8.0",
"@tiptap/extension-link": "2.2.3", "@tiptap/extension-floating-menu": "^2.8.0",
"@tiptap/extension-list-item": "2.2.3", "@tiptap/extension-focus": "^2.8.0",
"@tiptap/extension-ordered-list": "2.2.3", "@tiptap/extension-gapcursor": "^2.8.0",
"@tiptap/extension-paragraph": "2.2.3", "@tiptap/extension-hard-break": "^2.8.0",
"@tiptap/extension-placeholder": "2.2.3", "@tiptap/extension-heading": "^2.8.0",
"@tiptap/extension-strike": "2.2.3", "@tiptap/extension-highlight": "^2.8.0",
"@tiptap/extension-text": "2.2.3", "@tiptap/extension-history": "^2.8.0",
"@tiptap/extension-underline": "2.2.3", "@tiptap/extension-horizontal-rule": "^2.8.0",
"@tiptap/extension-youtube": "2.2.3", "@tiptap/extension-image": "^2.8.0",
"@types/js-cookie": "3.0.6", "@tiptap/extension-italic": "^2.8.0",
"@types/node": "^20.11.0", "@tiptap/extension-link": "^2.8.0",
"@urql/core": "4.2.3", "@tiptap/extension-list-item": "^2.8.0",
"@urql/devtools": "^2.0.3", "@tiptap/extension-ordered-list": "^2.8.0",
"babel-preset-solid": "1.8.4", "@tiptap/extension-paragraph": "^2.8.0",
"bootstrap": "5.3.2", "@tiptap/extension-placeholder": "^2.8.0",
"clsx": "2.0.0", "@tiptap/extension-strike": "^2.8.0",
"cropperjs": "1.6.1", "@tiptap/extension-text": "^2.8.0",
"cross-env": "7.0.3", "@tiptap/extension-underline": "^2.8.0",
"fast-deep-equal": "3.1.3", "@tiptap/extension-youtube": "^2.8.0",
"ga-gtag": "1.2.0", "@tiptap/starter-kit": "^2.8.0",
"graphql": "16.8.1", "@types/cookie": "^0.6.0",
"graphql-tag": "2.12.6", "@types/cookie-signature": "^1.1.2",
"hygen": "6.2.11", "@types/node": "^22.7.4",
"i18next": "22.4.15", "@types/throttle-debounce": "^5.0.2",
"i18next-http-backend": "2.2.0", "@urql/core": "^5.0.6",
"i18next-icu": "2.3.0", "axe-playwright": "^2.0.3",
"intl-messageformat": "10.5.3", "bootstrap": "^5.3.3",
"javascript-time-ago": "2.5.9", "clsx": "^2.1.1",
"js-cookie": "3.0.5", "cookie": "^0.6.0",
"lint-staged": "15.1.0", "cookie-signature": "^1.2.1",
"loglevel": "1.8.1", "cropperjs": "^1.6.2",
"loglevel-plugin-prefix": "0.8.4", "extended-eventsource": "^1.6.4",
"nanostores": "0.9.5", "fast-deep-equal": "^3.1.3",
"graphql": "^16.9.0",
"i18next": "^23.15.1",
"i18next-http-backend": "^2.6.1",
"i18next-icu": "^2.3.0",
"intl-messageformat": "^10.5.14",
"javascript-time-ago": "^2.5.11",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"prosemirror-history": "1.3.2", "prosemirror-history": "^1.4.1",
"prosemirror-trailing-node": "2.0.7", "prosemirror-trailing-node": "^2.0.9",
"prosemirror-view": "1.32.7", "prosemirror-view": "^1.34.3",
"rollup": "4.17.2", "rollup-plugin-visualizer": "^5.12.0",
"sass": "1.69.5", "sass": "1.77.6",
"solid-js": "1.8.17", "solid-js": "^1.9.1",
"solid-popper": "0.3.0", "solid-popper": "^0.3.0",
"solid-tiptap": "0.7.0", "solid-tiptap": "0.7.0",
"solid-transition-group": "0.2.3", "solid-transition-group": "^0.2.3",
"stylelint": "^16.0.0", "storybook": "^8.3.4",
"stylelint-config-standard-scss": "^13.0.0", "storybook-addon-sass-postcss": "^0.3.2",
"stylelint-order": "^6.0.3", "storybook-solidjs": "^1.0.0-beta.2",
"stylelint-scss": "^6.1.0", "storybook-solidjs-vite": "^1.0.0-beta.2",
"swiper": "11.0.5", "stylelint": "^16.9.0",
"throttle-debounce": "5.0.0", "stylelint-config-recommended": "^14.0.1",
"typescript": "5.2.2", "stylelint-config-standard-scss": "^13.1.0",
"typograf": "7.3.0", "stylelint-order": "^6.0.4",
"uniqolor": "1.1.0", "stylelint-scss": "^6.7.0",
"vike": "0.4.148", "swiper": "^11.1.14",
"vite": "5.2.11", "throttle-debounce": "^5.0.2",
"vite-plugin-mkcert": "^1.17.3", "tslib": "^2.7.0",
"vite-plugin-node-polyfills": "0.21.0", "typescript": "^5.6.2",
"vite-plugin-sass-dts": "^1.3.17", "typograf": "^7.4.1",
"vite-plugin-solid": "2.10.1", "uniqolor": "^1.1.1",
"y-prosemirror": "1.2.2", "vinxi": "^0.4.3",
"yjs": "13.6.12" "vite-plugin-mkcert": "^1.17.6",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-sass-dts": "^1.3.29",
"y-prosemirror": "1.2.12",
"yjs": "13.6.19"
}, },
"overrides": { "overrides": {
"y-prosemirror": "1.2.2", "sass": "1.77.6",
"yjs": "13.6.12" "vite": "5.3.5",
"yjs": "13.6.19",
"y-prosemirror": "1.2.12"
}, },
"trustedDependencies": ["@biomejs/biome"] "engines": {
"node": ">= 20"
},
"trustedDependencies": ["@biomejs/biome", "@swc/core", "esbuild", "protobufjs"],
"dependencies": {
"form-data": "^4.0.0",
"idb": "^8.0.0",
"mailgun.js": "^10.2.3"
}
} }

View File

@ -10,44 +10,40 @@ import { defineConfig, devices } from '@playwright/test'
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.
*/ */
export default defineConfig({ export default defineConfig({
/* Directory to search for tests */
testDir: './tests', testDir: './tests',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 2 : 0, retries: 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'list',
/* Timeout for each test */
timeout: 40000,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000', baseURL: process.env.BASE_URL || 'https://localhost:3000',
/* Headless */
headless: true,
/* Ignode SSL certificates */
ignoreHTTPSErrors: true,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry'
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{ {
name: 'webkit', name: 'webkit',
use: { ...devices['Desktop Safari'] }, use: { ...devices['Desktop Safari'] }
}, }
/* Test against mobile viewports. */ /* Test against many viewports.
// { // {
// name: 'Mobile Chrome', // name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] }, // use: { ...devices['Pixel 5'] },
@ -68,10 +64,15 @@ export default defineConfig({
// }, // },
], ],
/* Run your local dev server before starting the tests */ /* Run local dev server before starting the tests */
// webServer: { /* If process env CI is set to false */
// command: 'npm run start', webServer: process.env.CI
// url: 'http://127.0.0.1:3000', ? undefined
// reuseExistingServer: !process.env.CI, : {
// }, command: 'npm run dev',
url: 'http://localhost:3000',
ignoreHTTPSErrors: true,
reuseExistingServer: !process.env.CI,
timeout: 5 * 60 * 1000
}
}) })

View File

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.125 12.75H4.5C4.08854 12.75 3.75 12.4115 3.75 12C3.75 11.5885 4.08854 11.25 4.5 11.25H19.125C19.5365 11.25 19.875 11.5885 19.875 12C19.875 12.4115 19.5365 12.75 19.125 12.75Z" fill="currentColor"/>
<path
d="M14.0678 18.3593C13.8803 18.3593 13.6928 18.2916 13.547 18.151C13.2501 17.8593 13.2397 17.3853 13.5314 17.0885L18.4584 11.9999L13.5314 6.91137C13.2397 6.6145 13.2501 6.14054 13.547 5.84887C13.8439 5.56241 14.3178 5.57283 14.6043 5.8697L20.0366 11.4791C20.3178 11.7707 20.3178 12.2291 20.0366 12.5207L14.6043 18.1301C14.4584 18.2864 14.2657 18.3593 14.0678 18.3593Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 713 B

View File

Before

Width:  |  Height:  |  Size: 714 B

After

Width:  |  Height:  |  Size: 714 B

View File

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 350 B

View File

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 290 B

View File

@ -1,11 +0,0 @@
<svg
width="13" height="16"
viewBox="0 0 13 16"
fill="none"
version="1.1"
xmlns="http://www.w3.org/2000/svg">
<path
d="M 10.1573,7.43667 C 11.2197,6.70286 11.9645,5.49809 11.9645,4.38095 11.9645,1.90571 10.0478,0 7.58352,0 H 0.738281 V 15.3333 H 8.44876 c 2.28904,0 4.06334,-1.8619 4.06334,-4.1509 0,-1.66478 -0.9419,-3.08859 -2.3548,-3.74573 z M 4.02344,2.73828 h 3.28571 c 0.90905,0 1.64286,0.73381 1.64286,1.64286 0,0.90905 -0.73381,1.64286 -1.64286,1.64286 H 4.02344 Z M 4.01629,9.3405869 h 3.87946 c 0.9090501,0 1.6428601,0.7338101 1.6428601,1.6428601 0,0.90905 -0.73381,1.64286 -1.6428601,1.64286 H 4.01629 Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 677 B

4
public/icons/expert.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.9967 4.51318C11.5931 4.51318 11.1868 4.59652 10.8118 4.75798L7.9056 6.01058L9.83268 6.81266L11.4056 6.13558C11.5931 6.05225 11.7962 6.01058 11.9993 6.01058C12.2025 6.01058 12.4056 6.05225 12.5931 6.13558L20.5801 9.57829C20.6504 9.60693 20.6947 9.67464 20.6947 9.75016C20.6947 9.82568 20.6504 9.89339 20.5801 9.92204L12.5931 13.3647C12.2181 13.5262 11.7806 13.5262 11.4056 13.3647L3.41862 9.92204C3.34831 9.89339 3.30404 9.82568 3.30404 9.75016C3.30404 9.67464 3.34831 9.60693 3.41862 9.57829L6.47591 8.26058L11.7103 10.4429C11.804 10.4819 11.903 10.5002 11.9993 10.5002C12.291 10.5002 12.5723 10.3283 12.6921 10.0392C12.8509 9.65641 12.6712 9.21631 12.2884 9.05746L8.39258 7.43506L8.39518 7.43246L6.4681 6.63037L2.42643 8.37516C1.87435 8.60954 1.51758 9.1512 1.51758 9.75016C1.51758 10.3491 1.87435 10.8908 2.42643 11.1252L4.87435 12.1825V18.5679C4.64779 18.7371 4.49935 19.008 4.49935 19.3127V20.8127C4.49935 21.3309 4.91862 21.7502 5.43685 21.7502H5.81185C6.33008 21.7502 6.74935 21.3309 6.74935 20.8127V19.3127C6.74935 19.008 6.60091 18.7371 6.37435 18.5679V17.1512C7.42904 17.909 9.2181 18.7502 11.9993 18.7502C15.5384 18.7502 17.4889 17.3856 18.3353 16.5705C18.8379 16.0887 19.1243 15.4064 19.1243 14.6955V12.1825L21.5723 11.1252C22.1243 10.8908 22.4811 10.3491 22.4811 9.75016C22.4811 9.1512 22.1243 8.60954 21.5723 8.37516L13.1868 4.75798C12.8092 4.59652 12.403 4.51318 11.9967 4.51318ZM6.37435 12.8283L10.8118 14.7424C11.1895 14.9064 11.5931 14.9845 11.9993 14.9845C12.4056 14.9845 12.8092 14.9064 13.1868 14.7424L17.6243 12.8283V14.6955C17.6243 15.0002 17.5046 15.2892 17.2962 15.4897C16.6113 16.146 15.015 17.2502 11.9993 17.2502C8.98372 17.2502 7.38737 16.146 6.70247 15.4897C6.49414 15.2892 6.37435 15.0002 6.37435 14.6955V12.8283Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

View File

@ -1,544 +0,0 @@
{
"A guide to horizontal editorial: how an open journal works": "A guide to horizontal editorial: how an open journal works",
"About the project": "About the project",
"About": "About",
"Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title": "Add a few topics so that the reader knows what your content is about and can find it on pages of topics that interest them. Topics can be swapped, the first topic becomes the title",
"Add a link or click plus to embed media": "Add a link or click plus to embed media",
"Add an embed widget": "Add an embed widget",
"Add another image": "Add another image",
"Add audio": "Add audio",
"Add blockquote": "Add blockquote",
"Add comment": "Comment",
"Add cover": "Add cover",
"Add image": "Add image",
"Add images": "Add images",
"Add intro": "Add intro",
"Add link": "Add link",
"Add rule": "Add rule",
"Add signature": "Add signature",
"Add subtitle": "Add subtitle",
"Add url": "Add url",
"try": "попробуйте",
"Add": "Add",
"Address on Discours": "Address on Discours",
"Album name": "Название aльбома",
"Alignment center": "Alignment center",
"Alignment left": "Alignment left",
"Alignment right": "Alignment right",
"All articles": "All articles",
"All authors": "All authors",
"All posts": "All posts",
"All topics": "All topics",
"All": "All",
"Almost done! Check your email.": "Almost done! Just checking your email.",
"Are you sure you want to delete this comment?": "Are you sure you want to delete this comment?",
"Are you sure you want to delete this draft?": "Are you sure you want to delete this draft?",
"Are you sure you want to to proceed the action?": "Are you sure you want to to proceed the action?",
"Art": "Art",
"Artist": "Artist",
"Artworks": "Artworks",
"Audio": "Audio",
"Author": "Author",
"Authors": "Authors",
"Autotypograph": "Autotypograph",
"Back to editor": "Back to editor",
"Back to main page": "Back to main page",
"Back": "Back",
"Be the first to rate": "Be the first to rate",
"Become an author": "Become an author",
"Bold": "Bold",
"Bookmarked": "Saved",
"Bookmarks": "Bookmarks",
"Bullet list": "Bullet list",
"By alphabet": "By alphabet",
"By authors": "By authors",
"By name": "By name",
"By popularity": "By popularity",
"By rating": "By popularity",
"By relevance": "By relevance",
"By shouts": "By publications",
"By signing up you agree with our": "By signing up you agree with our",
"By time": "By time",
"By title": "By title",
"By updates": "By updates",
"By views": "By views",
"Can make any changes, accept or reject suggestions, and share access with others": "Can make any changes, accept or reject suggestions, and share access with others",
"Can offer edits and comments, but cannot edit the post or share access with others": "Can offer edits and comments, but cannot edit the post or share access with others",
"Can write and edit text directly, and accept or reject suggestions from others": "Can write and edit text directly, and accept or reject suggestions from others",
"Cancel changes": "Cancel changes",
"Cancel": "Cancel",
"Change password": "Change password",
"Characters": "Знаков",
"Chat Title": "Chat Title",
"Choose a post type": "Choose a post type",
"Choose a title image for the article. You can immediately see how the publication card will look like.": "Choose a title image for the article. You can immediately see how the publication card will look like.",
"Choose who you want to write to": "Choose who you want to write to",
"Close": "Close",
"Co-author": "Co-author",
"Collaborate": "Help Edit",
"Collaborators": "Collaborators",
"Collections": "Collections",
"Come up with a subtitle for your story": "Come up with a subtitle for your story",
"Come up with a title for your story": "Come up with a title for your story",
"Coming soon": "Coming soon",
"Comment successfully deleted": "Comment successfully deleted",
"Commentator": "Commentator",
"Commenting": "Commenting",
"Comments": "Comments",
"CommentsWithCount": "{count, plural, =0 {{count} comments} one {{count} comment} few {{count} comments} other {{count} comments}}",
"Communities": "Communities",
"Community Discussion Rules": "Community Discussion Rules",
"Community Principles": "Community Principles",
"Community values and rules of engagement for the open editorial team": "Community values and rules of engagement for the open editorial team",
"Confirm": "Confirm",
"Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom": "Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom",
"Cooperate": "Cooperate",
"Copy link": "Copy link",
"Copy": "Copy",
"Corrections history": "Corrections history",
"Create Chat": "Create Chat",
"Create Group": "Create a group",
"Create account": "Create an account",
"Create an account to add to your bookmarks": "Create an account to add to your bookmarks",
"Create an account to participate in discussions": "Create an account to participate in discussions",
"Create an account to publish articles": "Create an account to publish articles",
"Create an account to subscribe to new publications": "Create an account to subscribe to new publications",
"Create an account to subscribe": "Create an account to subscribe",
"Create an account to vote": "Create an account to vote",
"Create gallery": "Create gallery",
"Create post": "Create post",
"Create video": "Create video",
"Crop image": "Crop image",
"Culture": "Culture",
"Date of Birth": "Date of Birth",
"Decline": "Decline",
"Delete cover": "Delete cover",
"Delete userpic": "Delete userpic",
"Delete": "Delete",
"Description": "Description",
"Discours Manifest": "Discours Manifest",
"Discours Partners": "Discours Partners",
"Discours is an intellectual environment, a web space and tools that allows authors to collaborate with readers and come together to co-create publications and media projects": "Discours is&#160;an&#160;intellectual environment, a&#160;web space and tools that allows authors to&#160;collaborate with readers and come together to&#160;co-create publications and media projects.<br/><em>We&#160;are convinced that one voice is&#160;good, but many is&#160;better. We&#160;create the most amazing stories together</em>",
"Discours is created with our common effort": "Discours exists because of our common effort",
"Discours an open magazine about culture, science and society": "Discours an open magazine about culture, science and society",
"Discours": "Discours",
"Discussing": "Discussing",
"Discussion rules": "Discussion rules",
"Discussions": "Discussions",
"Do you really want to reset all changes?": "Do you really want to reset all changes?",
"Dogma": "Dogma",
"Draft successfully deleted": "Draft successfully deleted",
"Drafts": "Drafts",
"Drag the image to this area": "Drag the image to this area",
"Each image must be no larger than 5 MB.": "Each image must be no larger than 5 MB.",
"Edit profile": "Edit profile",
"Edit": "Edit",
"Editing": "Editing",
"Editor": "Editor",
"Email": "Mail",
"Enter URL address": "Enter URL address",
"Enter a new password": "Enter a new password",
"Enter footnote text": "Enter footnote text",
"Enter image description": "Enter image description",
"Enter image title": "Enter image title",
"Enter text": "Enter text",
"Enter the code or click the link from email to confirm": "Enter the code from the email or follow the link in the email to confirm registration",
"Enter your new password": "Enter your new password",
"Enter": "Enter",
"Error": "Error",
"Experience": "Experience",
"FAQ": "Tips and suggestions",
"Favorite topics": "Favorite topics",
"Favorite": "Favorites",
"Feed settings": "Feed settings",
"Feed": "Feed",
"Feedback": "Feedback",
"Fill email": "Fill email",
"Fixed": "Fixed",
"Follow the topic": "Follow the topic",
"Follow": "Follow",
"Followers": "Followers",
"Following": "Following",
"Forward": "Forward",
"Full name": "First and last name",
"Gallery name": "Gallery name",
"Gallery": "Gallery",
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine",
"Go to main page": "Go to main page",
"Group Chat": "Group Chat",
"Groups": "Groups",
"Header 1": "Header 1",
"Header 2": "Header 2",
"Header 3": "Header 3",
"Headers": "Headers",
"Help to edit": "Help to edit",
"Help": "Помощь",
"Here you can customize your profile the way you want.": "Here you can customize your profile the way you want.",
"Here you can manage all your Discours subscriptions": "Here you can manage all your Discours subscriptions",
"Here you can upload your photo": "Here you can upload your photo",
"Hide table of contents": "Hide table of contents",
"Highlight": "Highlight",
"Hooray! Welcome!": "Hooray! Welcome!",
"Horizontal collaborative journalistic platform": "Horizontal collaborative journalism platform",
"Hot topics": "Hot topics",
"Hotkeys": "Горячие клавиши",
"How Discours works": "How Discours works",
"How can I help/skills": "How can I help/skills",
"How it works": "How it works",
"How to help": "How to help?",
"How to write a good article": "Как написать хорошую статью",
"How to write an article": "How to write an article",
"Hundreds of people from different countries and cities share their knowledge and art on the Discours. Join us!": "Hundreds of people from different countries and cities share their knowledge and art on the Discours. Join us!",
"I have an account": "I have an account!",
"I have no account yet": "I don't have an account yet",
"I know the password": "I know the password",
"Image format not supported": "Image format not supported",
"In&nbsp;bookmarks, you can save favorite discussions and&nbsp;materials that you want to return to": "In&nbsp;bookmarks, you can save favorite discussions and&nbsp;materials that you want to return to",
"Inbox": "Inbox",
"Incut": "Incut",
"Independant magazine with an open horizontal cooperation about culture, science and society": "Independant magazine with an open horizontal cooperation about culture, science and society",
"Independent media project about culture, science, art and society with horizontal editing": "Independent media project about culture, science, art and society with horizontal editing",
"Insert footnote": "Insert footnote",
"Insert video link": "Insert video link",
"Interview": "Interview",
"Introduce": "Introduction",
"Invalid email": "Check if your email is correct",
"Invalid image URL": "Invalid image URL",
"Invalid url format": "Invalid url format",
"Invite co-authors": "Invite co-authors",
"Invite collaborators": "Invite collaborators",
"Invite to collab": "Invite to Collab",
"Invite": "Invite",
"It does not look like url": "It doesn't look like a link",
"Italic": "Italic",
"Join our maillist": "To receive the best postings, just enter your email",
"Join the community": "Join the community",
"Join the global community of authors!": "Join the global community of authors from all over the world!",
"Join": "Join",
"Just start typing...": "Just start typing...",
"Knowledge base": "Knowledge base",
"Language": "Language",
"Last rev.": "Посл. изм.",
"Let's log in": "Let's log in",
"Link copied to clipboard": "Link copied to clipboard",
"Link copied": "Link copied",
"Link sent, check your email": "Link sent, check your email",
"List of authors of the open editorial community": "List of authors of the open editorial community",
"Lists": "Lists",
"Literature": "Literature",
"Load more": "Show more",
"Loading": "Loading",
"Logout": "Logout",
"Looks like you forgot to upload the video": "Looks like you forgot to upload the video",
"Manifest of samizdat: principles and mission of an open magazine with a horizontal editorial board": "Manifest of samizdat: principles and mission of an open magazine with a horizontal editorial board",
"Manifesto": "Manifesto",
"Many files, choose only one": "Many files, choose only one",
"Mark as read": "Mark as read",
"Material card": "Material card",
"Message": "Message",
"More": "More",
"Most commented": "Commented",
"Most read": "Readable",
"Move down": "Move down",
"Move up": "Move up",
"Music": "Music",
"My feed": "My feed",
"My subscriptions": "Subscriptions",
"Name": "Name",
"New literary work": "New literary work",
"New only": "New only",
"New password": "New password",
"New stories every day and even more!": "New stories and more are waiting for you every day!",
"Newsletter": "Newsletter",
"Night mode": "Night mode",
"No notifications yet": "No notifications yet",
"Nothing here yet": "There's nothing here yet",
"Nothing is here": "There is nothing here",
"Notifications": "Notifications",
"Or paste a link to an image": "Or paste a link to an image",
"Ordered list": "Ordered list",
"Our regular contributor": "Our regular contributor",
"Paragraphs": "Абзацев",
"Participate in the Discours: share information, join the editorial team": "Участвуйте в Дискурсе: делитесь информацией, присоединяйтесь к редакции",
"Participating": "Participating",
"Participation": "Participation",
"Partners": "Partners",
"Password again": "Password again",
"Password should be at least 8 characters": "Password should be at least 8 characters",
"Password should contain at least one number": "Password should contain at least one number",
"Password should contain at least one special character: !@#$%^&*": "Password should contain at least one special character: !@#$%^&*",
"Password updated!": "Password updated!",
"Password": "Password",
"Passwords are not equal": "Passwords are not equal",
"Paste Embed code": "Paste Embed code",
"Personal": "Personal",
"Pin": "Pin",
"Platform Guide": "Platform Guide",
"Please check your email address": "Please check your email address",
"Please confirm your email to finish": "Confirm your email and the action will complete",
"Please enter a name to sign your comments and publication": "Please enter a name to sign your comments and publication",
"Please enter email": "Please enter your email",
"Please enter password again": "Please enter password again",
"Please enter password": "Please enter a password",
"Please, confirm email": "Please confirm email",
"Please, set the main topic first": "Please, set the main topic first",
"Podcasts": "Podcasts",
"Poetry": "Poetry",
"Popular authors": "Popular authors",
"Popular": "Popular",
"Principles": "Community principles",
"Professional principles that the open editorial team follows in its work": "Professional principles that the open editorial team follows in its work",
"Profile settings": "Profile settings",
"Profile": "Profile",
"Publications": "Publications",
"PublicationsWithCount": "{count, plural, =0 {no publications} one {{count} publication} other {{count} publications}}",
"FollowersWithCount": "{count, plural, =0 {no followers} one {{count} follower} other {{count} followers}}",
"Publish Album": "Publish Album",
"Publish Settings": "Publish Settings",
"Published": "Published",
"Punchline": "Punchline",
"Quit": "Quit",
"Quote": "Quote",
"Quotes": "Quotes",
"Reason uknown": "Reason unknown",
"Recent": "Fresh",
"Registered since {date}": "Registered since {date}",
"Remove link": "Remove link",
"Reply": "Reply",
"Report": "Complain",
"Reports": "Reports",
"Required": "Required",
"Resend code": "Send confirmation",
"Rules of the journal Discours": "Rules of the journal Discours",
"Save draft": "Save draft",
"Save settings": "Save settings",
"Saving...": "Saving...",
"Scroll up": "Scroll up",
"Search author": "Search author",
"Search topic": "Search topic",
"Search": "Search",
"Sections": "Sections",
"Security": "Security",
"Select": "Select",
"Self-publishing exists thanks to the help of wonderful people from all over the world. Thank you!": "Samizdat exists thanks to the help of wonderful people from all over the world. Thank you!",
"Send link again": "Send link again",
"Send": "Send",
"Forgot password?": "Forgot password?",
"Settings": "Settings",
"Share publication": "Share publication",
"Share": "Share",
"Show lyrics": "Show lyrics",
"Show more": "Show more",
"Show table of contents": "Show table of contents",
"Show": "Show",
"to see the voters": "to see the voters",
"Site search": "Site search",
"Slug": "Slug",
"Social networks": "Social networks",
"Society": "Society",
"Some new comments to your publication": "{commentsCount, plural, one {New comment} other {{commentsCount} comments}} to your publication",
"Some new replies to your comment": "{commentsCount, plural, one {New reply} other {{commentsCount} replays}} to your publication",
"Something went wrong, check email and password": "Something went wrong. Check your email and password",
"Something went wrong, please try again": "Something went wrong, please try again",
"Song lyrics": "Song lyrics...",
"Song title": "Song title",
"Soon": "Скоро",
"Sorry, this address is already taken, please choose another one.": "Sorry, this address is already taken, please choose another one",
"Special Projects": "Special Projects",
"Special projects": "Special projects",
"Specify the source and the name of the author": "Specify the source and the name of the author",
"Start conversation": "Start a conversation",
"Start dialog": "Start dialog",
"Subsccriptions": "Subscriptions",
"Subscribe to the best publications newsletter": "Subscribe to the best publications newsletter",
"Subscribe us": "Subscribe us",
"Subscribe what you like to tune your personal feed": "Subscribe to topics that interest you to customize your personal feed and get instant updates on new posts and discussions",
"Subscribe who you like to tune your personal feed": "Subscribe to authors you're interested in to customize your personal feed and get instant updates on new posts and discussions",
"Subscribe": "Subscribe",
"SubscriberWithCount": "{count, plural, =0 {no followers} one {{count} follower} other {{count} followers}}",
"Subscription": "Subscription",
"SubscriptionWithCount": "{count, plural, =0 {no subscriptions} one {{count} subscription} other {{count} subscriptions}}",
"Subscriptions": "Subscriptions",
"Substrate": "Substrate",
"Success": "Success",
"Successfully authorized": "Authorization successful",
"Suggest an idea": "Suggest an idea",
"Support Discours": "Support Discours",
"Support the project": "Support the project",
"Support us": "Support us",
"Terms of use": "Site rules",
"Text checking": "Text checking",
"Thank you!": "Thank you!",
"Thank you": "Thank you",
"The address is already taken": "The address is already taken",
"The most interesting publications on the topic": "The most interesting publications on the topic {topicName}",
"Thematic table of contents of the magazine. Here you can find all the topics that community authors have written about.": "Thematic table of contents of the magazine. Here you can find all the topics that community authors have written about.",
"Thematic table of contents of the magazine. Here you can find all the topics that the community authors wrote about": "Thematic table of contents of the magazine. Here you can find all the topics that the community authors wrote about",
"Themes and plots": "Themes and plots",
"Please, set the article title": "Please, set the article title",
"Theory": "Theory",
"There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?",
"There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?",
"This comment has not yet been rated": "This comment has not yet been rated",
"This content is not published yet": "This content is not published yet",
"This email is": "This email is",
"This email is not verified": "This email is not verified",
"This email is verified": "This email is verified",
"This email is registered": "This email is registered",
"This functionality is currently not available, we would like to work on this issue. Use the download link.": "This functionality is currently not available, we would like to work on this issue. Use the download link.",
"This month": "This month",
"No one rated yet": "No one rated yet",
"This way we&nbsp;ll realize that you&nbsp;re a real person and&nbsp;ll take your vote into account. And&nbsp;you&nbsp;ll see how others voted": "This way we&nbsp;ll realize that you&nbsp;re a real person and&nbsp;ll take your vote into account. And&nbsp;you&nbsp;ll see how others voted",
"This way you&nbsp;ll be able to subscribe to&nbsp;authors, interesting topics and&nbsp;customize your feed": "This way you&nbsp;ll be able to subscribe to&nbsp;authors, interesting topics and&nbsp;customize your feed",
"This week": "This week",
"This year": "This year",
"To find publications, art, comments, authors and topics of interest to you, just start typing your query": "To&nbsp;find publications, art, comments, authors and topics of&nbsp;interest to&nbsp;you, just start typing your query",
"To leave a comment please": "To leave a comment please",
"To write a comment, you must": "To write a comment, you must",
"Top authors": "Authors rating",
"Top commented": "Most commented",
"Top discussed": "Top discussed",
"Top month": "Top of the month",
"Top rated": "Popular",
"Top recent": "Most recent",
"Top topics": "Interesting topics",
"Top viewed": "Most viewed",
"Topic is supported by": "Topic is supported by",
"Topics which supported by author": "Topics which supported by author",
"Topics": "Topics",
"Try to find another way": "Try to find another way",
"Unfollow the topic": "Unfollow the topic",
"Unfollow": "Unfollow",
"Unnamed draft": "Unnamed draft",
"Upload error": "Upload error",
"Upload userpic": "Upload userpic",
"Upload video": "Upload video",
"Upload": "Upload",
"Uploading image": "Uploading image",
"Username": "Username",
"Userpic": "Userpic",
"Users": "Users",
"Video format not supported": "Video format not supported",
"Video": "Video",
"Views": "Views",
"We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues": "We are working on collaborative editing of articles and in the near future you will have an amazing opportunity - to create together with your colleagues",
"We can't find you, check email or": "We can't find you, check email or",
"We couldn't find anything for your request": "We&nbsp;couldn&rsquo;t find anything for your request",
"We know you, please try to login": "This email address is already registered, please try to login",
"We've sent you a message with a link to enter our website.": "We've sent you an email with a link to your email. Follow the link in the email to enter our website.",
"Welcome to Discours to add to your bookmarks": "Welcome to Discours to add to your bookmarks",
"Welcome to Discours to participate in discussions": "Welcome to Discours to participate in discussions",
"Welcome to Discours to publish articles": "Welcome to Discours to publish articles",
"Welcome to Discours to subscribe to new publications": "Welcome to Discours to subscribe to new publications",
"Welcome to Discours to subscribe": "Welcome to Discours to subscribe",
"Welcome to Discours to vote": "Welcome to Discours to vote",
"Welcome to Discours": "Welcome to Discours",
"Where": "From",
"Why you can earn a hole in your karma and how to receive rays of gratitude for your contribution to discussions in samizdat communities": "Why you can earn a hole in your karma and how to receive rays of gratitude for your contribution to discussions in samizdat communities",
"Words": "Слов",
"Work with us": "Cooperate with Discours",
"Write a comment...": "Write a comment...",
"Write a short introduction": "Write a short introduction",
"Write about the topic": "Write about the topic",
"Write an article": "Write an article",
"Write comment": "Write comment",
"Write good articles, comment\nand it won't be so empty here": "Write good articles, comment\nand it won't be so empty here",
"Write message": "Write a message",
"Write to us": "Write to us",
"You can": "You can",
"Write your colleagues name or email": "Write your colleague's name or email",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "You can download multiple tracks at once in .mp3, .wav or .flac formats",
"You can now login using your new password": "Теперь вы можете входить с помощью нового пароля",
"You can't edit this post": "You can't edit this post",
"You were successfully authorized": "You were successfully authorized",
"You&nbsp;ll be able to participate in&nbsp;discussions, rate others' comments and&nbsp;learn about&nbsp;new responses": "You&nbsp;ll be able to participate in&nbsp;discussions, rate others' comments and&nbsp;learn about&nbsp;new responses",
"You've confirmed email": "You've confirmed email",
"You've reached a non-existed page": "You've reached a non-existed page",
"Your email": "Your email",
"Your name will appear on your profile page and as your signature in publications, comments and responses.": "Your name will appear on your profile page and as your signature in publications, comments and responses",
"actions": "actions",
"add link": "add link",
"all topics": "all topics",
"and some more authors": "{restUsersCount, plural, =0 {} one { and one more user} other { and more {restUsersCount} users}}",
"article": "article",
"author": "author",
"authors": "authors",
"authorsWithCount": "{count} {count, plural, one {author} other {authors}}",
"back to menu": "back to menu",
"bold": "bold",
"bookmarks": "bookmarks",
"cancel": "cancel",
"collections": "collections",
"community": "community",
"contents": "contents",
"delimiter": "delimiter",
"discussion": "Discours",
"dogma keywords": "Discours.io, dogma, editorial principles, code of ethics, journalism, community",
"drafts": "drafts",
"earlier": "earlier",
"email not confirmed": "email not confirmed",
"enter": "enter",
"feed": "feed",
"follower": "follower",
"followersWithCount": "{count} {count, plural, one {follower} other {followers}}",
"from": "from",
"header 1": "header 1",
"header 2": "header 2",
"header 3": "header 3",
"images": "images",
"invalid password": "invalid password",
"italic": "italic",
"journal": "journal",
"jpg, .png, max. 10 mb.": "jpg, .png, макс. 10 мб.",
"keywords": "Discours.io, Discours magazine, Discours, culture, science, art, society, independent journalism, literature, music, cinema, video, photography",
"literature": "literature",
"marker list": "marker list",
"min. 1400×1400 pix": "мин. 1400×1400 пикс.",
"music": "music",
"my feed": "my ribbon",
"not verified": "not verified",
"number list": "number list",
"or sign in with social networks": "or sign in with social networks",
"personal data usage and email notifications": "to process personal data and receive email notifications",
"post": "post",
"principles keywords": "Discours.io, communities, values, editorial rules, polyphony, creation",
"register": "register",
"registered": "registered",
"repeat": "repeat",
"resend confirmation link": "resend confirmation link",
"shout": "post",
"shoutsWithCount": "{count} {count, plural, one {post} other {posts}}",
"sign up or sign in": "sign up or sign in",
"slug is used by another user": "Slug is already taken by another user",
"subscriber": "subscriber",
"subscriber_rp": "subscriber",
"subscribers": "subscribers",
"subscribing...": "subscribing...",
"subscription": "subscription",
"subscription_rp": "subscription",
"subscriptions": "subscriptions",
"terms of use keywords": "Discours.io, site rules, terms of use",
"terms of use": "terms of use",
"today": "today",
"topicKeywords": "{topic}, Discours.io, articles, journalism, research",
"topics": "topics",
"user already exist": "user already exists",
"verified": "verified",
"video": "video",
"view": "view",
"viewsWithCount": "{count} {count, plural, one {view} other {views}}",
"yesterday": "yesterday",
"Failed to delete comment": "Failed to delete comment",
"It's OK. Just enter your email to receive a link to change your password": "It's OK. Just enter your email to receive a link to change your password",
"Restore password": "Restore password",
"Subscribing...": "Subscribing...",
"Unsubscribing...": "Unsubscribing...",
"Login and security": "Login and security",
"Settings for account, email, password and login methods.": "Settings for account, email, password and login methods.",
"Current password": "Current password",
"Confirm your new password": "Confirm your new password",
"Connect": "Connect",
"Incorrect old password": "Incorrect old password",
"Repeat new password": "Repeat new password",
"Incorrect new password confirm": "Incorrect new password confirm"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

51
src/app.tsx Normal file
View File

@ -0,0 +1,51 @@
import { Meta, MetaProvider } from '@solidjs/meta'
import { Router } from '@solidjs/router'
import { FileRoutes } from '@solidjs/start/router'
import { type JSX, Suspense } from 'solid-js'
import { AuthToken } from '@authorizerdev/authorizer-js'
import { Loading } from './components/_shared/Loading'
import { AuthorsProvider } from './context/authors'
import { EditorProvider } from './context/editor'
import { FeedProvider } from './context/feed'
import { LocalizeProvider } from './context/localize'
import { SessionProvider } from './context/session'
import { TopicsProvider } from './context/topics'
import { UIProvider } from './context/ui'
import '~/styles/app.scss'
export const Providers = (props: { children?: JSX.Element }) => {
const sessionStateChanged = (payload: AuthToken) => {
console.debug(payload)
// TODO: maybe load subs here
}
return (
<LocalizeProvider>
<SessionProvider onStateChangeCallback={sessionStateChanged}>
<TopicsProvider>
<FeedProvider>
<MetaProvider>
<Meta name="viewport" content="width=device-width, initial-scale=1" />
<UIProvider>
<EditorProvider>
<AuthorsProvider>
<Suspense fallback={<Loading />}>{props.children}</Suspense>
</AuthorsProvider>
</EditorProvider>
</UIProvider>
</MetaProvider>
</FeedProvider>
</TopicsProvider>
</SessionProvider>
</LocalizeProvider>
)
}
export const App = () => (
<Router root={Providers}>
<FileRoutes />
</Router>
)
export default App

View File

@ -1,142 +0,0 @@
import type { PageProps, RootSearchParams } from '../pages/types'
import { Component, createEffect, createMemo } from 'solid-js'
import { Dynamic } from 'solid-js/web'
import { Meta, MetaProvider } from '../context/meta'
import { ConfirmProvider } from '../context/confirm'
import { ConnectProvider } from '../context/connect'
import { EditorProvider } from '../context/editor'
import { FollowingProvider } from '../context/following'
import { InboxProvider } from '../context/inbox'
import { LocalizeProvider } from '../context/localize'
import { MediaQueryProvider } from '../context/mediaQuery'
import { NotificationsProvider } from '../context/notifications'
import { SeenProvider } from '../context/seen'
import { SessionProvider } from '../context/session'
import { SnackbarProvider } from '../context/snackbar'
import { TopicsProvider } from '../context/topics'
import { DiscussionRulesPage } from '../pages/about/discussionRules.page'
import { DogmaPage } from '../pages/about/dogma.page'
import { GuidePage } from '../pages/about/guide.page'
import { HelpPage } from '../pages/about/help.page'
import { ManifestPage } from '../pages/about/manifest.page'
import { PartnersPage } from '../pages/about/partners.page'
import { PrinciplesPage } from '../pages/about/principles.page'
import { ProjectsPage } from '../pages/about/projects.page'
import { TermsOfUsePage } from '../pages/about/termsOfUse.page'
import { ThanksPage } from '../pages/about/thanks.page'
import { AllAuthorsPage } from '../pages/allAuthors.page'
import { AllTopicsPage } from '../pages/allTopics.page'
import { ArticlePage } from '../pages/article.page'
import { AuthorPage } from '../pages/author.page'
import { ConnectPage } from '../pages/connect.page'
import { CreatePage } from '../pages/create.page'
import { DraftsPage } from '../pages/drafts.page'
import { EditPage } from '../pages/edit.page'
import { ExpoPage } from '../pages/expo/expo.page'
import { FeedPage } from '../pages/feed.page'
import { FourOuFourPage } from '../pages/fourOuFour.page'
import { InboxPage } from '../pages/inbox.page'
import { HomePage } from '../pages/index.page'
import { ProfileSecurityPage } from '../pages/profile/profileSecurity.page'
import { ProfileSettingsPage } from '../pages/profile/profileSettings.page'
import { ProfileSubscriptionsPage } from '../pages/profile/profileSubscriptions.page'
import { SearchPage } from '../pages/search.page'
import { TopicPage } from '../pages/topic.page'
import { ROUTES, useRouter } from '../stores/router'
import { MODALS, showModal } from '../stores/ui'
const pagesMap: Record<keyof typeof ROUTES, Component<PageProps>> = {
author: AuthorPage,
authorComments: AuthorPage,
authorAbout: AuthorPage,
inbox: InboxPage,
expo: ExpoPage,
connect: ConnectPage,
create: CreatePage,
edit: EditPage,
editSettings: EditPage,
drafts: DraftsPage,
home: HomePage,
topics: AllTopicsPage,
topic: TopicPage,
authors: AllAuthorsPage,
feed: FeedPage,
feedMy: FeedPage,
feedNotifications: FeedPage,
feedBookmarks: FeedPage,
feedCollaborations: FeedPage,
feedDiscussions: FeedPage,
article: ArticlePage,
search: SearchPage,
discussionRules: DiscussionRulesPage,
dogma: DogmaPage,
guide: GuidePage,
help: HelpPage,
manifest: ManifestPage,
projects: ProjectsPage,
partners: PartnersPage,
principles: PrinciplesPage,
termsOfUse: TermsOfUsePage,
thanks: ThanksPage,
profileSettings: ProfileSettingsPage,
profileSecurity: ProfileSecurityPage,
profileSubscriptions: ProfileSubscriptionsPage,
fourOuFour: FourOuFourPage,
}
type Props = PageProps & { is404: boolean }
export const App = (props: Props) => {
const { page, searchParams } = useRouter<RootSearchParams>()
const is404 = createMemo(() => props.is404)
createEffect(() => {
const modal = MODALS[searchParams().m]
if (modal) {
showModal(modal)
}
})
const pageComponent = createMemo(() => {
const result = pagesMap[page()?.route || 'home']
if (is404() || !result || page()?.path === '/404') {
return FourOuFourPage
}
return result
})
return (
<MetaProvider>
<Meta name="viewport" content="width=device-width, initial-scale=1" />
<LocalizeProvider>
<MediaQueryProvider>
<SnackbarProvider>
<TopicsProvider>
<SeenProvider>
<ConfirmProvider>
<SessionProvider onStateChangeCallback={console.log}>
<FollowingProvider>
<ConnectProvider>
<NotificationsProvider>
<EditorProvider>
<InboxProvider>
<Dynamic component={pageComponent()} {...props} />
</InboxProvider>
</EditorProvider>
</NotificationsProvider>
</ConnectProvider>
</FollowingProvider>
</SessionProvider>
</ConfirmProvider>
</SeenProvider>
</TopicsProvider>
</SnackbarProvider>
</MediaQueryProvider>
</LocalizeProvider>
</MetaProvider>
)
}

View File

@ -27,6 +27,16 @@ img {
} }
.shoutBody { .shoutBody {
@include media-breakpoint-up(sm) {
:global(.width-30) {
width: 30%;
}
:global(.width-50) {
width: 50%;
}
}
font-size: 1.6rem; font-size: 1.6rem;
line-height: 1.6; line-height: 1.6;
@ -65,6 +75,16 @@ img {
blockquote[data-type='quote'], blockquote[data-type='quote'],
ta-quotation { ta-quotation {
@include media-breakpoint-up(sm) {
&[data-float='left'] {
margin-right: 1.5em;
}
&[data-float='right'] {
margin-left: 1.5em;
}
}
border: solid #000; border: solid #000;
border-width: 0 0 0 2px; border-width: 0 0 0 2px;
clear: both; clear: both;
@ -78,21 +98,11 @@ img {
&[data-float='right'] { &[data-float='right'] {
@include font-size(2.2rem); @include font-size(2.2rem);
line-height: 1.4;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
clear: none; clear: none;
} }
}
@include media-breakpoint-up(sm) { line-height: 1.4;
&[data-float='left'] {
margin-right: 1.5em;
}
&[data-float='right'] {
margin-left: 1.5em;
}
} }
&::before { &::before {
@ -106,17 +116,17 @@ img {
ta-border-sub { ta-border-sub {
@include font-size(1.4rem); @include font-size(1.4rem);
@include media-breakpoint-up(md) {
margin: 3.2rem -8.3333%;
padding: 3.2rem 8.3333%;
}
background: #f1f2f3; background: #f1f2f3;
clear: both; clear: both;
display: block; display: block;
margin: 3.2rem 0; margin: 3.2rem 0;
padding: 3.2rem; padding: 3.2rem;
@include media-breakpoint-up(md) {
margin: 3.2rem -8.3333%;
padding: 3.2rem 8.3333%;
}
p:last-child { p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@ -194,16 +204,6 @@ img {
margin: 0 8.3333% 1.5em 0; margin: 0 8.3333% 1.5em 0;
} }
@include media-breakpoint-up(sm) {
:global(.width-30) {
width: 30%;
}
:global(.width-50) {
width: 50%;
}
}
:global(.img-align-left.width-50) { :global(.img-align-left.width-50) {
@include media-breakpoint-up(xl) { @include media-breakpoint-up(xl) {
margin-left: -16.6666%; margin-left: -16.6666%;
@ -313,20 +313,24 @@ img {
} }
.shoutStats { .shoutStats {
@include media-breakpoint-down(lg) {
flex-wrap: wrap;
}
border-top: 4px solid #000; border-top: 4px solid #000;
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
padding: 3rem 0 0; padding: 3rem 0 0;
position: relative; position: relative;
@include media-breakpoint-down(lg) {
flex-wrap: wrap;
}
} }
.shoutStatsItem { .shoutStatsItem {
@include font-size(1.5rem); @include font-size(1.5rem);
@include media-breakpoint-up(xl) {
margin-right: 3.2rem;
}
align-items: center; align-items: center;
font-weight: 500; font-weight: 500;
display: flex; display: flex;
@ -334,10 +338,6 @@ img {
vertical-align: baseline; vertical-align: baseline;
cursor: pointer; cursor: pointer;
@include media-breakpoint-up(xl) {
margin-right: 3.2rem;
}
.icon { .icon {
display: inline-block; display: inline-block;
margin-right: 0.2em; margin-right: 0.2em;
@ -379,11 +379,11 @@ img {
} }
.shoutStatsItemBookmarks { .shoutStatsItemBookmarks {
margin-left: auto;
@include media-breakpoint-up(lg) { @include media-breakpoint-up(lg) {
margin-left: 0; margin-left: 0;
} }
margin-left: auto;
} }
.shoutStatsItemInner { .shoutStatsItemInner {
@ -408,6 +408,15 @@ img {
} }
.shoutStatsItemAdditionalData { .shoutStatsItemAdditionalData {
@include media-breakpoint-down(lg) {
flex: 1 100%;
order: 9;
.shoutStatsItemAdditionalDataItem {
margin-left: 0;
}
}
color: rgb(0 0 0 / 40%); color: rgb(0 0 0 / 40%);
cursor: default; cursor: default;
font-weight: normal; font-weight: normal;
@ -418,24 +427,9 @@ img {
opacity: 0.4; opacity: 0.4;
height: 2rem; height: 2rem;
} }
@include media-breakpoint-down(lg) {
flex: 1 100%;
order: 9;
.shoutStatsItemAdditionalDataItem {
margin-left: 0;
}
}
} }
.shoutStatsItemViews { .shoutStatsItemViews {
color: rgb(0 0 0 / 40%);
cursor: default;
font-weight: normal;
margin-left: auto;
white-space: nowrap;
@include media-breakpoint-down(lg) { @include media-breakpoint-down(lg) {
bottom: 0; bottom: 0;
flex: 1 40%; flex: 1 40%;
@ -449,6 +443,12 @@ img {
display: none !important; display: none !important;
} }
} }
color: rgb(0 0 0 / 40%);
cursor: default;
font-weight: normal;
margin-left: auto;
white-space: nowrap;
} }
.shoutStatsItemLabel { .shoutStatsItemLabel {
@ -457,11 +457,11 @@ img {
} }
.commentsTextLabel { .commentsTextLabel {
display: none;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
display: block; display: block;
} }
display: none;
} }
.shoutStatsItemCount { .shoutStatsItemCount {
@ -471,6 +471,12 @@ img {
} }
.shoutStatsItemAdditionalDataItem { .shoutStatsItemAdditionalDataItem {
@include media-breakpoint-down(sm) {
&:first-child {
margin-left: 0;
}
}
font-weight: normal; font-weight: normal;
display: inline-block; display: inline-block;
@ -478,12 +484,6 @@ img {
margin-right: 0; margin-right: 0;
margin-bottom: 0; margin-bottom: 0;
cursor: default; cursor: default;
@include media-breakpoint-down(sm) {
&:first-child {
margin-left: 0;
}
}
} }
.topicsList { .topicsList {

View File

@ -36,7 +36,7 @@
width: 200px; width: 200px;
height: 200px; height: 200px;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
background: var(--placeholder-color-semi) url('/icons/create-music.svg') no-repeat 50% 50%; background: var(--placeholder-color-semi) url('/icons/create-audio.svg') no-repeat 50% 50%;
.image { .image {
object-fit: cover; object-fit: cover;

View File

@ -1,11 +1,11 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js' import { Show, createSignal } from 'solid-js'
import { Topic } from '../../../graphql/schema/core.gen' import { Icon } from '~/components/_shared/Icon'
import { MediaItem } from '../../../pages/types' import { Image } from '~/components/_shared/Image'
import { Topic } from '~/graphql/schema/core.gen'
import { MediaItem } from '~/types/mediaitem'
import { CardTopic } from '../../Feed/CardTopic' import { CardTopic } from '../../Feed/CardTopic'
import { Icon } from '../../_shared/Icon'
import { Image } from '../../_shared/Image'
import styles from './AudioHeader.module.scss' import styles from './AudioHeader.module.scss'
@ -30,19 +30,19 @@ export const AudioHeader = (props: Props) => {
</div> </div>
<div class={styles.albumInfo}> <div class={styles.albumInfo}>
<Show when={props.topic}> <Show when={props.topic}>
<CardTopic title={props.topic.title} slug={props.topic.slug} /> <CardTopic title={props.topic.title || ''} slug={props.topic.slug} />
</Show> </Show>
<h1>{props.title}</h1> <h1>{props.title}</h1>
<Show when={props.artistData}> <Show when={props.artistData}>
<div class={styles.artistData}> <div class={styles.artistData}>
<Show when={props.artistData?.artist}> <Show when={props.artistData?.artist}>
<div class={styles.item}>{props.artistData.artist}</div> <div class={styles.item}>{props.artistData?.artist || ''}</div>
</Show> </Show>
<Show when={props.artistData?.date}> <Show when={props.artistData?.date}>
<div class={styles.item}>{props.artistData.date}</div> <div class={styles.item}>{props.artistData?.date || ''}</div>
</Show> </Show>
<Show when={props.artistData?.genre}> <Show when={props.artistData?.genre}>
<div class={styles.item}>{props.artistData.genre}</div> <div class={styles.item}>{props.artistData?.genre || ''}</div>
</Show> </Show>
</div> </div>
</Show> </Show>

View File

@ -3,27 +3,32 @@
} }
.playerHeader { .playerHeader {
width: 100%;
display: flex;
justify-content: space-between;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
flex-direction: column; flex-direction: column;
} }
width: 100%;
display: flex;
justify-content: space-between;
} }
.playerTitle { .playerTitle {
@include media-breakpoint-down(sm) {
max-width: 100%;
}
max-width: 50%; max-width: 50%;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@include media-breakpoint-down(sm) {
max-width: 100%;
}
} }
.playerControls { .playerControls {
@include media-breakpoint-down(sm) {
margin-top: 20px;
margin-left: 0;
}
display: flex; display: flex;
min-width: 160px; min-width: 160px;
align-items: center; align-items: center;
@ -42,11 +47,6 @@
} }
} }
@include media-breakpoint-down(sm) {
margin-top: 20px;
margin-left: 0;
}
.playButton { .playButton {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -1,6 +1,6 @@
import { Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js' import { Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
import { MediaItem } from '../../../pages/types' import { MediaItem } from '~/types/mediaitem'
import { PlayerHeader } from './PlayerHeader' import { PlayerHeader } from './PlayerHeader'
import { PlayerPlaylist } from './PlayerPlaylist' import { PlayerPlaylist } from './PlayerPlaylist'
@ -12,18 +12,22 @@ type Props = {
articleSlug?: string articleSlug?: string
body?: string body?: string
editorMode?: boolean editorMode?: boolean
onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void onMediaItemFieldChange?: (
onChangeMediaIndex?: (direction: 'up' | 'down', index) => void index: number,
field: keyof MediaItem | string | number | symbol,
value: string
) => void
onChangeMediaIndex?: (direction: 'up' | 'down', index: number) => void
} }
const getFormattedTime = (point: number) => new Date(point * 1000).toISOString().slice(14, -5) const getFormattedTime = (point: number) => new Date(point * 1000).toISOString().slice(14, -5)
export const AudioPlayer = (props: Props) => { export const AudioPlayer = (props: Props) => {
const audioRef: { current: HTMLAudioElement } = { current: null } let audioRef: HTMLAudioElement | undefined
const gainNodeRef: { current: GainNode } = { current: null } let gainNodeRef: GainNode | undefined
const progressRef: { current: HTMLDivElement } = { current: null } let progressRef: HTMLDivElement | undefined
const audioContextRef: { current: AudioContext } = { current: null } let audioContextRef: AudioContext | undefined
const mouseDownRef: { current: boolean } = { current: false } let mouseDownRef: boolean | undefined
const [currentTrackDuration, setCurrentTrackDuration] = createSignal(0) const [currentTrackDuration, setCurrentTrackDuration] = createSignal(0)
const [currentTime, setCurrentTime] = createSignal(0) const [currentTime, setCurrentTime] = createSignal(0)
@ -31,34 +35,25 @@ export const AudioPlayer = (props: Props) => {
const [isPlaying, setIsPlaying] = createSignal(false) const [isPlaying, setIsPlaying] = createSignal(false)
const currentTack = createMemo(() => props.media[currentTrackIndex()]) const currentTack = createMemo(() => props.media[currentTrackIndex()])
createEffect(on(currentTrackIndex, () => setCurrentTrackDuration(0), { defer: true }))
createEffect(
on(
() => currentTrackIndex(),
() => {
setCurrentTrackDuration(0)
},
{ defer: true },
),
)
const handlePlayMedia = async (trackIndex: number) => { const handlePlayMedia = async (trackIndex: number) => {
setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex()) setIsPlaying(!isPlaying() || trackIndex !== currentTrackIndex())
setCurrentTrackIndex(trackIndex) setCurrentTrackIndex(trackIndex)
if (audioContextRef.current.state === 'suspended') { if (audioContextRef?.state === 'suspended') {
await audioContextRef.current.resume() await audioContextRef?.resume()
} }
if (isPlaying()) { if (isPlaying()) {
await audioRef.current.play() await audioRef?.play()
} else { } else {
audioRef.current.pause() audioRef?.pause()
} }
} }
const handleVolumeChange = (volume: number) => { const handleVolumeChange = (volume: number) => {
gainNodeRef.current.gain.value = volume if (gainNodeRef) gainNodeRef.gain.value = volume
} }
const handleAudioEnd = () => { const handleAudioEnd = () => {
@ -67,21 +62,22 @@ export const AudioPlayer = (props: Props) => {
return return
} }
audioRef.current.currentTime = 0 if (audioRef) audioRef.currentTime = 0
setIsPlaying(false) setIsPlaying(false)
setCurrentTrackIndex(0) setCurrentTrackIndex(0)
} }
const handleAudioTimeUpdate = () => { const handleAudioTimeUpdate = () => {
setCurrentTime(audioRef.current.currentTime) setCurrentTime(audioRef?.currentTime || 0)
} }
onMount(() => { onMount(() => {
audioContextRef.current = new AudioContext() audioContextRef = new AudioContext()
gainNodeRef.current = audioContextRef.current.createGain() gainNodeRef = audioContextRef.createGain()
if (audioRef) {
const track = audioContextRef.current.createMediaElementSource(audioRef.current) const track = audioContextRef?.createMediaElementSource(audioRef)
track.connect(gainNodeRef.current).connect(audioContextRef.current.destination) track.connect(gainNodeRef).connect(audioContextRef?.destination)
}
}) })
const playPrevTrack = () => { const playPrevTrack = () => {
@ -102,13 +98,18 @@ export const AudioPlayer = (props: Props) => {
setCurrentTrackIndex(newCurrentTrackIndex) setCurrentTrackIndex(newCurrentTrackIndex)
} }
const handleMediaItemFieldChange = (index: number, field: keyof MediaItem, value) => { const handleMediaItemFieldChange = (
props.onMediaItemFieldChange(index, field, value) index: number,
field: keyof MediaItem | string | number | symbol,
value: string
) => {
props.onMediaItemFieldChange?.(index, field, value)
} }
const scrub = (event) => { const scrub = (event: MouseEvent | undefined) => {
audioRef.current.currentTime = if (progressRef && audioRef) {
(event.offsetX / progressRef.current.offsetWidth) * currentTrackDuration() audioRef.currentTime = (event?.offsetX || 0 / progressRef.offsetWidth) * currentTrackDuration()
}
} }
return ( return (
@ -125,16 +126,16 @@ export const AudioPlayer = (props: Props) => {
<div class={styles.timeline}> <div class={styles.timeline}>
<div <div
class={styles.progress} class={styles.progress}
ref={(el) => (progressRef.current = el)} ref={(el) => (progressRef = el)}
onClick={(e) => scrub(e)} onClick={scrub}
onMouseMove={(e) => mouseDownRef.current && scrub(e)} onMouseMove={(e) => mouseDownRef && scrub(e)}
onMouseDown={() => (mouseDownRef.current = true)} onMouseDown={() => (mouseDownRef = true)}
onMouseUp={() => (mouseDownRef.current = false)} onMouseUp={() => (mouseDownRef = false)}
> >
<div <div
class={styles.progressFilled} class={styles.progressFilled}
style={{ style={{
width: `${(currentTime() / currentTrackDuration()) * 100 || 0}%`, width: `${(currentTime() / currentTrackDuration()) * 100 || 0}%`
}} }}
/> />
</div> </div>
@ -145,13 +146,13 @@ export const AudioPlayer = (props: Props) => {
</Show> </Show>
</div> </div>
<audio <audio
ref={(el) => (audioRef.current = el)} ref={(el) => (audioRef = el)}
onTimeUpdate={handleAudioTimeUpdate} onTimeUpdate={handleAudioTimeUpdate}
src={currentTack().url.replace('images.discours.io', 'cdn.discours.io')} src={currentTack().url.replace('images.discours.io', 'cdn.discours.io')}
onCanPlay={() => { onCanPlay={() => {
// start to play the next track on src change // start to play the next track on src change
if (isPlaying()) { if (isPlaying() && audioRef) {
audioRef.current.play() audioRef.play()
} }
}} }}
onLoadedMetadata={({ currentTarget }) => setCurrentTrackDuration(currentTarget.duration)} onLoadedMetadata={({ currentTarget }) => setCurrentTrackDuration(currentTarget.duration)}
@ -162,7 +163,7 @@ export const AudioPlayer = (props: Props) => {
<PlayerPlaylist <PlayerPlaylist
editorMode={props.editorMode} editorMode={props.editorMode}
onPlayMedia={handlePlayMedia} onPlayMedia={handlePlayMedia}
onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex(direction, index)} onChangeMediaIndex={(direction, index) => props.onChangeMediaIndex?.(direction, index)}
isPlaying={isPlaying()} isPlaying={isPlaying()}
media={props.media} media={props.media}
currentTrackIndex={currentTrackIndex()} currentTrackIndex={currentTrackIndex()}

View File

@ -1,10 +1,9 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js' import { Show, createSignal } from 'solid-js'
import { Icon } from '~/components/_shared/Icon'
import { useOutsideClickHandler } from '~/lib/useOutsideClickHandler'
import { MediaItem } from '../../../pages/types' import { MediaItem } from '~/types/mediaitem'
import { useOutsideClickHandler } from '../../../utils/useOutsideClickHandler'
import { Icon } from '../../_shared/Icon'
import styles from './AudioPlayer.module.scss' import styles from './AudioPlayer.module.scss'
type Props = { type Props = {
@ -17,10 +16,7 @@ type Props = {
} }
export const PlayerHeader = (props: Props) => { export const PlayerHeader = (props: Props) => {
const volumeContainerRef: { current: HTMLDivElement } = { let volumeContainerRef: HTMLDivElement | undefined
current: null,
}
const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false) const [isVolumeBarOpened, setIsVolumeBarOpened] = createSignal(false)
const toggleVolumeBar = () => { const toggleVolumeBar = () => {
@ -30,7 +26,7 @@ export const PlayerHeader = (props: Props) => {
useOutsideClickHandler({ useOutsideClickHandler({
containerRef: volumeContainerRef, containerRef: volumeContainerRef,
predicate: () => isVolumeBarOpened(), predicate: () => isVolumeBarOpened(),
handler: () => toggleVolumeBar(), handler: () => toggleVolumeBar()
}) })
return ( return (
@ -42,7 +38,7 @@ export const PlayerHeader = (props: Props) => {
onClick={props.onPlayMedia} onClick={props.onPlayMedia}
class={clsx( class={clsx(
styles.playButton, styles.playButton,
props.isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay, props.isPlaying ? styles.playButtonInvertPause : styles.playButtonInvertPlay
)} )}
aria-label="Play" aria-label="Play"
data-playing="false" data-playing="false"
@ -65,7 +61,7 @@ export const PlayerHeader = (props: Props) => {
> >
<Icon name="player-arrow" /> <Icon name="player-arrow" />
</button> </button>
<div ref={(el) => (volumeContainerRef.current = el)} class={styles.volumeContainer}> <div ref={(el) => (volumeContainerRef = el)} class={styles.volumeContainer}>
<Show when={isVolumeBarOpened()}> <Show when={isVolumeBarOpened()}>
<input <input
type="range" type="range"
@ -78,7 +74,7 @@ export const PlayerHeader = (props: Props) => {
onChange={({ target }) => props.onVolumeChange(Number(target.value))} onChange={({ target }) => props.onVolumeChange(Number(target.value))}
/> />
</Show> </Show>
<button onClick={toggleVolumeBar} class={styles.volumeButton} role="button" aria-label="Volume"> <button onClick={toggleVolumeBar} class={styles.volumeButton} aria-label="Volume">
<Icon name="volume" /> <Icon name="volume" />
</button> </button>
</div> </div>

View File

@ -1,17 +1,16 @@
import { gtag } from 'ga-gtag'
import { For, Show, createSignal, lazy } from 'solid-js' import { For, Show, createSignal, lazy } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { Icon } from '~/components/_shared/Icon'
import { MediaItem } from '../../../pages/types' import { Popover } from '~/components/_shared/Popover'
import { getDescription } from '../../../utils/meta' import { useLocalize } from '~/context/localize'
import { Icon } from '../../_shared/Icon' import { MediaItem } from '~/types/mediaitem'
import { Popover } from '../../_shared/Popover' import { descFromBody } from '~/utils/meta'
import { SharePopup, getShareUrl } from '../SharePopup' import { SharePopup, getShareUrl } from '../SharePopup'
import styles from './AudioPlayer.module.scss' import styles from './AudioPlayer.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor')) const MicroEditor = lazy(() => import('../../Editor/MicroEditor'))
const GrowingTextarea = lazy(() => import('../../_shared/GrowingTextarea/GrowingTextarea')) const GrowingTextarea = lazy(() => import('~/components/_shared/GrowingTextarea/GrowingTextarea'))
type Props = { type Props = {
media: MediaItem[] media: MediaItem[]
@ -22,30 +21,30 @@ type Props = {
body?: string body?: string
editorMode?: boolean editorMode?: boolean
onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void onMediaItemFieldChange?: (index: number, field: keyof MediaItem, value: string) => void
onChangeMediaIndex?: (direction: 'up' | 'down', index) => void onChangeMediaIndex?: (direction: 'up' | 'down', index: number) => void
} }
const getMediaTitle = (itm: MediaItem, idx: number) => `${idx}. ${itm.artist} - ${itm.title}` const _getMediaTitle = (itm: MediaItem, idx: number) => `${idx}. ${itm.artist} - ${itm.title}`
export const PlayerPlaylist = (props: Props) => { export const PlayerPlaylist = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const [activeEditIndex, setActiveEditIndex] = createSignal(-1) const [activeEditIndex, setActiveEditIndex] = createSignal(-1)
const toggleDropDown = (index) => { const toggleDropDown = (index: number) => {
setActiveEditIndex(activeEditIndex() === index ? -1 : index) setActiveEditIndex(activeEditIndex() === index ? -1 : index)
} }
const handleMediaItemFieldChange = (field: keyof MediaItem, value: string) => { const handleMediaItemFieldChange = (field: keyof MediaItem, value: string) => {
props.onMediaItemFieldChange(activeEditIndex(), field, value) props.onMediaItemFieldChange?.(activeEditIndex(), field, value)
} }
const play = (index: number) => { const play = (index: number) => {
props.onPlayMedia(index) props.onPlayMedia(index)
const mi = props.media[index] //const mi = props.media[index]
gtag('event', 'select_item', { //gtag('event', 'select_item', {
item_list_id: props.articleSlug, //item_list_id: props.articleSlug,
item_list_name: getMediaTitle(mi, index), //item_list_name: getMediaTitle(mi, index),
items: props.media.map((it, ix) => getMediaTitle(it, ix)), //items: props.media.map((it, ix) => getMediaTitle(it, ix)),
}) //})
} }
return ( return (
<ul class={styles.playlist}> <ul class={styles.playlist}>
@ -90,26 +89,26 @@ export const PlayerPlaylist = (props: Props) => {
<div class={styles.actions}> <div class={styles.actions}>
<Show when={props.editorMode}> <Show when={props.editorMode}>
<Popover content={t('Move up')}> <Popover content={t('Move up')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el: HTMLElement) => void) => (
<button <button
type="button" type="button"
ref={triggerRef} ref={triggerRef}
class={styles.action} class={styles.action}
disabled={index() === 0} disabled={index() === 0}
onClick={() => props.onChangeMediaIndex('up', index())} onClick={() => props.onChangeMediaIndex?.('up', index())}
> >
<Icon name="up-button" /> <Icon name="up-button" />
</button> </button>
)} )}
</Popover> </Popover>
<Popover content={t('Move down')}> <Popover content={t('Move down')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el: HTMLElement) => void) => (
<button <button
type="button" type="button"
ref={triggerRef} ref={triggerRef}
class={styles.action} class={styles.action}
disabled={index() === props.media.length - 1} disabled={index() === props.media.length - 1}
onClick={() => props.onChangeMediaIndex('down', index())} onClick={() => props.onChangeMediaIndex?.('down', index())}
> >
<Icon name="up-button" class={styles.moveIconDown} /> <Icon name="up-button" class={styles.moveIconDown} />
</button> </button>
@ -118,7 +117,7 @@ export const PlayerPlaylist = (props: Props) => {
</Show> </Show>
<Show when={(mi.lyrics || mi.body) && !props.editorMode}> <Show when={(mi.lyrics || mi.body) && !props.editorMode}>
<Popover content={t('Show lyrics')}> <Popover content={t('Show lyrics')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el: HTMLElement) => void) => (
<button ref={triggerRef} type="button" onClick={() => toggleDropDown(index())}> <button ref={triggerRef} type="button" onClick={() => toggleDropDown(index())}>
<Icon name="list" /> <Icon name="list" />
</button> </button>
@ -126,7 +125,7 @@ export const PlayerPlaylist = (props: Props) => {
</Popover> </Popover>
</Show> </Show>
<Popover content={props.editorMode ? t('Edit') : t('Share')}> <Popover content={props.editorMode ? t('Edit') : t('Share')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el: HTMLElement) => void) => (
<div ref={triggerRef}> <div ref={triggerRef}>
<Show <Show
when={!props.editorMode} when={!props.editorMode}
@ -138,8 +137,8 @@ export const PlayerPlaylist = (props: Props) => {
> >
<SharePopup <SharePopup
title={mi.title} title={mi.title}
description={getDescription(props.body)} description={descFromBody(props.body || '')}
imageUrl={mi.pic} imageUrl={mi.pic || ''}
shareUrl={getShareUrl({ pathname: `/${props.articleSlug}` })} shareUrl={getShareUrl({ pathname: `/${props.articleSlug}` })}
trigger={ trigger={
<div> <div>
@ -172,11 +171,10 @@ export const PlayerPlaylist = (props: Props) => {
} }
> >
<div class={styles.descriptionBlock}> <div class={styles.descriptionBlock}>
<SimplifiedEditor <MicroEditor
initialContent={mi.body} content={mi.body}
placeholder={`${t('Description')}...`} placeholder={`${t('Description')}...`}
smallHeight={true} onChange={(value: string) => handleMediaItemFieldChange('body', value)}
onChange={(value) => handleMediaItemFieldChange('body', value)}
/> />
<GrowingTextarea <GrowingTextarea
allowEnterKey={true} allowEnterKey={true}

View File

@ -1,14 +1,14 @@
.comment { .comment {
@include media-breakpoint-down(sm) {
padding-right: 0;
}
margin: 0 0 0.5em; margin: 0 0 0.5em;
padding: 0 1rem; padding: 0 1rem;
transition: background-color 0.3s; transition: background-color 0.3s;
position: relative; position: relative;
list-style: none; list-style: none;
@include media-breakpoint-down(sm) {
padding-right: 0;
}
&.isNew { &.isNew {
border-radius: 6px; border-radius: 6px;
background: rgb(38 56 217 / 5%); background: rgb(38 56 217 / 5%);
@ -193,9 +193,6 @@
.articleLink { .articleLink {
@include font-size(1.2rem); @include font-size(1.2rem);
flex: 0 0 50%;
margin-right: 2em;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
margin: 0.3em 0 0.5em; margin: 0.3em 0 0.5em;
} }
@ -208,20 +205,25 @@
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
} }
flex: 0 0 50%;
margin-right: 2em;
} }
.articleLinkIcon { .articleLinkIcon {
@include media-breakpoint-up(md) {
margin-left: 1em;
}
display: inline-block; display: inline-block;
margin-right: 1em; margin-right: 1em;
vertical-align: middle; vertical-align: middle;
width: 1em; width: 1em;
@include media-breakpoint-up(md) {
margin-left: 1em;
}
} }
.commentDates { .commentDates {
@include font-size(1.2rem);
flex: 1; flex: 1;
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@ -231,8 +233,6 @@
margin: 0 1em 4px 0; margin: 0 1em 4px 0;
color: rgb(0 0 0 / 30%); color: rgb(0 0 0 / 30%);
@include font-size(1.2rem);
.date { .date {
.icon { .icon {
line-height: 1; line-height: 1;
@ -246,13 +246,13 @@
} }
.commentDetails { .commentDetails {
padding: 1rem 0.2rem 0;
margin-bottom: 1.2rem;
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
align-items: center; align-items: center;
display: flex; display: flex;
} }
padding: 1rem 0.2rem 0;
margin-bottom: 1.2rem;
} }
.compactUserpic { .compactUserpic {

View File

@ -1,24 +1,27 @@
import { getPagePath } from '@nanostores/router' import { A } from '@solidjs/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js' import { For, Show, Suspense, createMemo, createSignal, lazy } from 'solid-js'
import { Icon } from '~/components/_shared/Icon'
import { useConfirm } from '../../../context/confirm' import { ShowIfAuthenticated } from '~/components/_shared/ShowIfAuthenticated'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '~/context/localize'
import { useReactions } from '../../../context/reactions' import { useReactions } from '~/context/reactions'
import { useSession } from '../../../context/session' import { useSession } from '~/context/session'
import { useSnackbar } from '../../../context/snackbar' import { useSnackbar, useUI } from '~/context/ui'
import { Author, Reaction, ReactionKind } from '../../../graphql/schema/core.gen' import deleteReactionMutation from '~/graphql/mutation/core/reaction-destroy'
import { router } from '../../../stores/router' import {
Author,
MutationCreate_ReactionArgs,
MutationUpdate_ReactionArgs,
Reaction,
ReactionKind
} from '~/graphql/schema/core.gen'
import { AuthorLink } from '../../Author/AuthorLink' import { AuthorLink } from '../../Author/AuthorLink'
import { Userpic } from '../../Author/Userpic' import { Userpic } from '../../Author/Userpic'
import { Icon } from '../../_shared/Icon'
import { ShowIfAuthenticated } from '../../_shared/ShowIfAuthenticated'
import { CommentDate } from '../CommentDate' import { CommentDate } from '../CommentDate'
import { RatingControl as CommentRatingControl } from '../RatingControl' import { CommentRatingControl } from '../CommentRatingControl'
import styles from './Comment.module.scss' import styles from './Comment.module.scss'
const SimplifiedEditor = lazy(() => import('../../Editor/SimplifiedEditor')) const MiniEditor = lazy(() => import('../../Editor/MiniEditor'))
type Props = { type Props = {
comment: Reaction comment: Reaction
@ -38,20 +41,19 @@ export const Comment = (props: Props) => {
const [isReplyVisible, setIsReplyVisible] = createSignal(false) const [isReplyVisible, setIsReplyVisible] = createSignal(false)
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [editMode, setEditMode] = createSignal(false) const [editMode, setEditMode] = createSignal(false)
const [clearEditor, setClearEditor] = createSignal(false)
const [editedBody, setEditedBody] = createSignal<string>() const [editedBody, setEditedBody] = createSignal<string>()
const { author, session } = useSession() const { session, client } = useSession()
const { createReaction, deleteReaction, updateReaction } = useReactions() const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { showConfirm } = useConfirm() const { createShoutReaction, updateShoutReaction } = useReactions()
const { showConfirm } = useUI()
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const canEdit = createMemo( const canEdit = createMemo(
() => () =>
Boolean(author()?.id) && Boolean(author()?.id) &&
(props.comment?.created_by?.slug === author().slug || session()?.user?.roles.includes('editor')), (props.comment?.created_by?.slug === author()?.slug || session()?.user?.roles?.includes('editor'))
) )
const body = createMemo(() => (editedBody() ? editedBody().trim() : props.comment.body.trim() || '')) const body = createMemo(() => (editedBody() ? editedBody()?.trim() : props.comment.body?.trim() || ''))
const remove = async () => { const remove = async () => {
if (props.comment?.id) { if (props.comment?.id) {
@ -60,16 +62,24 @@ export const Comment = (props: Props) => {
confirmBody: t('Are you sure you want to delete this comment?'), confirmBody: t('Are you sure you want to delete this comment?'),
confirmButtonLabel: t('Delete'), confirmButtonLabel: t('Delete'),
confirmButtonVariant: 'danger', confirmButtonVariant: 'danger',
declineButtonVariant: 'primary', declineButtonVariant: 'primary'
}) })
if (isConfirmed) { if (isConfirmed) {
const { error } = await deleteReaction(props.comment.id) const resp = await client()
?.mutation(deleteReactionMutation, { id: props.comment.id })
.toPromise()
const result = resp?.data?.delete_reaction
const { error } = result
const notificationType = error ? 'error' : 'success' const notificationType = error ? 'error' : 'success'
const notificationMessage = error const notificationMessage = error
? t('Failed to delete comment') ? t('Failed to delete comment')
: t('Comment successfully deleted') : t('Comment successfully deleted')
await showSnackbar({ type: notificationType, body: notificationMessage }) await showSnackbar({
type: notificationType,
body: notificationMessage,
duration: 3
})
if (!error && props.onDelete) { if (!error && props.onDelete) {
props.onDelete(props.comment.id) props.onDelete(props.comment.id)
@ -82,37 +92,39 @@ export const Comment = (props: Props) => {
} }
} }
const handleCreate = async (value) => { const handleCreate = async (value: string) => {
try { try {
setLoading(true) setLoading(true)
await createReaction({ await createShoutReaction({
reaction: {
kind: ReactionKind.Comment, kind: ReactionKind.Comment,
reply_to: props.comment.id, reply_to: props.comment.id,
body: value, body: value,
shout: props.comment.shout.id, shout: props.comment.shout.id
}) }
setClearEditor(true) } as MutationCreate_ReactionArgs)
setIsReplyVisible(false) setIsReplyVisible(false)
setLoading(false) setLoading(false)
} catch (error) { } catch (error) {
console.error('[handleCreate reaction]:', error) console.error('[handleCreate reaction]:', error)
} }
setClearEditor(false)
} }
const toggleEditMode = () => { const toggleEditMode = () => {
setEditMode((oldEditMode) => !oldEditMode) setEditMode((oldEditMode) => !oldEditMode)
} }
const handleUpdate = async (value) => { const handleUpdate = async (value: string) => {
setLoading(true) setLoading(true)
try { try {
const reaction = await updateReaction({ const reaction = await updateShoutReaction({
id: props.comment.id, reaction: {
id: props.comment.id || 0,
kind: ReactionKind.Comment, kind: ReactionKind.Comment,
body: value, body: value,
shout: props.comment.shout.id, shout: props.comment.shout.id
}) }
} as MutationUpdate_ReactionArgs)
if (reaction) { if (reaction) {
setEditedBody(value) setEditedBody(value)
} }
@ -127,7 +139,8 @@ export const Comment = (props: Props) => {
<li <li
id={`comment_${props.comment.id}`} id={`comment_${props.comment.id}`}
class={clsx(styles.comment, props.class, { class={clsx(styles.comment, props.class, {
[styles.isNew]: props.lastSeen > (props.comment.updated_at || props.comment.created_at), [styles.isNew]:
(props.lastSeen || Date.now()) > (props.comment.updated_at || props.comment.created_at)
})} })}
> >
<Show when={!!body()}> <Show when={!!body()}>
@ -137,10 +150,10 @@ export const Comment = (props: Props) => {
fallback={ fallback={
<div> <div>
<Userpic <Userpic
name={props.comment.created_by.name} name={props.comment.created_by.name || ''}
userpic={props.comment.created_by.pic} userpic={props.comment.created_by.pic || ''}
class={clsx({ class={clsx({
[styles.compactUserpic]: props.compact, [styles.compactUserpic]: props.compact
})} })}
/> />
<small> <small>
@ -161,13 +174,9 @@ export const Comment = (props: Props) => {
<Show when={props.showArticleLink}> <Show when={props.showArticleLink}>
<div class={styles.articleLink}> <div class={styles.articleLink}>
<Icon name="arrow-right" class={styles.articleLinkIcon} /> <Icon name="arrow-right" class={styles.articleLinkIcon} />
<a <A href={`${props.comment.shout.slug}?commentId=${props.comment.id}`}>
href={`${getPagePath(router, 'article', {
slug: props.comment.shout.slug,
})}?commentId=${props.comment.id}`}
>
{props.comment.shout.title} {props.comment.shout.title}
</a> </A>
</div> </div>
</Show> </Show>
<CommentDate showOnHover={true} comment={props.comment} isShort={true} /> <CommentDate showOnHover={true} comment={props.comment} isShort={true} />
@ -177,16 +186,11 @@ export const Comment = (props: Props) => {
<div class={styles.commentBody}> <div class={styles.commentBody}>
<Show when={editMode()} fallback={<div innerHTML={body()} />}> <Show when={editMode()} fallback={<div innerHTML={body()} />}>
<Suspense fallback={<p>{t('Loading')}</p>}> <Suspense fallback={<p>{t('Loading')}</p>}>
<SimplifiedEditor <MiniEditor
initialContent={editedBody() || props.comment.body} content={editedBody() || props.comment.body || ''}
submitButtonText={t('Save')}
quoteEnabled={true}
imageEnabled={true}
placeholder={t('Write a comment...')} placeholder={t('Write a comment...')}
onSubmit={(value) => handleUpdate(value)} onSubmit={(value) => handleUpdate(value)}
submitByCtrlEnter={true}
onCancel={() => setEditMode(false)} onCancel={() => setEditMode(false)}
setClear={clearEditor()}
/> />
</Suspense> </Suspense>
</Show> </Show>
@ -199,7 +203,7 @@ export const Comment = (props: Props) => {
disabled={loading()} disabled={loading()}
onClick={() => { onClick={() => {
setIsReplyVisible(!isReplyVisible()) setIsReplyVisible(!isReplyVisible())
props.clickedReply(props.comment.id) props.clickedReply?.(props.comment.id)
}} }}
class={clsx(styles.commentControl, styles.commentControlReply)} class={clsx(styles.commentControl, styles.commentControlReply)}
> >
@ -240,18 +244,15 @@ export const Comment = (props: Props) => {
{/* class={clsx(styles.commentControl, styles.commentControlComplain)}*/} {/* class={clsx(styles.commentControl, styles.commentControlComplain)}*/}
{/* onClick={() => showModal('reportComment')}*/} {/* onClick={() => showModal('reportComment')}*/}
{/*>*/} {/*>*/}
{/* {t('Report')}*/} {/* {t('Complain')}*/}
{/*</button>*/} {/*</button>*/}
</div> </div>
<Show when={isReplyVisible() && props.clickedReplyId === props.comment.id}> <Show when={isReplyVisible() && props.clickedReplyId === props.comment.id}>
<Suspense fallback={<p>{t('Loading')}</p>}> <Suspense fallback={<p>{t('Loading')}</p>}>
<SimplifiedEditor <MiniEditor
quoteEnabled={true}
imageEnabled={true}
placeholder={t('Write a comment...')} placeholder={t('Write a comment...')}
onSubmit={(value) => handleCreate(value)} onSubmit={(value) => handleCreate(value)}
submitByCtrlEnter={true}
/> />
</Suspense> </Suspense>
</Show> </Show>
@ -260,7 +261,7 @@ export const Comment = (props: Props) => {
</Show> </Show>
<Show when={props.sortedComments}> <Show when={props.sortedComments}>
<ul> <ul>
<For each={props.sortedComments.filter((r) => r.reply_to === props.comment.id)}> <For each={props.sortedComments?.filter((r) => r.reply_to === props.comment.id)}>
{(c) => ( {(c) => (
<Comment <Comment
sortedComments={props.sortedComments} sortedComments={props.sortedComments}

View File

@ -1,8 +1,8 @@
import type { Reaction } from '../../../graphql/schema/core.gen' import type { Reaction } from '~/graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '~/context/localize'
import styles from './CommentDate.module.scss' import styles from './CommentDate.module.scss'
@ -28,7 +28,7 @@ export const CommentDate = (props: Props) => {
<div <div
class={clsx(styles.commentDates, { class={clsx(styles.commentDates, {
[styles.commentDatesLastInRow]: props.isLastInRow, [styles.commentDatesLastInRow]: props.isLastInRow,
[styles.showOnHover]: props.showOnHover, [styles.showOnHover]: props.showOnHover
})} })}
> >
<time class={styles.date}>{formattedDate(props.comment.created_at * 1000)}</time> <time class={styles.date}>{formattedDate(props.comment.created_at * 1000)}</time>

View File

@ -0,0 +1,120 @@
import { clsx } from 'clsx'
import { createMemo } from 'solid-js'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session'
import { useSnackbar } from '~/context/ui'
import { Reaction, ReactionKind } from '~/graphql/schema/core.gen'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
import styles from './CommentRatingControl.module.scss'
type Props = {
comment: Reaction
}
export const CommentRatingControl = (props: Props) => {
const { t } = useLocalize()
const { loadShout } = useFeed()
const { session } = useSession()
const uid = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
const { showSnackbar } = useSnackbar()
const { reactionEntities, createShoutReaction, deleteShoutReaction, loadReactionsBy } = useReactions()
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.created_by.id === uid() &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const canVote = createMemo(() => uid() !== props.comment.created_by.id)
const commentRatingReactions = createMemo(() =>
Object.values(reactionEntities).filter(
(r) =>
[ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id
)
)
const deleteCommentReaction = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.created_by.id === uid() &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id
)
if (reactionToDelete) return deleteShoutReaction(reactionToDelete.id)
}
const handleRatingChange = async (isUpvote: boolean) => {
try {
if (isUpvoted()) {
await deleteCommentReaction(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteCommentReaction(ReactionKind.Dislike)
} else {
await createShoutReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,
reply_to: props.comment.id
}
})
}
} catch {
showSnackbar({ type: 'error', body: t('Error') })
}
await loadShout(props.comment.shout.slug)
await loadReactionsBy({
by: { shout: props.comment.shout.slug }
})
}
return (
<div class={styles.commentRating}>
<button
disabled={!(canVote() && uid())}
onClick={() => handleRatingChange(true)}
class={clsx(styles.commentRatingControl, styles.commentRatingControlUp, {
[styles.voted]: isUpvoted()
})}
/>
<Popup
trigger={
<div
class={clsx(styles.commentRatingValue, {
[styles.commentRatingPositive]: (props.comment?.stat?.rating || 0) > 0,
[styles.commentRatingNegative]: (props.comment?.stat?.rating || 0) < 0
})}
>
{props.comment?.stat?.rating || 0}
</div>
}
variant="tiny"
>
<VotersList
reactions={commentRatingReactions()}
fallbackMessage={t('This comment has not yet been rated')}
/>
</Popup>
<button
disabled={!(canVote() && uid())}
onClick={() => handleRatingChange(false)}
class={clsx(styles.commentRatingControl, styles.commentRatingControlDown, {
[styles.voted]: isDownvoted()
})}
/>
</div>
)
}

View File

@ -1,20 +1,20 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createMemo, createSignal, lazy, onMount } from 'solid-js' import { For, Show, createMemo, createSignal, lazy, onMount } from 'solid-js'
import { useLocalize } from '../../context/localize' import { useFeed } from '~/context/feed'
import { useReactions } from '../../context/reactions' import { useLocalize } from '~/context/localize'
import { useSession } from '../../context/session' import { useReactions } from '~/context/reactions'
import { Author, Reaction, ReactionKind, ReactionSort } from '../../graphql/schema/core.gen' import { useSession } from '~/context/session'
import { byCreated, byStat } from '../../utils/sortby' import { Author, Reaction, ReactionKind, ReactionSort } from '~/graphql/schema/core.gen'
import { SortFunction } from '~/types/common'
import { byCreated, byStat } from '~/utils/sort'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import { Loading } from '../_shared/Loading'
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated' import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
import styles from './Article.module.scss'
import { Comment } from './Comment' import { Comment } from './Comment'
import { useSeen } from '../../context/seen' const MiniEditor = lazy(() => import('../Editor/MiniEditor'))
import styles from './Article.module.scss'
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
type Props = { type Props = {
articleAuthors: Author[] articleAuthors: Author[]
@ -23,17 +23,16 @@ type Props = {
} }
export const CommentsTree = (props: Props) => { export const CommentsTree = (props: Props) => {
const { author } = useSession() const { session } = useSession()
const { t } = useLocalize() const { t } = useLocalize()
const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest) const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
const [onlyNew, setOnlyNew] = createSignal(false) const [onlyNew, setOnlyNew] = createSignal(false)
const [newReactions, setNewReactions] = createSignal<Reaction[]>([]) const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
const [clearEditor, setClearEditor] = createSignal(false)
const [clickedReplyId, setClickedReplyId] = createSignal<number>() const [clickedReplyId, setClickedReplyId] = createSignal<number>()
const { reactionEntities, createReaction, loadReactionsBy } = useReactions() const { reactionEntities, createShoutReaction, loadReactionsBy } = useReactions()
const comments = createMemo(() => const comments = createMemo(() =>
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT'), Object.values(reactionEntities()).filter((reaction) => reaction.kind === 'COMMENT')
) )
const sortedComments = createMemo(() => { const sortedComments = createMemo(() => {
@ -45,21 +44,24 @@ export const CommentsTree = (props: Props) => {
} }
if (commentsOrder() === ReactionSort.Like) { if (commentsOrder() === ReactionSort.Like) {
newSortedComments = newSortedComments.sort(byStat('rating')) newSortedComments = newSortedComments.sort(byStat('rating') as SortFunction<Reaction>)
} }
return newSortedComments return newSortedComments
}) })
const { seen } = useSeen() const { seen } = useFeed()
const shoutLastSeen = createMemo(() => seen()[props.shoutSlug] ?? 0) const shoutLastSeen = createMemo(() => seen()[props.shoutSlug] ?? 0)
const currentDate = new Date()
const setCookie = () => localStorage.setItem(`${props.shoutSlug}`, `${currentDate}`)
onMount(() => { onMount(() => {
const currentDate = new Date()
const setCookie = () => localStorage?.setItem(`${props.shoutSlug}`, `${currentDate}`)
if (!shoutLastSeen()) { if (!shoutLastSeen()) {
setCookie() setCookie()
} else if (currentDate.getTime() > shoutLastSeen()) { } else if (currentDate.getTime() > shoutLastSeen()) {
const newComments = comments().filter((c) => { const newComments = comments().filter((c) => {
if (c.reply_to || c.created_by.slug === author()?.slug) { if (
(session()?.user?.app_data?.profile?.id && c.reply_to) ||
c.created_by.id === session()?.user?.app_data?.profile?.id
) {
return return
} }
return (c.updated_at || c.created_at) > shoutLastSeen() return (c.updated_at || c.created_at) > shoutLastSeen()
@ -68,21 +70,22 @@ export const CommentsTree = (props: Props) => {
setCookie() setCookie()
} }
}) })
const [posting, setPosting] = createSignal(false) const [posting, setPosting] = createSignal(false)
const handleSubmitComment = async (value: string) => { const handleSubmitComment = async (value: string) => {
setPosting(true) setPosting(true)
try { try {
await createReaction({ await createShoutReaction({
reaction: {
kind: ReactionKind.Comment, kind: ReactionKind.Comment,
body: value, body: value,
shout: props.shoutId, shout: props.shoutId
}
}) })
setClearEditor(true)
await loadReactionsBy({ by: { shout: props.shoutSlug } }) await loadReactionsBy({ by: { shout: props.shoutSlug } })
} catch (error) { } catch (error) {
console.error('[handleCreate reaction]:', error) console.error('[handleCreate reaction]:', error)
} }
setClearEditor(false)
setPosting(false) setPosting(false)
} }
@ -92,7 +95,7 @@ export const CommentsTree = (props: Props) => {
<h2 class={styles.commentsHeader}> <h2 class={styles.commentsHeader}>
{t('Comments')} {comments().length.toString() || ''} {t('Comments')} {comments().length.toString() || ''}
<Show when={newReactions().length > 0}> <Show when={newReactions().length > 0}>
<span class={styles.newReactions}>&nbsp;+{newReactions().length}</span> <span class={styles.newReactions}>{` +${newReactions().length}`}</span>
</Show> </Show>
</h2> </h2>
<Show when={comments().length > 0}> <Show when={comments().length > 0}>
@ -128,9 +131,7 @@ export const CommentsTree = (props: Props) => {
{(reaction) => ( {(reaction) => (
<Comment <Comment
sortedComments={sortedComments()} sortedComments={sortedComments()}
isArticleAuthor={Boolean( isArticleAuthor={Boolean(props.articleAuthors.some((a) => a?.id === reaction.created_by.id))}
props.articleAuthors.some((a) => a?.slug === reaction.created_by.slug),
)}
comment={reaction} comment={reaction}
clickedReply={(id) => setClickedReplyId(id)} clickedReply={(id) => setClickedReplyId(id)}
clickedReplyId={clickedReplyId()} clickedReplyId={clickedReplyId()}
@ -146,23 +147,17 @@ export const CommentsTree = (props: Props) => {
<a href="?m=auth&mode=register" class={styles.link}> <a href="?m=auth&mode=register" class={styles.link}>
{t('sign up')} {t('sign up')}
</a>{' '} </a>{' '}
{t('or')}&nbsp; {t('or')}{' '}
<a href="?m=auth&mode=login" class={styles.link}> <a href="?m=auth&mode=login" class={styles.link}>
{t('sign in')} {t('sign in')}
</a> </a>
</div> </div>
} }
> >
<SimplifiedEditor <MiniEditor placeholder={t('Write a comment...')} onSubmit={handleSubmitComment} />
quoteEnabled={true} <Show when={posting()}>
imageEnabled={true} <Loading />
autoFocus={false} </Show>
submitByCtrlEnter={true}
placeholder={t('Write a comment...')}
onSubmit={(value) => handleSubmitComment(value)}
setClear={clearEditor()}
isPosting={posting()}
/>
</ShowIfAuthenticated> </ShowIfAuthenticated>
</> </>
) )

View File

@ -26,7 +26,7 @@ const coverImages = [
CoverImage9, CoverImage9,
CoverImage10, CoverImage10,
CoverImage11, CoverImage11,
CoverImage12, CoverImage12
] ]
let counter = 0 let counter = 0

View File

@ -1,50 +1,44 @@
import type { Author, Reaction, Shout, Topic } from '../../graphql/schema/core.gen' import { AuthToken } from '@authorizerdev/authorizer-js'
import { getPagePath } from '@nanostores/router'
import { createPopper } from '@popperjs/core' import { createPopper } from '@popperjs/core'
import { Link } from '@solidjs/meta'
import { A, useSearchParams } from '@solidjs/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { install } from 'ga-gtag'
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js' import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
import { isServer } from 'solid-js/web' import { isServer } from 'solid-js/web'
import { Link, Meta } from '../../context/meta' import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { useLocalize } from '../../context/localize' import { useReactions } from '~/context/reactions'
import { useReactions } from '../../context/reactions' import { useSession } from '~/context/session'
import { useSession } from '../../context/session' import { DEFAULT_HEADER_OFFSET, useUI } from '~/context/ui'
import { MediaItem } from '../../pages/types' import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen'
import { DEFAULT_HEADER_OFFSET, router, useRouter } from '../../stores/router' import { processPrepositions } from '~/intl/prepositions'
import { showModal } from '../../stores/ui' import { isCyrillic } from '~/intl/translate'
import { capitalize } from '../../utils/capitalize' import { getImageUrl } from '~/lib/getThumbUrl'
import { getImageUrl, getOpenGraphImageUrl } from '../../utils/getImageUrl' import { MediaItem } from '~/types/mediaitem'
import { getDescription, getKeywords } from '../../utils/meta' import { capitalize } from '~/utils/capitalize'
import { isCyrillic } from '../../utils/translate'
import { AuthorBadge } from '../Author/AuthorBadge' import { AuthorBadge } from '../Author/AuthorBadge'
import { CardTopic } from '../Feed/CardTopic' import { CardTopic } from '../Feed/CardTopic'
import { FeedArticlePopup } from '../Feed/FeedArticlePopup' import { FeedArticlePopup } from '../Feed/FeedArticlePopup'
import { Modal } from '../Nav/Modal' import stylesHeader from '../HeaderNav/Header.module.scss'
import { TableOfContents } from '../TableOfContents'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { Image } from '../_shared/Image' import { Image } from '../_shared/Image'
import { InviteMembers } from '../_shared/InviteMembers' import { InviteMembers } from '../_shared/InviteMembers'
import { Lightbox } from '../_shared/Lightbox' import { Lightbox } from '../_shared/Lightbox'
import { Modal } from '../_shared/Modal'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { ShareModal } from '../_shared/ShareModal' import { ShareModal } from '../_shared/ShareModal'
import { ImageSwiper } from '../_shared/SolidSwiper' import { ImageSwiper } from '../_shared/SolidSwiper'
import { TableOfContents } from '../_shared/TableOfContents'
import { VideoPlayer } from '../_shared/VideoPlayer' import { VideoPlayer } from '../_shared/VideoPlayer'
import styles from './Article.module.scss'
import { AudioHeader } from './AudioHeader' import { AudioHeader } from './AudioHeader'
import { AudioPlayer } from './AudioPlayer' import { AudioPlayer } from './AudioPlayer'
import { CommentsTree } from './CommentsTree' import { CommentsTree } from './CommentsTree'
import { RatingControl as ShoutRatingControl } from './RatingControl'
import { SharePopup, getShareUrl } from './SharePopup' import { SharePopup, getShareUrl } from './SharePopup'
import { ShoutRatingControl } from './ShoutRatingControl'
import { useSeen } from '../../context/seen'
import stylesHeader from '../Nav/Header/Header.module.scss'
import styles from './Article.module.scss'
type Props = { type Props = {
article: Shout article: Shout
scrollToComments?: boolean
} }
type IframeSize = { type IframeSize = {
@ -53,73 +47,105 @@ type IframeSize = {
} }
export type ArticlePageSearchParams = { export type ArticlePageSearchParams = {
scrollTo: 'comments' commentId?: string
commentId: string
slide?: string slide?: string
} }
const scrollTo = (el: HTMLElement) => { const scrollTo = (el: HTMLElement) => {
const { top } = el.getBoundingClientRect() const { top } = el.getBoundingClientRect()
window.scrollTo({
window?.scrollTo({
top: top + window.scrollY - DEFAULT_HEADER_OFFSET, top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
left: 0, left: 0,
behavior: 'smooth', behavior: 'smooth'
}) })
} }
const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi
export const COMMENTS_PER_PAGE = 30
const VOTES_PER_PAGE = 50
export const FullArticle = (props: Props) => { export const FullArticle = (props: Props) => {
const { searchParams, changeSearchParams } = useRouter<ArticlePageSearchParams>() const [searchParams] = useSearchParams<ArticlePageSearchParams>()
const { showModal } = useUI()
const { loadReactionsBy } = useReactions() const { loadReactionsBy } = useReactions()
const [selectedImage, setSelectedImage] = createSignal('') const [selectedImage, setSelectedImage] = createSignal('')
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false) const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false) const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const { t, formatDate, lang } = useLocalize() const { t, formatDate, lang } = useLocalize()
const { author, session, requireAuthentication } = useSession() const { session, requireAuthentication } = useSession()
const { addSeen } = useSeen() const { addSeen } = useFeed()
const formattedDate = createMemo(() => formatDate(new Date((props.article.published_at || 0) * 1000)))
const formattedDate = createMemo(() => formatDate(new Date(props.article.published_at * 1000))) const [pages, setPages] = createSignal<Record<string, number>>({})
createEffect(
on(
pages,
(p: Record<string, number>) => {
console.debug('content paginated')
loadReactionsBy({
by: { shout: props.article.slug, comment: true },
limit: COMMENTS_PER_PAGE,
offset: COMMENTS_PER_PAGE * p.comments || 0
})
loadReactionsBy({
by: { shout: props.article.slug, rating: true },
limit: VOTES_PER_PAGE,
offset: VOTES_PER_PAGE * p.rating || 0
})
setIsReactionsLoaded(true)
console.debug('reactions paginated')
},
{ defer: true }
)
)
const canEdit = createMemo( const [canEdit, setCanEdit] = createSignal<boolean>(false)
() => createEffect(
Boolean(author()?.id) && on(
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) || () => session(),
props.article?.created_by?.id === author().id || (s?: AuthToken) => {
session()?.user?.roles.includes('editor')), const profile = s?.user?.app_data?.profile
if (!profile) return
const isEditor = s?.user?.roles?.includes('editor')
const isCreator = props.article.created_by?.id === profile.id
const fit = (a: Maybe<Author>) => a?.id === profile.id || isCreator || isEditor
setCanEdit((_: boolean) => Boolean(props.article.authors?.some(fit)))
}
)
) )
const mainTopic = createMemo(() => { const mainTopic = createMemo(() => {
const mainTopicSlug = props.article.topics.length > 0 ? props.article.main_topic : null const mainTopicSlug = (props.article.topics?.length || 0) > 0 ? props.article.main_topic : null
const mt = props.article.topics.find((tpc: Topic) => tpc.slug === mainTopicSlug) const mt = props.article.topics?.find((tpc: Maybe<Topic>) => tpc?.slug === mainTopicSlug)
if (mt) { if (mt) {
mt.title = lang() === 'en' ? capitalize(mt.slug.replace(/-/, ' ')) : mt.title mt.title = lang() === 'en' ? capitalize(mt.slug.replaceAll('-', ' ')) : mt.title
return mt return mt
} }
return props.article.topics[0] return props.article.topics?.[0]
}) })
const handleBookmarkButtonClick = (ev) => { const handleBookmarkButtonClick = (ev: MouseEvent | undefined) => {
requireAuthentication(() => { requireAuthentication(() => {
// TODO: implement bookmark clicked // TODO: implement bookmark clicked
ev.preventDefault() ev?.preventDefault()
}, 'bookmark') }, 'bookmark')
} }
const body = createMemo(() => { const body = createMemo(() => {
if (props.article.layout === 'literature') { if (props.article.layout === 'literature') {
try { try {
if (props.article?.media) { if (props.article.media) {
const media = JSON.parse(props.article.media) const media = JSON.parse(props.article.media)
if (media.length > 0) { if (media.length > 0) {
return media[0].body return processPrepositions(media[0].body)
} }
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} }
} }
return props.article.body return processPrepositions(props.article.body) || ''
}) })
const imageUrls = createMemo(() => { const imageUrls = createMemo(() => {
@ -129,10 +155,11 @@ export const FullArticle = (props: Props) => {
if (isServer) { if (isServer) {
const result: string[] = [] const result: string[] = []
let match: RegExpMatchArray let match: RegExpMatchArray | null
while ((match = imgSrcRegExp.exec(body())) !== null) { while ((match = imgSrcRegExp.exec(body())) !== null) {
result.push(match[1]) if (match) result.push(match[1])
else break
} }
return result return result
} }
@ -142,32 +169,25 @@ export const FullArticle = (props: Props) => {
return Array.from(imageElements).map((img) => img.src) return Array.from(imageElements).map((img) => img.src)
}) })
const media = createMemo<MediaItem[]>(() => { const media = createMemo<MediaItem[]>(() => JSON.parse(props.article.media || '[]'))
try {
return JSON.parse(props.article?.media || '[]')
} catch {
return []
}
})
const commentsRef: {
current: HTMLDivElement
} = { current: null }
let commentsRef: HTMLDivElement | undefined
createEffect(() => { createEffect(() => {
if (searchParams().commentId && isReactionsLoaded()) { if (searchParams?.commentId && isReactionsLoaded()) {
const commentElement = document.querySelector<HTMLElement>( console.debug('comment id is in link, scroll to')
`[id='comment_${searchParams().commentId}']`, const scrollToElement =
) document.querySelector<HTMLElement>(`[id='comment_${searchParams?.commentId}']`) ||
commentsRef ||
document.body
if (commentElement) { if (scrollToElement) {
requestAnimationFrame(() => scrollTo(commentElement)) requestAnimationFrame(() => scrollTo(scrollToElement))
} }
} }
}) })
const clickHandlers = [] const clickHandlers: { element: HTMLElement; handler: () => void }[] = []
const documentClickHandlers = [] const documentClickHandlers: ((e: MouseEvent) => void)[] = []
createEffect(() => { createEffect(() => {
if (!body()) { if (!body()) {
@ -175,7 +195,7 @@ export const FullArticle = (props: Props) => {
} }
const tooltipElements: NodeListOf<HTMLElement> = document.querySelectorAll( const tooltipElements: NodeListOf<HTMLElement> = document.querySelectorAll(
'[data-toggle="tooltip"], footnote', '[data-toggle="tooltip"], footnote'
) )
if (!tooltipElements) { if (!tooltipElements) {
return return
@ -185,7 +205,7 @@ export const FullArticle = (props: Props) => {
tooltip.classList.add(styles.tooltip) tooltip.classList.add(styles.tooltip)
const tooltipContent = document.createElement('div') const tooltipContent = document.createElement('div')
tooltipContent.classList.add(styles.tooltipContent) tooltipContent.classList.add(styles.tooltipContent)
tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value tooltipContent.innerHTML = element.dataset.originalTitle || element.dataset.value || ''
tooltip.append(tooltipContent) tooltip.append(tooltipContent)
@ -200,19 +220,19 @@ export const FullArticle = (props: Props) => {
modifiers: [ modifiers: [
{ {
name: 'eventListeners', name: 'eventListeners',
options: { scroll: false }, options: { scroll: false }
}, },
{ {
name: 'offset', name: 'offset',
options: { options: {
offset: [0, 8], offset: [0, 8]
}, }
}, },
{ {
name: 'flip', name: 'flip',
options: { fallbackPlacements: ['top'] }, options: { fallbackPlacements: ['top'] }
}, }
], ]
}) })
tooltip.style.visibility = 'hidden' tooltip.style.visibility = 'hidden'
@ -229,7 +249,7 @@ export const FullArticle = (props: Props) => {
popperInstance.update() popperInstance.update()
} }
const handleDocumentClick = (e) => { const handleDocumentClick = (e: MouseEvent) => {
if (isTooltipVisible && e.target !== element && e.target !== tooltip) { if (isTooltipVisible && e.target !== element && e.target !== tooltip) {
tooltip.style.visibility = 'hidden' tooltip.style.visibility = 'hidden'
isTooltipVisible = false isTooltipVisible = false
@ -253,14 +273,15 @@ export const FullArticle = (props: Props) => {
}) })
}) })
const openLightbox = (image) => { const openLightbox = (image: string) => {
setSelectedImage(image) setSelectedImage(image)
} }
const handleLightboxClose = () => { const handleLightboxClose = () => {
setSelectedImage() setSelectedImage('')
} }
const handleArticleBodyClick = (event) => { // biome-ignore lint/suspicious/noExplicitAny: FIXME: typing
const handleArticleBodyClick = (event: any) => {
if (event.target.tagName === 'IMG' && !event.target.dataset.disableLightbox) { if (event.target.tagName === 'IMG' && !event.target.dataset.disableLightbox) {
const src = event.target.src const src = event.target.src
openLightbox(getImageUrl(src)) openLightbox(getImageUrl(src))
@ -268,12 +289,13 @@ export const FullArticle = (props: Props) => {
} }
// Check iframes size // Check iframes size
const articleContainer: { current: HTMLElement } = { current: null } let articleContainer: HTMLElement | undefined
const updateIframeSizes = () => { const updateIframeSizes = () => {
if (!(articleContainer?.current && props.article.body)) return if (!window) return
const iframes = articleContainer?.current?.querySelectorAll('iframe') if (!(articleContainer && props.article.body)) return
const iframes = articleContainer?.querySelectorAll('iframe')
if (!iframes) return if (!iframes) return
const containerWidth = articleContainer.current?.offsetWidth const containerWidth = articleContainer?.offsetWidth
iframes.forEach((iframe) => { iframes.forEach((iframe) => {
const style = window.getComputedStyle(iframe) const style = window.getComputedStyle(iframe)
const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '') const originalWidth = iframe.getAttribute('width') || style.width.replace('px', '')
@ -292,86 +314,26 @@ export const FullArticle = (props: Props) => {
}) })
} }
createEffect( onMount(() => {
on( console.debug(props.article)
() => props.article, setPages((_) => ({ comments: 0, rating: 0 }))
() => {
updateIframeSizes()
},
),
)
const [ratings, setRatings] = createSignal<Reaction[]>([])
onMount(async () => {
install('G-LQ4B87H8C2')
await loadReactionsBy({ by: { shout: props.article.slug } })
addSeen(props.article.slug) addSeen(props.article.slug)
setIsReactionsLoaded(true)
document.title = props.article.title document.title = props.article.title
updateIframeSizes()
window?.addEventListener('resize', updateIframeSizes) window?.addEventListener('resize', updateIframeSizes)
onCleanup(() => window.removeEventListener('resize', updateIframeSizes)) onCleanup(() => window.removeEventListener('resize', updateIframeSizes))
createEffect(() => {
if (props.scrollToComments) {
scrollTo(commentsRef.current)
}
}) })
createEffect(() => { const shareUrl = createMemo(() => getShareUrl({ pathname: `/${props.article.slug || ''}` }))
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) { const getAuthorName = (a: Author) =>
requestAnimationFrame(() => scrollTo(commentsRef.current)) lang() === 'en' && isCyrillic(a.name || '') ? capitalize(a.slug.replaceAll('-', ' ')) : a.name
changeSearchParams({ scrollTo: null })
}
})
})
createEffect(
on(
() => props.article,
async (shout: Shout) => {
setIsReactionsLoaded(false)
const rrr = await loadReactionsBy({ by: { shout: shout?.slug } })
setRatings((_) => rrr.filter((r) => ['LIKE', 'DISLIKE'].includes(r.kind)))
setIsReactionsLoaded(true)
},
{ defer: true },
),
)
const cover = props.article.cover ?? 'production/image/logo_image.png'
const ogImage = getOpenGraphImageUrl(cover, {
title: props.article.title,
topic: mainTopic()?.title || '',
author: props.article?.authors[0]?.name || '',
width: 1200,
})
const description = getDescription(props.article.description || body() || media()[0]?.body)
const ogTitle = props.article.title
const keywords = getKeywords(props.article)
const shareUrl = getShareUrl({ pathname: `/${props.article.slug}` })
const getAuthorName = (a: Author) => {
return lang() === 'en' && isCyrillic(a.name) ? capitalize(a.slug.replace(/-/, ' ')) : a.name
}
return ( return (
<> <>
<Meta name="descprition" content={description} />
<Meta name="keywords" content={keywords} />
<Meta name="og:type" content="article" />
<Meta name="og:title" content={ogTitle} />
<Meta name="og:image" content={ogImage} />
<Meta name="og:description" content={description} />
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={ogTitle} />
<Meta name="twitter:description" content={description} />
<Meta name="twitter:image" content={ogImage} />
<For each={imageUrls()}>{(imageUrl) => <Link rel="preload" as="image" href={imageUrl} />}</For> <For each={imageUrls()}>{(imageUrl) => <Link rel="preload" as="image" href={imageUrl} />}</For>
<div class="wide-container"> <div class="wide-container">
<div class="row position-relative"> <div class="row position-relative">
<article <article
ref={(el) => (articleContainer.current = el)} ref={(el) => (articleContainer = el)}
class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)} class={clsx('col-md-16 col-lg-14 col-xl-12 offset-md-5', styles.articleContent)}
onClick={handleArticleBodyClick} onClick={handleArticleBodyClick}
> >
@ -379,20 +341,20 @@ export const FullArticle = (props: Props) => {
<Show when={props.article.layout !== 'audio'}> <Show when={props.article.layout !== 'audio'}>
<div class={styles.shoutHeader}> <div class={styles.shoutHeader}>
<Show when={mainTopic()}> <Show when={mainTopic()}>
<CardTopic title={mainTopic().title} slug={mainTopic().slug} /> <CardTopic title={mainTopic()?.title || ''} slug={mainTopic()?.slug || ''} />
</Show> </Show>
<h1>{props.article.title}</h1> <h1>{props.article.title || ''}</h1>
<Show when={props.article.subtitle}> <Show when={props.article.subtitle}>
<h4>{props.article.subtitle}</h4> <h4>{processPrepositions(props.article.subtitle || '')}</h4>
</Show> </Show>
<div class={styles.shoutAuthor}> <div class={styles.shoutAuthor}>
<For each={props.article.authors}> <For each={props.article.authors}>
{(a: Author, index) => ( {(a: Maybe<Author>, index: () => number) => (
<> <>
<Show when={index() > 0}>, </Show> <Show when={index() > 0}>, </Show>
<a href={getPagePath(router, 'author', { slug: a.slug })}>{getAuthorName(a)}</a> <A href={`/@${a?.slug}`}>{a && getAuthorName(a)}</A>
</> </>
)} )}
</For> </For>
@ -405,25 +367,29 @@ export const FullArticle = (props: Props) => {
} }
> >
<figure class="img-align-column"> <figure class="img-align-column">
<Image width={800} alt={props.article.cover_caption} src={props.article.cover} /> <Image
<figcaption innerHTML={props.article.cover_caption} /> width={800}
alt={props.article.cover_caption || ''}
src={props.article.cover || ''}
/>
<figcaption innerHTML={props.article.cover_caption || ''} />
</figure> </figure>
</Show> </Show>
</div> </div>
</Show> </Show>
<Show when={props.article.lead}> <Show when={props.article.lead}>
<section class={styles.lead} innerHTML={props.article.lead} /> <section class={styles.lead} innerHTML={processPrepositions(props.article.lead || '')} />
</Show> </Show>
<Show when={props.article.layout === 'audio'}> <Show when={props.article.layout === 'audio'}>
<AudioHeader <AudioHeader
title={props.article.title} title={props.article.title || ''}
cover={props.article.cover} cover={props.article.cover || ''}
artistData={media()?.[0]} artistData={media()?.[0]}
topic={mainTopic()} topic={mainTopic() as Topic}
/> />
<Show when={media().length > 0}> <Show when={media().length > 0}>
<div class="media-items"> <div class="media-items">
<AudioPlayer media={media()} articleSlug={props.article.slug} body={body()} /> <AudioPlayer media={media()} articleSlug={props.article.slug || ''} body={body()} />
</div> </div>
</Show> </Show>
</Show> </Show>
@ -477,19 +443,15 @@ export const FullArticle = (props: Props) => {
<div class="col-md-16 offset-md-5"> <div class="col-md-16 offset-md-5">
<div class={styles.shoutStats}> <div class={styles.shoutStats}>
<div class={styles.shoutStatsItem}> <div class={styles.shoutStatsItem}>
<ShoutRatingControl <ShoutRatingControl shout={props.article} class={styles.ratingControl} />
shout={props.article}
class={styles.ratingControl}
ratings={ratings()}
/>
</div> </div>
<Popover content={t('Comment')} disabled={isActionPopupActive()}> <Popover content={t('Comment')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el: HTMLElement) => void) => (
<div <div
class={clsx(styles.shoutStatsItem)} class={clsx(styles.shoutStatsItem)}
ref={triggerRef} ref={triggerRef}
onClick={() => scrollTo(commentsRef.current)} onClick={() => commentsRef && scrollTo(commentsRef)}
> >
<Icon name="comment" class={styles.icon} /> <Icon name="comment" class={styles.icon} />
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} /> <Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
@ -505,7 +467,7 @@ export const FullArticle = (props: Props) => {
<Show when={props.article.stat?.viewed}> <Show when={props.article.stat?.viewed}>
<div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}> <div class={clsx(styles.shoutStatsItem, styles.shoutStatsItemViews)}>
{t('viewsWithCount', { count: props.article.stat?.viewed })} {t('some views', { count: props.article.stat?.viewed || 0 })}
</div> </div>
</Show> </Show>
@ -516,7 +478,7 @@ export const FullArticle = (props: Props) => {
</div> </div>
<Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}> <Popover content={t('Add to bookmarks')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el: HTMLElement) => void) => (
<div <div
class={clsx(styles.shoutStatsItem, styles.shoutStatsItemBookmarks)} class={clsx(styles.shoutStatsItem, styles.shoutStatsItemBookmarks)}
ref={triggerRef} ref={triggerRef}
@ -531,13 +493,13 @@ export const FullArticle = (props: Props) => {
</Popover> </Popover>
<Popover content={t('Share')} disabled={isActionPopupActive()}> <Popover content={t('Share')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el: HTMLElement) => void) => (
<div class={styles.shoutStatsItem} ref={triggerRef}> <div class={styles.shoutStatsItem} ref={triggerRef}>
<SharePopup <SharePopup
title={props.article.title} title={props.article.title}
description={description} description={props.article.description || body() || media()[0]?.body}
imageUrl={props.article.cover} imageUrl={props.article.cover || ''}
shareUrl={shareUrl} shareUrl={shareUrl()}
containerCssClass={stylesHeader.control} containerCssClass={stylesHeader.control}
onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)} onVisibilityChange={(isVisible) => setIsActionPopupActive(isVisible)}
trigger={ trigger={
@ -553,22 +515,19 @@ export const FullArticle = (props: Props) => {
<Show when={canEdit()}> <Show when={canEdit()}>
<Popover content={t('Edit')}> <Popover content={t('Edit')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el: HTMLElement) => void) => (
<div class={styles.shoutStatsItem} ref={triggerRef}> <div class={styles.shoutStatsItem} ref={triggerRef}>
<a <A href={`/edit/${props.article.id}`} class={styles.shoutStatsItemInner}>
href={getPagePath(router, 'edit', { shoutId: props.article?.id.toString() })}
class={styles.shoutStatsItemInner}
>
<Icon name="pencil-outline" class={styles.icon} /> <Icon name="pencil-outline" class={styles.icon} />
<Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} /> <Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
</a> </A>
</div> </div>
)} )}
</Popover> </Popover>
</Show> </Show>
<FeedArticlePopup <FeedArticlePopup
canEdit={canEdit()} canEdit={Boolean(canEdit())}
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)} containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
onShareClick={() => showModal('share')} onShareClick={() => showModal('share')}
onInviteClick={() => showModal('inviteMembers')} onInviteClick={() => showModal('inviteMembers')}
@ -582,7 +541,7 @@ export const FullArticle = (props: Props) => {
/> />
</div> </div>
<Show when={author()?.id && !canEdit()}> <Show when={session()?.access_token && !canEdit()}>
<div class={styles.help}> <div class={styles.help}>
<button class="button">{t('Cooperate')}</button> <button class="button">{t('Cooperate')}</button>
</div> </div>
@ -593,14 +552,14 @@ export const FullArticle = (props: Props) => {
</div> </div>
</Show> </Show>
<Show when={props.article.topics.length}> <Show when={props.article.topics?.length}>
<div class={styles.topicsList}> <div class={styles.topicsList}>
<For each={props.article.topics}> <For each={props.article.topics || []}>
{(topic) => ( {(topic) => (
<div class={styles.shoutTopic}> <div class={styles.shoutTopic}>
<a href={getPagePath(router, 'topic', { slug: topic.slug })}> <A href={`/topic/${topic?.slug || ''}`}>
{lang() === 'en' ? capitalize(topic.slug) : topic.title} {lang() === 'en' ? capitalize(topic?.slug || '') : topic?.title || ''}
</a> </A>
</div> </div>
)} )}
</For> </For>
@ -608,23 +567,23 @@ export const FullArticle = (props: Props) => {
</Show> </Show>
<div class={styles.shoutAuthorsList}> <div class={styles.shoutAuthorsList}>
<Show when={props.article.authors.length > 1}> <Show when={(props.article.authors?.length || 0) > 1}>
<h4>{t('Authors')}</h4> <h4>{t('Authors')}</h4>
</Show> </Show>
<For each={props.article.authors}> <For each={props.article.authors}>
{(a: Author) => ( {(a: Maybe<Author>) => (
<div class="col-xl-12"> <div class="col-xl-12">
<AuthorBadge iconButtons={true} showMessageButton={true} author={a} /> <AuthorBadge iconButtons={true} showMessageButton={true} author={a as Author} />
</div> </div>
)} )}
</For> </For>
</div> </div>
<div id="comments" ref={(el) => (commentsRef.current = el)}> <div id="comments" ref={(el) => (commentsRef = el)}>
<Show when={isReactionsLoaded()}> <Show when={isReactionsLoaded()}>
<CommentsTree <CommentsTree
shoutId={props.article.id} shoutId={props.article.id}
shoutSlug={props.article.slug} shoutSlug={props.article.slug}
articleAuthors={props.article.authors} articleAuthors={props.article.authors as Author[]}
/> />
</Show> </Show>
</div> </div>
@ -639,9 +598,9 @@ export const FullArticle = (props: Props) => {
</Modal> </Modal>
<ShareModal <ShareModal
title={props.article.title} title={props.article.title}
description={description} description={props.article.description || body() || media()[0]?.body}
imageUrl={props.article.cover} imageUrl={props.article.cover || ''}
shareUrl={shareUrl} shareUrl={shareUrl()}
/> />
</> </>
) )

View File

@ -1,255 +0,0 @@
import { clsx } from 'clsx'
import { Show, createEffect, createMemo, createSignal, on } from 'solid-js'
import { useLocalize } from '../../context/localize'
import { useReactions } from '../../context/reactions'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar'
import { Reaction, ReactionKind, Shout } from '../../graphql/schema/core.gen'
import { useRouter } from '../../stores/router'
import { loadShout } from '../../stores/zine/articles'
import { byCreated } from '../../utils/sortby'
import { Icon } from '../_shared/Icon'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
import stylesComment from './CommentRatingControl.module.scss'
import stylesShout from './ShoutRatingControl.module.scss'
interface RatingControlProps {
shout?: Shout
comment?: Reaction
ratings?: Reaction[]
class?: string
}
export const RatingControl = (props: RatingControlProps) => {
const { t, lang } = useLocalize()
const { changeSearchParams } = useRouter()
const snackbar = useSnackbar()
const { author } = useSession()
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === author()?.slug &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id,
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const [myRate, setMyRate] = createSignal<Reaction | undefined>()
const [total, setTotal] = createSignal(props.comment?.stat?.rating || props.shout?.stat?.rating || 0)
const [ratingReactions, setRatingReactions] = createSignal<Reaction[]>([])
createEffect(() => {
const shout = props.comment.shout.id || props.shout.id
if (shout && !ratingReactions()) {
let result = Object.values(reactionEntities).filter(
(r) => [ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) && r.shout.id === shout,
)
if (props.comment?.id) result = result.filter((r) => r.reply_to === props.comment.id)
setRatingReactions(result)
}
})
const deleteRating = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === author()?.slug &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id,
)
return deleteReaction(reactionToDelete.id)
}
const [isLoading, setIsLoading] = createSignal(false)
const handleRatingChange = async (isUpvote: boolean) => {
setIsLoading(true)
try {
if (isUpvoted()) {
await deleteRating(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteRating(ReactionKind.Dislike)
} else {
await createReaction({
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,
reply_to: props.comment.id,
})
}
} catch {
snackbar?.showSnackbar({ type: 'error', body: t('Error') })
}
await loadShout(props.comment.shout.slug)
await loadReactionsBy({
by: { shout: props.comment.shout.slug },
})
setIsLoading(false)
}
createEffect(
on(
() => props.comment,
(comment) => {
if (comment) {
setTotal(comment?.stat?.rating)
}
},
{ defer: true },
),
)
createEffect(
on(
() => props.shout,
(shout) => {
if (shout) {
setTotal(shout.stat?.rating)
}
},
{ defer: true },
),
)
createEffect(
on(
() => reactionEntities,
(reactions) => {
const ratings = Object.values(reactions).filter((r) => !r?.reply_to)
const likes = ratings.filter((rating) => rating.kind === 'LIKE').length
const dislikes = ratings.filter((rating) => rating.kind === 'DISLIKE').length
const total = likes - dislikes
setTotal(total)
},
{ defer: true },
),
)
createEffect(
on(
[ratingReactions, author],
([reactions, me]) => {
console.debug('[RatingControl] on reactions update')
const ratingVotes = Object.values(reactions).filter((r) => !r.reply_to)
setRatingReactions((_) => ratingVotes.sort(byCreated))
const myReaction = reactions.find((r) => r.created_by.id === me?.id)
setMyRate((_) => myReaction)
},
{ defer: true },
),
)
const getTrigger = createMemo(() => {
return (
<div
class={clsx(stylesComment.commentRatingValue, {
[stylesComment.commentRatingPositive]: total() > 0 && Boolean(props.comment?.id),
[stylesComment.commentRatingNegative]: total() < 0 && Boolean(props.comment?.id),
[stylesShout.ratingValue]: !props.comment?.id,
})}
>
{total()}
</div>
)
})
return props.comment?.id ? (
<div class={stylesComment.commentRating}>
<button
role="button"
disabled={!author()}
onClick={() => handleRatingChange(true)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
[stylesComment.voted]: isUpvoted(),
})}
/>
<Popup
trigger={
<div
class={clsx(stylesComment.commentRatingValue, {
[stylesComment.commentRatingPositive]: props.comment.stat.rating > 0,
[stylesComment.commentRatingNegative]: props.comment.stat.rating < 0,
})}
>
{props.comment.stat.rating || 0}
</div>
}
variant="tiny"
>
<VotersList
reactions={ratingReactions()}
fallbackMessage={t('This comment has not yet been rated')}
/>
</Popup>
<button
role="button"
disabled={!author()}
onClick={() => handleRatingChange(false)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
[stylesComment.voted]: isDownvoted(),
})}
/>
</div>
) : (
<div class={clsx(props.comment ? stylesComment.commentRating : stylesShout.rating, props.class)}>
<button
onClick={() => handleRatingChange(false)}
disabled={isLoading()}
class={
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
[stylesComment.voted]: myRate()?.kind === 'LIKE',
})
: ''
}
>
<Show when={!props.comment}>
<Icon
name={isDownvoted() ? 'rating-control-checked' : 'rating-control-less'}
class={isLoading() ? 'rotating' : ''}
/>
</Show>
</button>
<Popup trigger={getTrigger()} variant="tiny">
<Show
when={author()}
fallback={
<>
<span class="link" onClick={() => changeSearchParams({ mode: 'login', modal: 'auth' })}>
{t('Enter')}
</span>
{lang() === 'ru' ? ', ' : ' '}
{t('to see the voters')}
</>
}
>
<VotersList
reactions={ratingReactions()}
fallbackMessage={isLoading() ? t('Loading') : t('No one rated yet')}
/>
</Show>
</Popup>
<button
onClick={() => handleRatingChange(true)}
disabled={isLoading()}
class={
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
[stylesComment.voted]: myRate()?.kind === 'DISLIKE',
})
: ''
}
>
<Show when={!props.comment}>
<Icon
name={isUpvoted() ? 'rating-control-checked' : 'rating-control-more'}
class={isLoading() ? 'rotating' : ''}
/>
</Show>
</button>
</div>
)
}

View File

@ -0,0 +1,107 @@
import { clsx } from 'clsx'
import { Show, createMemo, createSignal } from 'solid-js'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session'
import type { Author } from '~/graphql/schema/core.gen'
import { ReactionKind, Shout } from '~/graphql/schema/core.gen'
import { Icon } from '../_shared/Icon'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
import styles from './ShoutRatingControl.module.scss'
interface ShoutRatingControlProps {
shout: Shout
class?: string
}
export const ShoutRatingControl = (props: ShoutRatingControlProps) => {
const { t } = useLocalize()
const { loadShout } = useFeed()
const { requireAuthentication, session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { reactionEntities, createShoutReaction, deleteShoutReaction, loadReactionsBy } = useReactions()
const [isLoading, setIsLoading] = createSignal(false)
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities()).some(
(r) =>
r.kind === reactionKind &&
r.created_by.id === author()?.id &&
r.shout.id === props.shout.id &&
!r.reply_to
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const shoutRatingReactions = createMemo(() =>
Object.values(reactionEntities()).filter(
(r) => ['LIKE', 'DISLIKE'].includes(r.kind) && r.shout.id === props.shout.id && !r.reply_to
)
)
const removeReaction = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.created_by.id === author()?.id &&
r.shout.id === props.shout.id &&
!r.reply_to
)
if (reactionToDelete) return deleteShoutReaction(reactionToDelete.id)
}
const handleRatingChange = (isUpvote: boolean) => {
requireAuthentication(async () => {
setIsLoading(true)
if (isUpvoted()) {
await removeReaction(ReactionKind.Like)
} else if (isDownvoted()) {
await removeReaction(ReactionKind.Dislike)
} else {
await createShoutReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.shout.id
}
})
}
loadShout(props.shout.slug)
loadReactionsBy({
by: { shout: props.shout.slug }
})
setIsLoading(false)
}, 'vote')
}
return (
<div class={clsx(styles.rating, props.class)}>
<button onClick={() => handleRatingChange(false)} disabled={isLoading()}>
<Show when={!isDownvoted()} fallback={<Icon name="rating-control-checked" />}>
<Icon name="rating-control-less" />
</Show>
</button>
<Popup
trigger={<span class={styles.ratingValue}>{props.shout.stat?.rating || 0}</span>}
variant="tiny"
>
<VotersList
reactions={shoutRatingReactions()}
fallbackMessage={t('This post has not been rated yet')}
/>
</Popup>
<button onClick={() => handleRatingChange(true)} disabled={isLoading()}>
<Show when={!isUpvoted()} fallback={<Icon name="rating-control-checked" />}>
<Icon name="rating-control-more" />
</Show>
</button>
</div>
)
}

View File

@ -1,10 +1,7 @@
import { JSX, Show, createEffect } from 'solid-js' import { useSearchParams } from '@solidjs/router'
import { JSX, Show, createEffect, createMemo, on } from 'solid-js'
import { useSession } from '../../context/session' import { useSession } from '~/context/session'
import { RootSearchParams } from '../../pages/types' import { useUI } from '~/context/ui'
import { useRouter } from '../../stores/router'
import { hideModal } from '../../stores/ui'
import { AuthModalSearchParams } from '../Nav/AuthModal/types'
type Props = { type Props = {
children: JSX.Element children: JSX.Element
@ -12,30 +9,32 @@ type Props = {
} }
export const AuthGuard = (props: Props) => { export const AuthGuard = (props: Props) => {
const { author, isSessionLoaded } = useSession() const { session } = useSession()
const { changeSearchParams } = useRouter<RootSearchParams & AuthModalSearchParams>() const author = createMemo<number>(() => session()?.user?.app_data?.profile?.id || 0)
const [, changeSearchParams] = useSearchParams()
const { hideModal } = useUI()
createEffect(() => { createEffect(
if (props.disabled) { on(
return [() => props.disabled, author],
} ([disabled, a]) => {
if (isSessionLoaded()) { if (disabled || !a) return
if (author()?.id) { if (a) {
console.debug('[AuthGuard] profile is loaded')
hideModal() hideModal()
} else { } else {
changeSearchParams( changeSearchParams(
{ {
source: 'authguard', source: 'authguard',
m: 'auth', m: 'auth'
}, },
true, { replace: true }
) )
} }
} else { },
// await loadSession() { defer: true }
console.warn('session is not loaded') )
} )
})
return <Show when={(isSessionLoaded() && author()?.id) || props.disabled}>{props.children}</Show> return <Show when={author() || props.disabled}>{props.children}</Show>
} }

View File

@ -1,13 +1,13 @@
.view { .view {
@include media-breakpoint-up(md) {
min-height: 600px;
}
background: var(--background-color); background: var(--background-color);
min-height: 550px; min-height: 550px;
position: relative; position: relative;
justify-content: center; justify-content: center;
@include media-breakpoint-up(md) {
min-height: 600px;
}
input { input {
font-size: 1.7rem; font-size: 1.7rem;
} }
@ -41,6 +41,10 @@
.authImage { .authImage {
@include font-size(1.5rem); @include font-size(1.5rem);
@include media-breakpoint-down(sm) {
display: none;
}
background: var(--background-color-invert) background: var(--background-color-invert)
url('https://images.discours.io/unsafe/1600x/production/image/auth-page.jpg') center no-repeat; url('https://images.discours.io/unsafe/1600x/production/image/auth-page.jpg') center no-repeat;
background-size: cover; background-size: cover;
@ -49,10 +53,6 @@
padding: 3em; padding: 3em;
position: relative; position: relative;
@include media-breakpoint-down(sm) {
display: none;
}
h2 { h2 {
text-transform: uppercase; text-transform: uppercase;
} }
@ -118,13 +118,13 @@
} }
.auth { .auth {
display: flex;
flex-direction: column;
padding: $container-padding-x;
@include media-breakpoint-up(lg) { @include media-breakpoint-up(lg) {
padding: 4rem !important; padding: 4rem !important;
} }
display: flex;
flex-direction: column;
padding: $container-padding-x;
} }
.submitButton { .submitButton {

View File

@ -1,9 +1,6 @@
import { useSearchParams } from '@solidjs/router'
import { Show } from 'solid-js' import { Show } from 'solid-js'
import { useLocalize } from '~/context/localize'
import { useLocalize } from '../../../../context/localize'
import { useRouter } from '../../../../stores/router'
import { AuthModalSearchParams } from '../types'
import styles from './AuthModalHeader.module.scss' import styles from './AuthModalHeader.module.scss'
type Props = { type Props = {
@ -12,65 +9,64 @@ type Props = {
export const AuthModalHeader = (props: Props) => { export const AuthModalHeader = (props: Props) => {
const { t } = useLocalize() const { t } = useLocalize()
const { searchParams } = useRouter<AuthModalSearchParams>() const [searchParams] = useSearchParams<{ source: string }>()
const { source } = searchParams()
const generateModalTextsFromSource = ( const generateModalTextsFromSource = (
modalType: 'login' | 'register', modalType: 'login' | 'register'
): { title: string; description: string } => { ): { title: string; description: string } => {
const title = modalType === 'login' ? 'Welcome to Discours' : 'Create account' const title = modalType === 'login' ? 'Welcome to Discours' : 'Sign up'
switch (source) { switch (searchParams?.source) {
case 'create': { case 'create': {
return { return {
title: t(`${title} to publish articles`), title: t(`${title} to publish articles`),
description: '', description: ''
} }
} }
case 'bookmark': { case 'bookmark': {
return { return {
title: t(`${title} to add to your bookmarks`), title: t(`${title} to add to your bookmarks`),
description: t( description: t(
'In&nbsp;bookmarks, you can save favorite discussions and&nbsp;materials that you want to return to', 'In bookmarks, you can save favorite discussions and materials that you want to return to'
), )
} }
} }
case 'discussions': { case 'discussions': {
return { return {
title: t(`${title} to participate in discussions`), title: t(`${title} to participate in discussions`),
description: t( description: t(
"You&nbsp;ll be able to participate in&nbsp;discussions, rate others' comments and&nbsp;learn about&nbsp;new responses", "You ll be able to participate in discussions, rate others' comments and learn about new responses"
), )
} }
} }
case 'follow': { case 'follow': {
return { return {
title: t(`${title} to subscribe`), title: t(`${title} to subscribe`),
description: t( description: t(
'This way you&nbsp;ll be able to subscribe to&nbsp;authors, interesting topics and&nbsp;customize your feed', 'This way you ll be able to subscribe to authors, interesting topics and customize your feed'
), )
} }
} }
case 'subscribe': { case 'subscribe': {
return { return {
title: t(`${title} to subscribe to new publications`), title: t(`${title} to subscribe to new publications`),
description: t( description: t(
'This way you&nbsp;ll be able to subscribe to&nbsp;authors, interesting topics and&nbsp;customize your feed', 'This way you ll be able to subscribe to authors, interesting topics and customize your feed'
), )
} }
} }
case 'vote': { case 'vote': {
return { return {
title: t(`${title} to vote`), title: t(`${title} to vote`),
description: t( description: t(
'This way we&nbsp;ll realize that you&nbsp;re a real person and&nbsp;ll take your vote into account. And&nbsp;you&nbsp;ll see how others voted', 'This way we ll realize that you re a real person and ll take your vote into account. And you ll see how others voted'
), )
} }
} }
default: { default: {
return { return {
title: t(title), title: t(title),
description: '', description: ''
} }
} }
} }

View File

@ -1,15 +1,12 @@
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { JSX, Show, createSignal } from 'solid-js' import { JSX, Show, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '../../../context/session' import { useSession } from '~/context/session'
import { useRouter } from '../../../stores/router' import { useUI } from '~/context/ui'
import { hideModal } from '../../../stores/ui'
import { PasswordField } from './PasswordField' import { PasswordField } from './PasswordField'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss' import styles from './AuthModal.module.scss'
type FormFields = { type FormFields = {
@ -19,27 +16,27 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>> type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const ChangePasswordForm = () => { export const ChangePasswordForm = () => {
const { searchParams, changeSearchParams } = useRouter<AuthModalSearchParams>() const [searchParams, changeSearchParams] = useSearchParams<{ token?: string }>()
const { hideModal } = useUI()
const { t } = useLocalize() const { t } = useLocalize()
const { changePassword } = useSession() const { changePassword } = useSession()
const [isSubmitting, setIsSubmitting] = createSignal(false) const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({}) const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const [newPassword, setNewPassword] = createSignal<string>() const [newPassword, setNewPassword] = createSignal<string>('')
const [passwordError, setPasswordError] = createSignal<string>() const [passwordError, setPasswordError] = createSignal<string>('')
const [isSuccess, setIsSuccess] = createSignal(false) const [isSuccess, setIsSuccess] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null } let authFormRef: HTMLFormElement | undefined
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
setIsSubmitting(true) setIsSubmitting(true)
if (newPassword()) { if (!newPassword()) return
changePassword(newPassword(), searchParams()?.token) if (searchParams?.token) changePassword(newPassword(), searchParams.token)
setTimeout(() => { setTimeout(() => {
setIsSubmitting(false) setIsSubmitting(false)
setIsSuccess(true) setIsSuccess(true)
}, 1000) }, 1000)
} }
}
const handlePasswordInput = (value: string) => { const handlePasswordInput = (value: string) => {
setNewPassword(value) setNewPassword(value)
@ -56,7 +53,7 @@ export const ChangePasswordForm = () => {
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
class={clsx(styles.authForm, styles.authFormForgetPassword)} class={clsx(styles.authForm, styles.authFormForgetPassword)}
ref={(el) => (authFormRef.current = el)} ref={(el) => (authFormRef = el)}
> >
<div> <div>
<h4>{t('Enter a new password')}</h4> <h4>{t('Enter a new password')}</h4>
@ -78,7 +75,7 @@ export const ChangePasswordForm = () => {
class={styles.authLink} class={styles.authLink}
onClick={() => onClick={() =>
changeSearchParams({ changeSearchParams({
mode: 'login', mode: 'login'
}) })
} }
> >

View File

@ -1,18 +1,24 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createEffect, createSignal } from 'solid-js' import { Show, createEffect, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '../../../context/session' import { useSession } from '~/context/session'
import { useRouter } from '../../../stores/router' import { useUI } from '~/context/ui'
import { hideModal } from '../../../stores/ui'
import { email, setEmail } from './sharedLogic' import { email, setEmail } from './sharedLogic'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss' import styles from './AuthModal.module.scss'
export type ConfirmEmailSearchParams = {
access_token?: string
token?: string
}
export const EmailConfirm = () => { export const EmailConfirm = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { changeSearchParams } = useRouter() const { hideModal } = useUI()
const [, changeSearchParams] = useSearchParams<ConfirmEmailSearchParams>()
const { session, authError } = useSession() const { session, authError } = useSession()
const [emailConfirmed, setEmailConfirmed] = createSignal(false) const [emailConfirmed, setEmailConfirmed] = createSignal(false)
@ -24,7 +30,7 @@ export const EmailConfirm = () => {
setEmail(email.toLowerCase()) setEmail(email.toLowerCase())
if (isVerified) setEmailConfirmed(isVerified) if (isVerified) setEmailConfirmed(isVerified)
if (authError()) { if (authError()) {
changeSearchParams({}, true) changeSearchParams({}, { replace: true })
} }
} }

View File

@ -1,20 +1,17 @@
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { JSX, Show, createSignal } from 'solid-js' import { JSX, Show, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '../../../context/session' import { useSession } from '~/context/session'
import { useSnackbar } from '../../../context/snackbar' import { useSnackbar, useUI } from '~/context/ui'
import { useRouter } from '../../../stores/router' import { validateEmail } from '~/utils/validate'
import { hideModal } from '../../../stores/ui'
import { validateEmail } from '../../../utils/validateEmail'
import { AuthModalHeader } from './AuthModalHeader' import { AuthModalHeader } from './AuthModalHeader'
import { PasswordField } from './PasswordField' import { PasswordField } from './PasswordField'
import { SocialProviders } from './SocialProviders' import { SocialProviders } from './SocialProviders'
import { email, setEmail } from './sharedLogic' import { email, setEmail } from './sharedLogic'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss' import styles from './AuthModal.module.scss'
type FormFields = { type FormFields = {
@ -25,7 +22,8 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string>> type ValidationErrors = Partial<Record<keyof FormFields, string>>
export const LoginForm = () => { export const LoginForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>() const { hideModal } = useUI()
const [, setSearchParams] = useSearchParams()
const { t } = useLocalize() const { t } = useLocalize()
const [submitError, setSubmitError] = createSignal<string | JSX.Element>() const [submitError, setSubmitError] = createSignal<string | JSX.Element>()
const [isSubmitting, setIsSubmitting] = createSignal(false) const [isSubmitting, setIsSubmitting] = createSignal(false)
@ -33,9 +31,9 @@ export const LoginForm = () => {
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({}) const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
// FIXME: use signal or remove // FIXME: use signal or remove
const [_isLinkSent, setIsLinkSent] = createSignal(false) const [_isLinkSent, setIsLinkSent] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null } let authFormRef: HTMLFormElement
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const { signIn } = useSession() const { signIn, authError } = useSession()
const handleEmailInput = (newEmail: string) => { const handleEmailInput = (newEmail: string) => {
setValidationErrors(({ email: _notNeeded, ...rest }) => rest) setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
@ -52,7 +50,7 @@ export const LoginForm = () => {
setIsLinkSent(true) setIsLinkSent(true)
setSubmitError() setSubmitError()
changeSearchParams({ mode: 'send-confirm-email' }) setSearchParams({ mode: 'send-confirm-email' })
} }
const preSendValidate = async (value: string, type: 'email' | 'password'): Promise<boolean> => { const preSendValidate = async (value: string, type: 'email' | 'password'): Promise<boolean> => {
@ -60,7 +58,7 @@ export const LoginForm = () => {
if (value === '' || !validateEmail(value)) { if (value === '' || !validateEmail(value)) {
setValidationErrors((prev) => ({ setValidationErrors((prev) => ({
...prev, ...prev,
email: t('Invalid email'), email: t('Invalid email')
})) }))
return false return false
} }
@ -68,7 +66,7 @@ export const LoginForm = () => {
if (value === '') { if (value === '') {
setValidationErrors((prev) => ({ setValidationErrors((prev) => ({
...prev, ...prev,
password: t('Please enter password'), password: t('Please enter password')
})) }))
return false return false
} }
@ -85,7 +83,7 @@ export const LoginForm = () => {
setSubmitError() setSubmitError()
if (Object.keys(validationErrors()).length > 0) { if (Object.keys(validationErrors()).length > 0) {
authFormRef.current authFormRef
.querySelector<HTMLInputElement>(`input[name="${Object.keys(validationErrors())[0]}"]`) .querySelector<HTMLInputElement>(`input[name="${Object.keys(validationErrors())[0]}"]`)
?.focus() ?.focus()
return return
@ -94,53 +92,54 @@ export const LoginForm = () => {
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const { errors } = await signIn({ email: email(), password: password() }) const success = await signIn({ email: email(), password: password() })
console.error('[signIn errors]', errors) if (!success) {
if (errors?.length > 0) { switch (authError()) {
if ( case 'user has not signed up email & password':
errors.some( case 'bad user credentials': {
(error) =>
error.message.includes('bad user credentials') || error.message.includes('user not found'),
)
) {
setValidationErrors((prev) => ({ setValidationErrors((prev) => ({
...prev, ...prev,
password: t('Something went wrong, check email and password'), password: t('Something went wrong, check email and password')
})) }))
} else if (errors.some((error) => error.message.includes('user not found'))) { break
setSubmitError('Пользователь не найден') }
} else if (errors.some((error) => error.message.includes('email not verified'))) { case 'user not found': {
setValidationErrors((prev) => ({ ...prev, email: t('User was not found') }))
break
}
case 'email not verified': {
setValidationErrors((prev) => ({ ...prev, email: t('This email is not verified') }))
break
}
default:
setSubmitError( setSubmitError(
<div class={styles.info}> <div class={styles.info}>
{t('This email is not verified')} {t('Error', authError())}
{'. '} {'. '}
<span class={'link'} onClick={handleSendLinkAgainClick}> <span class={'link'} onClick={handleSendLinkAgainClick}>
{t('Send link again')} {t('Send link again')}
</span> </span>
</div>, </div>
) )
} else {
setSubmitError(t('Error', errors[0].message))
} }
return
} }
hideModal() hideModal()
showSnackbar({ body: t('Welcome!') }) showSnackbar({ body: t('Welcome!') })
} catch (error) { } catch (error) {
console.error(error) console.error(error)
setSubmitError(error.message) setSubmitError(authError())
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
} }
return ( return (
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}> <form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef = el)}>
<div> <div>
<AuthModalHeader modalType="login" /> <AuthModalHeader modalType="login" />
<div <div
class={clsx('pretty-form__item', { class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().email, 'pretty-form__item--error': validationErrors().email
})} })}
> >
<input <input
@ -177,8 +176,8 @@ export const LoginForm = () => {
<span <span
class="link" class="link"
onClick={() => onClick={() =>
changeSearchParams({ setSearchParams({
mode: 'send-reset-link', mode: 'send-reset-link'
}) })
} }
> >
@ -194,8 +193,8 @@ export const LoginForm = () => {
<span <span
class={styles.authLink} class={styles.authLink}
onClick={() => onClick={() =>
changeSearchParams({ setSearchParams({
mode: 'register', mode: 'register'
}) })
} }
> >

View File

@ -26,13 +26,13 @@
line-height: 16px; line-height: 16px;
margin-top: 0.3em; margin-top: 0.3em;
/* Red/500 */
color: orange;
&.registerPassword { &.registerPassword {
margin-bottom: -32px; margin-bottom: -32px;
} }
/* Red/500 */
color: orange;
a { a {
color: orange; color: orange;
border-color: orange; border-color: orange;

View File

@ -1,9 +1,7 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createEffect, createSignal, on } from 'solid-js' import { Show, createEffect, createSignal, on } from 'solid-js'
import { useLocalize } from '~/context/localize'
import { useLocalize } from '../../../../context/localize' import { Icon } from '../../_shared/Icon'
import { Icon } from '../../../_shared/Icon'
import styles from './PasswordField.module.scss' import styles from './PasswordField.module.scss'
type Props = { type Props = {
@ -52,7 +50,7 @@ export const PasswordField = (props: Props) => {
return return
} }
props.onInput(value) props.onInput?.(value)
if (!props.noValidate) { if (!props.noValidate) {
const errorValue = validatePassword(value) const errorValue = validatePassword(value)
if (errorValue) { if (errorValue) {
@ -63,18 +61,8 @@ export const PasswordField = (props: Props) => {
} }
} }
createEffect( createEffect(on(error, (er) => er && props.errorMessage?.(er), { defer: true }))
on( createEffect(() => setError(props.setError))
() => error(),
() => {
props.errorMessage?.(error())
},
{ defer: true },
),
)
createEffect(() => {
setError(props.setError)
})
return ( return (
<div class={clsx(styles.PassportField, props.class)}> <div class={clsx(styles.PassportField, props.class)}>
@ -101,7 +89,7 @@ export const PasswordField = (props: Props) => {
<Show when={error()}> <Show when={error()}>
<div <div
class={clsx(styles.registerPassword, styles.validationError, { class={clsx(styles.registerPassword, styles.validationError, {
'form-message--error': props.setError, 'form-message--error': props.setError
})} })}
> >
{error()} {error()}

View File

@ -1,21 +1,17 @@
import { clsx } from 'clsx'
import type { JSX } from 'solid-js' import type { JSX } from 'solid-js'
import { Show, createMemo, createSignal } from 'solid-js' import { Show, createMemo, createSignal } from 'solid-js'
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx'
import { useLocalize } from '../../../context/localize'
import { useSession } from '../../../context/session'
import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { validateEmail } from '../../../utils/validateEmail'
import { useSearchParams } from '@solidjs/router'
import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session'
import { useUI } from '~/context/ui'
import { validateEmail } from '~/utils/validate'
import { AuthModalHeader } from './AuthModalHeader' import { AuthModalHeader } from './AuthModalHeader'
import { PasswordField } from './PasswordField' import { PasswordField } from './PasswordField'
import { SocialProviders } from './SocialProviders' import { SocialProviders } from './SocialProviders'
import { email, setEmail } from './sharedLogic' import { email, setEmail } from './sharedLogic'
import { GenericResponse } from '@authorizerdev/authorizer-js'
import styles from './AuthModal.module.scss' import styles from './AuthModal.module.scss'
type EmailStatus = 'not verified' | 'verified' | 'registered' | '' type EmailStatus = 'not verified' | 'verified' | 'registered' | ''
@ -29,7 +25,8 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>> type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const RegisterForm = () => { export const RegisterForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>() const [, changeSearchParams] = useSearchParams()
const { hideModal } = useUI()
const { t } = useLocalize() const { t } = useLocalize()
const { signUp, isRegistered, resendVerifyEmail } = useSession() const { signUp, isRegistered, resendVerifyEmail } = useSession()
// FIXME: use submit error data or remove signal // FIXME: use submit error data or remove signal
@ -42,7 +39,7 @@ export const RegisterForm = () => {
const [passwordError, setPasswordError] = createSignal<string>() const [passwordError, setPasswordError] = createSignal<string>()
const [emailStatus, setEmailStatus] = createSignal<string>('') const [emailStatus, setEmailStatus] = createSignal<string>('')
const authFormRef: { current: HTMLFormElement } = { current: null } let authFormRef: HTMLFormElement
const handleNameInput = (newName: string) => { const handleNameInput = (newName: string) => {
setFullName(newName) setFullName(newName)
@ -81,11 +78,10 @@ export const RegisterForm = () => {
const isValid = createMemo(() => Object.keys(newValidationErrors).length === 0) const isValid = createMemo(() => Object.keys(newValidationErrors).length === 0)
if (!isValid()) { if (!isValid() && authFormRef) {
authFormRef.current authFormRef
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`) .querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
.focus() ?.focus()
return return
} }
setIsSubmitting(true) setIsSubmitting(true)
@ -95,11 +91,10 @@ export const RegisterForm = () => {
email: cleanEmail, email: cleanEmail,
password: password(), password: password(),
confirm_password: password(), confirm_password: password(),
redirect_uri: window.location.origin, redirect_uri: window?.location?.origin || ''
} }
const { errors } = await signUp(opts) const success = await signUp(opts)
if (errors.length > 0) return setIsSuccess(success)
setIsSuccess(true)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
@ -107,12 +102,13 @@ export const RegisterForm = () => {
} }
} }
const handleResendLink = async (_ev) => { // biome-ignore lint/suspicious/noExplicitAny: <explanation>
const response: GenericResponse = await resendVerifyEmail({ const handleResendLink = async (_ev: any) => {
const success: boolean = await resendVerifyEmail({
email: email(), email: email(),
identifier: 'basic_signup', identifier: 'basic_signup'
}) })
setIsSuccess(response?.message === 'Verification email has been sent. Please check your inbox') setIsSuccess(success)
} }
const handleCheckEmailStatus = (status: EmailStatus | string) => { const handleCheckEmailStatus = (status: EmailStatus | string) => {
@ -127,7 +123,7 @@ export const RegisterForm = () => {
{t('resend confirmation link')} {t('resend confirmation link')}
</span> </span>
</> </>
), )
})) }))
break break
} }
@ -138,10 +134,10 @@ export const RegisterForm = () => {
{t('This email is registered')}. {t('try')} {t('This email is registered')}. {t('try')}
{', '} {', '}
<span class="link" onClick={() => changeSearchParams({ mode: 'login' })}> <span class="link" onClick={() => changeSearchParams({ mode: 'login' })}>
{t('enter')} {t('Enter').toLocaleLowerCase()}
</span> </span>
</> </>
), )
})) }))
break break
} }
@ -156,7 +152,7 @@ export const RegisterForm = () => {
{t('Set the new password')} {t('Set the new password')}
</span> </span>
</> </>
), )
})) }))
break break
} }
@ -184,12 +180,12 @@ export const RegisterForm = () => {
return ( return (
<> <>
<Show when={!isSuccess()}> <Show when={!isSuccess()}>
<form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef.current = el)}> <form onSubmit={handleSubmit} class={styles.authForm} ref={(el) => (authFormRef = el)}>
<div> <div>
<AuthModalHeader modalType="register" /> <AuthModalHeader modalType="register" />
<div <div
class={clsx('pretty-form__item', { class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().fullName, 'pretty-form__item--error': validationErrors().fullName
})} })}
> >
<input <input
@ -208,7 +204,7 @@ export const RegisterForm = () => {
<div <div
class={clsx('pretty-form__item', { class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().email && !emailStatus(), 'pretty-form__item--error': validationErrors().email && !emailStatus()
})} })}
> >
<input <input
@ -254,7 +250,7 @@ export const RegisterForm = () => {
class={styles.authLink} class={styles.authLink}
onClick={() => onClick={() =>
changeSearchParams({ changeSearchParams({
mode: 'login', mode: 'login'
}) })
} }
> >

View File

@ -1,16 +1,18 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useLocalize } from '../../../context/localize'
import { hideModal } from '../../../stores/ui' import { useLocalize } from '~/context/localize'
import { useUI } from '~/context/ui'
import styles from './AuthModal.module.scss' import styles from './AuthModal.module.scss'
export const SendEmailConfirm = () => { export const SendEmailConfirm = () => {
const { hideModal } = useUI()
const { t } = useLocalize() const { t } = useLocalize()
return ( return (
<div <div
style={{ style={{
'align-items': 'center', 'align-items': 'center',
'justify-content': 'center', 'justify-content': 'center'
}} }}
> >
<div class={styles.text}>{t('Link sent, check your email')}</div> <div class={styles.text}>{t('Link sent, check your email')}</div>

View File

@ -1,15 +1,12 @@
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { JSX, Show, createSignal, onMount } from 'solid-js' import { JSX, Show, createSignal, onMount } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '../../../context/session' import { useSession } from '~/context/session'
import { useRouter } from '../../../stores/router' import { validateEmail } from '~/utils/validate'
import { validateEmail } from '../../../utils/validateEmail'
import { email, setEmail } from './sharedLogic' import { email, setEmail } from './sharedLogic'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss' import styles from './AuthModal.module.scss'
type FormFields = { type FormFields = {
@ -19,7 +16,7 @@ type FormFields = {
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>> type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const SendResetLinkForm = () => { export const SendResetLinkForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>() const [, changeSearchParams] = useSearchParams()
const { t } = useLocalize() const { t } = useLocalize()
const handleEmailInput = (newEmail: string) => { const handleEmailInput = (newEmail: string) => {
setValidationErrors(({ email: _notNeeded, ...rest }) => rest) setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
@ -29,7 +26,7 @@ export const SendResetLinkForm = () => {
const [isSubmitting, setIsSubmitting] = createSignal(false) const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({}) const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const [isUserNotFound, setIsUserNotFound] = createSignal(false) const [isUserNotFound, setIsUserNotFound] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null } let authFormRef: HTMLFormElement
const [message, setMessage] = createSignal<string>('') const [message, setMessage] = createSignal<string>('')
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
@ -47,29 +44,25 @@ export const SendResetLinkForm = () => {
const isValid = Object.keys(newValidationErrors).length === 0 const isValid = Object.keys(newValidationErrors).length === 0
if (!isValid) { if (!isValid) {
authFormRef.current authFormRef
.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`) ?.querySelector<HTMLInputElement>(`input[name="${Object.keys(newValidationErrors)[0]}"]`)
.focus() ?.focus()
return return
} }
setIsSubmitting(true) setIsSubmitting(true)
try { try {
const { data, errors } = await forgotPassword({ const result = await forgotPassword({
email: email(), email: email(),
redirect_uri: window.location.origin, redirect_uri: window?.location?.origin || ''
}) })
console.debug('[SendResetLinkForm] authorizer response:', data) if (result) {
if ( setMessage(result || '')
errors?.some( } else {
(error) => console.warn('[SendResetLinkForm] forgot password mutation failed')
error.message.includes('bad user credentials') || error.message.includes('user not found'), setIsUserNotFound(false)
)
) {
setIsUserNotFound(true)
} }
if (data.message) setMessage(data.message)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
} finally { } finally {
@ -87,7 +80,7 @@ export const SendResetLinkForm = () => {
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
class={clsx(styles.authForm, styles.authFormForgetPassword)} class={clsx(styles.authForm, styles.authFormForgetPassword)}
ref={(el) => (authFormRef.current = el)} ref={(el) => (authFormRef = el)}
> >
<div> <div>
<h4>{t('Forgot password?')}</h4> <h4>{t('Forgot password?')}</h4>
@ -98,7 +91,7 @@ export const SendResetLinkForm = () => {
</Show> </Show>
<div <div
class={clsx('pretty-form__item', { class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().email, 'pretty-form__item--error': validationErrors().email
})} })}
> >
<input <input
@ -119,7 +112,7 @@ export const SendResetLinkForm = () => {
class={'link'} class={'link'}
onClick={() => onClick={() =>
changeSearchParams({ changeSearchParams({
mode: 'register', mode: 'register'
}) })
} }
> >
@ -147,7 +140,7 @@ export const SendResetLinkForm = () => {
class={styles.authLink} class={styles.authLink}
onClick={() => onClick={() =>
changeSearchParams({ changeSearchParams({
mode: 'login', mode: 'login'
}) })
} }
> >

View File

@ -1,9 +1,7 @@
import { For } from 'solid-js' import { For } from 'solid-js'
import { useLocalize } from '~/context/localize'
import { useLocalize } from '../../../../context/localize' import { useSession } from '~/context/session'
import { useSession } from '../../../../context/session' import { Icon } from '../../_shared/Icon'
import { Icon } from '../../../_shared/Icon'
import styles from './SocialProviders.module.scss' import styles from './SocialProviders.module.scss'
export const PROVIDERS = ['facebook', 'google', 'github'] // 'vk' | 'telegram' export const PROVIDERS = ['facebook', 'google', 'github'] // 'vk' | 'telegram'
@ -18,7 +16,11 @@ export const SocialProviders = () => {
<div class={styles.social}> <div class={styles.social}>
<For each={PROVIDERS}> <For each={PROVIDERS}>
{(provider) => ( {(provider) => (
<button type="button" class={styles[provider]} onClick={(_e) => oauth(provider)}> <button
type="button"
class={styles[provider as keyof typeof styles]}
onClick={(_e) => oauth(provider)}
>
<Icon name={provider} /> <Icon name={provider} />
</button> </button>
)} )}

View File

@ -1,22 +1,33 @@
import type { AuthModalMode, AuthModalSearchParams } from './types'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Component, Show, createEffect, createMemo } from 'solid-js' import { Component, Show, createEffect, createMemo } from 'solid-js'
import { Dynamic } from 'solid-js/web' import { Dynamic } from 'solid-js/web'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '~/context/localize'
import { useRouter } from '../../../stores/router' import { ModalSource, useUI } from '~/context/ui'
import { hideModal } from '../../../stores/ui' import { isMobile } from '~/lib/mediaQuery'
import { isMobile } from '../../../utils/media-query'
import { ChangePasswordForm } from './ChangePasswordForm' import { ChangePasswordForm } from './ChangePasswordForm'
import { EmailConfirm } from './EmailConfirm' import { EmailConfirm } from './EmailConfirm'
import { LoginForm } from './LoginForm' import { LoginForm } from './LoginForm'
import { RegisterForm } from './RegisterForm' import { RegisterForm } from './RegisterForm'
import { SendEmailConfirm } from './SendEmailConfirm'
import { SendResetLinkForm } from './SendResetLinkForm' import { SendResetLinkForm } from './SendResetLinkForm'
import { useSearchParams } from '@solidjs/router'
import styles from './AuthModal.module.scss' import styles from './AuthModal.module.scss'
import { SendEmailConfirm } from './SendEmailConfirm'
export type AuthModalMode =
| 'login'
| 'register'
| 'confirm-email'
| 'send-confirm-email'
| 'send-reset-link'
| 'change-password'
export type AuthModalSearchParams = {
mode: AuthModalMode
source?: ModalSource
token?: string
}
const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = { const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
login: LoginForm, login: LoginForm,
@ -24,34 +35,35 @@ const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
'send-reset-link': SendResetLinkForm, 'send-reset-link': SendResetLinkForm,
'confirm-email': EmailConfirm, 'confirm-email': EmailConfirm,
'send-confirm-email': SendEmailConfirm, 'send-confirm-email': SendEmailConfirm,
'change-password': ChangePasswordForm, 'change-password': ChangePasswordForm
} }
export const AuthModal = () => { export const AuthModal = () => {
const rootRef: { current: HTMLDivElement } = { current: null } let rootRef: HTMLDivElement | null
const { t } = useLocalize() const { t } = useLocalize()
const { searchParams } = useRouter<AuthModalSearchParams>() const [searchParams] = useSearchParams<AuthModalSearchParams>()
const { source } = searchParams() const { hideModal } = useUI()
const mode = createMemo(() => {
const mode = createMemo<AuthModalMode>(() => { return (
return AUTH_MODAL_MODES[searchParams().mode] ? searchParams().mode : 'login' AUTH_MODAL_MODES[searchParams?.mode as AuthModalMode] ? searchParams?.mode : 'login'
) as AuthModalMode
}) })
createEffect((oldMode) => { createEffect((oldMode) => {
if (oldMode !== mode() && !isMobile()) { if (oldMode !== mode() && !isMobile()) {
rootRef.current?.querySelector('input')?.focus() rootRef?.querySelector('input')?.focus()
} }
}, null) }, null)
return ( return (
<div <div
ref={(el) => (rootRef.current = el)} ref={(el) => (rootRef = el)}
class={clsx(styles.view, { class={clsx(styles.view, {
row: !source, row: !searchParams?.source,
[styles.signUp]: mode() === 'register' || mode() === 'confirm-email', [styles.signUp]: mode() === 'register' || mode() === 'confirm-email'
})} })}
> >
<Show when={!source}> <Show when={!searchParams?.source}>
<div class={clsx('col-md-12 d-none d-md-flex', styles.authImage)}> <div class={clsx('col-md-12 d-none d-md-flex', styles.authImage)}>
<div <div
class={styles.authImageText} class={styles.authImageText}
@ -61,33 +73,32 @@ export const AuthModal = () => {
<h4>{t('Join the global community of authors!')}</h4> <h4>{t('Join the global community of authors!')}</h4>
<p class={styles.authBenefits}> <p class={styles.authBenefits}>
{t( {t(
'Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine', 'Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine'
)} )}
.&nbsp; . {t('New stories and more are waiting for you every day!')}
{t('New stories every day and even more!')}
</p> </p>
</div> </div>
<p class={styles.disclaimer}> <p class={styles.disclaimer}>
{t('By signing up you agree with our')}{' '} {t('By signing up you agree with our')}{' '}
<a <a
href="/about/terms-of-use" href="/terms"
onClick={() => { onClick={() => {
hideModal() hideModal()
}} }}
> >
{t('terms of use')} {t('terms of use')}
</a> </a>
, {t('personal data usage and email notifications')}. , {t('to process personal data and receive email notifications')}.
</p> </p>
</div> </div>
</div>{' '} </div>{' '}
</Show> </Show>
<div <div
class={clsx(styles.auth, { class={clsx(styles.auth, {
'col-md-12': !source, 'col-md-12': !searchParams?.source
})} })}
> >
<Dynamic component={AUTH_MODAL_MODES[mode()]} /> <Dynamic component={AUTH_MODAL_MODES[mode() as AuthModalMode]} />
</div> </div>
</div> </div>
) )

View File

@ -1,4 +1,8 @@
.AuthorBadge { .AuthorBadge {
@include media-breakpoint-down(md) {
text-align: left;
}
align-items: flex-start; align-items: flex-start;
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@ -12,34 +16,30 @@
} }
} }
@include media-breakpoint-down(md) { .basicInfo {
text-align: left; @include media-breakpoint-down(sm) {
flex: 0 100%;
} }
.basicInfo {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
flex: 0 calc(100% - 5.2rem); flex: 0 calc(100% - 5.2rem);
gap: 1rem; gap: 1rem;
@include media-breakpoint-down(sm) {
flex: 0 100%;
}
} }
.info { .info {
@include font-size(1.4rem); @include font-size(1.4rem);
@include media-breakpoint-up(sm) {
flex: 1 100%;
}
border: none; border: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
line-height: 1.3; line-height: 1.3;
@include media-breakpoint-up(sm) {
flex: 1 100%;
}
&:hover { &:hover {
background: unset; background: unset;
} }
@ -70,12 +70,6 @@
} }
.actions { .actions {
flex: 0 20%;
display: flex;
flex-direction: row;
margin-left: 5.2rem;
gap: 1rem;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
margin-left: 0; margin-left: 0;
} }
@ -90,6 +84,12 @@
padding-left: 1rem; padding-left: 1rem;
text-align: right; text-align: right;
} }
flex: 0 20%;
display: flex;
flex-direction: row;
margin-left: 5.2rem;
gap: 1rem;
} }
.actionButton { .actionButton {

View File

@ -1,81 +1,80 @@
import { openPage } from '@nanostores/router' import { useNavigate } from '@solidjs/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Match, Show, Switch, createEffect, createMemo, createSignal } from 'solid-js' import { Match, Show, Switch, createEffect, createMemo, createSignal, on } from 'solid-js'
import { Button } from '~/components/_shared/Button'
import { useFollowing } from '../../../context/following' import { CheckButton } from '~/components/_shared/CheckButton'
import { useLocalize } from '../../../context/localize' import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper'
import { useMediaQuery } from '../../../context/mediaQuery' import { FollowingButton } from '~/components/_shared/FollowingButton'
import { useSession } from '../../../context/session' import { Icon } from '~/components/_shared/Icon'
import { Author, FollowingEntity } from '../../../graphql/schema/core.gen' import { useFollowing } from '~/context/following'
import { router, useRouter } from '../../../stores/router' import { useLocalize } from '~/context/localize'
import { translit } from '../../../utils/ru2en' import { useSession } from '~/context/session'
import { isCyrillic } from '../../../utils/translate' import { Author, FollowingEntity } from '~/graphql/schema/core.gen'
import { BadgeSubscribeButton } from '../../_shared/BadgeSubscribeButton' import { isCyrillic } from '~/intl/translate'
import { Button } from '../../_shared/Button' import { translit } from '~/intl/translit'
import { CheckButton } from '../../_shared/CheckButton' import { mediaMatches } from '~/lib/mediaQuery'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { Icon } from '../../_shared/Icon'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
import styles from './AuthorBadge.module.scss' import styles from './AuthorBadge.module.scss'
type Props = { type Props = {
author: Author author: Author
minimizeSubscribeButton?: boolean minimize?: boolean
showMessageButton?: boolean showMessageButton?: boolean
iconButtons?: boolean iconButtons?: boolean
nameOnly?: boolean nameOnly?: boolean
inviteView?: boolean inviteView?: boolean
onInvite?: (id: number) => void onInvite?: (id: number) => void
selected?: boolean selected?: boolean
subscriptionsMode?: boolean
} }
export const AuthorBadge = (props: Props) => { export const AuthorBadge = (props: Props) => {
const { mediaMatches } = useMediaQuery() const { session, requireAuthentication } = useSession()
const { author, requireAuthentication } = useSession() const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing() const { follow, unfollow, follows, following } = useFollowing()
const [isMobileView, setIsMobileView] = createSignal(false) const [isMobileView, setIsMobileView] = createSignal(false)
const [isSubscribed, setIsSubscribed] = createSignal<boolean>() const [isFollowed, setIsFollowed] = createSignal<boolean>(
Boolean(follows?.authors?.some((authorEntity) => Boolean(authorEntity.id === props.author?.id)))
)
createEffect(() => setIsMobileView(!mediaMatches.sm))
createEffect(
on(
[() => follows?.authors, () => props.author, following],
([followingAuthors, currentAuthor, _]) => {
setIsFollowed(
Boolean(followingAuthors?.some((followedAuthor) => followedAuthor.id === currentAuthor?.id))
)
},
{ defer: true }
)
)
createEffect(() => { const navigate = useNavigate()
if (!(subscriptions && props.author)) return
const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsSubscribed(subscribed)
})
createEffect(() => {
setIsMobileView(!mediaMatches.sm)
})
const { changeSearchParams } = useRouter()
const { t, formatDate, lang } = useLocalize() const { t, formatDate, lang } = useLocalize()
const initChat = () => { const initChat = () => {
// eslint-disable-next-line solid/reactivity // eslint-disable-next-line solid/reactivity
requireAuthentication(() => { requireAuthentication(() => {
openPage(router, 'inbox') props.author?.id && navigate(`/inbox/${props.author?.id}`, { replace: true })
changeSearchParams({
initChat: props.author?.id.toString(),
})
}, 'discussions') }, 'discussions')
} }
const name = createMemo(() => { const name = createMemo(() => {
if (lang() !== 'ru' && isCyrillic(props.author.name)) { if (lang() !== 'ru' && isCyrillic(props.author.name || '')) {
if (props.author.name === 'Дискурс') { if (props.author.name === 'Дискурс') {
return 'Discours' return 'Discours'
} }
return translit(props.author.name) return translit(props.author.name || '')
} }
return props.author.name return props.author.name
}) })
const handleFollowClick = () => { const handleFollowClick = () => {
requireAuthentication(() => { requireAuthentication(async () => {
isSubscribed() const handle = isFollowed() ? unfollow : follow
? unfollow(FollowingEntity.Author, props.author.slug) await handle(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug) }, 'follow')
}, 'subscribe')
} }
return ( return (
@ -84,14 +83,14 @@ export const AuthorBadge = (props: Props) => {
<Userpic <Userpic
hasLink={true} hasLink={true}
size={isMobileView() ? 'M' : 'L'} size={isMobileView() ? 'M' : 'L'}
name={name()} name={name() || ''}
userpic={props.author.pic} userpic={props.author.pic || ''}
slug={props.author.slug} slug={props.author.slug}
/> />
<ConditionalWrapper <ConditionalWrapper
condition={!props.inviteView} condition={!props.inviteView}
wrapper={(children) => ( wrapper={(children) => (
<a href={`/author/${props.author.slug}`} class={styles.info}> <a href={`/@${props.author.slug}`} class={styles.info}>
{children} {children}
</a> </a>
)} )}
@ -104,25 +103,25 @@ export const AuthorBadge = (props: Props) => {
fallback={ fallback={
<div class={styles.bio}> <div class={styles.bio}>
{t('Registered since {date}', { {t('Registered since {date}', {
date: formatDate(new Date(props.author.created_at * 1000)), date: formatDate(new Date((props.author.created_at || 0) * 1000))
})} })}
</div> </div>
} }
> >
<Match when={props.author.bio}> <Match when={props.author.bio}>
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio} /> <div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio || ''} />
</Match> </Match>
</Switch> </Switch>
<Show when={props.author?.stat}> <Show when={props.author?.stat && !props.subscriptionsMode}>
<div class={styles.bio}> <div class={styles.bio}>
<Show when={props.author?.stat.shouts > 0}> <Show when={(props.author?.stat?.shouts || 0) > 0}>
<div>{t('PublicationsWithCount', { count: props.author.stat?.shouts ?? 0 })}</div> <div>{t('some posts', { count: props.author.stat?.shouts ?? 0 })}</div>
</Show> </Show>
<Show when={props.author?.stat.comments > 0}> <Show when={(props.author?.stat?.comments || 0) > 0}>
<div>{t('CommentsWithCount', { count: props.author.stat?.comments ?? 0 })}</div> <div>{t('some comments', { count: props.author.stat?.comments ?? 0 })}</div>
</Show> </Show>
<Show when={props.author?.stat.followers > 0}> <Show when={(props.author?.stat?.followers || 0) > 0}>
<div>{t('FollowersWithCount', { count: props.author.stat?.followers ?? 0 })}</div> <div>{t('some followers', { count: props.author.stat?.followers ?? 0 })}</div>
</Show> </Show>
</div> </div>
</Show> </Show>
@ -131,12 +130,10 @@ export const AuthorBadge = (props: Props) => {
</div> </div>
<Show when={props.author.slug !== author()?.slug && !props.nameOnly}> <Show when={props.author.slug !== author()?.slug && !props.nameOnly}>
<div class={styles.actions}> <div class={styles.actions}>
<BadgeSubscribeButton <FollowingButton
action={() => handleFollowClick()} action={handleFollowClick}
isSubscribed={isSubscribed()} isFollowed={isFollowed()}
actionMessageType={ actionMessageType={following()?.slug === props.author.slug ? following()?.type : undefined}
subscribeInAction()?.slug === props.author.slug ? subscribeInAction().type : undefined
}
/> />
<Show when={props.showMessageButton}> <Show when={props.showMessageButton}>
<Button <Button
@ -152,8 +149,8 @@ export const AuthorBadge = (props: Props) => {
<Show when={props.inviteView}> <Show when={props.inviteView}>
<CheckButton <CheckButton
text={t('Invite')} text={t('Invite')}
checked={props.selected} checked={Boolean(props.selected)}
onClick={() => props.onInvite(props.author.id)} onClick={() => props.onInvite?.(props.author.id)}
/> />
</Show> </Show>
</div> </div>

View File

@ -1,4 +1,16 @@
.author { .author {
@include media-breakpoint-down(md) {
justify-content: center;
}
@include media-breakpoint-up(md) {
margin-bottom: 2.4rem;
}
@include media-breakpoint-down(lg) {
flex-wrap: wrap;
}
display: flex; display: flex;
align-items: center; align-items: center;
flex-flow: row nowrap; flex-flow: row nowrap;
@ -8,19 +20,11 @@
margin-bottom: 0; margin-bottom: 0;
} }
@include media-breakpoint-down(md) {
justify-content: center;
}
@include media-breakpoint-up(md) {
margin-bottom: 2.4rem;
}
.authorName { .authorName {
@include font-size(4rem); @include font-size(4rem);
font-weight: 700; font-weight: 700;
margin-bottom: 0.2em; margin-bottom: 1.2rem;
} }
.authorAbout { .authorAbout {
@ -32,15 +36,15 @@
} }
.authorActions { .authorActions {
@include media-breakpoint-down(md) {
justify-content: center;
}
margin: 2rem -0.8rem 0 0; margin: 2rem -0.8rem 0 0;
padding-left: 0; padding-left: 0;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
@include media-breakpoint-down(md) {
justify-content: center;
}
} }
.authorActionsLabel { .authorActionsLabel {
@ -50,27 +54,24 @@
} }
.authorActionsLabelMobile { .authorActionsLabelMobile {
display: none;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
display: block; display: block;
} }
display: none;
} }
.authorDetails { .authorDetails {
display: block;
margin-bottom: 0;
@include media-breakpoint-down(md) { @include media-breakpoint-down(md) {
flex: 1 100%; flex: 1 100%;
text-align: center; text-align: center;
} }
display: block;
margin-bottom: 0;
} }
.listWrapper & { .listWrapper & {
align-items: flex-start;
margin-bottom: 2rem;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
margin-bottom: 3rem; margin-bottom: 3rem;
} }
@ -79,6 +80,9 @@
margin-bottom: 3rem; margin-bottom: 3rem;
} }
align-items: flex-start;
margin-bottom: 2rem;
.circlewrap { .circlewrap {
margin-top: 1rem; margin-top: 1rem;
} }
@ -88,10 +92,6 @@
} }
} }
@include media-breakpoint-down(lg) {
flex-wrap: wrap;
}
.buttonWriteMessage { .buttonWriteMessage {
border-radius: 0.8rem; border-radius: 0.8rem;
padding-bottom: 0.6rem; padding-bottom: 0.6rem;
@ -100,8 +100,6 @@
} }
.authorDetails { .authorDetails {
flex: 0 0 auto;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
align-items: center; align-items: center;
display: flex; display: flex;
@ -118,12 +116,11 @@
flex-wrap: nowrap; flex-wrap: nowrap;
} }
} }
flex: 0 0 auto;
} }
.authorDetailsWrapper { .authorDetailsWrapper {
flex: 1 0;
position: relative;
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
flex: 1; flex: 1;
} }
@ -139,6 +136,9 @@
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
padding-right: 1.2rem; padding-right: 1.2rem;
} }
flex: 1 0;
position: relative;
} }
.authorName { .authorName {
@ -160,6 +160,15 @@
} }
.authorSubscribeSocial { .authorSubscribeSocial {
@include media-breakpoint-down(sm) {
flex: 1 100%;
justify-content: center;
}
@include media-breakpoint-down(md) {
justify-content: center;
}
align-items: center; align-items: center;
display: flex; display: flex;
margin: 0.5rem 0 2rem -0.4rem; margin: 0.5rem 0 2rem -0.4rem;
@ -175,7 +184,7 @@
width: 24px; width: 24px;
&::before { &::before {
background-image: url(/icons/user-link-default.svg); background-image: url('/icons/user-link-default.svg');
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: 50% 50%; background-position: 50% 50%;
background-size: contain; background-size: contain;
@ -209,7 +218,7 @@
&[href*='facebook.com/'] { &[href*='facebook.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-facebook.svg); background-image: url('/icons/user-link-facebook.svg');
} }
&:hover { &:hover {
@ -221,7 +230,7 @@
&[href*='twitter.com/'] { &[href*='twitter.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-twitter.svg); background-image: url('/icons/user-link-twitter.svg');
} }
&:hover { &:hover {
@ -234,7 +243,7 @@
&[href*='telegram.com/'], &[href*='telegram.com/'],
&[href*='t.me/'] { &[href*='t.me/'] {
&::before { &::before {
background-image: url(/icons/user-link-telegram.svg); background-image: url('/icons/user-link-telegram.svg');
} }
&:hover { &:hover {
@ -247,7 +256,7 @@
&[href*='vk.cc/'], &[href*='vk.cc/'],
&[href*='vk.com/'] { &[href*='vk.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-vk.svg); background-image: url('/icons/user-link-vk.svg');
} }
&:hover { &:hover {
@ -259,7 +268,7 @@
&[href*='tumblr.com/'] { &[href*='tumblr.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-tumblr.svg); background-image: url('/icons/user-link-tumblr.svg');
} }
&:hover { &:hover {
@ -271,7 +280,7 @@
&[href*='instagram.com/'] { &[href*='instagram.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-instagram.svg); background-image: url('/icons/user-link-instagram.svg');
} }
&:hover { &:hover {
@ -283,7 +292,7 @@
&[href*='behance.net/'] { &[href*='behance.net/'] {
&::before { &::before {
background-image: url(/icons/user-link-behance.svg); background-image: url('/icons/user-link-behance.svg');
} }
&:hover { &:hover {
@ -295,7 +304,7 @@
&[href*='dribbble.com/'] { &[href*='dribbble.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-dribbble.svg); background-image: url('/icons/user-link-dribbble.svg');
} }
&:hover { &:hover {
@ -307,7 +316,7 @@
&[href*='github.com/'] { &[href*='github.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-github.svg); background-image: url('/icons/user-link-github.svg');
} }
&:hover { &:hover {
@ -319,7 +328,7 @@
&[href*='linkedin.com/'] { &[href*='linkedin.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-linkedin.svg); background-image: url('/icons/user-link-linkedin.svg');
} }
&:hover { &:hover {
@ -331,7 +340,7 @@
&[href*='medium.com/'] { &[href*='medium.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-medium.svg); background-image: url('/icons/user-link-medium.svg');
} }
&:hover { &:hover {
@ -343,7 +352,7 @@
&[href*='ok.ru/'] { &[href*='ok.ru/'] {
&::before { &::before {
background-image: url(/icons/user-link-ok.svg); background-image: url('/icons/user-link-ok.svg');
} }
&:hover { &:hover {
@ -355,7 +364,7 @@
&[href*='pinterest.com/'] { &[href*='pinterest.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-pinterest.svg); background-image: url('/icons/user-link-pinterest.svg');
} }
&:hover { &:hover {
@ -367,7 +376,7 @@
&[href*='reddit.com/'] { &[href*='reddit.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-reddit.svg); background-image: url('/icons/user-link-reddit.svg');
} }
&:hover { &:hover {
@ -379,7 +388,7 @@
&[href*='tiktok.com/'] { &[href*='tiktok.com/'] {
&::before { &::before {
background-image: url(/icons/user-link-tiktok.svg); background-image: url('/icons/user-link-tiktok.svg');
} }
&:hover { &:hover {
@ -392,7 +401,7 @@
&[href*='youtube.com/'], &[href*='youtube.com/'],
&[href*='youtu.be/'] { &[href*='youtu.be/'] {
&::before { &::before {
background-image: url(/icons/user-link-youtube.svg); background-image: url('/icons/user-link-youtube.svg');
} }
&:hover { &:hover {
@ -404,7 +413,7 @@
&[href*='dzen.ru/'] { &[href*='dzen.ru/'] {
&::before { &::before {
background-image: url(/icons/user-link-dzen.svg); background-image: url('/icons/user-link-dzen.svg');
} }
&:hover { &:hover {
@ -415,78 +424,24 @@
} }
} }
@include media-breakpoint-down(sm) {
flex: 1 100%;
justify-content: center;
}
@include media-breakpoint-down(md) {
justify-content: center;
}
a:link { a:link {
border: none; border: none;
} }
} }
.subscribersContainer {
display: flex;
flex-wrap: wrap;
font-size: 1.4rem;
margin-top: 1.5rem;
@include media-breakpoint-down(md) {
justify-content: center;
}
}
.subscribers {
align-items: center;
cursor: pointer;
display: inline-flex;
margin: 0 2% 1rem;
vertical-align: top;
border-bottom: unset !important;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
.subscribersItem {
position: relative;
&:nth-child(1) {
z-index: 2;
}
&:nth-child(2) {
z-index: 1;
}
&:not(:last-child) {
margin-right: -4px;
box-shadow: 0 0 0 1px var(--background-color);
}
}
.subscribersCounter {
font-weight: 500;
margin-left: 1rem;
}
&:hover {
background: none !important;
.subscribersCounter {
background: var(--background-color-invert);
}
}
}
.listWrapper { .listWrapper {
max-height: 70vh; max-height: 70vh;
} }
.subscribersContainer {
@include media-breakpoint-down(md) {
justify-content: center;
}
display: flex;
flex-wrap: wrap;
font-size: 1.4rem;
gap: 1rem;
margin-top: 0;
white-space: nowrap;
}

View File

@ -1,103 +1,88 @@
import type { Author, Community } from '../../../graphql/schema/core.gen' import { redirect, useNavigate } from '@solidjs/router'
import { openPage, redirectPage } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal, onMount } from 'solid-js' import { For, Show, createEffect, createMemo, createSignal, on } from 'solid-js'
import { Button } from '~/components/_shared/Button'
import { useFollowing } from '../../../context/following' import stylesButton from '~/components/_shared/Button/Button.module.scss'
import { useLocalize } from '../../../context/localize' import { FollowingCounters } from '~/components/_shared/FollowingCounters/FollowingCounters'
import { useSession } from '../../../context/session' import { ShowOnlyOnClient } from '~/components/_shared/ShowOnlyOnClient'
import { FollowingEntity, Topic } from '../../../graphql/schema/core.gen' import { FollowsFilter, useFollowing } from '~/context/following'
import { SubscriptionFilter } from '../../../pages/types' import { useLocalize } from '~/context/localize'
import { router, useRouter } from '../../../stores/router' import { useSession } from '~/context/session'
import { isAuthor } from '../../../utils/isAuthor' import type { Author, Community } from '~/graphql/schema/core.gen'
import { translit } from '../../../utils/ru2en' import { FollowingEntity, Topic } from '~/graphql/schema/core.gen'
import { isCyrillic } from '../../../utils/translate' import { isCyrillic } from '~/intl/translate'
import { translit } from '~/intl/translit'
import { SharePopup, getShareUrl } from '../../Article/SharePopup' import { SharePopup, getShareUrl } from '../../Article/SharePopup'
import { Modal } from '../../Nav/Modal'
import { TopicBadge } from '../../Topic/TopicBadge' import { TopicBadge } from '../../Topic/TopicBadge'
import { Button } from '../../_shared/Button' import { Modal } from '../../_shared/Modal'
import { ShowOnlyOnClient } from '../../_shared/ShowOnlyOnClient'
import { AuthorBadge } from '../AuthorBadge' import { AuthorBadge } from '../AuthorBadge'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
import stylesButton from '../../_shared/Button/Button.module.scss'
import styles from './AuthorCard.module.scss' import styles from './AuthorCard.module.scss'
type Props = { type Props = {
author: Author author: Author
followers?: Author[] followers?: Author[]
following?: Array<Author | Topic> flatFollows?: Array<Author | Topic>
} }
export const AuthorCard = (props: Props) => { export const AuthorCard = (props: Props) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()
const { author, isSessionLoaded, requireAuthentication } = useSession() const navigate = useNavigate()
const { session, isSessionLoaded, requireAuthentication } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([]) const [authorSubs, setAuthorSubs] = createSignal<Array<Author | Topic | Community>>([])
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all') const [followsFilter, setFollowsFilter] = createSignal<FollowsFilter>('all')
const [isSubscribed, setIsSubscribed] = createSignal<boolean>() const [isFollowed, setIsFollowed] = createSignal<boolean>()
const isProfileOwner = createMemo(() => author()?.slug === props.author.slug) const isProfileOwner = createMemo(() => author()?.slug === props.author.slug)
const { follow, unfollow, subscriptions, subscribeInAction } = useFollowing() const { follow, unfollow, follows, following } = useFollowing() // viewer's followings
onMount(() => {
setAuthorSubs(props.following)
})
createEffect(() => { createEffect(() => {
if (!(subscriptions && props.author)) return if (!(follows && props.author)) return
const subscribed = subscriptions.authors?.some((authorEntity) => authorEntity.id === props.author?.id) const followed = follows?.authors?.some((authorEntity) => authorEntity.id === props.author?.id)
setIsSubscribed(subscribed) setIsFollowed(followed)
}) })
const name = createMemo(() => { const name = createMemo(() => {
if (lang() !== 'ru' && isCyrillic(props.author.name)) { if (lang() !== 'ru' && isCyrillic(props.author?.name || '')) {
if (props.author.name === 'Дискурс') { if (props.author.name === 'Дискурс') {
return 'Discours' return 'Discours'
} }
return translit(props.author.name) return translit(props.author?.name || '')
} }
return props.author.name return props.author.name
}) })
// TODO: reimplement AuthorCard
const { changeSearchParams } = useRouter()
const initChat = () => { const initChat = () => {
// eslint-disable-next-line solid/reactivity // eslint-disable-next-line solid/reactivity
requireAuthentication(() => { requireAuthentication(() => {
openPage(router, 'inbox') props.author?.id && navigate(`/inbox/${props.author?.id}`, { replace: true })
changeSearchParams({
initChat: props.author?.id.toString(),
})
}, 'discussions') }, 'discussions')
} }
createEffect(() => { createEffect(
if (props.following) { on(followsFilter, (f = 'all') => {
if (subscriptionFilter() === 'authors') { const subs =
setAuthorSubs(props.following.filter((s) => 'name' in s)) f !== 'all'
} else if (subscriptionFilter() === 'topics') { ? follows[f as keyof typeof follows]
setAuthorSubs(props.following.filter((s) => 'title' in s)) : [...(follows.topics || []), ...(follows.authors || [])]
} else if (subscriptionFilter() === 'communities') { setAuthorSubs(subs || [])
setAuthorSubs(props.following.filter((s) => 'title' in s))
} else {
setAuthorSubs(props.following)
}
}
}) })
)
const handleFollowClick = () => { const handleFollowClick = () => {
requireAuthentication(() => { requireAuthentication(() => {
isSubscribed() isFollowed()
? unfollow(FollowingEntity.Author, props.author.slug) ? unfollow(FollowingEntity.Author, props.author.slug)
: follow(FollowingEntity.Author, props.author.slug) : follow(FollowingEntity.Author, props.author.slug)
}, 'subscribe') }, 'follow')
} }
const followButtonText = createMemo(() => { const followButtonText = createMemo(() => {
if (subscribeInAction()?.slug === props.author.slug) { if (following()?.slug === props.author.slug) {
return subscribeInAction().type === 'subscribe' ? t('Subscribing...') : t('Unsubscribing...') return following()?.type === 'follow' ? t('Following...') : t('Unfollowing...')
} }
if (isSubscribed()) { if (isFollowed()) {
return ( return (
<> <>
<span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span> <span class={stylesButton.buttonSubscribeLabel}>{t('Following')}</span>
@ -108,13 +93,82 @@ export const AuthorCard = (props: Props) => {
return t('Follow') return t('Follow')
}) })
const FollowersModalView = () => (
<>
<h2>{t('Followers')}</h2>
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={props.followers}>{(follower: Author) => <AuthorBadge author={follower} />}</For>
</div>
</div>
</div>
</>
)
const FollowingModalView = () => (
<>
<h2>{t('Subscriptions')}</h2>
<ul class="view-switcher">
<li
class={clsx({
'view-switcher__item--selected': followsFilter() === 'all'
})}
>
<button type="button" onClick={() => setFollowsFilter('all')}>
{t('All')}
</button>
<span class="view-switcher__counter">{props.flatFollows?.length}</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': followsFilter() === 'authors'
})}
>
<button type="button" onClick={() => setFollowsFilter('authors')}>
{t('Authors')}
</button>
<span class="view-switcher__counter">{props.flatFollows?.filter((s) => 'name' in s).length}</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': followsFilter() === 'topics'
})}
>
<button type="button" onClick={() => setFollowsFilter('topics')}>
{t('Topics')}
</button>
<span class="view-switcher__counter">
{props.flatFollows?.filter((s) => 'title' in s).length}
</span>
</li>
</ul>
<br />
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={authorSubs()}>
{(subscription) =>
'name' in subscription ? (
<AuthorBadge author={subscription as Author} subscriptionsMode={true} />
) : (
<TopicBadge topic={subscription as Topic} subscriptionsMode={true} />
)
}
</For>
</div>
</div>
</div>
</>
)
return ( return (
<div class={clsx(styles.author, 'row')}> <div class={clsx(styles.author, 'row')}>
<div class="col-md-5"> <div class="col-md-5">
<Userpic <Userpic
size={'XL'} size={'XL'}
name={props.author.name} name={props.author.name || ''}
userpic={props.author.pic} userpic={props.author.pic || ''}
slug={props.author.slug} slug={props.author.slug}
class={styles.circlewrap} class={styles.circlewrap}
/> />
@ -123,61 +177,16 @@ export const AuthorCard = (props: Props) => {
<div class={styles.authorDetailsWrapper}> <div class={styles.authorDetailsWrapper}>
<div class={styles.authorName}>{name()}</div> <div class={styles.authorName}>{name()}</div>
<Show when={props.author.bio}> <Show when={props.author.bio}>
<div class={styles.authorAbout} innerHTML={props.author.bio} /> <div class={styles.authorAbout} innerHTML={props.author.bio || ''} />
</Show> </Show>
<Show when={props.followers?.length > 0 || props.following?.length > 0}> <Show when={(props.followers || [])?.length > 0 || (props.flatFollows || []).length > 0}>
<div class={styles.subscribersContainer}> <div class={styles.subscribersContainer}>
<Show when={props.followers && props.followers.length > 0}> <FollowingCounters
<a href="?m=followers" class={styles.subscribers}> followers={props.followers}
<For each={props.followers.slice(0, 3)}> followersAmount={props.author?.stat?.followers || 0}
{(f) => ( following={props.flatFollows}
<Userpic size={'XS'} name={f.name} userpic={f.pic} class={styles.subscribersItem} /> followingAmount={props.flatFollows?.length || 0}
)}
</For>
<div class={styles.subscribersCounter}>
{t('SubscriberWithCount', {
count: props.followers.length ?? 0,
})}
</div>
</a>
</Show>
<Show when={props.following && props.following.length > 0}>
<a href="?m=following" class={styles.subscribers}>
<For each={props.following.slice(0, 3)}>
{(f) => {
if ('name' in f) {
return (
<Userpic
size={'XS'}
name={f.name}
userpic={f.pic}
class={styles.subscribersItem}
/> />
)
}
if ('title' in f) {
return (
<Userpic
size={'XS'}
name={f.title}
userpic={f.pic}
class={styles.subscribersItem}
/>
)
}
return null
}}
</For>
<div class={styles.subscribersCounter}>
{t('SubscriptionWithCount', {
count: props?.following.length ?? 0,
})}
</div>
</a>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
@ -186,15 +195,15 @@ export const AuthorCard = (props: Props) => {
<Show when={props.author.links && props.author.links.length > 0}> <Show when={props.author.links && props.author.links.length > 0}>
<div class={styles.authorSubscribeSocial}> <div class={styles.authorSubscribeSocial}>
<For each={props.author.links}> <For each={props.author.links}>
{(link) => ( {(link: string | null) => (
<a <a
class={styles.socialLink} class={styles.socialLink}
href={link.startsWith('http') ? link : `https://${link}`} href={link?.startsWith('http') ? link : `https://${link}`}
target="_blank" target="_blank"
rel="nofollow noopener noreferrer" rel="nofollow noopener noreferrer"
> >
<span class={styles.authorSubscribeSocialLabel}> <span class={styles.authorSubscribeSocialLabel}>
{link.startsWith('http') ? link : `https://${link}`} {link?.startsWith('http') ? link : `https://${link}`}
</span> </span>
</a> </a>
)} )}
@ -208,11 +217,11 @@ export const AuthorCard = (props: Props) => {
<Show when={authorSubs()?.length}> <Show when={authorSubs()?.length}>
<Button <Button
onClick={handleFollowClick} onClick={handleFollowClick}
disabled={Boolean(subscribeInAction())} disabled={Boolean(following())}
value={followButtonText()} value={followButtonText()}
isSubscribeButton={true} isSubscribeButton={true}
class={clsx({ class={clsx({
[stylesButton.subscribed]: isSubscribed(), [stylesButton.followed]: isFollowed()
})} })}
/> />
</Show> </Show>
@ -228,7 +237,7 @@ export const AuthorCard = (props: Props) => {
<div class={styles.authorActions}> <div class={styles.authorActions}>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => redirectPage(router, 'profileSettings')} onClick={() => redirect('/settings')}
value={ value={
<> <>
<span class={styles.authorActionsLabel}>{t('Edit profile')}</span> <span class={styles.authorActionsLabel}>{t('Edit profile')}</span>
@ -237,11 +246,11 @@ export const AuthorCard = (props: Props) => {
} }
/> />
<SharePopup <SharePopup
title={props.author.name} title={props.author.name || ''}
description={props.author.bio} description={props.author.bio || ''}
imageUrl={props.author.pic} imageUrl={props.author.pic || ''}
shareUrl={getShareUrl({ shareUrl={getShareUrl({
pathname: `/author/${props.author.slug}`, pathname: `/@${props.author.slug}`
})} })}
trigger={<Button variant="secondary" value={t('Share')} />} trigger={<Button variant="secondary" value={t('Share')} />}
/> />
@ -251,77 +260,12 @@ export const AuthorCard = (props: Props) => {
</ShowOnlyOnClient> </ShowOnlyOnClient>
<Show when={props.followers}> <Show when={props.followers}>
<Modal variant="medium" isResponsive={true} name="followers" maxHeight> <Modal variant="medium" isResponsive={true} name="followers" maxHeight>
<> <FollowersModalView />
<h2>{t('Followers')}</h2>
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={props.followers}>
{(follower: Author) => <AuthorBadge author={follower} />}
</For>
</div>
</div>
</div>
</>
</Modal> </Modal>
</Show> </Show>
<Show when={props.following}> <Show when={props.flatFollows}>
<Modal variant="medium" isResponsive={true} name="following" maxHeight> <Modal variant="medium" isResponsive={true} name="following" maxHeight>
<> <FollowingModalView />
<h2>{t('Subscriptions')}</h2>
<ul class="view-switcher">
<li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'all',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('all')}>
{t('All')}
</button>
<span class="view-switcher__counter">{props.following.length}</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'authors',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('authors')}>
{t('Authors')}
</button>
<span class="view-switcher__counter">
{props.following.filter((s) => 'name' in s).length}
</span>
</li>
<li
class={clsx({
'view-switcher__item--selected': subscriptionFilter() === 'topics',
})}
>
<button type="button" onClick={() => setSubscriptionFilter('topics')}>
{t('Topics')}
</button>
<span class="view-switcher__counter">
{props.following.filter((s) => 'title' in s).length}
</span>
</li>
</ul>
<br />
<div class={styles.listWrapper}>
<div class="row">
<div class="col-24">
<For each={authorSubs()}>
{(subscription) =>
isAuthor(subscription) ? (
<AuthorBadge author={subscription} />
) : (
<TopicBadge topic={subscription} subscriptionsMode={true} />
)
}
</For>
</div>
</div>
</div>
</>
</Modal> </Modal>
</Show> </Show>
</div> </div>

View File

@ -1,14 +1,14 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createMemo } from 'solid-js' import { createMemo } from 'solid-js'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '~/context/localize'
import { Author } from '../../../graphql/schema/core.gen' import { Author } from '~/graphql/schema/core.gen'
import { capitalize } from '../../../utils/capitalize' import { isCyrillic } from '~/intl/translate'
import { translit } from '../../../utils/ru2en' import { translit } from '~/intl/translit'
import { isCyrillic } from '../../../utils/translate' import { capitalize } from '~/utils/capitalize'
import { Userpic } from '../Userpic' import { Userpic } from '../Userpic'
import styles from './AhtorLink.module.scss' import styles from './AuthorLink.module.scss'
type Props = { type Props = {
author: Author author: Author
@ -20,18 +20,18 @@ type Props = {
export const AuthorLink = (props: Props) => { export const AuthorLink = (props: Props) => {
const { lang } = useLocalize() const { lang } = useLocalize()
const name = createMemo(() => { const name = createMemo(() => {
return lang() === 'en' && isCyrillic(props.author.name) return lang() === 'en' && isCyrillic(props.author.name || '')
? translit(capitalize(props.author.name)) ? translit(capitalize(props.author.name || ''))
: props.author.name : props.author.name
}) })
return ( return (
<div <div
class={clsx(styles.AuthorLink, props.class, styles[props.size ?? 'M'], { class={clsx(styles.AuthorLink, props.class, styles[(props.size ?? 'M') as keyof Props['size']], {
[styles.authorLinkFloorImportant]: props.isFloorImportant, [styles.authorLinkFloorImportant]: props.isFloorImportant
})} })}
> >
<a class={styles.link} href={`/author/${props.author.slug}`}> <a class={styles.link} href={`/@${props.author.slug}`}>
<Userpic size={props.size ?? 'M'} name={name()} userpic={props.author.pic} /> <Userpic size={props.size ?? 'M'} name={name() || ''} userpic={props.author.pic || ''} />
<div class={styles.name}>{name()}</div> <div class={styles.name}>{name()}</div>
</a> </a>
</div> </div>

View File

@ -1,10 +1,9 @@
import type { Author } from '../../graphql/schema/core.gen' import type { Author } from '~/graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js' import { Show, createSignal } from 'solid-js'
import { useSession } from '~/context/session'
import { apiClient } from '../../graphql/client/core' import rateAuthorMutation from '~/graphql/mutation/core/author-rate'
import styles from './AuthorRatingControl.module.scss' import styles from './AuthorRatingControl.module.scss'
interface AuthorRatingControlProps { interface AuthorRatingControlProps {
@ -15,22 +14,29 @@ interface AuthorRatingControlProps {
export const AuthorRatingControl = (props: AuthorRatingControlProps) => { export const AuthorRatingControl = (props: AuthorRatingControlProps) => {
const isUpvoted = false const isUpvoted = false
const isDownvoted = false const isDownvoted = false
const { client } = useSession()
// eslint-disable-next-line unicorn/consistent-function-scoping // eslint-disable-next-line unicorn/consistent-function-scoping
const handleRatingChange = async (isUpvote: boolean) => { const handleRatingChange = async (isUpvote: boolean) => {
console.log('handleRatingChange', { isUpvote }) console.log('handleRatingChange', { isUpvote })
if (props.author?.slug) { if (props.author?.slug) {
const value = isUpvote ? 1 : -1 const value = isUpvote ? 1 : -1
await apiClient.rateAuthor({ rated_slug: props.author?.slug, value }) const _resp = await client()
setRating((r) => r + value) ?.mutation(rateAuthorMutation, {
rated_slug: props.author?.slug,
value
})
.toPromise()
setRating((r) => (r || 0) + value)
} }
} }
const [rating, setRating] = createSignal(props.author?.stat?.rating) const [rating, setRating] = createSignal(props.author?.stat?.rating)
return ( return (
<div <div
class={clsx(styles.rating, props.class, { class={clsx(styles.rating, props.class, {
[styles.isUpvoted]: isUpvoted, [styles.isUpvoted]: isUpvoted,
[styles.isDownvoted]: isDownvoted, [styles.isDownvoted]: isDownvoted
})} })}
> >
<button <button

View File

@ -1,4 +1,4 @@
import type { Author } from '../../graphql/schema/core.gen' import type { Author } from '~/graphql/schema/core.gen'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createMemo } from 'solid-js' import { createMemo } from 'solid-js'
@ -11,12 +11,12 @@ interface AuthorShoutsRating {
} }
export const AuthorShoutsRating = (props: AuthorShoutsRating) => { export const AuthorShoutsRating = (props: AuthorShoutsRating) => {
const isUpvoted = createMemo(() => props.author?.stat?.rating_shouts > 0) const isUpvoted = createMemo(() => (props.author?.stat?.rating_shouts || 0) > 0)
return ( return (
<div <div
class={clsx(styles.rating, props.class, { class={clsx(styles.rating, props.class, {
[styles.isUpvoted]: isUpvoted(), [styles.isUpvoted]: isUpvoted(),
[styles.isDownvoted]: !isUpvoted(), [styles.isDownvoted]: !isUpvoted()
})} })}
> >
<span class={styles.ratingValue}>{props.author?.stat?.rating_shouts}</span> <span class={styles.ratingValue}>{props.author?.stat?.rating_shouts}</span>

View File

@ -86,17 +86,17 @@
} }
&.XL { &.XL {
@include media-breakpoint-up(md) {
margin: 0;
max-width: 100%;
}
aspect-ratio: 1/1; aspect-ratio: 1/1;
margin: 0 auto 1rem; margin: 0 auto 1rem;
max-width: 168px; max-width: 168px;
height: auto; height: auto;
width: 100%; width: 100%;
@include media-breakpoint-up(md) {
margin: 0;
max-width: 100%;
}
.letters { .letters {
align-items: center; align-items: center;
display: flex; display: flex;

View File

@ -1,9 +1,9 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Show, createMemo } from 'solid-js' import { Show, createMemo } from 'solid-js'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper' import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper'
import { Image } from '../../_shared/Image' import { Image } from '~/components/_shared/Image'
import { Loading } from '../../_shared/Loading' import { Loading } from '~/components/_shared/Loading'
import styles from './Userpic.module.scss' import styles from './Userpic.module.scss'
@ -22,7 +22,7 @@ export const Userpic = (props: Props) => {
const letters = () => { const letters = () => {
if (!props.name) return if (!props.name) return
const names = props.name ? props.name.split(' ') : [] const names = props.name ? props.name.split(' ') : []
return `${names[0][0 ?? names[0][0]]}.${names.length > 1 ? `${names[1][0]}.` : ''}` return `${names[0][0] ? names[0][0] : ''}.${names.length > 1 ? `${names[1][0]}.` : ''}`
} }
const avatarSize = createMemo(() => { const avatarSize = createMemo(() => {
@ -48,14 +48,14 @@ export const Userpic = (props: Props) => {
return ( return (
<div <div
class={clsx(styles.Userpic, props.class, styles[props.size ?? 'M'], { class={clsx(styles.Userpic, props.class, styles[props.size ?? 'M'], {
cursorPointer: props.onClick, cursorPointer: props.onClick
})} })}
onClick={props.onClick} onClick={props.onClick}
> >
<Show when={!props.loading} fallback={<Loading />}> <Show when={!props.loading} fallback={<Loading />}>
<ConditionalWrapper <ConditionalWrapper
condition={props.hasLink} condition={Boolean(props.hasLink)}
wrapper={(children) => <a href={`/author/${props.slug}`}>{children}</a>} wrapper={(children) => <a href={`/@${props.slug}`}>{children}</a>}
> >
<Show keyed={true} when={props.userpic} fallback={<div class={styles.letters}>{letters()}</div>}> <Show keyed={true} when={props.userpic} fallback={<div class={styles.letters}>{letters()}</div>}>
<Image src={props.userpic} width={avatarSize()} height={avatarSize()} alt={props.name} /> <Image src={props.userpic} width={avatarSize()} height={avatarSize()} alt={props.name} />

View File

@ -1,26 +0,0 @@
.AuthorsList {
.action {
display: flex;
align-items: center;
justify-content: center;
min-height: 8rem;
}
.loading {
@include font-size(1.4rem);
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
width: 100%;
flex-direction: row;
opacity: 0.5;
.icon {
position: relative;
width: 18px;
height: 18px;
}
}
}

View File

@ -1,102 +0,0 @@
import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal, on } from 'solid-js'
import { useLocalize } from '../../context/localize'
import { apiClient } from '../../graphql/client/core'
import { setAuthorsByFollowers, setAuthorsByShouts, useAuthorsStore } from '../../stores/zine/authors'
import { AuthorBadge } from '../Author/AuthorBadge'
import { InlineLoader } from '../InlineLoader'
import { Button } from '../_shared/Button'
import styles from './AuthorsList.module.scss'
type Props = {
class?: string
query: 'followers' | 'shouts'
searchQuery?: string
allAuthorsLength?: number
}
const PAGE_SIZE = 20
export const AuthorsList = (props: Props) => {
const { t } = useLocalize()
const { authorsByShouts, authorsByFollowers } = useAuthorsStore()
const [loading, setLoading] = createSignal(false)
const [currentPage, setCurrentPage] = createSignal({ shouts: 0, followers: 0 })
const [allLoaded, setAllLoaded] = createSignal(false)
const fetchAuthors = async (queryType: Props['query'], page: number) => {
setLoading(true)
const offset = PAGE_SIZE * page
const result = await apiClient.loadAuthorsBy({
by: { order: queryType },
limit: PAGE_SIZE,
offset,
})
if (queryType === 'shouts') {
setAuthorsByShouts((prev) => [...prev, ...result])
} else {
setAuthorsByFollowers((prev) => [...prev, ...result])
}
setLoading(false)
}
const loadMoreAuthors = () => {
const nextPage = currentPage()[props.query] + 1
fetchAuthors(props.query, nextPage).then(() =>
setCurrentPage({ ...currentPage(), [props.query]: nextPage }),
)
}
createEffect(
on(
() => props.query,
(query) => {
const authorsList = query === 'shouts' ? authorsByShouts() : authorsByFollowers()
if (authorsList.length === 0 && currentPage()[query] === 0) {
setCurrentPage((prev) => ({ ...prev, [query]: 0 }))
fetchAuthors(query, 0).then(() => setCurrentPage((prev) => ({ ...prev, [query]: 1 })))
}
},
),
)
const authorsList = () => (props.query === 'shouts' ? authorsByShouts() : authorsByFollowers())
// TODO: do it with backend
// createEffect(() => {
// if (props.searchQuery) {
// // search logic
// }
// })
createEffect(() => {
setAllLoaded(props.allAuthorsLength === authorsList.length)
})
return (
<div class={clsx(styles.AuthorsList, props.class)}>
<For each={authorsList()}>
{(author) => (
<div class="row">
<div class="col-lg-20 col-xl-18">
<AuthorBadge author={author} />
</div>
</div>
)}
</For>
<div class="row">
<div class="col-lg-20 col-xl-18">
<div class={styles.action}>
<Show when={!loading() && authorsList().length > 0 && !allLoaded()}>
<Button value={t('Load more')} onClick={loadMoreAuthors} />
</Show>
<Show when={loading() && !allLoaded()}>
<InlineLoader />
</Show>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@ -1,12 +1,12 @@
.discoursBanner { .discoursBanner {
background: #f8f8f8;
margin-bottom: 6.4rem;
padding: 0.8rem 0 0;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
font-size: 80%; font-size: 80%;
} }
background: #f8f8f8;
margin-bottom: 6.4rem;
padding: 0.8rem 0 0;
h3 { h3 {
font-size: 3.2rem; font-size: 3.2rem;
font-weight: 800; font-weight: 800;

View File

@ -1,22 +1,23 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useLocalize } from '../../context/localize' import { useLocalize } from '~/context/localize'
import { showModal } from '../../stores/ui' import { useUI } from '~/context/ui'
import { Image } from '../_shared/Image' import { Image } from '../_shared/Image'
import styles from './Banner.module.scss' import styles from './Banner.module.scss'
export default () => { export default () => {
const { t } = useLocalize() const { t } = useLocalize()
const { showModal } = useUI()
return ( return (
<div class={styles.discoursBanner}> <div class={styles.discoursBanner}>
<div class="wide-container"> <div class="wide-container">
<div class="row"> <div class="row">
<div class={clsx(styles.discoursBannerContent, 'col-lg-10')}> <div class={clsx(styles.discoursBannerContent, 'col-lg-10')}>
<h3>{t('Discours is created with our common effort')}</h3> <h3>{t('Discours exists because of our common effort')}</h3>
<p> <p>
<a href="/about/help">{t('Support us')}</a> <a href="/support">{t('Support us')}</a>
<a href="/create">{t('Become an author')}</a> <a href="/edit/new">{t('Become an author')}</a>
<a href={''} onClick={() => showModal('auth')}> <a href={''} onClick={() => showModal('auth')}>
{t('Join the community')} {t('Join the community')}
</a> </a>

View File

@ -24,12 +24,12 @@
} }
&:focus { &:focus {
box-shadow: inset 0 0 0 3px #000;
&::placeholder { &::placeholder {
opacity: 0; opacity: 0;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
box-shadow: inset 0 0 0 3px #000;
} }
&:valid, &:valid,
@ -49,18 +49,18 @@
} }
.donateForm .btn { .donateForm .btn {
@include media-breakpoint-down(sm) {
&:last-of-type {
margin-right: 0 !important;
}
}
cursor: pointer; cursor: pointer;
flex: 1; flex: 1;
padding: 5px 10px; padding: 5px 10px;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
transform: none !important; transform: none !important;
@include media-breakpoint-down(sm) {
&:last-of-type {
margin-right: 0 !important;
}
}
} }
.btnGroup { .btnGroup {
@ -82,22 +82,22 @@
} }
.donateButtonsContainer { .donateButtonsContainer {
@include media-breakpoint-down(sm) {
flex-wrap: wrap;
}
align-items: center; align-items: center;
display: flex; display: flex;
flex: 1; flex: 1;
justify-content: space-between; justify-content: space-between;
@include media-breakpoint-down(sm) {
flex-wrap: wrap;
}
input, input,
label { label {
margin: 0 8px;
@include media-breakpoint-down(sm) { @include media-breakpoint-down(sm) {
margin-bottom: 1em; margin-bottom: 1em;
} }
margin: 0 8px;
} }
input { input {

View File

@ -1,9 +1,8 @@
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { createSignal, onMount } from 'solid-js' import { createSignal, onMount } from 'solid-js'
import { useLocalize } from '../../context/localize' import { useLocalize } from '~/context/localize'
import { useSnackbar } from '../../context/snackbar' import { useSnackbar, useUI } from '~/context/ui'
import { showModal } from '../../stores/ui'
import styles from './Donate.module.scss' import styles from './Donate.module.scss'
@ -12,12 +11,13 @@ type DWindow = Window & { cp: any }
export const Donate = () => { export const Donate = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { showModal } = useUI()
const once = '' const once = ''
const monthly = 'Monthly' const monthly = 'Monthly'
const cpOptions = { const cpOptions = {
publicId: 'pk_0a37bab30ffc6b77b2f93d65f2aed', publicId: 'pk_0a37bab30ffc6b77b2f93d65f2aed',
description: t('Help discours to grow'), description: t('Help discours to grow'),
currency: 'RUB', currency: 'RUB'
} }
let amountSwitchElement: HTMLDivElement | undefined let amountSwitchElement: HTMLDivElement | undefined
@ -45,8 +45,8 @@ export const Donate = () => {
amount: amount() || 0, //сумма amount: amount() || 0, //сумма
vat: 20, //ставка НДС vat: 20, //ставка НДС
method: 0, // тег-1214 признак способа расчета - признак способа расчета method: 0, // тег-1214 признак способа расчета - признак способа расчета
object: 0, // тег-1212 признак предмета расчета - признак предмета товара, работы, услуги, платежа, выплаты, иного предмета расчета object: 0 // тег-1212 признак предмета расчета - признак предмета товара, работы, услуги, платежа, выплаты, иного предмета расчета
}, }
], ],
// taxationSystem: 0, //система налогообложения; необязательный, если у вас одна система налогообложения // taxationSystem: 0, //система налогообложения; необязательный, если у вас одна система налогообложения
// email: 'user@example.com', //e-mail покупателя, если нужно отправить письмо с чеком // email: 'user@example.com', //e-mail покупателя, если нужно отправить письмо с чеком
@ -56,8 +56,8 @@ export const Donate = () => {
electronic: amount(), // Сумма оплаты электронными деньгами electronic: amount(), // Сумма оплаты электронными деньгами
advancePayment: 0, // Сумма из предоплаты (зачетом аванса) (2 знака после запятой) advancePayment: 0, // Сумма из предоплаты (зачетом аванса) (2 знака после запятой)
credit: 0, // Сумма постоплатой(в кредит) (2 знака после запятой) credit: 0, // Сумма постоплатой(в кредит) (2 знака после запятой)
provision: 0, // Сумма оплаты встречным предоставлением (сертификаты, др. мат.ценности) (2 знака после запятой) provision: 0 // Сумма оплаты встречным предоставлением (сертификаты, др. мат.ценности) (2 знака после запятой)
}, }
}) })
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -98,27 +98,29 @@ export const Donate = () => {
recurrent: { recurrent: {
interval: period(), // local solid's signal interval: period(), // local solid's signal
period: 1, // internal widget's period: 1, // internal widget's
CustomerReciept: customerReciept(), // чек для регулярных платежей CustomerReciept: customerReciept() // чек для регулярных платежей
}
}
}
}, },
}, // biome-ignore lint/suspicious/noExplicitAny: <explanation>
}, (opts: any) => {
},
(opts) => {
// success // success
// действие при успешной оплате // действие при успешной оплате
console.debug('[donate] options', opts) console.debug('[donate] options', opts)
showModal('thank') showModal('thank')
}, },
(reason: string, options) => { // biome-ignore lint/suspicious/noExplicitAny: <explanation>
(reason: string, options: any) => {
// fail // fail
// действие при неуспешной оплате // действие при неуспешной оплате
console.debug('[donate] options', options) console.debug('[donate] options', options)
showSnackbar({ showSnackbar({
type: 'error', type: 'error',
body: reason, body: reason
}) })
}, }
) )
} }

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