Merge remote-tracking branch 'origin/dev' into editor

# Conflicts:
#	src/components/Feed/Row3.tsx
#	src/components/Nav/AuthModal/EmailConfirm.tsx
#	src/components/Nav/AuthModal/ForgotPasswordForm.tsx
#	src/components/Nav/AuthModal/LoginForm.tsx
#	src/components/Nav/AuthModal/RegisterForm.tsx
#	src/components/Nav/AuthModal/index.tsx
#	src/components/Nav/Header.tsx
#	src/components/Nav/Modal.tsx
#	src/components/Nav/Popup.tsx
#	src/components/Nav/Private.tsx
#	src/components/Root.tsx
#	src/components/Topic/Card.tsx
#	src/components/Views/AllAuthors.tsx
#	src/components/Views/Author.tsx
#	src/components/Views/Home.tsx
#	src/components/Views/Topic.tsx
#	src/graphql/mutation/auth-confirm-email.ts
#	src/locales/ru.json
#	src/pages/welcome.astro
#	src/stores/auth.ts
#	src/stores/ui.ts
#	src/stores/zine/authors.ts
#	src/utils/apiClient.ts
#	src/utils/config.ts
This commit is contained in:
Igor Lobanov 2022-10-31 14:06:32 +01:00
commit 5bcef1d1e2
56 changed files with 973 additions and 606 deletions

View File

@ -35,8 +35,9 @@ module.exports = {
varsIgnorePattern: '^log$' varsIgnorePattern: '^log$'
} }
], ],
'@typescript-eslint/no-explicit-any': 'warn', // TODO: Remove any usage and enable
'@typescript-eslint/no-non-null-assertion': 'warn', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
// solid-js fix // solid-js fix
'import/no-unresolved': [2, { ignore: ['solid-js/'] }] 'import/no-unresolved': [2, { ignore: ['solid-js/'] }]

View File

@ -1,6 +1,3 @@
{ {
"*.{js,ts,tsx,json,scss,css,html}": "prettier --write", "*.{js,ts,tsx,json,scss,css,html}": "prettier --write"
"package.json": "sort-package-json",
"*.{scss,css}": "stylelint",
"*.{ts,tsx,js}": "eslint --fix"
} }

6
.lintstagedrc.bak Normal file
View File

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

View File

@ -17,7 +17,7 @@
"lint:code:fix": "eslint . --fix", "lint:code:fix": "eslint . --fix",
"lint:styles": "stylelint **/*.{scss,css}", "lint:styles": "stylelint **/*.{scss,css}",
"lint:styles:fix": "stylelint **/*.{scss,css} --fix", "lint:styles:fix": "stylelint **/*.{scss,css} --fix",
"pre-commit": "", "pre-commit": "lint-staged",
"pre-push": "", "pre-push": "",
"pre-commit-old": "lint-staged", "pre-commit-old": "lint-staged",
"pre-push-old": "npm run typecheck", "pre-push-old": "npm run typecheck",

View File

@ -10,6 +10,7 @@ import { showModal } from '../../stores/ui'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { incrementView } from '../../stores/zine/articles' import { incrementView } from '../../stores/zine/articles'
import MD from './MD' import MD from './MD'
import { SharePopup } from './SharePopup'
const MAX_COMMENT_LEVEL = 6 const MAX_COMMENT_LEVEL = 6
@ -126,9 +127,13 @@ export const FullArticle = (props: ArticleProps) => {
{/* </a>*/} {/* </a>*/}
{/*</div>*/} {/*</div>*/}
<div class="shout-stats__item"> <div class="shout-stats__item">
<a href="#share" onClick={() => showModal('share')}> <SharePopup
trigger={
<a href="#" onClick={(event) => event.preventDefault()}>
<Icon name="share" /> <Icon name="share" />
</a> </a>
}
/>
</div> </div>
{/*FIXME*/} {/*FIXME*/}
{/*<Show when={canEdit()}>*/} {/*<Show when={canEdit()}>*/}

View File

@ -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<PopupProps, 'children'>
export const SharePopup = (props: SharePopupProps) => {
return (
<Popup {...props}>
<ul class="nodash">
<li>
<a href="#">
<Icon name="vk-white" class={styles.icon} />
VK
</a>
</li>
<li>
<a href="#">
<Icon name="facebook-white" class={styles.icon} />
Facebook
</a>
</li>
<li>
<a href="#">
<Icon name="twitter-white" class={styles.icon} />
Twitter
</a>
</li>
<li>
<a href="#">
<Icon name="telegram-white" class={styles.icon} />
Telegram
</a>
</li>
<li>
<a href="#">
<Icon name="link-white" class={styles.icon} />
{t('Copy link')}
</a>
</li>
</ul>
</Popup>
)
}

View File

@ -2,7 +2,7 @@ import type { Author } from '../../graphql/types.gen'
import { AuthorCard } from './Card' import { AuthorCard } from './Card'
import './Full.scss' import './Full.scss'
export default (props: { author: Author }) => { export const AuthorFull = (props: { author: Author }) => {
return ( return (
<div class="container"> <div class="container">
<div class="row"> <div class="row">

View File

@ -1,11 +1,13 @@
import { Show } from 'solid-js/web' import { Show } from 'solid-js/web'
import type { Author } from '../../graphql/types.gen' import type { Author } from '../../graphql/types.gen'
import style from './Userpic.module.scss' import style from './Userpic.module.scss'
import { clsx } from 'clsx'
interface UserpicProps { interface UserpicProps {
user: Author user: Author
hasLink?: boolean hasLink?: boolean
isBig?: boolean isBig?: boolean
class?: string
} }
export default (props: UserpicProps) => { export default (props: UserpicProps) => {
@ -16,7 +18,7 @@ export default (props: UserpicProps) => {
} }
return ( return (
<div class={style.circlewrap} classList={{ [style.big]: props.isBig }}> <div class={clsx(style.circlewrap, props.class)} classList={{ [style.big]: props.isBig }}>
<Show when={props.hasLink}> <Show when={props.hasLink}>
<a href={`/author/${props.user.slug}`}> <a href={`/author/${props.user.slug}`}>
<Show <Show

View File

@ -21,7 +21,7 @@ interface BesideProps {
iconButton?: boolean iconButton?: boolean
} }
export default (props: BesideProps) => { export const Beside = (props: BesideProps) => {
return ( return (
<Show when={!!props.beside?.slug && props.values?.length > 0}> <Show when={!!props.beside?.slug && props.values?.length > 0}>
<div class="floor floor--9"> <div class="floor floor--9">

View File

@ -418,7 +418,7 @@
display: flex; display: flex;
} }
.shoutCardDetailsTtem { .shoutCardDetailsItem {
align-items: center; align-items: center;
display: flex; display: flex;
margin-right: 1.7em; margin-right: 1.7em;
@ -454,6 +454,12 @@
} }
} }
.shoutCardDetailsViewed {
.icon {
margin-top: -0.1em;
}
}
.rating { .rating {
align-items: center; align-items: center;
display: flex; display: flex;

View File

@ -5,7 +5,7 @@ import type { Shout } from '../../graphql/types.gen'
import { capitalize } from '../../utils' import { capitalize } from '../../utils'
import { translit } from '../../utils/ru2en' import { translit } from '../../utils/ru2en'
import { Icon } from '../Nav/Icon' import { Icon } from '../Nav/Icon'
import style from './Card.module.scss' import styles from './Card.module.scss'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { handleClientRouteLinkClick } from '../../stores/router' import { handleClientRouteLinkClick } from '../../stores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
@ -72,33 +72,33 @@ export const ArticleCard = (props: ArticleCardProps) => {
return ( return (
<section <section
class={clsx(style.shoutCard, `${props.settings?.additionalClass || ''}`)} class={clsx(styles.shoutCard, `${props.settings?.additionalClass || ''}`)}
classList={{ classList={{
[style.shoutCardShort]: props.settings?.isShort, [styles.shoutCardShort]: props.settings?.isShort,
[style.shoutCardPhotoBottom]: props.settings?.noimage && props.settings?.photoBottom, [styles.shoutCardPhotoBottom]: props.settings?.noimage && props.settings?.photoBottom,
[style.shoutCardFeed]: props.settings?.isFeedMode, [styles.shoutCardFeed]: props.settings?.isFeedMode,
[style.shoutCardFloorImportant]: props.settings?.isFloorImportant, [styles.shoutCardFloorImportant]: props.settings?.isFloorImportant,
[style.shoutCardWithCover]: props.settings?.isWithCover, [styles.shoutCardWithCover]: props.settings?.isWithCover,
[style.shoutCardBigTitle]: props.settings?.isBigTitle, [styles.shoutCardBigTitle]: props.settings?.isBigTitle,
[style.shoutCardVertical]: props.settings?.isVertical, [styles.shoutCardVertical]: props.settings?.isVertical,
[style.shoutCardWithBorder]: props.settings?.withBorder, [styles.shoutCardWithBorder]: props.settings?.withBorder,
[style.shoutCardCompact]: props.settings?.isCompact, [styles.shoutCardCompact]: props.settings?.isCompact,
[style.shoutCardSingle]: props.settings?.isSingle [styles.shoutCardSingle]: props.settings?.isSingle
}} }}
> >
<Show when={!props.settings?.noimage && cover}> <Show when={!props.settings?.noimage && cover}>
<div class={style.shoutCardCoverContainer}> <div class={styles.shoutCardCoverContainer}>
<div class={style.shoutCardCover}> <div class={styles.shoutCardCover}>
<img src={cover || ''} alt={title || ''} loading="lazy" /> <img src={cover || ''} alt={title || ''} loading="lazy" />
</div> </div>
</div> </div>
</Show> </Show>
<div class={style.shoutCardContent}> <div class={styles.shoutCardContent}>
<Show when={layout && layout !== 'article' && !(props.settings?.noicon || props.settings?.noimage)}> <Show when={layout && layout !== 'article' && !(props.settings?.noicon || props.settings?.noimage)}>
<div class={style.shoutCardType}> <div class={styles.shoutCardType}>
<a href={`/topic/${mainTopic.slug}`}> <a href={`/topic/${mainTopic.slug}`}>
<Icon name={layout} class={style.icon} /> <Icon name={layout} class={styles.icon} />
</a> </a>
</div> </div>
</Show> </Show>
@ -113,24 +113,24 @@ export const ArticleCard = (props: ArticleCardProps) => {
/> />
</Show> </Show>
<div class={style.shoutCardTitlesContainer}> <div class={styles.shoutCardTitlesContainer}>
<a href={`/${slug || ''}`} onClick={handleClientRouteLinkClick}> <a href={`/${slug || ''}`} onClick={handleClientRouteLinkClick}>
<div class={style.shoutCardTitle}> <div class={styles.shoutCardTitle}>
<span class={style.shoutCardLinkContainer}>{title}</span> <span class={styles.shoutCardLinkContainer}>{title}</span>
</div> </div>
<Show when={!props.settings?.nosubtitle && subtitle}> <Show when={!props.settings?.nosubtitle && subtitle}>
<div class={style.shoutCardSubtitle}> <div class={styles.shoutCardSubtitle}>
<span class={style.shoutCardLinkContainer}>{subtitle}</span> <span class={styles.shoutCardLinkContainer}>{subtitle}</span>
</div> </div>
</Show> </Show>
</a> </a>
</div> </div>
<Show when={!props.settings?.noauthor || !props.settings?.nodate}> <Show when={!props.settings?.noauthor || !props.settings?.nodate}>
<div class={style.shoutDetails}> <div class={styles.shoutDetails}>
<Show when={!props.settings?.noauthor}> <Show when={!props.settings?.noauthor}>
<div class={style.shoutAuthor}> <div class={styles.shoutAuthor}>
<For each={authors}> <For each={authors}>
{(author, index) => { {(author, index) => {
const name = const name =
@ -150,44 +150,50 @@ export const ArticleCard = (props: ArticleCardProps) => {
</Show> </Show>
<Show when={!props.settings?.nodate}> <Show when={!props.settings?.nodate}>
<div class={style.shoutDate}>{formattedDate()}</div> <div class={styles.shoutDate}>{formattedDate()}</div>
</Show> </Show>
</div> </div>
</Show> </Show>
<Show when={props.settings?.isFeedMode}> <Show when={props.settings?.isFeedMode}>
<section class={style.shoutCardDetails}> <section class={styles.shoutCardDetails}>
<div class={style.shoutCardDetailsContent}> <div class={styles.shoutCardDetailsContent}>
<div class={clsx(style.shoutCardDetailsItem, 'rating')}> <div class={clsx(styles.shoutCardDetailsItem, styles.rating)}>
<button class="rating__control">&minus;</button> <button class={styles.ratingControl}>&minus;</button>
<span class="rating__value">{stat?.rating || ''}</span> <span class={styles.ratingValue}>{stat?.rating || ''}</span>
<button class="rating__control">+</button> <button class={styles.ratingControl}>+</button>
</div> </div>
<div class={clsx(style.shoutCardDetailsItem, style.shoutCardComments)}> <div
<Icon name="eye" class={style.icon} /> class={clsx(
styles.shoutCardDetailsItem,
styles.shoutCardDetailsViewed,
styles.shoutCardComments
)}
>
<Icon name="eye" class={styles.icon} />
{stat?.viewed} {stat?.viewed}
</div> </div>
<div class={clsx(style.shoutCardDetailsTtem, style.shoutCardComments)}> <div class={clsx(styles.shoutCardDetailsItem, styles.shoutCardComments)}>
<a href={`/${slug + '#comments' || ''}`}> <a href={`/${slug + '#comments' || ''}`}>
<Icon name="comment" class={style.icon} /> <Icon name="comment" class={styles.icon} />
{stat?.commented || ''} {stat?.commented || ''}
</a> </a>
</div> </div>
<div class={style.shoutCardDetailsItem}> <div class={styles.shoutCardDetailsItem}>
<button> <button>
<Icon name="bookmark" class={style.icon} /> <Icon name="bookmark" class={styles.icon} />
</button> </button>
</div> </div>
<div class={style.shoutCardDetailsItem}> <div class={styles.shoutCardDetailsItem}>
<button> <button>
<Icon name="ellipsis" class={style.icon} /> <Icon name="ellipsis" class={styles.icon} />
</button> </button>
</div> </div>
</div> </div>
<button class="button--light shout-card__edit-control">{t('Collaborate')}</button> <button class={clsx('button--light', styles.shoutCardEditControl)}>{t('Collaborate')}</button>
</section> </section>
</Show> </Show>
</div> </div>

View File

@ -1,7 +1,7 @@
import { For, Suspense } from 'solid-js/web' import { For, Suspense } from 'solid-js/web'
import OneWide from './Row1' import { Row1 } from './Row1'
import Row2 from './Row2' import { Row2 } from './Row2'
import Row3 from './Row3' import { Row3 } from './Row3'
import { shuffle } from '../../utils' import { shuffle } from '../../utils'
import { createMemo, createSignal } from 'solid-js' import { createMemo, createSignal } from 'solid-js'
import type { JSX } from 'solid-js' import type { JSX } from 'solid-js'
@ -10,7 +10,7 @@ import './List.scss'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
export const Block6 = (props: { articles: Shout[] }) => { export const Block6 = (props: { articles: Shout[] }) => {
const dice = createMemo(() => shuffle([OneWide, Row2, Row3])) const dice = createMemo(() => shuffle([Row1, Row2, Row3]))
return ( return (
<> <>

View File

@ -2,7 +2,7 @@ import { Show } from 'solid-js'
import type { Shout } from '../../graphql/types.gen' import type { Shout } from '../../graphql/types.gen'
import { ArticleCard } from './Card' import { ArticleCard } from './Card'
export default (props: { article: Shout }) => ( export const Row1 = (props: { article: Shout }) => (
<Show when={!!props.article}> <Show when={!!props.article}>
<div class="floor floor--one-article"> <div class="floor floor--one-article">
<div class="wide-container row"> <div class="wide-container row">

View File

@ -2,13 +2,14 @@ import { createComputed, createSignal, Show } from 'solid-js'
import { For } from 'solid-js/web' import { For } from 'solid-js/web'
import type { Shout } from '../../graphql/types.gen' import type { Shout } from '../../graphql/types.gen'
import { ArticleCard } from './Card' import { ArticleCard } from './Card'
const x = [ const x = [
['6', '6'], ['6', '6'],
['4', '8'], ['4', '8'],
['8', '4'] ['8', '4']
] ]
export default (props: { articles: Shout[] }) => { export const Row2 = (props: { articles: Shout[] }) => {
const [y, setY] = createSignal(0) const [y, setY] = createSignal(0)
createComputed(() => setY(Math.floor(Math.random() * x.length))) createComputed(() => setY(Math.floor(Math.random() * x.length)))

View File

@ -3,7 +3,7 @@ import { For } from 'solid-js/web'
import type { Shout } from '../../graphql/types.gen' import type { Shout } from '../../graphql/types.gen'
import { ArticleCard } from './Card' import { ArticleCard } from './Card'
export default (props: { articles: Shout[]; header?: JSX.Element }) => { export const Row3 = (props: { articles: Shout[]; header?: JSX.Element }) => {
return ( return (
<div class="floor"> <div class="floor">
<div class="wide-container row"> <div class="wide-container row">

View File

@ -85,12 +85,12 @@ export const FeedSidebar = (props: FeedSidebarProps) => {
</For> </For>
</ul> </ul>
<p class="settings"> <div class="settings">
<a href="/feed/settings"> <a href="/feed/settings">
<strong>{t('Feed settings')}</strong> <strong>{t('Feed settings')}</strong>
</a>
<Icon name="settings" /> <Icon name="settings" />
</p> </a>
</div>
</> </>
) )
} }

View File

@ -160,3 +160,17 @@
} }
} }
} }
.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;
}

View File

@ -1,18 +1,19 @@
import styles from './EmailConfirm.module.scss' import styles from './AuthModal.module.scss'
import authModalStyles from './AuthModal.module.scss'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { t } from '../../../utils/intl' import { t } from '../../../utils/intl'
import { hideModal } from '../../../stores/ui' import { hideModal } from '../../../stores/ui'
import { onMount } from 'solid-js' import { createMemo, onMount, Show } from 'solid-js'
import { useRouter } from '../../../stores/router' import { useRouter } from '../../../stores/router'
import { confirmEmail } from '../../../stores/auth' import { confirmEmail, useAuthStore } from '../../../stores/auth'
type ConfirmEmailSearchParams = { type ConfirmEmailSearchParams = {
token: string token: string
} }
export const EmailConfirm = () => { export const EmailConfirm = () => {
const confirmedEmail = 'test@test.com' const { session } = useAuthStore()
const confirmedEmail = createMemo(() => session()?.user?.email || '')
const { searchParams } = useRouter<ConfirmEmailSearchParams>() const { searchParams } = useRouter<ConfirmEmailSearchParams>()
@ -28,12 +29,14 @@ export const EmailConfirm = () => {
return ( return (
<div> <div>
<div class={styles.title}>{t('Hooray! Welcome!')}</div> <div class={styles.title}>{t('Hooray! Welcome!')}</div>
<Show when={Boolean(confirmedEmail())}>
<div class={styles.text}> <div class={styles.text}>
{t("You've confirmed email")} {confirmedEmail} {t("You've confirmed email")} {confirmedEmail()}
</div> </div>
</Show>
<div> <div>
<button class={clsx('button', authModalStyles.submitButton)} onClick={() => hideModal()}> <button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
Перейти на главную {t('Go to main page')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -7,8 +7,6 @@ import { useRouter } from '../../../stores/router'
import { email, setEmail } from './sharedLogic' import { email, setEmail } from './sharedLogic'
import type { AuthModalSearchParams } from './types' import type { AuthModalSearchParams } from './types'
import { isValidEmail } from './validators' import { isValidEmail } from './validators'
import { checkEmail, register } from '../../../stores/auth'
import { ApiError } from '../../../utils/apiClient'
type FormFields = { type FormFields = {
email: string email: string
@ -61,9 +59,9 @@ export const ForgotPasswordForm = () => {
} }
return ( return (
<form> <form onSubmit={handleSubmit}>
<h4>{t('Forgot password?')}</h4> <h4>{t('Forgot password?')}</h4>
{t('Everything is ok, please give us your email address')} <div class={styles.authSubtitle}>{t('Everything is ok, please give us your email address')}</div>
<Show when={submitError()}> <Show when={submitError()}>
<div class={styles.authInfo}> <div class={styles.authInfo}>
<ul> <ul>

View File

@ -3,13 +3,14 @@ import { t } from '../../../utils/intl'
import styles from './AuthModal.module.scss' import styles from './AuthModal.module.scss'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { SocialProviders } from './SocialProviders' import { SocialProviders } from './SocialProviders'
import { signIn } from '../../../stores/auth' import { signIn, signSendLink } from '../../../stores/auth'
import { ApiError } from '../../../utils/apiClient' import { ApiError } from '../../../utils/apiClient'
import { createSignal } from 'solid-js' import { createSignal } from 'solid-js'
import { isValidEmail } from './validators' import { isValidEmail } from './validators'
import { email, setEmail } from './sharedLogic' import { email, setEmail } from './sharedLogic'
import { useRouter } from '../../../stores/router' import { useRouter } from '../../../stores/router'
import type { AuthModalSearchParams } from './types' import type { AuthModalSearchParams } from './types'
import { hideModal } from '../../../stores/ui'
type FormFields = { type FormFields = {
email: string email: string
@ -22,6 +23,9 @@ export const LoginForm = () => {
const [submitError, setSubmitError] = createSignal('') const [submitError, setSubmitError] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false) const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({}) const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
// TODO: better solution for interactive error messages
const [isEmailNotConfirmed, setIsEmailNotConfirmed] = createSignal(false)
const [isLinkSent, setIsLinkSent] = createSignal(false)
const { changeSearchParam } = useRouter<AuthModalSearchParams>() const { changeSearchParam } = useRouter<AuthModalSearchParams>()
@ -37,9 +41,18 @@ export const LoginForm = () => {
setPassword(newPassword) setPassword(newPassword)
} }
const handleSendLinkAgainClick = (event: Event) => {
event.preventDefault()
setIsEmailNotConfirmed(false)
setSubmitError('')
setIsLinkSent(true)
signSendLink({ email: email() })
}
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
setIsLinkSent(false)
setSubmitError('') setSubmitError('')
const newValidationErrors: ValidationErrors = {} const newValidationErrors: ValidationErrors = {}
@ -63,10 +76,12 @@ export const LoginForm = () => {
try { try {
await signIn({ email: email(), password: password() }) await signIn({ email: email(), password: password() })
hideModal()
} catch (error) { } catch (error) {
if (error instanceof ApiError) { if (error instanceof ApiError) {
if (error.code === 'email_not_confirmed') { if (error.code === 'email_not_confirmed') {
setSubmitError(t('Please, confirm email')) setSubmitError(t('Please, confirm email'))
setIsEmailNotConfirmed(true)
return return
} }
@ -87,11 +102,17 @@ export const LoginForm = () => {
<h4>{t('Enter the Discours')}</h4> <h4>{t('Enter the Discours')}</h4>
<Show when={submitError()}> <Show when={submitError()}>
<div class={styles.authInfo}> <div class={styles.authInfo}>
<ul> <div class={styles.warn}>{submitError()}</div>
<li class={styles.warn}>{submitError()}</li> <Show when={isEmailNotConfirmed()}>
</ul> <a href="#" class={styles.sendLink} onClick={handleSendLinkAgainClick}>
{t('Send link again')}
</a>
</Show>
</div> </div>
</Show> </Show>
<Show when={isLinkSent()}>
<div class={styles.authInfo}>{t('Link sent, check your email')}</div>
</Show>
<div class="pretty-form__item"> <div class="pretty-form__item">
<input <input
id="email" id="email"

View File

@ -11,6 +11,7 @@ import { ApiError } from '../../../utils/apiClient'
import { email, setEmail } from './sharedLogic' import { email, setEmail } from './sharedLogic'
import { useRouter } from '../../../stores/router' import { useRouter } from '../../../stores/router'
import type { AuthModalSearchParams } from './types' import type { AuthModalSearchParams } from './types'
import { hideModal } from '../../../stores/ui'
type FormFields = { type FormFields = {
name: string name: string
@ -29,6 +30,7 @@ export const RegisterForm = () => {
const [name, setName] = createSignal('') const [name, setName] = createSignal('')
const [password, setPassword] = createSignal('') const [password, setPassword] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false) const [isSubmitting, setIsSubmitting] = createSignal(false)
const [isSuccess, setIsSuccess] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({}) const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const handleEmailInput = (newEmail: string) => { const handleEmailInput = (newEmail: string) => {
@ -91,6 +93,8 @@ export const RegisterForm = () => {
email: email(), email: email(),
password: password() password: password()
}) })
setIsSuccess(true)
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.code === 'user_already_exists') { if (error instanceof ApiError && error.code === 'user_already_exists') {
return return
@ -103,6 +107,8 @@ export const RegisterForm = () => {
} }
return ( return (
<>
<Show when={!isSuccess()}>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<h4>{t('Create account')}</h4> <h4>{t('Create account')}</h4>
<Show when={submitError()}> <Show when={submitError()}>
@ -185,5 +191,16 @@ export const RegisterForm = () => {
</span> </span>
</div> </div>
</form> </form>
</Show>
<Show when={isSuccess()}>
<div class={styles.title}>{t('Almost done! Check your email.')}</div>
<div class={styles.text}>{t("We've sent you a message with a link to enter our website.")}</div>
<div>
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
{t('Back to main page')}
</button>
</div>
</Show>
</>
) )
} }

View File

@ -1,5 +1,5 @@
import { Show } from 'solid-js/web' import { Dynamic } from 'solid-js/web'
import { createEffect, createMemo, onMount } from 'solid-js' import { Component, createEffect, createMemo } from 'solid-js'
import { t } from '../../../utils/intl' import { t } from '../../../utils/intl'
import { hideModal } from '../../../stores/ui' import { hideModal } from '../../../stores/ui'
import { handleClientRouteLinkClick, useRouter } from '../../../stores/router' import { handleClientRouteLinkClick, useRouter } from '../../../stores/router'
@ -11,12 +11,11 @@ import { ForgotPasswordForm } from './ForgotPasswordForm'
import { EmailConfirm } from './EmailConfirm' import { EmailConfirm } from './EmailConfirm'
import type { AuthModalMode, AuthModalSearchParams } from './types' import type { AuthModalMode, AuthModalSearchParams } from './types'
const AUTH_MODAL_MODES: Record<AuthModalMode, AuthModalMode> = { const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
login: 'login', login: LoginForm,
register: 'register', register: RegisterForm,
'forgot-password': 'forgot-password', 'forgot-password': ForgotPasswordForm,
// eslint-disable-next-line sonarjs/no-duplicate-string 'confirm-email': EmailConfirm
'confirm-email': 'confirm-email'
} }
export const AuthModal = () => { export const AuthModal = () => {
@ -25,7 +24,7 @@ export const AuthModal = () => {
const { searchParams } = useRouter<AuthModalSearchParams>() const { searchParams } = useRouter<AuthModalSearchParams>()
const mode = createMemo<AuthModalMode>(() => { const mode = createMemo<AuthModalMode>(() => {
return AUTH_MODAL_MODES[searchParams().mode] || 'login' return AUTH_MODAL_MODES[searchParams().mode] ? searchParams().mode : 'login'
}) })
createEffect((oldMode) => { createEffect((oldMode) => {
@ -70,18 +69,7 @@ export const AuthModal = () => {
</div> </div>
</div> </div>
<div class={clsx('col-sm-6', styles.auth)}> <div class={clsx('col-sm-6', styles.auth)}>
<Show when={mode() === 'login'}> <Dynamic component={AUTH_MODAL_MODES[mode()]} />
<LoginForm />
</Show>
<Show when={mode() === 'register'}>
<RegisterForm />
</Show>
<Show when={mode() === 'forgot-password'}>
<ForgotPasswordForm />
</Show>
<Show when={mode() === 'confirm-email'}>
<EmailConfirm />
</Show>
</div> </div>
</div> </div>
) )

View File

@ -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 { .headerFixed {
position: fixed; position: fixed;
top: 0; top: 0;
@ -327,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 { .articleControls {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@ -348,19 +324,15 @@
transform: translateY(-50%); transform: translateY(-50%);
width: 100%; width: 100%;
.control {
cursor: pointer;
border: 0;
.icon { .icon {
margin-left: 1.6rem;
opacity: 0.6; opacity: 0.6;
transition: opacity 0.3s; transition: opacity 0.3s;
} }
img {
vertical-align: middle;
}
a {
border: none;
&:hover { &:hover {
background: none; background: none;
@ -370,4 +342,138 @@
} }
} }
} }
.control + .control {
margin-left: 1.6rem;
}
img {
vertical-align: middle;
}
}
.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);
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;
}
}
.userControlItemInbox,
.userControlItemSearch {
@include media-breakpoint-down(sm) {
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;
}
} }

View File

@ -1,19 +1,22 @@
import { For, Show, createSignal, createMemo, createEffect, onMount, onCleanup } from 'solid-js' import { For, Show, createSignal, createMemo, createEffect, onMount, onCleanup } from 'solid-js'
import Private from './Private'
import Notifications from './Notifications' import Notifications from './Notifications'
import { Icon } from './Icon' import { Icon } from './Icon'
import { Modal } from './Modal' import { Modal } from './Modal'
import { Popup } from './Popup'
import { AuthModal } from './AuthModal' import { AuthModal } from './AuthModal'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { useModalStore, showModal, useWarningsStore } from '../../stores/ui' import { useModalStore, showModal, useWarningsStore } from '../../stores/ui'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { handleClientRouteLinkClick, router, Routes, useRouter } from '../../stores/router' import { handleClientRouteLinkClick, router, Routes, useRouter } from '../../stores/router'
import styles from './Header.module.scss' import styles from './Header.module.scss'
import stylesPopup from './Popup.module.scss'
import privateStyles from './Private.module.scss'
import { getPagePath } from '@nanostores/router' import { getPagePath } from '@nanostores/router'
import { getLogger } from '../../utils/logger'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { SharePopup } from '../Article/SharePopup'
import { ProfilePopup } from './ProfilePopup'
import Userpic from '../Author/Userpic'
import type { Author } from '../../graphql/types.gen'
const log = getLogger('header')
const resources: { name: string; route: keyof Routes }[] = [ const resources: { name: string; route: keyof Routes }[] = [
{ name: t('zine'), route: 'home' }, { name: t('zine'), route: 'home' },
@ -32,6 +35,9 @@ export const Header = (props: Props) => {
const [getIsScrolled, setIsScrolled] = createSignal(false) const [getIsScrolled, setIsScrolled] = createSignal(false)
const [fixed, setFixed] = createSignal(false) const [fixed, setFixed] = createSignal(false)
const [visibleWarnings, setVisibleWarnings] = createSignal(false) const [visibleWarnings, setVisibleWarnings] = createSignal(false)
const [isSharePopupVisible, setIsSharePopupVisible] = createSignal(false)
const [isProfilePopupVisible, setIsProfilePopupVisible] = createSignal(false)
// stores // stores
const { warnings } = useWarningsStore() const { warnings } = useWarningsStore()
const { session } = useAuthStore() const { session } = useAuthStore()
@ -41,13 +47,11 @@ export const Header = (props: Props) => {
// methods // methods
const toggleWarnings = () => setVisibleWarnings(!visibleWarnings()) const toggleWarnings = () => setVisibleWarnings(!visibleWarnings())
const toggleFixed = () => setFixed(!fixed()) const toggleFixed = () => setFixed((oldFixed) => !oldFixed)
// effects // effects
createEffect(() => { createEffect(() => {
const isFixed = fixed() || (modal() && modal() !== 'share') document.body.classList.toggle('fixed', fixed() || modal() !== null)
document.body.classList.toggle(styles.fixed, fixed() && !modal())
document.body.classList.toggle('fixed', isFixed)
document.body.classList.toggle(styles.fixed, isFixed && !modal())
}) })
// derived // derived
@ -85,7 +89,8 @@ export const Header = (props: Props) => {
classList={{ classList={{
[styles.headerFixed]: props.isHeaderFixed, [styles.headerFixed]: props.isHeaderFixed,
[styles.headerScrolledTop]: !getIsScrollingBottom() && getIsScrolled(), [styles.headerScrolledTop]: !getIsScrollingBottom() && getIsScrolled(),
[styles.headerScrolledBottom]: getIsScrollingBottom() && getIsScrolled(), [styles.headerScrolledBottom]:
(getIsScrollingBottom() && getIsScrolled() && !isProfilePopupVisible()) || isSharePopupVisible(),
[styles.headerWithTitle]: Boolean(props.title) [styles.headerWithTitle]: Boolean(props.title)
}} }}
> >
@ -94,41 +99,6 @@ export const Header = (props: Props) => {
</Modal> </Modal>
<div class={clsx(styles.mainHeaderInner, 'wide-container')}> <div class={clsx(styles.mainHeaderInner, 'wide-container')}>
<Popup name="share" class={clsx(styles.popupShare, stylesPopup.popupShare)}>
<ul class="nodash">
<li>
<a href="#">
<Icon name="vk-white" class={stylesPopup.icon} />
VK
</a>
</li>
<li>
<a href="#">
<Icon name="facebook-white" class={stylesPopup.icon} />
Facebook
</a>
</li>
<li>
<a href="#">
<Icon name="twitter-white" class={stylesPopup.icon} />
Twitter
</a>
</li>
<li>
<a href="#">
<Icon name="telegram-white" class={stylesPopup.icon} />
Telegram
</a>
</li>
<li>
<a href="#">
<Icon name="link-white" class={stylesPopup.icon} />
{t('Copy link')}
</a>
</li>
</ul>
</Popup>
<nav class={clsx(styles.headerInner, 'row')} classList={{ fixed: fixed() }}> <nav class={clsx(styles.headerInner, 'row')} classList={{ fixed: fixed() }}>
<div class={clsx(styles.mainLogo, 'col-auto')}> <div class={clsx(styles.mainLogo, 'col-auto')}>
<a href={getPagePath(router, 'home')} onClick={handleClientRouteLinkClick}> <a href={getPagePath(router, 'home')} onClick={handleClientRouteLinkClick}>
@ -162,8 +132,15 @@ export const Header = (props: Props) => {
</ul> </ul>
</div> </div>
<div class={styles.usernav}> <div class={styles.usernav}>
<div class={clsx(privateStyles.userControl, styles.userControl, 'col')}> <div class={clsx(styles.userControl, styles.userControl, 'col')}>
<div class={privateStyles.userControlItem}> <div class={clsx(styles.userControlItem, styles.userControlItemWritePost)}>
<a href="/create">
<span class={styles.textLabel}>{t('Create post')}</span>
<Icon name="pencil" class={styles.icon} />
</a>
</div>
<div class={styles.userControlItem}>
<a href="#" onClick={handleBellIconClick}> <a href="#" onClick={handleBellIconClick}>
<div> <div>
<Icon name="bell-white" counter={authorized() ? warnings().length : 1} /> <Icon name="bell-white" counter={authorized() ? warnings().length : 1} />
@ -172,7 +149,7 @@ export const Header = (props: Props) => {
</div> </div>
<Show when={visibleWarnings()}> <Show when={visibleWarnings()}>
<div class={clsx(privateStyles.userControlItem, 'notifications')}> <div class={clsx(styles.userControlItem, 'notifications')}>
<Notifications /> <Notifications />
</div> </div>
</Show> </Show>
@ -180,31 +157,56 @@ export const Header = (props: Props) => {
<Show <Show
when={authorized()} when={authorized()}
fallback={ fallback={
<div class={clsx(privateStyles.userControlItem, 'loginbtn')}> <div class={clsx(styles.userControlItem, 'loginbtn')}>
<a href="?modal=auth&mode=login" onClick={handleClientRouteLinkClick}> <a href="?modal=auth&mode=login" onClick={handleClientRouteLinkClick}>
<Icon name="user-anonymous" /> <Icon name="user-anonymous" />
</a> </a>
</div> </div>
} }
> >
<Private /> <div class={clsx(styles.userControlItem, styles.userControlItemInbox)}>
<a href="/inbox">
{/*FIXME: replace with route*/}
<div classList={{ entered: page().path === '/inbox' }}>
<Icon name="inbox-white" counter={session()?.news?.unread || 0} />
</div>
</a>
</div>
<ProfilePopup
onVisibilityChange={(isVisible) => {
setIsProfilePopupVisible(isVisible)
}}
containerCssClass={styles.control}
trigger={
<div class={styles.userControlItem}>
<button class={styles.button}>
<div classList={{ entered: page().path === `/${session().user?.slug}` }}>
<Userpic user={session().user as Author} class={styles.userpic} />
</div>
</button>
</div>
}
/>
</Show> </Show>
</div> </div>
<Show when={props.title}> <Show when={props.title}>
<div class={styles.articleControls}> <div class={styles.articleControls}>
<button <SharePopup
onClick={() => { onVisibilityChange={(isVisible) => {
// FIXME: Popup setIsSharePopupVisible(isVisible)
showModal('share')
}} }}
> containerCssClass={styles.control}
<Icon name="share-outline" class={styles.icon} /> trigger={<Icon name="share-outline" class={styles.icon} />}
</button> />
<a href="#comments"> <a href="#comments" class={styles.control}>
<Icon name="comments-outline" class={styles.icon} /> <Icon name="comments-outline" class={styles.icon} />
</a> </a>
<a href="#" class={styles.control} onClick={(event) => event.preventDefault()}>
<Icon name="pencil-outline" class={styles.icon} /> <Icon name="pencil-outline" class={styles.icon} />
</a>
<a href="#" class={styles.control} onClick={(event) => event.preventDefault()}>
<Icon name="bookmark" class={styles.icon} /> <Icon name="bookmark" class={styles.icon} />
</a>
</div> </div>
</Show> </Show>
</div> </div>

View File

@ -1,8 +1,11 @@
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js' import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import type { JSX } from 'solid-js' import type { JSX } from 'solid-js'
import { getLogger } from '../../utils/logger'
import './Modal.scss' import './Modal.scss'
import { hideModal, useModalStore } from '../../stores/ui' import { hideModal, useModalStore } from '../../stores/ui'
const log = getLogger('modal')
interface ModalProps { interface ModalProps {
name: string name: string
children: JSX.Element children: JSX.Element
@ -31,7 +34,7 @@ export const Modal = (props: ModalProps) => {
createEffect(() => { createEffect(() => {
setVisible(modal() === props.name) setVisible(modal() === props.name)
console.debug(`[auth.modal] ${props.name} is ${modal() === props.name ? 'visible' : 'hidden'}`) log.debug(`${props.name} is ${modal() === props.name ? 'visible' : 'hidden'}`)
}) })
return ( return (

View File

@ -1,10 +1,25 @@
.container {
position: relative;
}
.popup { .popup {
background: #fff; background: #fff;
border: 2px solid #000; border: 2px solid #000;
top: calc(100% + 8px);
opacity: 1;
&.horizontalAnchorCenter {
left: 50%;
transform: translateX(-50%);
}
&.horizontalAnchorRight {
right: 0;
}
@include font-size(1.6rem); @include font-size(1.6rem);
padding: 2.4rem 2.4rem 2.4rem 1.6rem; padding: 2.4rem;
position: absolute; position: absolute;
z-index: 10; z-index: 10;
@ -14,7 +29,6 @@
li { li {
margin-bottom: 1.6rem; margin-bottom: 1.6rem;
padding-left: 3.6rem;
position: relative; position: relative;
&:last-child { &:last-child {
@ -24,23 +38,36 @@
a { a {
border: none; border: none;
white-space: nowrap;
&:hover {
img {
filter: invert(0);
}
}
} }
img { img {
filter: invert(1); filter: invert(1);
max-height: 2rem; max-height: 2rem;
max-width: 2rem; max-width: 2rem;
transition: filter 0.3s;
} }
.icon { .icon {
left: 1.5rem; display: inline-block;
position: absolute; width: 3.6rem;
top: 50%;
transform: translate(-50%, -50%);
} }
} }
.popupShare { // TODO: animation
right: 1em; // .popup {
top: 4.5rem; // opacity: 1;
} // transition: opacity 0.3s;
// z-index: 1;
// &.visible {
// opacity: 0;
// transition: opacity 0.3s, z-index 0s 0.3s;
// z-index: -1;
// }
// }

View File

@ -1,31 +1,61 @@
import { createEffect, createSignal, onMount, Show } from 'solid-js' import { createEffect, createSignal, JSX, onCleanup, onMount, Show } from 'solid-js'
import style from './Popup.module.scss' import styles from './Popup.module.scss'
import { hideModal, useModalStore } from '../../stores/ui'
import { clsx } from 'clsx' import { clsx } from 'clsx'
interface PopupProps { type HorizontalAnchor = 'center' | 'right'
name: string
children: any export type PopupProps = {
class?: string containerCssClass?: string
trigger: JSX.Element
children: JSX.Element
onVisibilityChange?: (isVisible) => void
horizontalAnchor?: HorizontalAnchor
} }
export const Popup = (props: PopupProps) => { export const Popup = (props: PopupProps) => {
const { modal } = useModalStore() const [isVisible, setIsVisible] = createSignal(false)
const horizontalAnchor: HorizontalAnchor = props.horizontalAnchor || 'center'
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(() => { onMount(() => {
window.addEventListener('keydown', (e: KeyboardEvent) => { document.addEventListener('click', handleClickOutside, { capture: true })
if (e.key === 'Escape') hideModal() onCleanup(() => document.removeEventListener('click', handleClickOutside, { capture: true }))
})
}) })
const [visible, setVisible] = createSignal(false) const toggle = () => setIsVisible((oldVisible) => !oldVisible)
createEffect(() => {
setVisible(modal() === props.name)
})
return ( return (
<Show when={visible()}> <span class={clsx(styles.container, props.containerCssClass)} ref={container}>
<div class={clsx(style.popup, props.class)}>{props.children}</div> <span onClick={toggle}>{props.trigger}</span>
<Show when={isVisible()}>
<div
class={clsx(styles.popup, {
[styles.horizontalAnchorCenter]: horizontalAnchor === 'center',
[styles.horizontalAnchorRight]: horizontalAnchor === 'right'
})}
>
{props.children}
</div>
</Show> </Show>
</span>
) )
} }

View File

@ -1,100 +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);
}
.circlewrap {
margin-right: 0;
}
}
.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;
}
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 {
@include media-breakpoint-up(lg) {
.icon {
display: none;
}
.textLabel {
display: inline;
}
}
}
.userControlItemInbox,
.userControlItemSearch {
@include media-breakpoint-down(sm) {
display: none;
}
}

View File

@ -1,39 +0,0 @@
import type { Author } from '../../graphql/types.gen'
import Userpic from '../Author/Userpic'
import { Icon } from './Icon'
import styles from './Private.module.scss'
import { useAuthStore } from '../../stores/auth'
import { useRouter } from '../../stores/router'
import { clsx } from 'clsx'
export default () => {
const { session } = useAuthStore()
const { page } = useRouter()
return (
<div class={clsx(styles.userControl, 'col')}>
<div class={clsx(styles.userControlItem, styles.userControlItemWritePost)}>
<a href="/create">
<span class={styles.textLabel}>опубликовать материал</span>
<Icon name="pencil" />
</a>
</div>
<div class={clsx(styles.userControlItem, styles.userControlItemInbox)}>
<a href="/inbox">
{/*FIXME: replace with route*/}
<div classList={{ entered: page().path === '/inbox' }}>
<Icon name="inbox-white" counter={session()?.news?.unread || 0} />
</div>
</a>
</div>
<div class={styles.userControlItem}>
<a href={`/${session().user?.slug}`}>
{/*FIXME: replace with route*/}
<div classList={{ entered: page().path === `/${session().user?.slug}` }}>
<Userpic user={session().user as Author} />
</div>
</a>
</div>
</div>
)
}

View File

@ -0,0 +1,44 @@
import { Popup, PopupProps } from './Popup'
import { signOut, useAuthStore } from '../../stores/auth'
type ProfilePopupProps = Omit<PopupProps, 'children'>
export const ProfilePopup = (props: ProfilePopupProps) => {
const { session } = useAuthStore()
return (
<Popup {...props} horizontalAnchor="right">
<ul class="nodash">
<li>
<a href={`/${session().user?.slug}`}>Профиль</a>
</li>
<li>
<a href="#">Черновики</a>
</li>
<li>
<a href="#">Подписки</a>
</li>
<li>
<a href="#">Комментарии</a>
</li>
<li>
<a href="#">Закладки</a>
</li>
<li>
<a href="#">Настройки</a>
</li>
<li>
<a
href="#"
onClick={(event) => {
event.preventDefault()
signOut()
}}
>
Выйти из&nbsp;аккаунта
</a>
</li>
</ul>
</Popup>
)
}

View File

@ -1,8 +1,8 @@
import { MainLayout } from '../Layouts/MainLayout' import { MainLayout } from '../Layouts/MainLayout'
import { AuthorView } from '../Views/Author' import { AuthorView, PRERENDERED_ARTICLES_COUNT } from '../Views/Author'
import type { PageProps } from '../types' import type { PageProps } from '../types'
import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js' 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 { useRouter } from '../../stores/router'
import { loadAuthor } from '../../stores/zine/authors' import { loadAuthor } from '../../stores/zine/authors'
import { Loading } from '../Loading' import { Loading } from '../Loading'
@ -27,7 +27,7 @@ export const AuthorPage = (props: PageProps) => {
return return
} }
await loadArticlesForAuthors({ authorSlugs: [slug()] }) await loadAuthorArticles({ authorSlug: slug(), limit: PRERENDERED_ARTICLES_COUNT })
await loadAuthor({ slug: slug() }) await loadAuthor({ slug: slug() })
setIsLoaded(true) setIsLoaded(true)

View File

@ -1,30 +1,14 @@
import { MainLayout } from '../Layouts/MainLayout' import { MainLayout } from '../Layouts/MainLayout'
import { FeedView } from '../Views/Feed' import { FeedView } from '../Views/Feed'
import type { PageProps } from '../types' import { onCleanup } from 'solid-js'
import { createSignal, onCleanup, onMount, Show } from 'solid-js' import { resetSortedArticles } from '../../stores/zine/articles'
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)
})
export const FeedPage = () => {
onCleanup(() => resetSortedArticles()) onCleanup(() => resetSortedArticles())
return ( return (
<MainLayout> <MainLayout>
<Show when={isLoaded()} fallback={<Loading />}> <FeedView />
<FeedView articles={props.feedArticles} />
</Show>
</MainLayout> </MainLayout>
) )
} }

View File

@ -1,4 +1,4 @@
import { HomeView } from '../Views/Home' import { HomeView, PRERENDERED_ARTICLES_COUNT } from '../Views/Home'
import { MainLayout } from '../Layouts/MainLayout' import { MainLayout } from '../Layouts/MainLayout'
import type { PageProps } from '../types' import type { PageProps } from '../types'
import { createSignal, onCleanup, onMount, Show } from 'solid-js' import { createSignal, onCleanup, onMount, Show } from 'solid-js'
@ -14,7 +14,7 @@ export const HomePage = (props: PageProps) => {
return return
} }
await loadPublishedArticles({ limit: 5, offset: 0 }) await loadPublishedArticles({ limit: PRERENDERED_ARTICLES_COUNT, offset: 0 })
await loadRandomTopics() await loadRandomTopics()
setIsLoaded(true) setIsLoaded(true)

View File

@ -1,8 +1,8 @@
import { MainLayout } from '../Layouts/MainLayout' import { MainLayout } from '../Layouts/MainLayout'
import { TopicView } from '../Views/Topic' import { PRERENDERED_ARTICLES_COUNT, TopicView } from '../Views/Topic'
import type { PageProps } from '../types' import type { PageProps } from '../types'
import { createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js' 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 { useRouter } from '../../stores/router'
import { loadTopic } from '../../stores/zine/topics' import { loadTopic } from '../../stores/zine/topics'
import { Loading } from '../Loading' import { Loading } from '../Loading'
@ -27,7 +27,7 @@ export const TopicPage = (props: PageProps) => {
return return
} }
await loadArticlesForTopics({ topicSlugs: [slug()] }) await loadTopicArticles({ topicSlug: slug(), limit: PRERENDERED_ARTICLES_COUNT, offset: 0 })
await loadTopic({ slug: slug() }) await loadTopic({ slug: slug() })
setIsLoaded(true) setIsLoaded(true)

View File

@ -1,10 +1,11 @@
// FIXME: breaks on vercel, research // FIXME: breaks on vercel, research
// import 'solid-devtools' // import 'solid-devtools'
import { hideModal, MODALS, setLocale, showModal } from '../stores/ui' import { MODALS, setLocale, showModal } from '../stores/ui'
import { Component, createEffect, createMemo } from 'solid-js' import { Component, createEffect, createMemo, onMount } from 'solid-js'
import { Routes, useRouter } from '../stores/router' import { Routes, useRouter } from '../stores/router'
import { Dynamic, isServer } from 'solid-js/web' import { Dynamic, isServer } from 'solid-js/web'
import { getLogger } from '../utils/logger'
import type { PageProps } from './types' import type { PageProps } from './types'
@ -26,6 +27,7 @@ import { ProjectsPage } from './Pages/about/ProjectsPage'
import { TermsOfUsePage } from './Pages/about/TermsOfUsePage' import { TermsOfUsePage } from './Pages/about/TermsOfUsePage'
import { ThanksPage } from './Pages/about/ThanksPage' import { ThanksPage } from './Pages/about/ThanksPage'
import { CreatePage } from './Pages/CreatePage' import { CreatePage } from './Pages/CreatePage'
import { renewSession } from '../stores/auth'
// TODO: lazy load // TODO: lazy load
// const HomePage = lazy(() => import('./Pages/HomePage')) // const HomePage = lazy(() => import('./Pages/HomePage'))
@ -47,6 +49,8 @@ import { CreatePage } from './Pages/CreatePage'
// const ThanksPage = lazy(() => import('./Pages/about/ThanksPage')) // const ThanksPage = lazy(() => import('./Pages/about/ThanksPage'))
// const CreatePage = lazy(() => import('./Pages/about/CreatePage')) // const CreatePage = lazy(() => import('./Pages/about/CreatePage'))
const log = getLogger('root')
type RootSearchParams = { type RootSearchParams = {
modal: string modal: string
lang: string lang: string
@ -82,6 +86,10 @@ export const Root = (props: PageProps) => {
} }
}) })
onMount(() => {
renewSession()
})
const pageComponent = createMemo(() => { const pageComponent = createMemo(() => {
const result = pagesMap[page().route] const result = pagesMap[page().route]

View File

@ -8,6 +8,10 @@ import { t } from '../../utils/intl'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { follow, unfollow } from '../../stores/zine/common' import { follow, unfollow } from '../../stores/zine/common'
import { getLogger } from '../../utils/logger'
const log = getLogger('TopicCard')
interface TopicProps { interface TopicProps {
topic: Topic topic: Topic
compact?: boolean compact?: boolean

View File

@ -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 type { Author, Shout } from '../../graphql/types.gen'
import Row2 from '../Feed/Row2' import { Row2 } from '../Feed/Row2'
import Row3 from '../Feed/Row3' import { Row3 } from '../Feed/Row3'
// import Beside from '../Feed/Beside' import { AuthorFull } from '../Author/Full'
import AuthorFull from '../Author/Full'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { useAuthorsStore } from '../../stores/zine/authors' import { useAuthorsStore } from '../../stores/zine/authors'
import { useArticlesStore } from '../../stores/zine/articles' import { loadAuthorArticles, useArticlesStore } from '../../stores/zine/articles'
import '../../styles/Topic.scss' import '../../styles/Topic.scss'
import { useTopicsStore } from '../../stores/zine/topics' import { useTopicsStore } from '../../stores/zine/topics'
import { useRouter } from '../../stores/router' 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 // TODO: load reactions on client
type AuthorProps = { type AuthorProps = {
@ -26,16 +27,37 @@ type AuthorPageSearchParams = {
by: '' | 'viewed' | 'rating' | 'commented' | 'recent' 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) => { export const AuthorView = (props: AuthorProps) => {
const { sortedArticles } = useArticlesStore({ const { sortedArticles } = useArticlesStore({
sortedArticles: props.authorArticles sortedArticles: props.authorArticles
}) })
const { authorEntities } = useAuthorsStore({ authors: [props.author] }) const { authorEntities } = useAuthorsStore({ authors: [props.author] })
const { topicsByAuthor } = useTopicsStore() const { topicsByAuthor } = useTopicsStore()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const author = createMemo(() => authorEntities()[props.authorSlug]) const author = createMemo(() => authorEntities()[props.authorSlug])
const { searchParams, changeSearchParam } = useRouter<AuthorPageSearchParams>() const { searchParams, changeSearchParam } = useRouter<AuthorPageSearchParams>()
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 title = createMemo(() => {
const m = searchParams().by const m = searchParams().by
if (m === 'viewed') return t('Top viewed') if (m === 'viewed') return t('Top viewed')
@ -44,6 +66,10 @@ export const AuthorView = (props: AuthorProps) => {
return t('Top recent') return t('Top recent')
}) })
const pages = createMemo<Shout[][]>(() =>
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE)
)
return ( return (
<div class="container author-page"> <div class="container author-page">
<Show when={author()} fallback={<div class="center">{t('Loading')}</div>}> <Show when={author()} fallback={<div class="center">{t('Loading')}</div>}>
@ -83,8 +109,8 @@ export const AuthorView = (props: AuthorProps) => {
</div> </div>
<h3 class="col-12">{title()}</h3> <h3 class="col-12">{title()}</h3>
<div class="row"> <div class="row">
<Show when={sortedArticles().length > 0}>
<Beside <Beside
title={t('Topics which supported by author')} title={t('Topics which supported by author')}
values={topicsByAuthor()[author().slug].slice(0, 5)} values={topicsByAuthor()[author().slug].slice(0, 5)}
@ -96,18 +122,26 @@ export const AuthorView = (props: AuthorProps) => {
iconButton={true} iconButton={true}
/> />
<Row3 articles={sortedArticles().slice(1, 4)} /> <Row3 articles={sortedArticles().slice(1, 4)} />
<Show when={sortedArticles().length > 4}>
<Row2 articles={sortedArticles().slice(4, 6)} /> <Row2 articles={sortedArticles().slice(4, 6)} />
</Show>
<Show when={sortedArticles().length > 6}>
<Row3 articles={sortedArticles().slice(6, 9)} /> <Row3 articles={sortedArticles().slice(6, 9)} />
</Show>
<Show when={sortedArticles().length > 9}>
<Row3 articles={sortedArticles().slice(9, 12)} /> <Row3 articles={sortedArticles().slice(9, 12)} />
</Show>
<For each={pages()}>
{(page) => (
<>
<Row3 articles={page.slice(0, 3)} />
<Row3 articles={page.slice(3, 6)} />
<Row3 articles={page.slice(6, 9)} />
</>
)}
</For>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show> </Show>
</div> </div>
</Show> </Show>

View File

@ -1,6 +1,6 @@
import { createMemo, For, Show } from 'solid-js' import { createMemo, createSignal, For, onMount, Show } from 'solid-js'
import type { Shout, Reaction } from '../../graphql/types.gen'
import '../../styles/Feed.scss' import '../../styles/Feed.scss'
import stylesBeside from '../../components/Feed/Beside.module.scss'
import { Icon } from '../Nav/Icon' import { Icon } from '../Nav/Icon'
import { byCreated, sortBy } from '../../utils/sortby' import { byCreated, sortBy } from '../../utils/sortby'
import { TopicCard } from '../Topic/Card' import { TopicCard } from '../Topic/Card'
@ -16,11 +16,6 @@ import { useAuthorsStore } from '../../stores/zine/authors'
import { useTopicsStore } from '../../stores/zine/topics' import { useTopicsStore } from '../../stores/zine/topics'
import { useTopAuthorsStore } from '../../stores/zine/topAuthors' import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
interface FeedProps {
articles: Shout[]
reactions?: Reaction[]
}
// const AUTHORSHIP_REACTIONS = [ // const AUTHORSHIP_REACTIONS = [
// ReactionKind.Accept, // ReactionKind.Accept,
// ReactionKind.Reject, // ReactionKind.Reject,
@ -28,9 +23,11 @@ interface FeedProps {
// ReactionKind.Ask // ReactionKind.Ask
// ] // ]
export const FeedView = (props: FeedProps) => { export const FEED_PAGE_SIZE = 20
export const FeedView = () => {
// state // state
const { sortedArticles } = useArticlesStore({ sortedArticles: props.articles }) const { sortedArticles } = useArticlesStore()
const reactions = useReactionsStore() const reactions = useReactionsStore()
const { sortedAuthors } = useAuthorsStore() const { sortedAuthors } = useAuthorsStore()
const { topTopics } = useTopicsStore() const { topTopics } = useTopicsStore()
@ -39,6 +36,8 @@ export const FeedView = (props: FeedProps) => {
const topReactions = createMemo(() => sortBy(reactions(), byCreated)) const topReactions = createMemo(() => sortBy(reactions(), byCreated))
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
// const expectingFocus = createMemo<Shout[]>(() => { // const expectingFocus = createMemo<Shout[]>(() => {
// // 1 co-author notifications needs // // 1 co-author notifications needs
// // TODO: list of articles where you are co-author // // TODO: list of articles where you are co-author
@ -52,13 +51,15 @@ export const FeedView = (props: FeedProps) => {
// return [] // return []
// }) // })
// eslint-disable-next-line unicorn/consistent-function-scoping const loadMore = async () => {
const loadMore = () => { const { hasMore } = await loadRecentArticles({ limit: FEED_PAGE_SIZE, offset: sortedArticles().length })
// const limit = props.limit || 50 setIsLoadMoreButtonVisible(hasMore)
// const offset = props.offset || 0
// FIXME
loadRecentArticles({ limit: 50, offset: 0 })
} }
onMount(() => {
loadMore()
})
return ( return (
<> <>
<div class="container feed"> <div class="container feed">
@ -90,7 +91,7 @@ export const FeedView = (props: FeedProps) => {
{(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />} {(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />}
</For> </For>
<div class="beside-column-title"> <div class={stylesBeside.besideColumnTitle}>
<h4>{t('Popular authors')}</h4> <h4>{t('Popular authors')}</h4>
<a href="/user/list"> <a href="/user/list">
{t('All authors')} {t('All authors')}
@ -98,7 +99,7 @@ export const FeedView = (props: FeedProps) => {
</a> </a>
</div> </div>
<ul class="beside-column"> <ul class={stylesBeside.besideColumn}>
<For each={topAuthors().slice(0, 5)}> <For each={topAuthors().slice(0, 5)}>
{(author) => ( {(author) => (
<li> <li>
@ -112,10 +113,6 @@ export const FeedView = (props: FeedProps) => {
{(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />} {(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />}
</For> </For>
</Show> </Show>
<p class="load-more-container">
<button class="button">{t('Load more')}</button>
</p>
</div> </div>
<aside class="col-md-3"> <aside class="col-md-3">
@ -135,12 +132,13 @@ export const FeedView = (props: FeedProps) => {
</Show> </Show>
</aside> </aside>
</div> </div>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container"> <p class="load-more-container">
<button class="button" onClick={loadMore}> <button class="button" onClick={loadMore}>
{t('Load more')} {t('Load more')}
</button> </button>
</p> </p>
</Show>
</div> </div>
</> </>
) )

View File

@ -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 Banner from '../Discours/Banner'
import { NavTopics } from '../Nav/Topics' import { NavTopics } from '../Nav/Topics'
import { Row5 } from '../Feed/Row5' import { Row5 } from '../Feed/Row5'
import Row3 from '../Feed/Row3' import { Row3 } from '../Feed/Row3'
import Row2 from '../Feed/Row2' import { Row2 } from '../Feed/Row2'
import Row1 from '../Feed/Row1' import { Row1 } from '../Feed/Row1'
import Hero from '../Discours/Hero' import Hero from '../Discours/Hero'
import Beside from '../Feed/Beside' import { Beside } from '../Feed/Beside'
import RowShort from '../Feed/RowShort' import RowShort from '../Feed/RowShort'
import Slider from '../Feed/Slider' import Slider from '../Feed/Slider'
import Group from '../Feed/Group' import Group from '../Feed/Group'
@ -23,12 +23,14 @@ import {
import { useTopAuthorsStore } from '../../stores/zine/topAuthors' import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll' import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages'
type HomeProps = { type HomeProps = {
randomTopics: Topic[] randomTopics: Topic[]
recentPublishedArticles: Shout[] recentPublishedArticles: Shout[]
} }
const PRERENDERED_ARTICLES_COUNT = 5
export const PRERENDERED_ARTICLES_COUNT = 5
const CLIENT_LOAD_ARTICLES_COUNT = 29 const CLIENT_LOAD_ARTICLES_COUNT = 29
const LOAD_MORE_PAGE_SIZE = 16 // Row1 + Row3 + Row2 + Beside (3 + 1) + Row1 + Row 2 + Row3 const LOAD_MORE_PAGE_SIZE = 16 // Row1 + Row3 + Row2 + Beside (3 + 1) + Row1 + Row 2 + Row3
@ -46,14 +48,20 @@ export const HomeView = (props: HomeProps) => {
const { randomTopics, topTopics } = useTopicsStore({ const { randomTopics, topTopics } = useTopicsStore({
randomTopics: props.randomTopics randomTopics: props.randomTopics
}) })
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const { topAuthors } = useTopAuthorsStore() const { topAuthors } = useTopAuthorsStore()
onMount(() => { onMount(async () => {
loadTopArticles() loadTopArticles()
loadTopMonthArticles() loadTopMonthArticles()
if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) { 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)
} }
}) })
@ -82,22 +90,23 @@ export const HomeView = (props: HomeProps) => {
const loadMore = async () => { const loadMore = async () => {
saveScrollPosition() 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() restoreScrollPosition()
} }
const pages = createMemo<Shout[][]>(() => { const pages = createMemo<Shout[][]>(() =>
return sortedArticles() splitToPages(
.slice(PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) sortedArticles(),
.reduce((acc, article, index) => { PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT,
if (index % LOAD_MORE_PAGE_SIZE === 0) { LOAD_MORE_PAGE_SIZE
acc.push([]) )
} )
acc[acc.length - 1].push(article)
return acc
}, [] as Shout[][])
})
return ( return (
<Show when={locale() && sortedArticles().length > 0}> <Show when={locale() && sortedArticles().length > 0}>
@ -170,11 +179,13 @@ export const HomeView = (props: HomeProps) => {
)} )}
</For> </For>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container"> <p class="load-more-container">
<button class="button" onClick={loadMore}> <button class="button" onClick={loadMore}>
{t('Load more')} {t('Load more')}
</button> </button>
</p> </p>
</Show> </Show>
</Show>
) )
} }

View File

@ -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 type { Shout, Topic } from '../../graphql/types.gen'
import Row3 from '../Feed/Row3' import { Row3 } from '../Feed/Row3'
import Row2 from '../Feed/Row2' import { Row2 } from '../Feed/Row2'
import Beside from '../Feed/Beside' import { Beside } from '../Feed/Beside'
import { ArticleCard } from '../Feed/Card' import { ArticleCard } from '../Feed/Card'
import '../../styles/Topic.scss' import '../../styles/Topic.scss'
import { FullTopic } from '../Topic/Full' import { FullTopic } from '../Topic/Full'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { useRouter } from '../../stores/router' import { useRouter } from '../../stores/router'
import { useTopicsStore } from '../../stores/zine/topics' 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 { useAuthorsStore } from '../../stores/zine/authors'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
import { splitToPages } from '../../utils/splitToPages'
type TopicsPageSearchParams = { type TopicsPageSearchParams = {
by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented' by: 'comments' | '' | 'recent' | 'viewed' | 'rating' | 'commented'
@ -22,9 +24,14 @@ interface TopicProps {
topicSlug: string topicSlug: string
} }
export const PRERENDERED_ARTICLES_COUNT = 21
const LOAD_MORE_PAGE_SIZE = 9 // Row3 + Row3 + Row3
export const TopicView = (props: TopicProps) => { export const TopicView = (props: TopicProps) => {
const { searchParams, changeSearchParam } = useRouter<TopicsPageSearchParams>() const { searchParams, changeSearchParam } = useRouter<TopicsPageSearchParams>()
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const { sortedArticles } = useArticlesStore({ sortedArticles: props.topicArticles }) const { sortedArticles } = useArticlesStore({ sortedArticles: props.topicArticles })
const { topicEntities } = useTopicsStore({ topics: [props.topic] }) const { topicEntities } = useTopicsStore({ topics: [props.topic] })
@ -32,6 +39,24 @@ export const TopicView = (props: TopicProps) => {
const topic = createMemo(() => topicEntities()[props.topicSlug]) 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 title = createMemo(() => {
const m = searchParams().by const m = searchParams().by
if (m === 'viewed') return t('Top viewed') if (m === 'viewed') return t('Top viewed')
@ -40,6 +65,10 @@ export const TopicView = (props: TopicProps) => {
return t('Top recent') return t('Top recent')
}) })
const pages = createMemo<Shout[][]>(() =>
splitToPages(sortedArticles(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE)
)
return ( return (
<div class="topic-page container"> <div class="topic-page container">
<Show when={topic()}> <Show when={topic()}>
@ -110,6 +139,24 @@ export const TopicView = (props: TopicProps) => {
<Row3 articles={sortedArticles().slice(15, 18)} /> <Row3 articles={sortedArticles().slice(15, 18)} />
<Row3 articles={sortedArticles().slice(18, 21)} /> <Row3 articles={sortedArticles().slice(18, 21)} />
</Show> </Show>
<For each={pages()}>
{(page) => (
<>
<Row3 articles={page.slice(0, 3)} />
<Row3 articles={page.slice(3, 6)} />
<Row3 articles={page.slice(6, 9)} />
</>
)}
</For>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</div> </div>
</Show> </Show>
</div> </div>

View File

@ -8,7 +8,6 @@ export type PageProps = {
authorArticles?: Shout[] authorArticles?: Shout[]
topicArticles?: Shout[] topicArticles?: Shout[]
homeArticles?: Shout[] homeArticles?: Shout[]
feedArticles?: Shout[]
author?: Author author?: Author
allAuthors?: Author[] allAuthors?: Author[]
topic?: Topic topic?: Topic

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
mutation ConfirmEmailMutation($code: String!) { mutation ConfirmEmailMutation($token: String!) {
confirmEmail(code: $code) { confirmEmail(token: $token) {
error error
token token
user { user {

View File

@ -1,7 +1,7 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
query SendLinkQuery($email: String!) { mutation SendLinkQuery($email: String!) {
sendLink(email: $email) { sendLink(email: $email) {
error error
} }

View File

@ -158,5 +158,12 @@
"Hooray! Welcome!": "Ура! Добро пожаловать!", "Hooray! Welcome!": "Ура! Добро пожаловать!",
"You've confirmed email": "Вы подтвердили почту", "You've confirmed email": "Вы подтвердили почту",
"This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы", "This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы",
"enter": "войдите" "enter": "войдите",
"Go to main page": "Перейти на главную",
"Back to main page": "Вернуться на главную",
"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": "Ссылка отправлена, проверьте почту",
"Create post": "Создать публикацию"
} }

View File

@ -3,9 +3,10 @@ import { Root } from '../../../components/Root'
import Zine from '../../../layouts/zine.astro' import Zine from '../../../layouts/zine.astro'
import { apiClient } from '../../../utils/apiClient' import { apiClient } from '../../../utils/apiClient'
import { initRouter } from '../../../stores/router' import { initRouter } from '../../../stores/router'
import { PRERENDERED_ARTICLES_COUNT } from '../../../components/Views/Author'
const slug = Astro.params.slug.toString() 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 author = articles[0].authors.find((a) => a.slug === slug)
const { pathname, search } = Astro.url const { pathname, search } = Astro.url

View File

@ -1,16 +1,12 @@
--- ---
import { Root } from '../../components/Root' import { Root } from '../../components/Root'
import Zine from '../../layouts/zine.astro' import Zine from '../../layouts/zine.astro'
import { apiClient } from '../../utils/apiClient'
import { initRouter } from '../../stores/router' import { initRouter } from '../../stores/router'
const { pathname, search } = Astro.url const { pathname, search } = Astro.url
initRouter(pathname, search) initRouter(pathname, search)
const articles = await apiClient.getRecentArticles({ limit: 50 })
--- ---
<Zine> <Zine>
<Root feedArticles={articles} client:load /> <Root client:load />
</Zine> </Zine>

View File

@ -3,14 +3,14 @@ import Zine from '../layouts/zine.astro'
import { Root } from '../components/Root' import { Root } from '../components/Root'
import { apiClient } from '../utils/apiClient' import { apiClient } from '../utils/apiClient'
import { initRouter } from '../stores/router' import { initRouter } from '../stores/router'
import { PRERENDERED_ARTICLES_COUNT } from '../components/Views/Home'
const randomTopics = await apiClient.getRandomTopics({ amount: 12 }) 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 const { pathname, search } = Astro.url
initRouter(pathname, search) initRouter(pathname, search)
Astro.response.headers.set('Cache-Control', 's-maxage=1, stale-while-revalidate') Astro.response.headers.set('Cache-Control', 's-maxage=1, stale-while-revalidate')
--- ---

View File

@ -2,9 +2,10 @@
import { Root } from '../../components/Root' import { Root } from '../../components/Root'
import Zine from '../../layouts/zine.astro' import Zine from '../../layouts/zine.astro'
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
import { PRERENDERED_ARTICLES_COUNT } from '../../components/Views/Topic'
const slug = Astro.params.slug?.toString() || '' 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) const topic = articles[0].topics.find(({ slug: topicSlug }) => topicSlug === slug)
import { initRouter } from '../../stores/router' import { initRouter } from '../../stores/router'

View File

@ -1,3 +1,3 @@
--- ---
return Astro.redirect('/?modal=auth&mode=welcome') return Astro.redirect('/?modal=auth&mode=register')
--- ---

View File

@ -11,7 +11,6 @@ export const signIn = async (params) => {
setToken(authResult.token) setToken(authResult.token)
console.debug('signed in') console.debug('signed in')
} }
export const signOut = () => { export const signOut = () => {
// TODO: call backend to revoke token // TODO: call backend to revoke token
setSession(null) setSession(null)
@ -54,9 +53,8 @@ export const register = async ({
}) })
} }
export const signSendLink = async (params) => { export const signSendLink = async ({ email }: { email: string }) => {
await apiClient.authSendLink(params) // { email } await apiClient.authSendLink({ email })
resetToken()
} }
export const renewSession = async () => { export const renewSession = async () => {
@ -71,6 +69,12 @@ export const confirmEmail = async (token: string) => {
setSession(authResult) setSession(authResult)
} }
export const confirmEmail = async (token: string) => {
const authResult = await apiClient.confirmEmail({ token })
setToken(authResult.token)
setSession(authResult)
}
export const useAuthStore = () => { export const useAuthStore = () => {
return { session, emailChecks } return { session, emailChecks }
} }

View File

@ -4,7 +4,7 @@ import { useRouter } from './router'
//export const locale = persistentAtom<string>('locale', 'ru') //export const locale = persistentAtom<string>('locale', 'ru')
export const [locale, setLocale] = createSignal('ru') export const [locale, setLocale] = createSignal('ru')
export type ModalType = 'auth' | 'subscribe' | 'feedback' | 'share' | 'thank' | 'donate' export type ModalType = 'auth' | 'subscribe' | 'feedback' | 'thank' | 'donate'
type WarnKind = 'error' | 'warn' | 'info' type WarnKind = 'error' | 'warn' | 'info'
export interface Warning { export interface Warning {
@ -17,7 +17,6 @@ export const MODALS: Record<ModalType, ModalType> = {
auth: 'auth', auth: 'auth',
subscribe: 'subscribe', subscribe: 'subscribe',
feedback: 'feedback', feedback: 'feedback',
share: 'share',
thank: 'thank', thank: 'thank',
donate: 'donate' donate: 'donate'
} }

View File

@ -123,40 +123,109 @@ const addSortedArticles = (articles: Shout[]) => {
setSortedArticles((prevSortedArticles) => [...prevSortedArticles, ...articles]) 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 ({ export const loadRecentArticles = async ({
limit, limit,
offset offset
}: { }: {
limit?: number limit: number
offset?: number offset?: number
}): Promise<void> => { }): Promise<{ hasMore: boolean }> => {
const newArticles = await apiClient.getRecentArticles({ limit, offset }) const newArticles = await apiClient.getRecentArticles({ limit: limit + 1, offset })
const hasMore = newArticles.length === limit + 1
if (hasMore) {
newArticles.splice(-1)
}
addArticles(newArticles) addArticles(newArticles)
addSortedArticles(newArticles) addSortedArticles(newArticles)
return { hasMore }
} }
export const loadPublishedArticles = async ({ export const loadPublishedArticles = async ({
limit, limit,
offset offset = 0
}: { }: {
limit?: number limit: number
offset?: number offset?: number
}): Promise<void> => { }): Promise<{ hasMore: boolean }> => {
const newArticles = await apiClient.getPublishedArticles({ limit, offset }) const newArticles = await apiClient.getPublishedArticles({ limit: limit + 1, offset })
const hasMore = newArticles.length === limit + 1
if (hasMore) {
newArticles.splice(-1)
}
addArticles(newArticles) addArticles(newArticles)
addSortedArticles(newArticles) addSortedArticles(newArticles)
return { hasMore }
} }
export const loadArticlesForAuthors = async ({ authorSlugs }: { authorSlugs: string[] }): Promise<void> => { export const loadAuthorArticles = async ({
const articles = await apiClient.getArticlesForAuthors({ authorSlugs, limit: 50 }) authorSlug,
addArticles(articles) limit,
setSortedArticles(articles) 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)
} }
export const loadArticlesForTopics = async ({ topicSlugs }: { topicSlugs: string[] }): Promise<void> => { addArticles(newArticles)
const articles = await apiClient.getArticlesForTopics({ topicSlugs, limit: 50 }) addSortedArticles(newArticles)
addArticles(articles)
setSortedArticles(articles) return { hasMore }
}
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 = () => { export const resetSortedArticles = () => {

View File

@ -120,11 +120,25 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
img {
transition: filter 0.3s;
}
a { a {
margin-right: 0.3em; margin-right: 0.3em;
&:hover {
img {
filter: invert(1);
}
}
} }
.icon { .icon {
display: inline-block;
line-height: 1;
margin-left: 0.3em;
vertical-align: middle;
width: 1em; width: 1em;
} }
} }

View File

@ -98,14 +98,12 @@ export const apiClient = {
}, },
authSendLink: async ({ email }) => { authSendLink: async ({ email }) => {
// send link with code on email // send link with code on email
const response = await publicGraphQLClient.query(authSendLinkMutation, { email }).toPromise() const response = await publicGraphQLClient.mutation(authSendLinkMutation, { email }).toPromise()
return response.data.reset return response.data.reset
}, },
confirmEmail: async ({ token }: { token: string }) => { confirmEmail: async ({ token }: { token: string }) => {
// confirm email with code from link // confirm email with code from link
const response = await publicGraphQLClient const response = await publicGraphQLClient.mutation(authConfirmEmailMutation, { token }).toPromise()
.mutation(authConfirmEmailMutation, { code: token })
.toPromise()
if (response.error) { if (response.error) {
throw new ApiError('unknown', response.error.message) throw new ApiError('unknown', response.error.message)
@ -183,7 +181,7 @@ export const apiClient = {
}, },
getArticlesForTopics: async ({ getArticlesForTopics: async ({
topicSlugs, topicSlugs,
limit = FEED_SIZE, limit,
offset = 0 offset = 0
}: { }: {
topicSlugs: string[] topicSlugs: string[]
@ -206,7 +204,7 @@ export const apiClient = {
}, },
getArticlesForAuthors: async ({ getArticlesForAuthors: async ({
authorSlugs, authorSlugs,
limit = FEED_SIZE, limit,
offset = 0 offset = 0
}: { }: {
authorSlugs: string[] authorSlugs: string[]

View File

@ -1,4 +1,4 @@
export const isDev = import.meta.env.MODE === 'development' export const isDev = import.meta.env.MODE === 'development'
export const apiBaseUrl = 'https://newapi.discours.io' export const apiBaseUrl = 'https://newapi.discours.io'
// export const apiBaseUrl = 'http://localhost:8000' // export const apiBaseUrl = 'http://localhost:8080'

10
src/utils/splitToPages.ts Normal file
View File

@ -0,0 +1,10 @@
export function splitToPages<T>(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[][])
}