From 06beecac54cdf02705d017a3e3ef93e2c4a9b371 Mon Sep 17 00:00:00 2001 From: Igor Lobanov Date: Tue, 25 Oct 2022 17:36:32 +0200 Subject: [PATCH 01/11] common Popup component & SharePopup --- src/components/Article/FullArticle.tsx | 11 ++-- src/components/Article/SharePopup.tsx | 45 ++++++++++++++++ src/components/Nav/Header.module.scss | 32 ++++------- src/components/Nav/Header.tsx | 73 ++++++++------------------ src/components/Nav/Popup.module.scss | 24 +++++++-- src/components/Nav/Popup.tsx | 61 +++++++++++++-------- src/stores/ui.ts | 3 +- 7 files changed, 146 insertions(+), 103 deletions(-) create mode 100644 src/components/Article/SharePopup.tsx diff --git a/src/components/Article/FullArticle.tsx b/src/components/Article/FullArticle.tsx index 512cdac9..ab6d6b07 100644 --- a/src/components/Article/FullArticle.tsx +++ b/src/components/Article/FullArticle.tsx @@ -10,6 +10,7 @@ import { showModal } from '../../stores/ui' import { useAuthStore } from '../../stores/auth' import { incrementView } from '../../stores/zine/articles' import MD from './MD' +import { SharePopup } from './SharePopup' const MAX_COMMENT_LEVEL = 6 @@ -126,9 +127,13 @@ export const FullArticle = (props: ArticleProps) => { {/* */} {/**/}
- showModal('share')}> - - + event.preventDefault()}> + + + } + />
{/*FIXME*/} {/**/} diff --git a/src/components/Article/SharePopup.tsx b/src/components/Article/SharePopup.tsx new file mode 100644 index 00000000..851cf599 --- /dev/null +++ b/src/components/Article/SharePopup.tsx @@ -0,0 +1,45 @@ +import { Icon } from '../Nav/Icon' +import styles from '../Nav/Popup.module.scss' +import { t } from '../../utils/intl' +import { Popup, PopupProps } from '../Nav/Popup' + +type SharePopupProps = Omit + +export const SharePopup = (props: SharePopupProps) => { + return ( + + + + ) +} diff --git a/src/components/Nav/Header.module.scss b/src/components/Nav/Header.module.scss index 0a34a45e..9078872b 100644 --- a/src/components/Nav/Header.module.scss +++ b/src/components/Nav/Header.module.scss @@ -35,18 +35,6 @@ } } -.popupShare { - opacity: 1; - transition: opacity 0.3s; - z-index: 1; - - .headerScrolledTop & { - opacity: 0; - transition: opacity 0.3s, z-index 0s 0.3s; - z-index: -1; - } -} - .headerFixed { position: fixed; top: 0; @@ -348,18 +336,16 @@ transform: translateY(-50%); width: 100%; - .icon { + .control { + cursor: pointer; margin-left: 1.6rem; - opacity: 0.6; - transition: opacity 0.3s; - } + border: 0; - img { - vertical-align: middle; - } + .icon { + opacity: 0.6; + transition: opacity 0.3s; + } - a { - border: none; &:hover { background: none; @@ -370,4 +356,8 @@ } } } + + img { + vertical-align: middle; + } } diff --git a/src/components/Nav/Header.tsx b/src/components/Nav/Header.tsx index ab2d6aeb..8a4e46f8 100644 --- a/src/components/Nav/Header.tsx +++ b/src/components/Nav/Header.tsx @@ -3,18 +3,17 @@ import Private from './Private' import Notifications from './Notifications' import { Icon } from './Icon' import { Modal } from './Modal' -import { Popup } from './Popup' import AuthModal from './AuthModal' import { t } from '../../utils/intl' -import {useModalStore, showModal, useWarningsStore, toggleModal} from '../../stores/ui' +import { useModalStore, showModal, useWarningsStore } from '../../stores/ui' import { useAuthStore } from '../../stores/auth' import { handleClientRouteLinkClick, router, Routes, useRouter } from '../../stores/router' import styles from './Header.module.scss' -import stylesPopup from './Popup.module.scss' import privateStyles from './Private.module.scss' import { getPagePath } from '@nanostores/router' import { getLogger } from '../../utils/logger' import { clsx } from 'clsx' +import { SharePopup } from '../Article/SharePopup' const log = getLogger('header') @@ -39,6 +38,7 @@ export const Header = (props: Props) => { const [getIsScrolled, setIsScrolled] = createSignal(false) const [fixed, setFixed] = createSignal(false) const [visibleWarnings, setVisibleWarnings] = createSignal(false) + const [isSharePopupVisible, setIsSharePopupVisible] = createSignal(false) // stores const { getWarnings } = useWarningsStore() const { session } = useAuthStore() @@ -48,14 +48,11 @@ export const Header = (props: Props) => { // methods const toggleWarnings = () => setVisibleWarnings(!visibleWarnings()) - const toggleFixed = () => setFixed(!fixed()) + const toggleFixed = () => setFixed((oldFixed) => !oldFixed) // effects createEffect(() => { - const isFixed = fixed() || (getModal() && getModal() !== 'share'); - - document.body.classList.toggle('fixed', isFixed); - document.body.classList.toggle(styles.fixed, isFixed && !getModal()); - }, [fixed(), getModal()]) + document.body.classList.toggle('fixed', fixed() || getModal() !== null) + }) // derived const authorized = createMemo(() => session()?.user?.slug) @@ -90,7 +87,7 @@ export const Header = (props: Props) => { classList={{ [styles.headerFixed]: props.isHeaderFixed, [styles.headerScrolledTop]: !getIsScrollingBottom() && getIsScrolled(), - [styles.headerScrolledBottom]: getIsScrollingBottom() && getIsScrolled(), + [styles.headerScrolledBottom]: (getIsScrollingBottom() && getIsScrolled()) || isSharePopupVisible(), [styles.headerWithTitle]: Boolean(props.title) }} > @@ -99,41 +96,6 @@ export const Header = (props: Props) => { diff --git a/src/components/Nav/Popup.module.scss b/src/components/Nav/Popup.module.scss index 63405563..d62d1c09 100644 --- a/src/components/Nav/Popup.module.scss +++ b/src/components/Nav/Popup.module.scss @@ -1,6 +1,13 @@ +.container { + position: relative; +} + .popup { background: #fff; border: 2px solid #000; + top: calc(100% + 8px); + transform: translateX(-50%); + opacity: 1; @include font-size(1.6rem); @@ -24,6 +31,7 @@ a { border: none; + white-space: nowrap; } img { @@ -40,7 +48,15 @@ } } -.popupShare { - right: 1em; - top: 4.5rem; -} +// TODO: animation +// .popup { +// opacity: 1; +// transition: opacity 0.3s; +// z-index: 1; +// &.visible { +// opacity: 0; +// transition: opacity 0.3s, z-index 0s 0.3s; +// z-index: -1; +// } +// } + diff --git a/src/components/Nav/Popup.tsx b/src/components/Nav/Popup.tsx index 451caf88..a53517cf 100644 --- a/src/components/Nav/Popup.tsx +++ b/src/components/Nav/Popup.tsx @@ -1,33 +1,50 @@ -import { createEffect, createSignal, onMount, Show } from 'solid-js' -import style from './Popup.module.scss' -import { hideModal, useModalStore } from '../../stores/ui' -import {clsx} from 'clsx'; +import { createEffect, createSignal, JSX, onCleanup, onMount, Show } from 'solid-js' +import styles from './Popup.module.scss' +import { clsx } from 'clsx' -interface PopupProps { - name: string - children: any - class?: string +export type PopupProps = { + containerCssClass?: string + trigger: JSX.Element + children: JSX.Element + onVisibilityChange?: (isVisible) => void } export const Popup = (props: PopupProps) => { - const { getModal } = useModalStore() + const [isVisible, setIsVisible] = createSignal(false) + + createEffect(() => { + if (props.onVisibilityChange) { + props.onVisibilityChange(isVisible()) + } + }) + + let container: HTMLDivElement | undefined + + const handleClickOutside = (event: MouseEvent & { target: Element }) => { + if (!isVisible()) { + return + } + + if (event.target === container || container?.contains(event.target)) { + return + } + + setIsVisible(false) + } onMount(() => { - window.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape') hideModal() - }) - }) - - const [visible, setVisible] = createSignal(false) - createEffect(() => { - setVisible(getModal() === props.name) + document.addEventListener('click', handleClickOutside, { capture: true }) + onCleanup(() => document.removeEventListener('click', handleClickOutside, { capture: true })) }) + const toggle = () => setIsVisible((oldVisible) => !oldVisible) + // class={clsx(styles.popupShare, stylesPopup.popupShare)} return ( - -
- {props.children} -
-
+ + {props.trigger} + +
{props.children}
+
+
) } diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 39d4c717..e36e48c7 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -5,7 +5,7 @@ import { createSignal } from 'solid-js' //export const locale = persistentAtom('locale', 'ru') export const [locale, setLocale] = createSignal('ru') -export type ModalType = 'auth' | 'subscribe' | 'feedback' | 'share' | 'thank' | 'donate' | null +export type ModalType = 'auth' | 'subscribe' | 'feedback' | 'thank' | 'donate' | null type WarnKind = 'error' | 'warn' | 'info' export interface Warning { @@ -20,7 +20,6 @@ const warnings = atom([]) export const showModal = (modalType: ModalType) => modal.set(modalType) export const hideModal = () => modal.set(null) -export const toggleModal = (modalType) => modal.get() ? hideModal() : showModal(modalType) export const clearWarns = () => warnings.set([]) export const warn = (warning: Warning) => warnings.set([...warnings.get(), warning]) From f64bcc08f08020e4d29588ca5659cc6b260849d2 Mon Sep 17 00:00:00 2001 From: Igor Lobanov Date: Tue, 25 Oct 2022 17:40:12 +0200 Subject: [PATCH 02/11] prettier --- src/components/Discours/Banner.tsx | 2 +- src/components/Discours/Footer.tsx | 2 +- src/components/Feed/Row1.tsx | 2 +- src/components/Nav/Header.module.scss | 1 - src/components/Nav/Popup.module.scss | 1 - src/components/Pages/about/ManifestPage.tsx | 83 +++++++++++---------- 6 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/components/Discours/Banner.tsx b/src/components/Discours/Banner.tsx index 17c8875a..f14d2a8e 100644 --- a/src/components/Discours/Banner.tsx +++ b/src/components/Discours/Banner.tsx @@ -1,7 +1,7 @@ import styles from './Banner.module.scss' import { t } from '../../utils/intl' import { showModal } from '../../stores/ui' -import {clsx} from "clsx"; +import { clsx } from 'clsx' export default () => { return ( diff --git a/src/components/Discours/Footer.tsx b/src/components/Discours/Footer.tsx index ed0a50f7..941c5d3a 100644 --- a/src/components/Discours/Footer.tsx +++ b/src/components/Discours/Footer.tsx @@ -4,7 +4,7 @@ import { Icon } from '../Nav/Icon' import Subscribe from './Subscribe' import { t } from '../../utils/intl' import { locale } from '../../stores/ui' -import {clsx} from "clsx"; +import { clsx } from 'clsx' export const Footer = () => { const locale_title = createMemo(() => (locale() === 'ru' ? 'English' : 'Русский')) diff --git a/src/components/Feed/Row1.tsx b/src/components/Feed/Row1.tsx index abf6b9f8..b654fb82 100644 --- a/src/components/Feed/Row1.tsx +++ b/src/components/Feed/Row1.tsx @@ -7,7 +7,7 @@ export default (props: { article: Shout }) => (
- +
diff --git a/src/components/Nav/Header.module.scss b/src/components/Nav/Header.module.scss index 9078872b..83f0d992 100644 --- a/src/components/Nav/Header.module.scss +++ b/src/components/Nav/Header.module.scss @@ -346,7 +346,6 @@ transition: opacity 0.3s; } - &:hover { background: none; diff --git a/src/components/Nav/Popup.module.scss b/src/components/Nav/Popup.module.scss index d62d1c09..57c7263f 100644 --- a/src/components/Nav/Popup.module.scss +++ b/src/components/Nav/Popup.module.scss @@ -59,4 +59,3 @@ // z-index: -1; // } // } - diff --git a/src/components/Pages/about/ManifestPage.tsx b/src/components/Pages/about/ManifestPage.tsx index 68e318c2..3a9cf69f 100644 --- a/src/components/Pages/about/ManifestPage.tsx +++ b/src/components/Pages/about/ManifestPage.tsx @@ -54,28 +54,29 @@ export const ManifestPage = () => {

- Дискурс — независимый художественно-аналитический журнал с горизонтальной редакцией, - основанный на принципах свободы слова, прямой демократии и совместного редактирования. - Дискурс создаётся открытым медиасообществом ученых, журналистов, музыкантов, писателей, - предпринимателей, философов, инженеров, художников и специалистов со всего мира, - объединившихся, чтобы вместе делать общий журнал и объяснять с разных точек - зрения мозаичную картину современности. + Дискурс — независимый художественно-аналитический журнал с горизонтальной + редакцией, основанный на принципах свободы слова, прямой демократии и совместного + редактирования. Дискурс создаётся открытым медиасообществом ученых, журналистов, музыкантов, + писателей, предпринимателей, философов, инженеров, художников и специалистов + со всего мира, объединившихся, чтобы вместе делать общий журнал и объяснять + с разных точек зрения мозаичную картину современности.

- Мы пишем о культуре, науке и обществе, рассказываем о новых идеях и современном искусстве, - публикуем статьи, исследования, репортажи, интервью людей, чью прямую речь стоит услышать, - и работы художников из разных стран — от фильмов и музыки - до живописи и фотографии. Помогая друг другу делать публикации качественнее - и общим голосованием выбирая лучшие материалы для журнала, мы создаём новую - горизонтальную журналистику, чтобы честно рассказывать о важном и интересном. + Мы пишем о культуре, науке и обществе, рассказываем о новых идеях + и современном искусстве, публикуем статьи, исследования, репортажи, интервью людей, чью + прямую речь стоит услышать, и работы художников из разных стран — + от фильмов и музыки до живописи и фотографии. Помогая друг другу делать + публикации качественнее и общим голосованием выбирая лучшие материалы для журнала, + мы создаём новую горизонтальную журналистику, чтобы честно рассказывать о важном + и интересном.

- Редакция Дискурса открыта для всех: у нас нет цензуры, запретных тем и идеологических рамок. - Каждый может прислать материал в журнал - и присоединиться к редакции. Предоставляя трибуну - для независимой журналистики и художественных проектов, мы помогаем людям - рассказывать свои истории так, чтобы они были услышаны. Мы убеждены: чем больше - голосов будет звучать на Дискурсе, тем громче в полифонии мнений будет слышна истина. + Редакция Дискурса открыта для всех: у нас нет цензуры, запретных тем + и идеологических рамок. Каждый может прислать материал{' '} + в журнал и присоединиться к редакции. Предоставляя + трибуну для независимой журналистики и художественных проектов, мы помогаем людям + рассказывать свои истории так, чтобы они были услышаны. Мы убеждены: чем больше голосов + будет звучать на Дискурсе, тем громче в полифонии мнений будет слышна истина.

@@ -91,23 +92,26 @@ export const ManifestPage = () => {

Предлагать материалы

- Создавайте свои статьи и художественные работы — лучшие из них будут - опубликованы в журнале. Дискурс — некоммерческое издание, авторы публикуются - в журнале на общественных началах, получая при этом поддержку редакции, - право голоса, множество других возможностей и читателей по всему миру. + Создавайте свои статьи и художественные работы — + лучшие из них будут опубликованы в журнале. Дискурс — некоммерческое + издание, авторы публикуются в журнале на общественных началах, получая при этом{' '} + поддержку редакции, право голоса, множество других + возможностей и читателей по всему миру.

-

Дискурс существует на пожертвования читателей. Если вам нравится журнал, пожалуйста,

- поддержите нашу работу. Ваши пожертвования пойдут на выпуск новых - материалов, оплату серверов, труда программистов, дизайнеров и редакторов. + Дискурс существует на пожертвования читателей. Если вам нравится журнал, пожалуйста, +

+

+ поддержите нашу работу. Ваши пожертвования пойдут на выпуск + новых материалов, оплату серверов, труда программистов, дизайнеров и редакторов.

Сотрудничать с журналом

Мы всегда открыты для сотрудничества и рады единомышленникам. Если вы хотите помогать журналу с редактурой, корректурой, иллюстрациями, переводами, версткой, подкастами, - мероприятиями, фандрайзингом или как-то ещё — скорее пишите нам - на welcome@discours.io. + мероприятиями, фандрайзингом или как-то ещё — скорее пишите нам на  + welcome@discours.io.

Если вы представляете некоммерческую организацию и хотите сделать с нами @@ -116,25 +120,26 @@ export const ManifestPage = () => {

Если вы разработчик и хотите помогать с развитием сайта Дискурса,{' '} - присоединяйтесь к IT-команде самиздата. Открытый - код платформы для независимой журналистики, а также всех наших спецпроектов - и медиаинструментов находится в свободном доступе на GitHub. + присоединяйтесь к IT-команде самиздата. + Открытый код платформы для независимой журналистики, а также всех наших спецпроектов + и медиаинструментов находится{' '} + в свободном доступе на GitHub.

Как еще можно помочь

Советуйте Дискурс друзьям и знакомым. Обсуждайте и распространяйте наши публикации — все материалы открытой редакции можно читать и перепечатывать - бесплатно. Подпишитесь на самиздат{' '} - ВКонтакте, + бесплатно. Подпишитесь на самиздат ВКонтакте, в Фейсбуке - и в Телеграме, а также - на рассылку лучших материалов, - чтобы не пропустить ничего интересного. + и в Телеграме, а также на  + рассылку лучших материалов, чтобы не пропустить + ничего интересного.

Рассказывайте о впечатлениях от материалов открытой редакции, делитесь идеями, - интересными темами, о которых хотели бы узнать больше, и историями, которые нужно рассказать. + интересными темами, о которых хотели бы узнать больше, и историями, которые нужно + рассказать.

@@ -145,9 +150,9 @@ export const ManifestPage = () => {
Если вы хотите предложить материал, сотрудничать, рассказать о проблеме, которую нужно осветить, сообщить об ошибке или баге, что-то обсудить, уточнить или посоветовать, - пожалуйста, напишите нам здесь или - на почту welcome@discours.io. Мы обязательно - ответим и постараемся реализовать все хорошие задумки. + пожалуйста, напишите нам здесь или на почту{' '} + welcome@discours.io. Мы обязательно ответим + и постараемся реализовать все хорошие задумки.
From 0eb1430174be30fb30653cd1a4388c43190ddf87 Mon Sep 17 00:00:00 2001 From: Igor Lobanov Date: Tue, 25 Oct 2022 17:44:31 +0200 Subject: [PATCH 03/11] prettier on on precommit enabled --- .lintstagedrc | 5 +---- .lintstagedrc.bak | 6 ++++++ package.json | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) create mode 100644 .lintstagedrc.bak diff --git a/.lintstagedrc b/.lintstagedrc index 6de8a64b..8c919270 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -1,6 +1,3 @@ { - "*.{js,ts,tsx,json,scss,css,html}": "prettier --write", - "package.json": "sort-package-json", - "*.{scss,css}": "stylelint", - "*.{ts,tsx,js}": "eslint --fix" + "*.{js,ts,tsx,json,scss,css,html}": "prettier --write" } diff --git a/.lintstagedrc.bak b/.lintstagedrc.bak new file mode 100644 index 00000000..6de8a64b --- /dev/null +++ b/.lintstagedrc.bak @@ -0,0 +1,6 @@ +{ + "*.{js,ts,tsx,json,scss,css,html}": "prettier --write", + "package.json": "sort-package-json", + "*.{scss,css}": "stylelint", + "*.{ts,tsx,js}": "eslint --fix" +} diff --git a/package.json b/package.json index fd8ff98e..cd3dcbce 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "lint:code:fix": "eslint . --fix", "lint:styles": "stylelint **/*.{scss,css}", "lint:styles:fix": "stylelint **/*.{scss,css} --fix", - "pre-commit": "", + "pre-commit": "lint-staged", "pre-push": "", "pre-commit-old": "lint-staged", "pre-push-old": "npm run typecheck", From 2e827901c2614516a1087b490b6ee59519cc1ead Mon Sep 17 00:00:00 2001 From: Igor Lobanov Date: Tue, 25 Oct 2022 18:25:42 +0200 Subject: [PATCH 04/11] Auth & registration --- .eslintrc.js | 5 +- src/components/Author/Card.module.scss | 1 + src/components/Nav/AuthModal.tsx | 356 ------------------ .../AuthModal.module.scss} | 127 +++---- src/components/Nav/AuthModal/EmailConfirm.tsx | 44 +++ .../Nav/AuthModal/ForgotPasswordForm.tsx | 98 +++++ src/components/Nav/AuthModal/LoginForm.tsx | 171 +++++++++ src/components/Nav/AuthModal/RegisterForm.tsx | 206 ++++++++++ .../Nav/AuthModal/SocialProviders.module.scss | 45 +++ .../Nav/AuthModal/SocialProviders.tsx | 42 +++ src/components/Nav/AuthModal/index.tsx | 76 ++++ src/components/Nav/AuthModal/sharedLogic.tsx | 5 + src/components/Nav/AuthModal/types.ts | 5 + src/components/Nav/AuthModal/validators.ts | 7 + src/components/Nav/Header.tsx | 26 +- src/components/Nav/Modal.tsx | 26 +- src/components/Nav/Notifications.tsx | 6 +- src/components/Nav/Private.tsx | 8 +- src/components/Pages/ArticlePage.tsx | 2 +- src/components/Pages/AuthorPage.tsx | 2 +- src/components/Pages/SearchPage.tsx | 2 +- src/components/Pages/TopicPage.tsx | 2 +- src/components/Root.tsx | 33 +- src/components/Topic/Card.tsx | 1 - src/components/Views/AllAuthors.tsx | 17 +- src/components/Views/AllTopics.tsx | 16 +- src/components/Views/Author.tsx | 6 +- src/components/Views/Home.tsx | 2 +- src/components/Views/Search.tsx | 6 +- src/components/Views/Topic.tsx | 6 +- src/graphql/mutation/auth-confirm-email.ts | 21 +- src/graphql/mutation/auth-register.ts | 13 +- src/graphql/mutation/auth-send-link.ts | 2 +- src/graphql/mutation/my-session.ts | 2 +- src/graphql/privateGraphQLClient.ts | 5 +- src/graphql/publicGraphQLClient.ts | 7 +- src/graphql/types.gen.ts | 4 +- src/locales/ru.json | 22 +- src/pages/api/sendlink.ts | 31 -- src/pages/welcome.astro | 3 + src/stores/auth.ts | 67 ++-- src/stores/router.ts | 28 +- src/stores/ui.ts | 37 +- src/stores/zine/authors.ts | 2 +- src/stores/zine/topics.ts | 2 +- src/styles/app.scss | 1 + src/utils/apiClient.ts | 52 ++- src/utils/config.ts | 3 + src/utils/validators.ts | 29 -- 49 files changed, 1027 insertions(+), 653 deletions(-) delete mode 100644 src/components/Nav/AuthModal.tsx rename src/components/Nav/{AuthModal.scss => AuthModal/AuthModal.module.scss} (62%) create mode 100644 src/components/Nav/AuthModal/EmailConfirm.tsx create mode 100644 src/components/Nav/AuthModal/ForgotPasswordForm.tsx create mode 100644 src/components/Nav/AuthModal/LoginForm.tsx create mode 100644 src/components/Nav/AuthModal/RegisterForm.tsx create mode 100644 src/components/Nav/AuthModal/SocialProviders.module.scss create mode 100644 src/components/Nav/AuthModal/SocialProviders.tsx create mode 100644 src/components/Nav/AuthModal/index.tsx create mode 100644 src/components/Nav/AuthModal/sharedLogic.tsx create mode 100644 src/components/Nav/AuthModal/types.ts create mode 100644 src/components/Nav/AuthModal/validators.ts delete mode 100644 src/pages/api/sendlink.ts create mode 100644 src/pages/welcome.astro delete mode 100644 src/utils/validators.ts diff --git a/.eslintrc.js b/.eslintrc.js index 13d10867..4b07ff99 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,8 +34,9 @@ module.exports = { varsIgnorePattern: '^log$' } ], - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-non-null-assertion': 'warn', + // TODO: Remove any usage and enable + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'error', // solid-js fix 'import/no-unresolved': [2, { ignore: ['solid-js/'] }] diff --git a/src/components/Author/Card.module.scss b/src/components/Author/Card.module.scss index b0b4bb2c..1dece175 100644 --- a/src/components/Author/Card.module.scss +++ b/src/components/Author/Card.module.scss @@ -97,6 +97,7 @@ } } + a[href*='vk.cc/'], a[href*='vk.com/'] { &::before { background-image: url(/icons/vk-white.svg); diff --git a/src/components/Nav/AuthModal.tsx b/src/components/Nav/AuthModal.tsx deleted file mode 100644 index e26ad6b2..00000000 --- a/src/components/Nav/AuthModal.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { Show } from 'solid-js/web' -import { Icon } from './Icon' -import { createEffect, createSignal, onMount } from 'solid-js' -import './AuthModal.scss' -import { Form } from 'solid-js-form' -import { t } from '../../utils/intl' -import { hideModal, useModalStore } from '../../stores/ui' -import { useAuthStore, signIn, register } from '../../stores/auth' -import { useValidator } from '../../utils/validators' -import { baseUrl } from '../../graphql/publicGraphQLClient' -import { ApiError } from '../../utils/apiClient' -import { handleClientRouteLinkClick } from '../../stores/router' - -type AuthMode = 'sign-in' | 'sign-up' | 'forget' | 'reset' | 'resend' | 'password' - -const statuses: { [key: string]: string } = { - 'email not found': 'No such account, please try to register', - 'invalid password': 'Invalid password', - 'invalid code': 'Invalid code', - 'unknown error': 'Unknown error' -} - -const titles = { - 'sign-up': t('Create account'), - 'sign-in': t('Enter the Discours'), - forget: t('Forgot password?'), - reset: t('Please, confirm your email to finish'), - resend: t('Resend code'), - password: t('Enter your new password') -} - -// const isProperEmail = (email) => email && email.length > 5 && email.includes('@') && email.includes('.') - -// 3rd party provider auth handler -const oauth = (provider: string): void => { - const popup = window.open(`${baseUrl}/oauth/${provider}`, provider, 'width=740, height=420') - popup?.focus() - hideModal() -} - -// FIXME !!! -// eslint-disable-next-line sonarjs/cognitive-complexity -export default (props: { code?: string; mode?: AuthMode }) => { - const { session } = useAuthStore() - const [handshaking] = createSignal(false) - const { getModal } = useModalStore() - const [authError, setError] = createSignal('') - const [mode, setMode] = createSignal('sign-in') - const [validation, setValidation] = createSignal({}) - const [initial, setInitial] = createSignal({}) - let emailElement: HTMLInputElement | undefined - let pass2Element: HTMLInputElement | undefined - let passElement: HTMLInputElement | undefined - let codeElement: HTMLInputElement | undefined - - // FIXME: restore logic - // const usedEmails = {} - // const checkEmailAsync = async (email: string) => { - // const handleChecked = (x: boolean) => { - // if (x && mode() === 'sign-up') setError(t('We know you, please try to sign in')) - // if (!x && mode() === 'sign-in') setError(t('No such account, please try to register')) - // usedEmails[email] = x - // } - // if (email in usedEmails) { - // handleChecked(usedEmails[email]) - // } else if (isProperEmail(email)) { - // const { error, data } = await apiClient.q(authCheck, { email }, true) - // if (error) setError(error.message) - // if (data) handleChecked(data.isEmailUsed) - // } - // } - - // let checkEmailTimeout - // createEffect(() => { - // const email = emailElement?.value - // if (isProperEmail(email)) { - // if (checkEmailTimeout) clearTimeout(checkEmailTimeout) - // checkEmailTimeout = setTimeout(checkEmailAsync, 3000) // after 3 secs - // } - // }, [emailElement?.value]) - - // switching initial values and validatiors - const setupValidators = () => { - const [vs, ini] = useValidator(mode()) - setValidation(vs) - setInitial(ini) - } - - onMount(setupValidators) - - const resetError = () => { - setError('') - } - - const changeMode = (newMode: AuthMode) => { - setMode(newMode) - resetError() - } - - // local auth handler - const localAuth = async () => { - console.log('[auth] native account processing') - switch (mode()) { - case 'sign-in': - try { - await signIn({ email: emailElement?.value, password: passElement?.value }) - } catch (error) { - if (error instanceof ApiError) { - if (error.code === 'email_not_confirmed') { - setError(t('Please, confirm email')) - return - } - - if (error.code === 'user_not_found') { - setError(t('Something went wrong, check email and password')) - return - } - } - - setError(error.message) - } - - break - case 'sign-up': - if (pass2Element?.value !== passElement?.value) { - setError(t('Passwords are not equal')) - } else { - await register({ - email: emailElement?.value, - password: passElement?.value - }) - } - break - case 'reset': - // send reset-code to login with email - console.log('[auth] reset code: ' + codeElement?.value) - // TODO: authReset(codeElement?.value) - break - case 'resend': - // TODO: authResend(emailElement?.value) - break - case 'forget': - // shows forget mode of auth-modal - if (pass2Element?.value !== passElement?.value) { - setError(t('Passwords are not equal')) - } else { - // TODO: authForget(passElement?.value) - } - break - default: - console.log('[auth] unknown auth mode', mode()) - } - } - - // FIXME move to handlers - createEffect(() => { - if (session()?.user?.slug && getModal() === 'auth') { - // hiding itself if finished - console.log('[auth] success, hiding modal') - hideModal() - } else if (session()?.error) { - console.log('[auth] failure, showing error') - setError(t(statuses[session().error || 'unknown error'])) - } else { - console.log('[auth] session', session()) - } - }) - - return ( -
-
-
-

{t('Discours')}

-

{t(`Join the global community of authors!`)}

-

- {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' - )} - .  - {t('New stories every day and even more!')} -

-

- {t('By signing up you agree with our')}{' '} - { - hideModal() - handleClientRouteLinkClick(event) - }} - > - {t('terms of use')} - - , {t('personal data usage and email notifications')}. -

-
-
-
-
{ - console.log('[auth] form values', form.values) - }} - > -
-

{titles[mode()]}

-
- - {t('Enter the code or click the link from email to confirm')} - - } - > - {t('Everything is ok, please give us your email address')} - -
- -
-
    -
  • {authError()}
  • -
-
-
- {/*FIXME*/} - {/**/} - {/*
*/} - {/* */} - {/* */} - {/*
*/} - {/*
*/} - -
- - -
-
- -
- - -
-
- -
- - -
-
- -
- - -
-
-
- -
- - - - - - -
-
- changeMode('sign-in')}> - {t('I have an account')} - -
-
- changeMode('sign-up')}> - {t('I have no account yet')} - -
-
- changeMode('sign-in')}> - {t('I know the password')} - -
-
- changeMode('resend')}> - {t('Resend code')} - -
-
-
-
-
-
- ) -} diff --git a/src/components/Nav/AuthModal.scss b/src/components/Nav/AuthModal/AuthModal.module.scss similarity index 62% rename from src/components/Nav/AuthModal.scss rename to src/components/Nav/AuthModal/AuthModal.module.scss index 13b66e81..3a89155e 100644 --- a/src/components/Nav/AuthModal.scss +++ b/src/components/Nav/AuthModal/AuthModal.module.scss @@ -12,12 +12,12 @@ } } -.view--sign-up { - .auth-image { +.signUp { + .authImage { order: 2; } - .auth-image::before { + .authImage::before { background: linear-gradient(0deg, rgb(20 20 20 / 80%), rgb(20 20 20 / 80%)); content: ''; height: 100%; @@ -27,12 +27,12 @@ width: 100%; } - & ~ .close-control { + & ~ :global(.close-control) { filter: invert(1); } } -.auth-image { +.authImage { background: #141414 url('/auth-page.jpg') center no-repeat; background-size: cover; color: #fff; @@ -55,8 +55,8 @@ } } -.auth-image__text { - display: none; +.authImageText { + display: flex; flex-direction: column; justify-content: space-between; position: relative; @@ -70,22 +70,22 @@ color: rgb(255 255 255 / 70%); } } + + &.hidden { + display: none; + } } -.auth-image__text.show { - display: flex; -} - -.auth-benefits { +.authBenefits { flex: 1; } -.disclamer { +.disclaimer { color: #9fa1a7; @include font-size(1.2rem); } -.auth-actions { +.authActions { @include font-size(1.5rem); margin-top: 1.6rem; @@ -107,84 +107,30 @@ } } -.submitbtn { +.submitButton { display: block; font-weight: 700; padding: 1.6rem; width: 100%; } -.social-provider { - border-bottom: 1px solid #141414; - border-top: 1px solid #141414; - margin-top: 1em; - padding: 0.8em 0 1em; -} - -.social { - background-color: white !important; - display: flex; - margin: 0 -5px; - - > * { - background-color: #f7f7f7; - cursor: pointer; - flex: 1; - margin: 0 5px; - padding: 0.5em; - text-align: center; - } - - img { - height: 1.4em; - max-width: 1.8em; - vertical-align: middle; - width: auto; - } - - a { - border: none; - } - - .github-auth:hover { - img { - filter: invert(1); - } - } -} - -.auth-control { +.authControl { color: $link-color; margin-top: 1em; text-align: center; - - div { - display: none; - } - - .show { - display: block; - } } -.auth-link { +.authLink { cursor: pointer; } -.providers-text { - @include font-size(1.5rem); - - margin-bottom: 1em; - text-align: center; -} - -.auth-subtitle { +.authSubtitle { @include font-size(1.5rem); margin: 1em; } -.auth-info { +.authInfo { min-height: 5em; font-weight: 400; font-size: smaller; @@ -192,8 +138,39 @@ .warn { color: #a00; } +} - .info { - color: gray; +.validationError { + position: relative; + top: -8px; + font-size: 12px; + line-height: 16px; + margin-bottom: 8px; + + /* Red/500 */ + color: #d00820; + + a { + color: #d00820; + border-color: #d00820; + + &:hover { + color: white; + border-color: black; + } } } + +.title { + font-size: 26px; + line-height: 32px; + font-weight: 700; + color: #141414; + margin-bottom: 16px; +} + +.text { + font-size: 15px; + line-height: 24px; + margin-bottom: 52px; +} diff --git a/src/components/Nav/AuthModal/EmailConfirm.tsx b/src/components/Nav/AuthModal/EmailConfirm.tsx new file mode 100644 index 00000000..1fd1c7aa --- /dev/null +++ b/src/components/Nav/AuthModal/EmailConfirm.tsx @@ -0,0 +1,44 @@ +import styles from './AuthModal.module.scss' +import { clsx } from 'clsx' +import { t } from '../../../utils/intl' +import { hideModal } from '../../../stores/ui' +import { createMemo, onMount, Show } from 'solid-js' +import { useRouter } from '../../../stores/router' +import { confirmEmail, useAuthStore } from '../../../stores/auth' + +type ConfirmEmailSearchParams = { + token: string +} + +export const EmailConfirm = () => { + const { session } = useAuthStore() + + const confirmedEmail = createMemo(() => session()?.user?.email || '') + + const { searchParams } = useRouter() + + onMount(async () => { + const token = searchParams().token + try { + await confirmEmail(token) + } catch (error) { + console.log(error) + } + }) + + return ( +
+
{t('Hooray! Welcome!')}
+ +
+ {t("You've confirmed email")} {confirmedEmail()} +
+
+
+ +
+
+ ) +} diff --git a/src/components/Nav/AuthModal/ForgotPasswordForm.tsx b/src/components/Nav/AuthModal/ForgotPasswordForm.tsx new file mode 100644 index 00000000..173727eb --- /dev/null +++ b/src/components/Nav/AuthModal/ForgotPasswordForm.tsx @@ -0,0 +1,98 @@ +import { Show } from 'solid-js/web' +import { t } from '../../../utils/intl' +import styles from './AuthModal.module.scss' +import { clsx } from 'clsx' +import { createSignal, JSX } from 'solid-js' +import { useRouter } from '../../../stores/router' +import { email, setEmail } from './sharedLogic' +import type { AuthModalSearchParams } from './types' +import { isValidEmail } from './validators' + +type FormFields = { + email: string +} + +type ValidationErrors = Partial> + +export const ForgotPasswordForm = () => { + const { changeSearchParam } = useRouter() + + const handleEmailInput = (newEmail: string) => { + setValidationErrors(({ email: _notNeeded, ...rest }) => rest) + setEmail(newEmail) + } + + const [submitError, setSubmitError] = createSignal('') + const [isSubmitting, setIsSubmitting] = createSignal(false) + const [validationErrors, setValidationErrors] = createSignal({}) + + const handleSubmit = async (event: Event) => { + event.preventDefault() + + setSubmitError('') + + const newValidationErrors: ValidationErrors = {} + + if (!email()) { + newValidationErrors.email = t('Please enter email') + } else if (!isValidEmail(email())) { + newValidationErrors.email = t('Invalid email') + } + + setValidationErrors(newValidationErrors) + + const isValid = Object.keys(newValidationErrors).length === 0 + + if (!isValid) { + return + } + + setIsSubmitting(true) + + try { + // TODO: send mail with link to new password form + } catch (error) { + setSubmitError(error.message) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+

{t('Forgot password?')}

+
{t('Everything is ok, please give us your email address')}
+ +
+
    +
  • {submitError()}
  • +
+
+
+
+ handleEmailInput(event.currentTarget.value)} + /> + + +
+ +
+ +
+
+ changeSearchParam('mode', 'login')}> + {t('I know the password')} + +
+
+ ) +} diff --git a/src/components/Nav/AuthModal/LoginForm.tsx b/src/components/Nav/AuthModal/LoginForm.tsx new file mode 100644 index 00000000..c683b223 --- /dev/null +++ b/src/components/Nav/AuthModal/LoginForm.tsx @@ -0,0 +1,171 @@ +import { Show } from 'solid-js/web' +import { t } from '../../../utils/intl' +import styles from './AuthModal.module.scss' +import { clsx } from 'clsx' +import { SocialProviders } from './SocialProviders' +import { signIn, signSendLink } from '../../../stores/auth' +import { ApiError } from '../../../utils/apiClient' +import { createSignal } from 'solid-js' +import { isValidEmail } from './validators' +import { email, setEmail } from './sharedLogic' +import { useRouter } from '../../../stores/router' +import type { AuthModalSearchParams } from './types' +import { hideModal } from '../../../stores/ui' + +type FormFields = { + email: string + password: string +} + +type ValidationErrors = Partial> + +export const LoginForm = () => { + const [submitError, setSubmitError] = createSignal('') + const [isSubmitting, setIsSubmitting] = createSignal(false) + const [validationErrors, setValidationErrors] = createSignal({}) + // TODO: better solution for interactive error messages + const [isEmailNotConfirmed, setIsEmailNotConfirmed] = createSignal(false) + const [isLinkSent, setIsLinkSent] = createSignal(false) + + const { changeSearchParam } = useRouter() + + const [password, setPassword] = createSignal('') + + const handleEmailInput = (newEmail: string) => { + setValidationErrors(({ email: _notNeeded, ...rest }) => rest) + setEmail(newEmail) + } + + const handlePasswordInput = (newPassword: string) => { + setValidationErrors(({ password: _notNeeded, ...rest }) => rest) + setPassword(newPassword) + } + + const handleSendLinkAgainClick = (event: Event) => { + event.preventDefault() + setIsEmailNotConfirmed(false) + setSubmitError('') + setIsLinkSent(true) + signSendLink({ email: email() }) + } + + const handleSubmit = async (event: Event) => { + event.preventDefault() + + setIsLinkSent(false) + setSubmitError('') + + const newValidationErrors: ValidationErrors = {} + + if (!email()) { + newValidationErrors.email = t('Please enter email') + } else if (!isValidEmail(email())) { + newValidationErrors.email = t('Invalid email') + } + + if (!password()) { + newValidationErrors.password = t('Please enter password') + } + + if (Object.keys(newValidationErrors).length > 0) { + setValidationErrors(newValidationErrors) + return + } + + setIsSubmitting(true) + + try { + await signIn({ email: email(), password: password() }) + hideModal() + } catch (error) { + if (error instanceof ApiError) { + if (error.code === 'email_not_confirmed') { + setSubmitError(t('Please, confirm email')) + setIsEmailNotConfirmed(true) + return + } + + if (error.code === 'user_not_found') { + setSubmitError(t('Something went wrong, check email and password')) + return + } + } + + setSubmitError(error.message) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+

{t('Enter the Discours')}

+ +
+
{submitError()}
+ + + {t('Send link again')} + + +
+
+ +
{t('Link sent, check your email')}
+
+
+ handleEmailInput(event.currentTarget.value)} + /> + +
+ +
{validationErrors().email}
+
+
+ handlePasswordInput(event.currentTarget.value)} + /> + +
+ +
{validationErrors().password}
+
+
+ +
+ + + + +
+ changeSearchParam('mode', 'register')}> + {t('I have no account yet')} + +
+ + ) +} diff --git a/src/components/Nav/AuthModal/RegisterForm.tsx b/src/components/Nav/AuthModal/RegisterForm.tsx new file mode 100644 index 00000000..06adadb7 --- /dev/null +++ b/src/components/Nav/AuthModal/RegisterForm.tsx @@ -0,0 +1,206 @@ +import { Show } from 'solid-js/web' +import type { JSX } from 'solid-js' +import { t } from '../../../utils/intl' +import styles from './AuthModal.module.scss' +import { clsx } from 'clsx' +import { SocialProviders } from './SocialProviders' +import { checkEmail, register, useAuthStore } from '../../../stores/auth' +import { createSignal } from 'solid-js' +import { isValidEmail } from './validators' +import { ApiError } from '../../../utils/apiClient' +import { email, setEmail } from './sharedLogic' +import { useRouter } from '../../../stores/router' +import type { AuthModalSearchParams } from './types' +import { hideModal } from '../../../stores/ui' + +type FormFields = { + name: string + email: string + password: string +} + +type ValidationErrors = Partial> + +export const RegisterForm = () => { + const { changeSearchParam } = useRouter() + + const { emailChecks } = useAuthStore() + + const [submitError, setSubmitError] = createSignal('') + const [name, setName] = createSignal('') + const [password, setPassword] = createSignal('') + const [isSubmitting, setIsSubmitting] = createSignal(false) + const [isSuccess, setIsSuccess] = createSignal(false) + const [validationErrors, setValidationErrors] = createSignal({}) + + const handleEmailInput = (newEmail: string) => { + setValidationErrors(({ email: _notNeeded, ...rest }) => rest) + setEmail(newEmail) + } + + const handleEmailBlur = () => { + if (isValidEmail(email())) { + checkEmail(email()) + } + } + + const handlePasswordInput = (newPassword: string) => { + setValidationErrors(({ password: _notNeeded, ...rest }) => rest) + setPassword(newPassword) + } + + const handleNameInput = (newPasswordCopy: string) => { + setValidationErrors(({ name: _notNeeded, ...rest }) => rest) + setName(newPasswordCopy) + } + + const handleSubmit = async (event: Event) => { + event.preventDefault() + + setSubmitError('') + + const newValidationErrors: ValidationErrors = {} + + if (!name()) { + newValidationErrors.name = t('Please enter a name to sign your comments and publication') + } + + if (!email()) { + newValidationErrors.email = t('Please enter email') + } else if (!isValidEmail(email())) { + newValidationErrors.email = t('Invalid email') + } + + if (!password()) { + newValidationErrors.password = t('Please enter password') + } + + setValidationErrors(newValidationErrors) + + const emailCheckResult = await checkEmail(email()) + + const isValid = Object.keys(newValidationErrors).length === 0 && !emailCheckResult + + if (!isValid) { + return + } + + setIsSubmitting(true) + + try { + await register({ + name: name(), + email: email(), + password: password() + }) + + setIsSuccess(true) + } catch (error) { + if (error instanceof ApiError && error.code === 'user_already_exists') { + return + } + + setSubmitError(error.message) + } finally { + setIsSubmitting(false) + } + } + + return ( + <> + +
+

{t('Create account')}

+ +
+
    +
  • {submitError()}
  • +
+
+
+
+ handleNameInput(event.currentTarget.value)} + /> + +
+ +
{validationErrors().name}
+
+
+ handleEmailInput(event.currentTarget.value)} + onBlur={handleEmailBlur} + /> + +
+ +
{validationErrors().email}
+
+ + + +
+ handlePasswordInput(event.currentTarget.value)} + /> + +
+ +
{validationErrors().password}
+
+ +
+ +
+ + + +
+ changeSearchParam('mode', 'login')}> + {t('I have an account')} + +
+ +
+ +
{t('Almost done! Check your email.')}
+
{t("We've sent you a message with a link to enter our website.")}
+
+ +
+
+ + ) +} diff --git a/src/components/Nav/AuthModal/SocialProviders.module.scss b/src/components/Nav/AuthModal/SocialProviders.module.scss new file mode 100644 index 00000000..b16a0b3a --- /dev/null +++ b/src/components/Nav/AuthModal/SocialProviders.module.scss @@ -0,0 +1,45 @@ +.container { + border-bottom: 1px solid #141414; + border-top: 1px solid #141414; + margin-top: 1em; + padding: 0.8em 0 1em; +} + +.text { + @include font-size(1.5rem); + + margin-bottom: 1em; + text-align: center; +} + +.social { + background-color: white; + display: flex; + margin: 0 -5px; + + > * { + background-color: #f7f7f7; + cursor: pointer; + flex: 1; + margin: 0 5px; + padding: 0.5em; + text-align: center; + } + + img { + height: 1em; + max-width: 1.8em; + vertical-align: middle; + width: auto; + } + + a { + border: none; + } + + .githubAuth:hover { + img { + filter: invert(1); + } + } +} diff --git a/src/components/Nav/AuthModal/SocialProviders.tsx b/src/components/Nav/AuthModal/SocialProviders.tsx new file mode 100644 index 00000000..1daefd67 --- /dev/null +++ b/src/components/Nav/AuthModal/SocialProviders.tsx @@ -0,0 +1,42 @@ +import { t } from '../../../utils/intl' +import { Icon } from '../Icon' +import { hideModal } from '../../../stores/ui' + +import styles from './SocialProviders.module.scss' +import { apiBaseUrl } from '../../../utils/config' + +type Provider = 'facebook' | 'google' | 'vk' | 'github' + +// 3rd party provider auth handler +const handleSocialAuthLinkClick = (event: MouseEvent, provider: Provider): void => { + event.preventDefault() + const popup = window.open(`${apiBaseUrl}/oauth/${provider}`, provider, 'width=740, height=420') + popup?.focus() + hideModal() +} + +export const SocialProviders = () => { + return ( + + ) +} diff --git a/src/components/Nav/AuthModal/index.tsx b/src/components/Nav/AuthModal/index.tsx new file mode 100644 index 00000000..f0a773d1 --- /dev/null +++ b/src/components/Nav/AuthModal/index.tsx @@ -0,0 +1,76 @@ +import { Dynamic } from 'solid-js/web' +import { Component, createEffect, createMemo } from 'solid-js' +import { t } from '../../../utils/intl' +import { hideModal } from '../../../stores/ui' +import { handleClientRouteLinkClick, useRouter } from '../../../stores/router' +import { clsx } from 'clsx' +import styles from './AuthModal.module.scss' +import { LoginForm } from './LoginForm' +import { RegisterForm } from './RegisterForm' +import { ForgotPasswordForm } from './ForgotPasswordForm' +import { EmailConfirm } from './EmailConfirm' +import type { AuthModalMode, AuthModalSearchParams } from './types' + +const AUTH_MODAL_MODES: Record = { + login: LoginForm, + register: RegisterForm, + 'forgot-password': ForgotPasswordForm, + 'confirm-email': EmailConfirm +} + +export const AuthModal = () => { + let rootRef: HTMLDivElement + + const { searchParams } = useRouter() + + const mode = createMemo(() => { + return AUTH_MODAL_MODES[searchParams().mode] ? searchParams().mode : 'login' + }) + + createEffect((oldMode) => { + if (oldMode !== mode()) { + rootRef?.querySelector('input')?.focus() + } + }, null) + + return ( +
+
+
+

{t('Discours')}

+

{t(`Join the global community of authors!`)}

+

+ {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' + )} + .  + {t('New stories every day and even more!')} +

+

+ {t('By signing up you agree with our')}{' '} + { + hideModal() + handleClientRouteLinkClick(event) + }} + > + {t('terms of use')} + + , {t('personal data usage and email notifications')}. +

+
+
+
+ +
+
+ ) +} diff --git a/src/components/Nav/AuthModal/sharedLogic.tsx b/src/components/Nav/AuthModal/sharedLogic.tsx new file mode 100644 index 00000000..1d956b0e --- /dev/null +++ b/src/components/Nav/AuthModal/sharedLogic.tsx @@ -0,0 +1,5 @@ +import { createSignal } from 'solid-js' + +const [email, setEmail] = createSignal('') + +export { email, setEmail } diff --git a/src/components/Nav/AuthModal/types.ts b/src/components/Nav/AuthModal/types.ts new file mode 100644 index 00000000..e7aeed0e --- /dev/null +++ b/src/components/Nav/AuthModal/types.ts @@ -0,0 +1,5 @@ +export type AuthModalMode = 'login' | 'register' | 'confirm-email' | 'forgot-password' + +export type AuthModalSearchParams = { + mode: AuthModalMode +} diff --git a/src/components/Nav/AuthModal/validators.ts b/src/components/Nav/AuthModal/validators.ts new file mode 100644 index 00000000..a4950dff --- /dev/null +++ b/src/components/Nav/AuthModal/validators.ts @@ -0,0 +1,7 @@ +export const isValidEmail = (email: string) => { + if (!email) { + return false + } + + return email.includes('@') && email.includes('.') && email.length > 5 +} diff --git a/src/components/Nav/Header.tsx b/src/components/Nav/Header.tsx index 8a4e46f8..d5bc84b7 100644 --- a/src/components/Nav/Header.tsx +++ b/src/components/Nav/Header.tsx @@ -3,7 +3,7 @@ import Private from './Private' import Notifications from './Notifications' import { Icon } from './Icon' import { Modal } from './Modal' -import AuthModal from './AuthModal' +import { AuthModal } from './AuthModal' import { t } from '../../utils/intl' import { useModalStore, showModal, useWarningsStore } from '../../stores/ui' import { useAuthStore } from '../../stores/auth' @@ -23,10 +23,6 @@ const resources: { name: string; route: keyof Routes }[] = [ { name: t('topics'), route: 'topics' } ] -const handleEnterClick = () => { - showModal('auth') -} - type Props = { title?: string isHeaderFixed?: boolean @@ -40,24 +36,26 @@ export const Header = (props: Props) => { const [visibleWarnings, setVisibleWarnings] = createSignal(false) const [isSharePopupVisible, setIsSharePopupVisible] = createSignal(false) // stores - const { getWarnings } = useWarningsStore() + const { warnings } = useWarningsStore() const { session } = useAuthStore() - const { getModal } = useModalStore() + const { modal } = useModalStore() - const { getPage } = useRouter() + const { page } = useRouter() // methods const toggleWarnings = () => setVisibleWarnings(!visibleWarnings()) const toggleFixed = () => setFixed((oldFixed) => !oldFixed) // effects createEffect(() => { - document.body.classList.toggle('fixed', fixed() || getModal() !== null) + document.body.classList.toggle('fixed', fixed() || modal() !== null) }) // derived const authorized = createMemo(() => session()?.user?.slug) - const handleBellIconClick = () => { + const handleBellIconClick = (event: Event) => { + event.preventDefault() + if (!authorized()) { showModal('auth') return @@ -113,7 +111,7 @@ export const Header = (props: Props) => { > {(r) => ( -
  • +
  • {r.name} @@ -131,9 +129,9 @@ export const Header = (props: Props) => {
    @@ -148,7 +146,7 @@ export const Header = (props: Props) => { when={authorized()} fallback={ diff --git a/src/components/Nav/Modal.tsx b/src/components/Nav/Modal.tsx index f5d04763..bca4c2f7 100644 --- a/src/components/Nav/Modal.tsx +++ b/src/components/Nav/Modal.tsx @@ -1,4 +1,5 @@ -import { createEffect, createSignal, onMount, Show } from 'solid-js' +import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js' +import type { JSX } from 'solid-js' import { getLogger } from '../../utils/logger' import './Modal.scss' import { hideModal, useModalStore } from '../../stores/ui' @@ -7,26 +8,33 @@ const log = getLogger('modal') interface ModalProps { name: string - children: any + children: JSX.Element +} + +const keydownHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') hideModal() } export const Modal = (props: ModalProps) => { - const { getModal } = useModalStore() + const { modal } = useModalStore() - const wrapClick = (ev: Event) => { - if ((ev.target as HTMLElement).classList.contains('modalwrap')) hideModal() + const wrapClick = (event: { target: Element }) => { + if (event.target.classList.contains('modalwrap')) hideModal() } onMount(() => { - window.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape') hideModal() + window.addEventListener('keydown', keydownHandler) + + onCleanup(() => { + window.removeEventListener('keydown', keydownHandler) }) }) const [visible, setVisible] = createSignal(false) + createEffect(() => { - setVisible(getModal() === props.name) - log.debug(`${props.name} is ${getModal() === props.name ? 'visible' : 'hidden'}`) + setVisible(modal() === props.name) + log.debug(`${props.name} is ${modal() === props.name ? 'visible' : 'hidden'}`) }) return ( diff --git a/src/components/Nav/Notifications.tsx b/src/components/Nav/Notifications.tsx index af8edc99..c7c28215 100644 --- a/src/components/Nav/Notifications.tsx +++ b/src/components/Nav/Notifications.tsx @@ -3,15 +3,15 @@ import { useWarningsStore } from '../../stores/ui' import { createMemo } from 'solid-js' export default () => { - const { getWarnings } = useWarningsStore() + const { warnings } = useWarningsStore() - const notSeen = createMemo(() => getWarnings().filter((warning) => !warning.seen)) + const notSeen = createMemo(() => warnings().filter((warning) => !warning.seen)) return ( 0}>
      - {(warning) =>
    • {warning.body}
    • }
      + {(warning) =>
    • {warning.body}
    • }
    diff --git a/src/components/Nav/Private.tsx b/src/components/Nav/Private.tsx index 42a5ceb3..2a3ad170 100644 --- a/src/components/Nav/Private.tsx +++ b/src/components/Nav/Private.tsx @@ -4,11 +4,11 @@ import { Icon } from './Icon' import styles from './Private.module.scss' import { useAuthStore } from '../../stores/auth' import { useRouter } from '../../stores/router' -import clsx from 'clsx' +import { clsx } from 'clsx' export default () => { const { session } = useAuthStore() - const { getPage } = useRouter() + const { page } = useRouter() return (
    @@ -21,7 +21,7 @@ export default () => {
    {/*FIXME: replace with route*/} -
    +
    @@ -29,7 +29,7 @@ export default () => {
    {/*FIXME: replace with route*/} -
    +
    diff --git a/src/components/Pages/ArticlePage.tsx b/src/components/Pages/ArticlePage.tsx index 357c5494..00e7d661 100644 --- a/src/components/Pages/ArticlePage.tsx +++ b/src/components/Pages/ArticlePage.tsx @@ -11,7 +11,7 @@ export const ArticlePage = (props: PageProps) => { const sortedArticles = props.article ? [props.article] : [] const slug = createMemo(() => { - const { getPage } = useRouter() + const { page: getPage } = useRouter() const page = getPage() diff --git a/src/components/Pages/AuthorPage.tsx b/src/components/Pages/AuthorPage.tsx index 1d159773..0eab1346 100644 --- a/src/components/Pages/AuthorPage.tsx +++ b/src/components/Pages/AuthorPage.tsx @@ -11,7 +11,7 @@ export const AuthorPage = (props: PageProps) => { const [isLoaded, setIsLoaded] = createSignal(Boolean(props.authorArticles) && Boolean(props.author)) const slug = createMemo(() => { - const { getPage } = useRouter() + const { page: getPage } = useRouter() const page = getPage() diff --git a/src/components/Pages/SearchPage.tsx b/src/components/Pages/SearchPage.tsx index ff636018..9ce346b1 100644 --- a/src/components/Pages/SearchPage.tsx +++ b/src/components/Pages/SearchPage.tsx @@ -10,7 +10,7 @@ export const SearchPage = (props: PageProps) => { const [isLoaded, setIsLoaded] = createSignal(Boolean(props.searchResults)) const q = createMemo(() => { - const { getPage } = useRouter() + const { page: getPage } = useRouter() const page = getPage() diff --git a/src/components/Pages/TopicPage.tsx b/src/components/Pages/TopicPage.tsx index a92e1d3f..7cbaa1b5 100644 --- a/src/components/Pages/TopicPage.tsx +++ b/src/components/Pages/TopicPage.tsx @@ -11,7 +11,7 @@ export const TopicPage = (props: PageProps) => { const [isLoaded, setIsLoaded] = createSignal(Boolean(props.authorArticles) && Boolean(props.author)) const slug = createMemo(() => { - const { getPage } = useRouter() + const { page: getPage } = useRouter() const page = getPage() diff --git a/src/components/Root.tsx b/src/components/Root.tsx index a770d97a..1827703b 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -1,8 +1,8 @@ // FIXME: breaks on vercel, research // import 'solid-devtools' -import { setLocale } from '../stores/ui' -import { Component, createEffect, createMemo } from 'solid-js' +import { MODALS, setLocale, showModal } from '../stores/ui' +import { Component, createEffect, createMemo, onMount } from 'solid-js' import { Routes, useRouter } from '../stores/router' import { Dynamic, isServer } from 'solid-js/web' import { getLogger } from '../utils/logger' @@ -27,6 +27,7 @@ import { ProjectsPage } from './Pages/about/ProjectsPage' import { TermsOfUsePage } from './Pages/about/TermsOfUsePage' import { ThanksPage } from './Pages/about/ThanksPage' import { CreatePage } from './Pages/CreatePage' +import { renewSession } from '../stores/auth' // TODO: lazy load // const HomePage = lazy(() => import('./Pages/HomePage')) @@ -50,6 +51,11 @@ import { CreatePage } from './Pages/CreatePage' const log = getLogger('root') +type RootSearchParams = { + modal: string + lang: string +} + const pagesMap: Record> = { create: CreatePage, home: HomePage, @@ -71,16 +77,23 @@ const pagesMap: Record> = { } export const Root = (props: PageProps) => { - const { getPage } = useRouter() + const { page, searchParams } = useRouter() - // log.debug({ route: getPage().route }) + createEffect(() => { + const modal = MODALS[searchParams().modal] + if (modal) { + showModal(modal) + } + }) + + onMount(() => { + renewSession() + }) const pageComponent = createMemo(() => { - const result = pagesMap[getPage().route] + const result = pagesMap[page().route] - // log.debug('page', getPage()) - - if (!result || getPage().path === '/404') { + if (!result || page().path === '/404') { return FourOuFourPage } @@ -89,10 +102,10 @@ export const Root = (props: PageProps) => { if (!isServer) { createEffect(() => { - const lang = new URLSearchParams(window.location.search).get('lang') || 'ru' + const lang = searchParams().lang || 'ru' console.log('[root] client locale is', lang) setLocale(lang) - }, [window.location.search]) + }) } return diff --git a/src/components/Topic/Card.tsx b/src/components/Topic/Card.tsx index 19684b27..31afbd10 100644 --- a/src/components/Topic/Card.tsx +++ b/src/components/Topic/Card.tsx @@ -9,7 +9,6 @@ import { locale } from '../../stores/ui' import { useAuthStore } from '../../stores/auth' import { follow, unfollow } from '../../stores/zine/common' import { getLogger } from '../../utils/logger' -import { Icon } from '../Nav/Icon' const log = getLogger('TopicCard') diff --git a/src/components/Views/AllAuthors.tsx b/src/components/Views/AllAuthors.tsx index 35592155..24752bff 100644 --- a/src/components/Views/AllAuthors.tsx +++ b/src/components/Views/AllAuthors.tsx @@ -1,14 +1,13 @@ -import { createEffect, createMemo, createSignal, For, Show } from 'solid-js' +import { createEffect, createMemo, For, Show } from 'solid-js' import type { Author } from '../../graphql/types.gen' import { AuthorCard } from '../Author/Card' import { Icon } from '../Nav/Icon' import { t } from '../../utils/intl' -import { useAuthorsStore, setSortAllBy as setSortAllAuthorsBy } from '../../stores/zine/authors' +import { useAuthorsStore, setAuthorsSort } from '../../stores/zine/authors' import { handleClientRouteLinkClick, useRouter } from '../../stores/router' import { useAuthStore } from '../../stores/auth' import { getLogger } from '../../utils/logger' import '../../styles/AllTopics.scss' -import { Topic } from '../../graphql/types.gen' const log = getLogger('AllAuthorsView') @@ -26,12 +25,12 @@ export const AllAuthorsView = (props: Props) => { const { session } = useAuthStore() createEffect(() => { - setSortAllAuthorsBy(getSearchParams().by || 'shouts') + setAuthorsSort(searchParams().by || 'shouts') }) const subscribed = (s) => Boolean(session()?.news?.authors && session()?.news?.authors?.includes(s || '')) - const { getSearchParams } = useRouter() + const { searchParams } = useRouter() const byLetter = createMemo<{ [letter: string]: Author[] }>(() => { return sortedAuthors().reduce((acc, author) => { @@ -73,17 +72,17 @@ export const AllAuthorsView = (props: Props) => {
    (
    diff --git a/src/components/Views/AllTopics.tsx b/src/components/Views/AllTopics.tsx index 15b40959..556866e9 100644 --- a/src/components/Views/AllTopics.tsx +++ b/src/components/Views/AllTopics.tsx @@ -2,7 +2,7 @@ import { createEffect, createMemo, For, Show } from 'solid-js' import type { Topic } from '../../graphql/types.gen' import { Icon } from '../Nav/Icon' import { t } from '../../utils/intl' -import { setSortAllBy as setSortAllTopicsBy, useTopicsStore } from '../../stores/zine/topics' +import { setTopicsSort, useTopicsStore } from '../../stores/zine/topics' import { handleClientRouteLinkClick, useRouter } from '../../stores/router' import { TopicCard } from '../Topic/Card' import { useAuthStore } from '../../stores/auth' @@ -20,17 +20,17 @@ type AllTopicsViewProps = { } export const AllTopicsView = (props: AllTopicsViewProps) => { - const { getSearchParams, changeSearchParam } = useRouter() + const { searchParams, changeSearchParam } = useRouter() const { sortedTopics } = useTopicsStore({ topics: props.topics, - sortBy: getSearchParams().by || 'shouts' + sortBy: searchParams().by || 'shouts' }) const { session } = useAuthStore() createEffect(() => { - setSortAllTopicsBy(getSearchParams().by || 'shouts') + setTopicsSort(searchParams().by || 'shouts') }) const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => { @@ -69,17 +69,17 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
    (
    diff --git a/src/components/Views/Author.tsx b/src/components/Views/Author.tsx index 93bb3268..4c84b7c5 100644 --- a/src/components/Views/Author.tsx +++ b/src/components/Views/Author.tsx @@ -34,10 +34,10 @@ export const AuthorView = (props: AuthorProps) => { const { topicsByAuthor } = useTopicsStore() const author = createMemo(() => authorEntities()[props.authorSlug]) - const { getSearchParams, changeSearchParam } = useRouter() + const { searchParams, changeSearchParam } = useRouter() const title = createMemo(() => { - const m = getSearchParams().by + const m = searchParams().by if (m === 'viewed') return t('Top viewed') if (m === 'rating') return t('Top rated') if (m === 'commented') return t('Top discussed') @@ -51,7 +51,7 @@ export const AuthorView = (props: AuthorProps) => {
    -
    -
    +
    + + +
    @@ -138,7 +149,7 @@ export const Header = (props: Props) => {
    -
    +
    @@ -146,21 +157,42 @@ export const Header = (props: Props) => { +
    } > - + + { + setIsProfilePopupVisible(isVisible) + }} + containerCssClass={styles.control} + trigger={ +
    + +
    + } + />
    { - console.log({ isVisible }) setIsSharePopupVisible(isVisible) }} containerCssClass={styles.control} diff --git a/src/components/Nav/Popup.module.scss b/src/components/Nav/Popup.module.scss index 4a8b32c5..75ca81ad 100644 --- a/src/components/Nav/Popup.module.scss +++ b/src/components/Nav/Popup.module.scss @@ -6,9 +6,17 @@ background: #fff; border: 2px solid #000; top: calc(100% + 8px); - transform: translateX(-50%); opacity: 1; + &.horizontalAnchorCenter { + left: 50%; + transform: translateX(-50%); + } + + &.horizontalAnchorRight { + right: 0; + } + @include font-size(1.6rem); padding: 2.4rem; diff --git a/src/components/Nav/Popup.tsx b/src/components/Nav/Popup.tsx index a53517cf..bf9f6ced 100644 --- a/src/components/Nav/Popup.tsx +++ b/src/components/Nav/Popup.tsx @@ -2,15 +2,19 @@ import { createEffect, createSignal, JSX, onCleanup, onMount, Show } from 'solid import styles from './Popup.module.scss' import { clsx } from 'clsx' +type HorizontalAnchor = 'center' | 'right' + export type PopupProps = { containerCssClass?: string trigger: JSX.Element children: JSX.Element onVisibilityChange?: (isVisible) => void + horizontalAnchor?: HorizontalAnchor } export const Popup = (props: PopupProps) => { const [isVisible, setIsVisible] = createSignal(false) + const horizontalAnchor: HorizontalAnchor = props.horizontalAnchor || 'center' createEffect(() => { if (props.onVisibilityChange) { @@ -38,12 +42,19 @@ export const Popup = (props: PopupProps) => { }) const toggle = () => setIsVisible((oldVisible) => !oldVisible) - // class={clsx(styles.popupShare, stylesPopup.popupShare)} + return ( {props.trigger} -
    {props.children}
    +
    + {props.children} +
    ) diff --git a/src/components/Nav/Private.module.scss b/src/components/Nav/Private.module.scss deleted file mode 100644 index 5d68cf08..00000000 --- a/src/components/Nav/Private.module.scss +++ /dev/null @@ -1,116 +0,0 @@ -.userControl { - align-items: baseline; - display: flex; - - @include font-size(1.7rem); - - justify-content: flex-end; - - @include media-breakpoint-down(md) { - padding: divide($container-padding-x, 2); - } - - .userpic { - margin-right: 0; - - img { - height: 100%; - width: 100%; - } - } -} - -.userControlItem { - align-items: center; - border: 2px solid #f6f6f6; - border-radius: 100%; - display: flex; - height: 2.4em; - justify-content: center; - margin-left: divide($container-padding-x, 2); - position: relative; - width: 2.4em; - - @include media-breakpoint-up(sm) { - margin-left: 1.2rem; - } - - .circlewrap { - height: 23px; - min-width: 23px; - width: 23px; - } - - .button, - a { - border: none; - - &:hover { - background: none; - - &::before { - background-color: #000; - } - - img { - filter: invert(1); - } - } - - img { - filter: invert(0); - transition: filter 0.3s; - } - - &::before { - background-color: #fff; - border-radius: 100%; - content: ''; - height: 100%; - left: 0; - position: absolute; - top: 0; - transition: background-color 0.3s; - width: 100%; - } - } - - img { - height: 20px; - vertical-align: middle; - width: auto; - } - - .textLabel { - display: none; - } -} - -.userControlItemWritePost { - width: auto; - - @include media-breakpoint-up(lg) { - .icon { - display: none; - } - - .textLabel { - display: inline; - padding: 0 1.2rem; - position: relative; - z-index: 1; - } - } - - &, - a::before { - border-radius: 1.2em; - } -} - -.userControlItemInbox, -.userControlItemSearch { - @include media-breakpoint-down(sm) { - display: none; - } -} diff --git a/src/components/Nav/Private.tsx b/src/components/Nav/Private.tsx deleted file mode 100644 index da4852e6..00000000 --- a/src/components/Nav/Private.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Author } from '../../graphql/types.gen' -import Userpic from '../Author/Userpic' -import { ProfilePopup } from './ProfilePopup' -import { Icon } from './Icon' -import styles from './Private.module.scss' -import { useAuthStore } from '../../stores/auth' -import { useRouter } from '../../stores/router' -import { clsx } from 'clsx' -import { createSignal } from 'solid-js' - -export default () => { - const { session } = useAuthStore() - const { page } = useRouter() - const [isProfilePopupVisible, setIsProfilePopupVisible] = createSignal(false) - - return ( -
    - - - { - setIsProfilePopupVisible(isVisible) - }} - containerCssClass={styles.control} - trigger={ -
    - -
    - } - /> -
    - ) -} diff --git a/src/components/Nav/ProfilePopup.tsx b/src/components/Nav/ProfilePopup.tsx index d81e4a13..0a3f7a28 100644 --- a/src/components/Nav/ProfilePopup.tsx +++ b/src/components/Nav/ProfilePopup.tsx @@ -1,6 +1,5 @@ -import styles from './Popup.module.scss' import { Popup, PopupProps } from './Popup' -import { useAuthStore } from '../../stores/auth' +import { signOut, useAuthStore } from '../../stores/auth' type ProfilePopupProps = Omit @@ -8,7 +7,7 @@ export const ProfilePopup = (props: ProfilePopupProps) => { const { session } = useAuthStore() return ( - + diff --git a/src/locales/ru.json b/src/locales/ru.json index c279ffab..47c07e87 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -164,5 +164,6 @@ "Almost done! Check your email.": "Почти готово! Осталось подтвердить вашу почту.", "We've sent you a message with a link to enter our website.": "Мы выслали вам письмо с ссылкой на почту. Перейдите по ссылке в письме, чтобы войти на сайт.", "Send link again": "Прислать ссылку ещё раз", - "Link sent, check your email": "Ссылка отправлена, проверьте почту" + "Link sent, check your email": "Ссылка отправлена, проверьте почту", + "Create post": "Создать публикацию" } From d7ceebf0dd6454a3225476d99bdd09927434bc81 Mon Sep 17 00:00:00 2001 From: Igor Lobanov Date: Fri, 28 Oct 2022 11:15:11 +0200 Subject: [PATCH 10/11] build fix --- src/components/Nav/Header.module.scss | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/components/Nav/Header.module.scss b/src/components/Nav/Header.module.scss index dd17f050..266c5cf1 100644 --- a/src/components/Nav/Header.module.scss +++ b/src/components/Nav/Header.module.scss @@ -315,18 +315,6 @@ } } -.userControl { - opacity: 1; - transition: opacity 0.3s; - z-index: 1; - - .headerWithTitle.headerScrolledBottom & { - transition: opacity 0.3s, z-index 0s 0.3s; - opacity: 0; - z-index: -1; - } -} - .articleControls { display: flex; justify-content: flex-end; @@ -367,6 +355,15 @@ .userControl { align-items: baseline; display: flex; + opacity: 1; + transition: opacity 0.3s; + z-index: 1; + + .headerWithTitle.headerScrolledBottom & { + transition: opacity 0.3s, z-index 0s 0.3s; + opacity: 0; + z-index: -1; + } @include font-size(1.7rem); From 25fd5bf42ff2ec442467afad059bc4a4a7858405 Mon Sep 17 00:00:00 2001 From: Igor Lobanov Date: Fri, 28 Oct 2022 23:21:47 +0200 Subject: [PATCH 11/11] load more (Feed, Author, Topic, Home update) --- src/components/Author/Full.tsx | 2 +- src/components/Feed/Beside.tsx | 2 +- src/components/Feed/List.tsx | 8 +-- src/components/Feed/Row1.tsx | 2 +- src/components/Feed/Row2.tsx | 3 +- src/components/Feed/Row3.tsx | 2 +- src/components/Pages/AuthorPage.tsx | 6 +- src/components/Pages/FeedPage.tsx | 24 ++----- src/components/Pages/HomePage.tsx | 4 +- src/components/Pages/TopicPage.tsx | 6 +- src/components/Views/Author.tsx | 92 ++++++++++++++++++--------- src/components/Views/Feed.tsx | 47 +++++++------- src/components/Views/Home.tsx | 63 ++++++++++-------- src/components/Views/Topic.tsx | 57 +++++++++++++++-- src/components/types.ts | 1 - src/pages/author/[slug]/index.astro | 3 +- src/pages/feed/index.astro | 6 +- src/pages/index.astro | 4 +- src/pages/topic/[slug].astro | 3 +- src/stores/zine/articles.ts | 99 ++++++++++++++++++++++++----- src/stores/zine/authors.ts | 1 - src/utils/apiClient.ts | 4 +- src/utils/splitToPages.ts | 10 +++ 23 files changed, 299 insertions(+), 150 deletions(-) create mode 100644 src/utils/splitToPages.ts diff --git a/src/components/Author/Full.tsx b/src/components/Author/Full.tsx index 9f869f7e..b83ed413 100644 --- a/src/components/Author/Full.tsx +++ b/src/components/Author/Full.tsx @@ -2,7 +2,7 @@ import type { Author } from '../../graphql/types.gen' import { AuthorCard } from './Card' import './Full.scss' -export default (props: { author: Author }) => { +export const AuthorFull = (props: { author: Author }) => { return (
    diff --git a/src/components/Feed/Beside.tsx b/src/components/Feed/Beside.tsx index 21b28f19..3f0e91df 100644 --- a/src/components/Feed/Beside.tsx +++ b/src/components/Feed/Beside.tsx @@ -21,7 +21,7 @@ interface BesideProps { iconButton?: boolean } -export default (props: BesideProps) => { +export const Beside = (props: BesideProps) => { return ( 0}>
    diff --git a/src/components/Feed/List.tsx b/src/components/Feed/List.tsx index dac7e127..055378a4 100644 --- a/src/components/Feed/List.tsx +++ b/src/components/Feed/List.tsx @@ -1,7 +1,7 @@ import { For, Suspense } from 'solid-js/web' -import OneWide from './Row1' -import Row2 from './Row2' -import Row3 from './Row3' +import { Row1 } from './Row1' +import { Row2 } from './Row2' +import { Row3 } from './Row3' import { shuffle } from '../../utils' import { createMemo, createSignal } from 'solid-js' import type { JSX } from 'solid-js' @@ -10,7 +10,7 @@ import './List.scss' import { t } from '../../utils/intl' export const Block6 = (props: { articles: Shout[] }) => { - const dice = createMemo(() => shuffle([OneWide, Row2, Row3])) + const dice = createMemo(() => shuffle([Row1, Row2, Row3])) return ( <> diff --git a/src/components/Feed/Row1.tsx b/src/components/Feed/Row1.tsx index b654fb82..bfb68aca 100644 --- a/src/components/Feed/Row1.tsx +++ b/src/components/Feed/Row1.tsx @@ -2,7 +2,7 @@ import { Show } from 'solid-js' import type { Shout } from '../../graphql/types.gen' import { ArticleCard } from './Card' -export default (props: { article: Shout }) => ( +export const Row1 = (props: { article: Shout }) => (
    diff --git a/src/components/Feed/Row2.tsx b/src/components/Feed/Row2.tsx index e12b22bd..274317a8 100644 --- a/src/components/Feed/Row2.tsx +++ b/src/components/Feed/Row2.tsx @@ -2,13 +2,14 @@ import { createComputed, createSignal, Show } from 'solid-js' import { For } from 'solid-js/web' import type { Shout } from '../../graphql/types.gen' import { ArticleCard } from './Card' + const x = [ ['6', '6'], ['4', '8'], ['8', '4'] ] -export default (props: { articles: Shout[] }) => { +export const Row2 = (props: { articles: Shout[] }) => { const [y, setY] = createSignal(0) createComputed(() => setY(Math.floor(Math.random() * x.length))) diff --git a/src/components/Feed/Row3.tsx b/src/components/Feed/Row3.tsx index 6af65f3e..4687cb2f 100644 --- a/src/components/Feed/Row3.tsx +++ b/src/components/Feed/Row3.tsx @@ -2,7 +2,7 @@ import { For } from 'solid-js/web' import type { Shout } from '../../graphql/types.gen' import { ArticleCard } from './Card' -export default (props: { articles: Shout[]; header?: any }) => { +export const Row3 = (props: { articles: Shout[]; header?: any }) => { return (
    diff --git a/src/components/Pages/AuthorPage.tsx b/src/components/Pages/AuthorPage.tsx index 0eab1346..d8d73f6c 100644 --- a/src/components/Pages/AuthorPage.tsx +++ b/src/components/Pages/AuthorPage.tsx @@ -1,8 +1,8 @@ import { MainLayout } from '../Layouts/MainLayout' -import { AuthorView } from '../Views/Author' +import { AuthorView, PRERENDERED_ARTICLES_COUNT } from '../Views/Author' import type { PageProps } from '../types' import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js' -import { loadArticlesForAuthors, resetSortedArticles } from '../../stores/zine/articles' +import { loadAuthorArticles, resetSortedArticles } from '../../stores/zine/articles' import { useRouter } from '../../stores/router' import { loadAuthor } from '../../stores/zine/authors' import { Loading } from '../Loading' @@ -27,7 +27,7 @@ export const AuthorPage = (props: PageProps) => { return } - await loadArticlesForAuthors({ authorSlugs: [slug()] }) + await loadAuthorArticles({ authorSlug: slug(), limit: PRERENDERED_ARTICLES_COUNT }) await loadAuthor({ slug: slug() }) setIsLoaded(true) diff --git a/src/components/Pages/FeedPage.tsx b/src/components/Pages/FeedPage.tsx index 13c18136..7aef8fbf 100644 --- a/src/components/Pages/FeedPage.tsx +++ b/src/components/Pages/FeedPage.tsx @@ -1,30 +1,14 @@ import { MainLayout } from '../Layouts/MainLayout' import { FeedView } from '../Views/Feed' -import type { PageProps } from '../types' -import { createSignal, onCleanup, onMount, Show } from 'solid-js' -import { loadRecentArticles, resetSortedArticles } from '../../stores/zine/articles' -import { Loading } from '../Loading' - -export const FeedPage = (props: PageProps) => { - const [isLoaded, setIsLoaded] = createSignal(Boolean(props.feedArticles)) - - onMount(async () => { - if (isLoaded()) { - return - } - - await loadRecentArticles({ limit: 50, offset: 0 }) - - setIsLoaded(true) - }) +import { onCleanup } from 'solid-js' +import { resetSortedArticles } from '../../stores/zine/articles' +export const FeedPage = () => { onCleanup(() => resetSortedArticles()) return ( - }> - - + ) } diff --git a/src/components/Pages/HomePage.tsx b/src/components/Pages/HomePage.tsx index f8430e03..392280bb 100644 --- a/src/components/Pages/HomePage.tsx +++ b/src/components/Pages/HomePage.tsx @@ -1,4 +1,4 @@ -import { HomeView } from '../Views/Home' +import { HomeView, PRERENDERED_ARTICLES_COUNT } from '../Views/Home' import { MainLayout } from '../Layouts/MainLayout' import type { PageProps } from '../types' import { createSignal, onCleanup, onMount, Show } from 'solid-js' @@ -14,7 +14,7 @@ export const HomePage = (props: PageProps) => { return } - await loadPublishedArticles({ limit: 5, offset: 0 }) + await loadPublishedArticles({ limit: PRERENDERED_ARTICLES_COUNT, offset: 0 }) await loadRandomTopics() setIsLoaded(true) diff --git a/src/components/Pages/TopicPage.tsx b/src/components/Pages/TopicPage.tsx index 7cbaa1b5..16e0c95a 100644 --- a/src/components/Pages/TopicPage.tsx +++ b/src/components/Pages/TopicPage.tsx @@ -1,8 +1,8 @@ import { MainLayout } from '../Layouts/MainLayout' -import { TopicView } from '../Views/Topic' +import { PRERENDERED_ARTICLES_COUNT, TopicView } from '../Views/Topic' import type { PageProps } from '../types' import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js' -import { loadArticlesForTopics, resetSortedArticles } from '../../stores/zine/articles' +import { loadTopicArticles, resetSortedArticles } from '../../stores/zine/articles' import { useRouter } from '../../stores/router' import { loadTopic } from '../../stores/zine/topics' import { Loading } from '../Loading' @@ -27,7 +27,7 @@ export const TopicPage = (props: PageProps) => { return } - await loadArticlesForTopics({ topicSlugs: [slug()] }) + await loadTopicArticles({ topicSlug: slug(), limit: PRERENDERED_ARTICLES_COUNT, offset: 0 }) await loadTopic({ slug: slug() }) setIsLoaded(true) diff --git a/src/components/Views/Author.tsx b/src/components/Views/Author.tsx index 4c84b7c5..52cd189f 100644 --- a/src/components/Views/Author.tsx +++ b/src/components/Views/Author.tsx @@ -1,17 +1,18 @@ -import { Show, createMemo } from 'solid-js' +import { Show, createMemo, createSignal, For, onMount } from 'solid-js' import type { Author, Shout } from '../../graphql/types.gen' -import Row2 from '../Feed/Row2' -import Row3 from '../Feed/Row3' -// import Beside from '../Feed/Beside' -import AuthorFull from '../Author/Full' +import { Row2 } from '../Feed/Row2' +import { Row3 } from '../Feed/Row3' +import { AuthorFull } from '../Author/Full' import { t } from '../../utils/intl' import { useAuthorsStore } from '../../stores/zine/authors' -import { useArticlesStore } from '../../stores/zine/articles' +import { loadAuthorArticles, useArticlesStore } from '../../stores/zine/articles' import '../../styles/Topic.scss' import { useTopicsStore } from '../../stores/zine/topics' import { useRouter } from '../../stores/router' -import Beside from '../Feed/Beside' +import { Beside } from '../Feed/Beside' +import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll' +import { splitToPages } from '../../utils/splitToPages' // TODO: load reactions on client type AuthorProps = { @@ -26,16 +27,37 @@ type AuthorPageSearchParams = { by: '' | 'viewed' | 'rating' | 'commented' | 'recent' } +export const PRERENDERED_ARTICLES_COUNT = 12 +const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3 + export const AuthorView = (props: AuthorProps) => { const { sortedArticles } = useArticlesStore({ sortedArticles: props.authorArticles }) const { authorEntities } = useAuthorsStore({ authors: [props.author] }) const { topicsByAuthor } = useTopicsStore() + const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const author = createMemo(() => authorEntities()[props.authorSlug]) const { searchParams, changeSearchParam } = useRouter() + const loadMore = async () => { + saveScrollPosition() + const { hasMore } = await loadAuthorArticles({ + authorSlug: author().slug, + limit: LOAD_MORE_PAGE_SIZE, + offset: sortedArticles().length + }) + setIsLoadMoreButtonVisible(hasMore) + restoreScrollPosition() + } + + onMount(async () => { + if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) { + loadMore() + } + }) + const title = createMemo(() => { const m = searchParams().by if (m === 'viewed') return t('Top viewed') @@ -44,6 +66,10 @@ export const AuthorView = (props: AuthorProps) => { return t('Top recent') }) + const pages = createMemo(() => + splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE) + ) + return (
    {t('Loading')}
    }> @@ -83,31 +109,39 @@ export const AuthorView = (props: AuthorProps) => {

    {title()}

    +
    - 0}> - - + + + + + - 4}> - - + + {(page) => ( + <> + + + + + )} + - 6}> - - - - 9}> - - + +

    + +

    diff --git a/src/components/Views/Feed.tsx b/src/components/Views/Feed.tsx index af1c07a2..c791331c 100644 --- a/src/components/Views/Feed.tsx +++ b/src/components/Views/Feed.tsx @@ -1,5 +1,4 @@ -import { createMemo, For, Show } from 'solid-js' -import type { Shout, Reaction } from '../../graphql/types.gen' +import { createMemo, createSignal, For, onMount, Show } from 'solid-js' import '../../styles/Feed.scss' import stylesBeside from '../../components/Feed/Beside.module.scss' import { Icon } from '../Nav/Icon' @@ -17,11 +16,6 @@ import { useAuthorsStore } from '../../stores/zine/authors' import { useTopicsStore } from '../../stores/zine/topics' import { useTopAuthorsStore } from '../../stores/zine/topAuthors' -interface FeedProps { - articles: Shout[] - reactions?: Reaction[] -} - // const AUTHORSHIP_REACTIONS = [ // ReactionKind.Accept, // ReactionKind.Reject, @@ -29,9 +23,11 @@ interface FeedProps { // ReactionKind.Ask // ] -export const FeedView = (props: FeedProps) => { +export const FEED_PAGE_SIZE = 20 + +export const FeedView = () => { // state - const { sortedArticles } = useArticlesStore({ sortedArticles: props.articles }) + const { sortedArticles } = useArticlesStore() const reactions = useReactionsStore() const { sortedAuthors } = useAuthorsStore() const { topTopics } = useTopicsStore() @@ -40,6 +36,8 @@ export const FeedView = (props: FeedProps) => { const topReactions = createMemo(() => sortBy(reactions(), byCreated)) + const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) + // const expectingFocus = createMemo(() => { // // 1 co-author notifications needs // // TODO: list of articles where you are co-author @@ -53,13 +51,15 @@ export const FeedView = (props: FeedProps) => { // return [] // }) - // eslint-disable-next-line unicorn/consistent-function-scoping - const loadMore = () => { - // const limit = props.limit || 50 - // const offset = props.offset || 0 - // FIXME - loadRecentArticles({ limit: 50, offset: 0 }) + const loadMore = async () => { + const { hasMore } = await loadRecentArticles({ limit: FEED_PAGE_SIZE, offset: sortedArticles().length }) + setIsLoadMoreButtonVisible(hasMore) } + + onMount(() => { + loadMore() + }) + return ( <>
    @@ -113,10 +113,6 @@ export const FeedView = (props: FeedProps) => { {(article) => } - -

    - -

    - -

    - -

    + +

    + +

    +
    ) diff --git a/src/components/Views/Home.tsx b/src/components/Views/Home.tsx index 418edf52..77509a43 100644 --- a/src/components/Views/Home.tsx +++ b/src/components/Views/Home.tsx @@ -1,12 +1,12 @@ -import { createMemo, For, onMount, Show } from 'solid-js' +import { createMemo, createSignal, For, onMount, Show } from 'solid-js' import Banner from '../Discours/Banner' import { NavTopics } from '../Nav/Topics' import { Row5 } from '../Feed/Row5' -import Row3 from '../Feed/Row3' -import Row2 from '../Feed/Row2' -import Row1 from '../Feed/Row1' +import { Row3 } from '../Feed/Row3' +import { Row2 } from '../Feed/Row2' +import { Row1 } from '../Feed/Row1' import Hero from '../Discours/Hero' -import Beside from '../Feed/Beside' +import { Beside } from '../Feed/Beside' import RowShort from '../Feed/RowShort' import Slider from '../Feed/Slider' import Group from '../Feed/Group' @@ -24,6 +24,7 @@ import { import { useTopAuthorsStore } from '../../stores/zine/topAuthors' import { locale } from '../../stores/ui' import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll' +import { splitToPages } from '../../utils/splitToPages' const log = getLogger('home view') @@ -31,7 +32,8 @@ type HomeProps = { randomTopics: Topic[] recentPublishedArticles: Shout[] } -const PRERENDERED_ARTICLES_COUNT = 5 + +export const PRERENDERED_ARTICLES_COUNT = 5 const CLIENT_LOAD_ARTICLES_COUNT = 29 const LOAD_MORE_PAGE_SIZE = 16 // Row1 + Row3 + Row2 + Beside (3 + 1) + Row1 + Row 2 + Row3 @@ -49,14 +51,20 @@ export const HomeView = (props: HomeProps) => { const { randomTopics, topTopics } = useTopicsStore({ randomTopics: props.randomTopics }) + const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const { topAuthors } = useTopAuthorsStore() - onMount(() => { + onMount(async () => { loadTopArticles() loadTopMonthArticles() if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) { - loadPublishedArticles({ limit: CLIENT_LOAD_ARTICLES_COUNT, offset: sortedArticles().length }) + const { hasMore } = await loadPublishedArticles({ + limit: CLIENT_LOAD_ARTICLES_COUNT, + offset: sortedArticles().length + }) + + setIsLoadMoreButtonVisible(hasMore) } }) @@ -85,22 +93,23 @@ export const HomeView = (props: HomeProps) => { const loadMore = async () => { saveScrollPosition() - await loadPublishedArticles({ limit: LOAD_MORE_PAGE_SIZE, offset: sortedArticles().length }) + + const { hasMore } = await loadPublishedArticles({ + limit: LOAD_MORE_PAGE_SIZE, + offset: sortedArticles().length + }) + setIsLoadMoreButtonVisible(hasMore) + restoreScrollPosition() } - const pages = createMemo(() => { - return sortedArticles() - .slice(PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) - .reduce((acc, article, index) => { - if (index % LOAD_MORE_PAGE_SIZE === 0) { - acc.push([]) - } - - acc[acc.length - 1].push(article) - return acc - }, [] as Shout[][]) - }) + const pages = createMemo(() => + splitToPages( + sortedArticles(), + PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT, + LOAD_MORE_PAGE_SIZE + ) + ) return ( 0}> @@ -173,11 +182,13 @@ export const HomeView = (props: HomeProps) => { )} -

    - -

    + +

    + +

    +
    ) } diff --git a/src/components/Views/Topic.tsx b/src/components/Views/Topic.tsx index a0784b55..8e96ea6f 100644 --- a/src/components/Views/Topic.tsx +++ b/src/components/Views/Topic.tsx @@ -1,16 +1,18 @@ -import { For, Show, createMemo } from 'solid-js' +import { For, Show, createMemo, onMount, createSignal } from 'solid-js' import type { Shout, Topic } from '../../graphql/types.gen' -import Row3 from '../Feed/Row3' -import Row2 from '../Feed/Row2' -import Beside from '../Feed/Beside' +import { Row3 } from '../Feed/Row3' +import { Row2 } from '../Feed/Row2' +import { Beside } from '../Feed/Beside' import { ArticleCard } from '../Feed/Card' import '../../styles/Topic.scss' import { FullTopic } from '../Topic/Full' import { t } from '../../utils/intl' import { useRouter } from '../../stores/router' import { useTopicsStore } from '../../stores/zine/topics' -import { useArticlesStore } from '../../stores/zine/articles' +import { loadPublishedArticles, useArticlesStore } from '../../stores/zine/articles' import { useAuthorsStore } from '../../stores/zine/authors' +import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll' +import { splitToPages } from '../../utils/splitToPages' type TopicsPageSearchParams = { by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented' @@ -22,9 +24,14 @@ interface TopicProps { topicSlug: string } +export const PRERENDERED_ARTICLES_COUNT = 21 +const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3 + export const TopicView = (props: TopicProps) => { const { searchParams, changeSearchParam } = useRouter() + const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) + const { sortedArticles } = useArticlesStore({ sortedArticles: props.topicArticles }) const { topicEntities } = useTopicsStore({ topics: [props.topic] }) @@ -32,6 +39,24 @@ export const TopicView = (props: TopicProps) => { const topic = createMemo(() => topicEntities()[props.topicSlug]) + const loadMore = async () => { + saveScrollPosition() + + const { hasMore } = await loadPublishedArticles({ + limit: LOAD_MORE_PAGE_SIZE, + offset: sortedArticles().length + }) + setIsLoadMoreButtonVisible(hasMore) + + restoreScrollPosition() + } + + onMount(async () => { + if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) { + loadMore() + } + }) + const title = createMemo(() => { const m = searchParams().by if (m === 'viewed') return t('Top viewed') @@ -40,6 +65,10 @@ export const TopicView = (props: TopicProps) => { return t('Top recent') }) + const pages = createMemo(() => + splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE) + ) + return (
    @@ -110,6 +139,24 @@ export const TopicView = (props: TopicProps) => { + + + {(page) => ( + <> + + + + + )} + + + +

    + +

    +
    diff --git a/src/components/types.ts b/src/components/types.ts index 0b2fca98..870943e1 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -8,7 +8,6 @@ export type PageProps = { authorArticles?: Shout[] topicArticles?: Shout[] homeArticles?: Shout[] - feedArticles?: Shout[] author?: Author allAuthors?: Author[] topic?: Topic diff --git a/src/pages/author/[slug]/index.astro b/src/pages/author/[slug]/index.astro index 7ad13be2..50e29aa4 100644 --- a/src/pages/author/[slug]/index.astro +++ b/src/pages/author/[slug]/index.astro @@ -3,9 +3,10 @@ import { Root } from '../../../components/Root' import Zine from '../../../layouts/zine.astro' import { apiClient } from '../../../utils/apiClient' import { initRouter } from '../../../stores/router' +import { PRERENDERED_ARTICLES_COUNT } from '../../../components/Views/Author' const slug = Astro.params.slug.toString() -const articles = await apiClient.getArticlesForAuthors({ authorSlugs: [slug], limit: 50 }) +const articles = await apiClient.getArticlesForAuthors({ authorSlugs: [slug], limit: PRERENDERED_ARTICLES_COUNT }) const author = articles[0].authors.find((a) => a.slug === slug) const { pathname, search } = Astro.url diff --git a/src/pages/feed/index.astro b/src/pages/feed/index.astro index 93c40a9c..e9063068 100644 --- a/src/pages/feed/index.astro +++ b/src/pages/feed/index.astro @@ -1,16 +1,12 @@ --- import { Root } from '../../components/Root' import Zine from '../../layouts/zine.astro' -import { apiClient } from '../../utils/apiClient' - import { initRouter } from '../../stores/router' const { pathname, search } = Astro.url initRouter(pathname, search) - -const articles = await apiClient.getRecentArticles({ limit: 50 }) --- - + diff --git a/src/pages/index.astro b/src/pages/index.astro index e03b076c..9bbde9a4 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,14 +3,14 @@ import Zine from '../layouts/zine.astro' import { Root } from '../components/Root' import { apiClient } from '../utils/apiClient' import { initRouter } from '../stores/router' +import { PRERENDERED_ARTICLES_COUNT } from '../components/Views/Home' const randomTopics = await apiClient.getRandomTopics({ amount: 12 }) -const articles = await apiClient.getRecentPublishedArticles({ limit: 5 }) +const articles = await apiClient.getRecentPublishedArticles({ limit: PRERENDERED_ARTICLES_COUNT }) const { pathname, search } = Astro.url initRouter(pathname, search) - Astro.response.headers.set('Cache-Control', 's-maxage=1, stale-while-revalidate') --- diff --git a/src/pages/topic/[slug].astro b/src/pages/topic/[slug].astro index 98134a4b..91ebde65 100644 --- a/src/pages/topic/[slug].astro +++ b/src/pages/topic/[slug].astro @@ -2,9 +2,10 @@ import { Root } from '../../components/Root' import Zine from '../../layouts/zine.astro' import { apiClient } from '../../utils/apiClient' +import { PRERENDERED_ARTICLES_COUNT } from '../../components/Views/Topic' const slug = Astro.params.slug?.toString() || '' -const articles = await apiClient.getArticlesForTopics({ topicSlugs: [slug], limit: 50 }) +const articles = await apiClient.getArticlesForTopics({ topicSlugs: [slug], limit: PRERENDERED_ARTICLES_COUNT }) const topic = articles[0].topics.find(({ slug: topicSlug }) => topicSlug === slug) import { initRouter } from '../../stores/router' diff --git a/src/stores/zine/articles.ts b/src/stores/zine/articles.ts index fa2ca139..ee746f1a 100644 --- a/src/stores/zine/articles.ts +++ b/src/stores/zine/articles.ts @@ -127,40 +127,109 @@ const addSortedArticles = (articles: Shout[]) => { setSortedArticles((prevSortedArticles) => [...prevSortedArticles, ...articles]) } +export const loadFeed = async ({ + limit, + offset +}: { + limit: number + offset?: number +}): Promise<{ hasMore: boolean }> => { + // TODO: load actual feed + return await loadRecentArticles({ limit, offset }) +} + export const loadRecentArticles = async ({ limit, offset }: { - limit?: number + limit: number offset?: number -}): Promise => { - const newArticles = await apiClient.getRecentArticles({ limit, offset }) +}): Promise<{ hasMore: boolean }> => { + const newArticles = await apiClient.getRecentArticles({ limit: limit + 1, offset }) + const hasMore = newArticles.length === limit + 1 + + if (hasMore) { + newArticles.splice(-1) + } + addArticles(newArticles) addSortedArticles(newArticles) + + return { hasMore } } export const loadPublishedArticles = async ({ limit, - offset + offset = 0 }: { - limit?: number + limit: number offset?: number -}): Promise => { - const newArticles = await apiClient.getPublishedArticles({ limit, offset }) +}): Promise<{ hasMore: boolean }> => { + const newArticles = await apiClient.getPublishedArticles({ limit: limit + 1, offset }) + const hasMore = newArticles.length === limit + 1 + + if (hasMore) { + newArticles.splice(-1) + } + addArticles(newArticles) addSortedArticles(newArticles) + + return { hasMore } } -export const loadArticlesForAuthors = async ({ authorSlugs }: { authorSlugs: string[] }): Promise => { - const articles = await apiClient.getArticlesForAuthors({ authorSlugs, limit: 50 }) - addArticles(articles) - setSortedArticles(articles) +export const loadAuthorArticles = async ({ + authorSlug, + limit, + offset = 0 +}: { + authorSlug: string + limit: number + offset?: number +}): Promise<{ hasMore: boolean }> => { + const newArticles = await apiClient.getArticlesForAuthors({ + authorSlugs: [authorSlug], + limit: limit + 1, + offset + }) + + const hasMore = newArticles.length === limit + 1 + + if (hasMore) { + newArticles.splice(-1) + } + + addArticles(newArticles) + addSortedArticles(newArticles) + + return { hasMore } } -export const loadArticlesForTopics = async ({ topicSlugs }: { topicSlugs: string[] }): Promise => { - const articles = await apiClient.getArticlesForTopics({ topicSlugs, limit: 50 }) - addArticles(articles) - setSortedArticles(articles) +export const loadTopicArticles = async ({ + topicSlug, + limit, + offset +}: { + topicSlug: string + limit: number + offset: number +}): Promise<{ hasMore: boolean }> => { + const newArticles = await apiClient.getArticlesForTopics({ + topicSlugs: [topicSlug], + limit: limit + 1, + offset + }) + + const hasMore = newArticles.length === limit + 1 + + if (hasMore) { + newArticles.splice(-1) + } + + addArticles(newArticles) + addSortedArticles(newArticles) + + return { hasMore } } export const resetSortedArticles = () => { diff --git a/src/stores/zine/authors.ts b/src/stores/zine/authors.ts index 072b172c..93aa272b 100644 --- a/src/stores/zine/authors.ts +++ b/src/stores/zine/authors.ts @@ -1,6 +1,5 @@ import { apiClient } from '../../utils/apiClient' import type { Author } from '../../graphql/types.gen' -import { byCreated, byStat, byTopicStatDesc } from '../../utils/sortby' import { getLogger } from '../../utils/logger' import { createSignal } from 'solid-js' diff --git a/src/utils/apiClient.ts b/src/utils/apiClient.ts index abcba96e..4f420bee 100644 --- a/src/utils/apiClient.ts +++ b/src/utils/apiClient.ts @@ -184,7 +184,7 @@ export const apiClient = { }, getArticlesForTopics: async ({ topicSlugs, - limit = FEED_SIZE, + limit, offset = 0 }: { topicSlugs: string[] @@ -207,7 +207,7 @@ export const apiClient = { }, getArticlesForAuthors: async ({ authorSlugs, - limit = FEED_SIZE, + limit, offset = 0 }: { authorSlugs: string[] diff --git a/src/utils/splitToPages.ts b/src/utils/splitToPages.ts new file mode 100644 index 00000000..c26d1913 --- /dev/null +++ b/src/utils/splitToPages.ts @@ -0,0 +1,10 @@ +export function splitToPages(arr: T[], startIndex: number, pageSize: number): T[][] { + return arr.slice(startIndex).reduce((acc, article, index) => { + if (index % pageSize === 0) { + acc.push([]) + } + + acc[acc.length - 1].push(article) + return acc + }, [] as T[][]) +}