This commit is contained in:
tonyrewin 2022-10-03 16:20:16 +03:00
commit 8b230d2a03
40 changed files with 613 additions and 554 deletions

View File

@ -34,10 +34,8 @@ module.exports = {
varsIgnorePattern: '^log$' varsIgnorePattern: '^log$'
} }
], ],
// TODO: Remove any usage and enable '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'warn',
// TODO: Fix errors and enable this rule
'@typescript-eslint/no-non-null-assertion': 'off',
// solid-js fix // solid-js fix
'import/no-unresolved': [2, { ignore: ['solid-js/'] }] 'import/no-unresolved': [2, { ignore: ['solid-js/'] }]
@ -58,8 +56,8 @@ module.exports = {
// FIXME // FIXME
'solid/reactivity': 'off', 'solid/reactivity': 'off',
// TODO: Should be enabled // Should be enabled
'promise/catch-or-return': 'off', // 'promise/catch-or-return': 'off',
'solid/no-innerhtml': 'off', 'solid/no-innerhtml': 'off',
@ -77,7 +75,8 @@ module.exports = {
eqeqeq: 'error', eqeqeq: 'error',
'no-param-reassign': 'error', 'no-param-reassign': 'error',
'no-nested-ternary': 'error' 'no-nested-ternary': 'error',
'no-shadow': 'error'
}, },
settings: { settings: {
'import/resolver': { 'import/resolver': {

View File

@ -33,6 +33,7 @@
"@nanostores/persistent": "^0.7.0", "@nanostores/persistent": "^0.7.0",
"@nanostores/router": "^0.7.0", "@nanostores/router": "^0.7.0",
"@nanostores/solid": "^0.3.0", "@nanostores/solid": "^0.3.0",
"@solid-primitives/memo": "^1.0.2",
"axios": "^0.27.2", "axios": "^0.27.2",
"google-translate-api-x": "^10.4.1", "google-translate-api-x": "^10.4.1",
"loglevel": "^1.8.0", "loglevel": "^1.8.0",

View File

@ -7,8 +7,7 @@ import { createEffect, createMemo, createSignal, For, onMount, Show } from 'soli
import type { Author, Reaction, Shout } from '../../graphql/types.gen' import type { Author, Reaction, Shout } from '../../graphql/types.gen'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { showModal } from '../../stores/ui' import { showModal } from '../../stores/ui'
import { useStore } from '@nanostores/solid' import { useAuthStore } from '../../stores/auth'
import { session } from '../../stores/auth'
import { incrementView } from '../../stores/zine/articles' import { incrementView } from '../../stores/zine/articles'
import { renderMarkdown } from '@astrojs/markdown-remark' import { renderMarkdown } from '@astrojs/markdown-remark'
import { markdownOptions } from '../../../mdx.config' import { markdownOptions } from '../../../mdx.config'
@ -41,7 +40,7 @@ const formatDate = (date: Date) => {
export const FullArticle = (props: ArticleProps) => { export const FullArticle = (props: ArticleProps) => {
const [body, setBody] = createSignal(props.article.body?.startsWith('<') ? props.article.body : '') const [body, setBody] = createSignal(props.article.body?.startsWith('<') ? props.article.body : '')
const auth = useStore(session) const { session } = useAuthStore()
createEffect(() => { createEffect(() => {
if (body() || !props.article.body) { if (body() || !props.article.body) {
@ -51,7 +50,9 @@ export const FullArticle = (props: ArticleProps) => {
if (props.article.body.startsWith('<')) { if (props.article.body.startsWith('<')) {
setBody(props.article.body) setBody(props.article.body)
} else { } else {
renderMarkdown(props.article.body, markdownOptions).then(({ code }) => setBody(code)) renderMarkdown(props.article.body, markdownOptions)
.then(({ code }) => setBody(code))
.catch(console.error)
} }
}) })
@ -180,12 +181,12 @@ export const FullArticle = (props: ArticleProps) => {
<ArticleComment <ArticleComment
comment={reaction} comment={reaction}
level={getCommentLevel(reaction)} level={getCommentLevel(reaction)}
canEdit={reaction.createdBy?.slug === auth()?.user?.slug} canEdit={reaction.createdBy?.slug === session()?.user?.slug}
/> />
)} )}
</For> </For>
</Show> </Show>
<Show when={!auth()?.user?.slug}> <Show when={!session()?.user?.slug}>
<div class="comment-warning" id="comments"> <div class="comment-warning" id="comments">
{t('To leave a comment you please')} {t('To leave a comment you please')}
<a <a
@ -199,7 +200,7 @@ export const FullArticle = (props: ArticleProps) => {
</a> </a>
</div> </div>
</Show> </Show>
<Show when={auth()?.user?.slug}> <Show when={session()?.user?.slug}>
<textarea class="write-comment" rows="1" placeholder={t('Write comment')} /> <textarea class="write-comment" rows="1" placeholder={t('Write comment')} />
</Show> </Show>
</div> </div>

View File

@ -6,10 +6,9 @@ import './Card.scss'
import { createMemo } from 'solid-js' import { createMemo } from 'solid-js'
import { translit } from '../../utils/ru2en' import { translit } from '../../utils/ru2en'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { session } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { follow, unfollow } from '../../stores/zine/common' import { follow, unfollow } from '../../stores/zine/common'
import { useStore } from '@nanostores/solid'
interface AuthorCardProps { interface AuthorCardProps {
compact?: boolean compact?: boolean
@ -21,14 +20,15 @@ interface AuthorCardProps {
} }
export const AuthorCard = (props: AuthorCardProps) => { export const AuthorCard = (props: AuthorCardProps) => {
const auth = useStore(session) const { session } = useAuthStore()
const subscribed = createMemo( const subscribed = createMemo(
() => () =>
!!auth() !!session()
?.info?.authors?.filter((u) => u === props.author.slug) ?.info?.authors?.filter((u) => u === props.author.slug)
.pop() .pop()
) )
const canFollow = createMemo(() => !props.hideFollow && auth()?.user?.slug !== props.author.slug) const canFollow = createMemo(() => !props.hideFollow && session()?.user?.slug !== props.author.slug)
const bio = () => props.author.bio || t('Our regular contributor') const bio = () => props.author.bio || t('Our regular contributor')
const name = () => { const name = () => {
return props.author.name === 'Дискурс' && locale() !== 'ru' return props.author.name === 'Дискурс' && locale() !== 'ru'

View File

@ -10,7 +10,7 @@ import { Icon } from '../Nav/Icon'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
interface BesideProps { interface BesideProps {
title: string title?: string
values: any[] values: any[]
beside: Shout beside: Shout
wrapper: 'topic' | 'author' | 'article' | 'top-article' wrapper: 'topic' | 'author' | 'article' | 'top-article'

View File

@ -1,7 +1,6 @@
import { useStore } from '@nanostores/solid'
import { For } from 'solid-js' import { For } from 'solid-js'
import type { Author } from '../../graphql/types.gen' import type { Author } from '../../graphql/types.gen'
import { session } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useAuthorsStore } from '../../stores/zine/authors' import { useAuthorsStore } from '../../stores/zine/authors'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { Icon } from '../Nav/Icon' import { Icon } from '../Nav/Icon'
@ -15,13 +14,13 @@ type FeedSidebarProps = {
export const FeedSidebar = (props: FeedSidebarProps) => { export const FeedSidebar = (props: FeedSidebarProps) => {
const { getSeen: seen } = useSeenStore() const { getSeen: seen } = useSeenStore()
const auth = useStore(session) const { session } = useAuthStore()
const { getSortedAuthors: authors } = useAuthorsStore({ authors: props.authors }) const { authorEntities } = useAuthorsStore({ authors: props.authors })
const { getArticlesByTopic } = useArticlesStore() const { articlesByTopic } = useArticlesStore()
const { getTopicEntities } = useTopicsStore() const { topicEntities } = useTopicsStore()
const checkTopicIsSeen = (topicSlug: string) => { const checkTopicIsSeen = (topicSlug: string) => {
return getArticlesByTopic()[topicSlug].every((article) => Boolean(seen()[article.slug])) return articlesByTopic()[topicSlug].every((article) => Boolean(seen()[article.slug]))
} }
const checkAuthorIsSeen = (authorSlug: string) => { const checkAuthorIsSeen = (authorSlug: string) => {
@ -64,22 +63,22 @@ export const FeedSidebar = (props: FeedSidebarProps) => {
</a> </a>
</li> </li>
<For each={auth()?.info?.authors}> <For each={session()?.info?.authors}>
{(authorSlug) => ( {(authorSlug) => (
<li> <li>
<a href={`/author/${authorSlug}`} classList={{ unread: checkAuthorIsSeen(authorSlug) }}> <a href={`/author/${authorSlug}`} classList={{ unread: checkAuthorIsSeen(authorSlug) }}>
<small>@{authorSlug}</small> <small>@{authorSlug}</small>
{authors()[authorSlug].name} {authorEntities()[authorSlug].name}
</a> </a>
</li> </li>
)} )}
</For> </For>
<For each={auth()?.info?.topics}> <For each={session()?.info?.topics}>
{(topicSlug) => ( {(topicSlug) => (
<li> <li>
<a href={`/author/${topicSlug}`} classList={{ unread: checkTopicIsSeen(topicSlug) }}> <a href={`/author/${topicSlug}`} classList={{ unread: checkTopicIsSeen(topicSlug) }}>
{getTopicEntities()[topicSlug]?.title} {topicEntities()[topicSlug]?.title}
</a> </a>
</li> </li>
)} )}

View File

@ -3,21 +3,23 @@ import { Header } from '../Nav/Header'
import { Footer } from '../Discours/Footer' import { Footer } from '../Discours/Footer'
import '../../styles/app.scss' import '../../styles/app.scss'
import { Show } from 'solid-js'
type Props = { type MainLayoutProps = {
headerTitle?: string headerTitle?: string
children: JSX.Element children: JSX.Element
isHeaderFixed?: boolean isHeaderFixed?: boolean
hideFooter?: boolean
} }
export const MainLayout = (props: Props) => { export const MainLayout = (props: MainLayoutProps) => {
const isHeaderFixed = props.isHeaderFixed !== undefined ? props.isHeaderFixed : true
return ( return (
<> <>
<Header title={props.headerTitle} isHeaderFixed={isHeaderFixed} /> <Header title={props.headerTitle} isHeaderFixed={props.isHeaderFixed === true} />
<main class="main-content">{props.children}</main> <main class="main-content">{props.children}</main>
<Footer /> <Show when={props.hideFooter !== true}>
<Footer />
</Show>
</> </>
) )
} }

View File

@ -5,11 +5,10 @@ import './AuthModal.scss'
import { Form } from 'solid-js-form' import { Form } from 'solid-js-form'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { hideModal, useModalStore } from '../../stores/ui' import { hideModal, useModalStore } from '../../stores/ui'
import { useStore } from '@nanostores/solid' import { useAuthStore, signIn, register } from '../../stores/auth'
import { session as sessionstore, signIn } from '../../stores/auth'
import { apiClient } from '../../utils/apiClient'
import { useValidator } from '../../utils/validators' import { useValidator } from '../../utils/validators'
import { baseUrl } from '../../graphql/publicGraphQLClient' import { baseUrl } from '../../graphql/publicGraphQLClient'
import { ApiError } from '../../utils/apiClient'
type AuthMode = 'sign-in' | 'sign-up' | 'forget' | 'reset' | 'resend' | 'password' type AuthMode = 'sign-in' | 'sign-up' | 'forget' | 'reset' | 'resend' | 'password'
@ -23,7 +22,7 @@ const statuses: { [key: string]: string } = {
const titles = { const titles = {
'sign-up': t('Create account'), 'sign-up': t('Create account'),
'sign-in': t('Enter the Discours'), 'sign-in': t('Enter the Discours'),
forget: t('Forget password?'), forget: t('Forgot password?'),
reset: t('Please, confirm your email to finish'), reset: t('Please, confirm your email to finish'),
resend: t('Resend code'), resend: t('Resend code'),
password: t('Enter your new password') password: t('Enter your new password')
@ -34,10 +33,10 @@ const titles = {
// FIXME !!! // FIXME !!!
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
export default (props: { code?: string; mode?: string }) => { export default (props: { code?: string; mode?: string }) => {
const session = useStore(sessionstore) const { session } = useAuthStore()
const [handshaking] = createSignal(false) const [handshaking] = createSignal(false)
const { getModal } = useModalStore() const { getModal } = useModalStore()
const [authError, setError] = createSignal('') const [authError, setError] = createSignal<string>('')
const [mode, setMode] = createSignal<AuthMode>('sign-in') const [mode, setMode] = createSignal<AuthMode>('sign-in')
const [validation, setValidation] = createSignal({}) const [validation, setValidation] = createSignal({})
const [initial, setInitial] = createSignal({}) const [initial, setInitial] = createSignal({})
@ -46,7 +45,7 @@ export default (props: { code?: string; mode?: string }) => {
let passElement: HTMLInputElement | undefined let passElement: HTMLInputElement | undefined
let codeElement: HTMLInputElement | undefined let codeElement: HTMLInputElement | undefined
// 3rd party providier auth handler // 3rd party provider auth handler
const oauth = (provider: string): void => { const oauth = (provider: string): void => {
const popup = window.open(`${baseUrl}/oauth/${provider}`, provider, 'width=740, height=420') const popup = window.open(`${baseUrl}/oauth/${provider}`, provider, 'width=740, height=420')
popup?.focus() popup?.focus()
@ -85,28 +84,50 @@ export default (props: { code?: string; mode?: string }) => {
setValidation(vs) setValidation(vs)
setInitial(ini) setInitial(ini)
} }
onMount(setupValidators) onMount(setupValidators)
const resetError = () => {
setError('')
}
const changeMode = (newMode: AuthMode) => {
setMode(newMode)
resetError()
}
// local auth handler // local auth handler
const localAuth = async () => { const localAuth = async () => {
console.log('[auth] native account processing') console.log('[auth] native account processing')
switch (mode()) { switch (mode()) {
case 'sign-in': case 'sign-in':
signIn({ email: emailElement?.value, password: passElement?.value }) 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 break
case 'sign-up': case 'sign-up':
if (pass2Element?.value !== passElement?.value) { if (pass2Element?.value !== passElement?.value) {
setError(t('Passwords are not equal')) setError(t('Passwords are not equal'))
} else { } else {
// FIXME use store actions await register({
const r = await apiClient.authRegiser({
email: emailElement?.value, email: emailElement?.value,
password: passElement?.value password: passElement?.value
}) })
if (r) {
console.debug('[auth] session update', r)
sessionstore.set(r)
}
} }
break break
case 'reset': case 'reset':
@ -130,6 +151,7 @@ export default (props: { code?: string; mode?: string }) => {
} }
} }
// FIXME move to handlers
createEffect(() => { createEffect(() => {
if (session()?.user?.slug && getModal() === 'auth') { if (session()?.user?.slug && getModal() === 'auth') {
// hiding itself if finished // hiding itself if finished
@ -141,7 +163,8 @@ export default (props: { code?: string; mode?: string }) => {
} else { } else {
console.log('[auth] session', session()) console.log('[auth] session', session())
} }
}, [session()]) })
return ( return (
<div class="row view" classList={{ 'view--sign-up': mode() === 'sign-up' }}> <div class="row view" classList={{ 'view--sign-up': mode() === 'sign-up' }}>
<div class="col-sm-6 d-md-none auth-image"> <div class="col-sm-6 d-md-none auth-image">
@ -174,7 +197,6 @@ export default (props: { code?: string; mode?: string }) => {
> >
<div class="auth__inner"> <div class="auth__inner">
<h4>{titles[mode()]}</h4> <h4>{titles[mode()]}</h4>
<div class={`auth-subtitle ${mode() === 'forget' ? '' : 'hidden'}`}> <div class={`auth-subtitle ${mode() === 'forget' ? '' : 'hidden'}`}>
<Show <Show
when={mode() === 'forget'} when={mode() === 'forget'}
@ -187,7 +209,6 @@ export default (props: { code?: string; mode?: string }) => {
{t('Everything is ok, please give us your email address')} {t('Everything is ok, please give us your email address')}
</Show> </Show>
</div> </div>
<Show when={authError()}> <Show when={authError()}>
<div class={`auth-info`}> <div class={`auth-info`}>
<ul> <ul>
@ -195,7 +216,6 @@ export default (props: { code?: string; mode?: string }) => {
</ul> </ul>
</div> </div>
</Show> </Show>
{/*FIXME*/} {/*FIXME*/}
{/*<Show when={false && mode() === 'sign-up'}>*/} {/*<Show when={false && mode() === 'sign-up'}>*/}
{/* <div class='pretty-form__item'>*/} {/* <div class='pretty-form__item'>*/}
@ -223,7 +243,6 @@ export default (props: { code?: string; mode?: string }) => {
<label for="email">{t('Email')}</label> <label for="email">{t('Email')}</label>
</div> </div>
</Show> </Show>
<Show when={mode() === 'sign-up' || mode() === 'sign-in' || mode() === 'password'}> <Show when={mode() === 'sign-up' || mode() === 'sign-in' || mode() === 'password'}>
<div class="pretty-form__item"> <div class="pretty-form__item">
<input <input
@ -250,7 +269,6 @@ export default (props: { code?: string; mode?: string }) => {
<label for="resetcode">{t('Reset code')}</label> <label for="resetcode">{t('Reset code')}</label>
</div> </div>
</Show> </Show>
<Show when={mode() === 'password' || mode() === 'sign-up'}> <Show when={mode() === 'password' || mode() === 'sign-up'}>
<div class="pretty-form__item"> <div class="pretty-form__item">
<input <input
@ -269,15 +287,19 @@ export default (props: { code?: string; mode?: string }) => {
{handshaking() ? '...' : titles[mode()]} {handshaking() ? '...' : titles[mode()]}
</button> </button>
</div> </div>
<Show when={mode() === 'sign-in'}> <Show when={mode() === 'sign-in'}>
<div class="auth-actions"> <div class="auth-actions">
<a href={''} onClick={() => setMode('forget')}> <a
{t('Forget password?')} href="#"
onClick={(ev) => {
ev.preventDefault()
changeMode('forget')
}}
>
{t('Forgot password?')}
</a> </a>
</div> </div>
</Show> </Show>
<Show when={mode() === 'sign-in' || mode() === 'sign-up'}> <Show when={mode() === 'sign-in' || mode() === 'sign-up'}>
<div class="social-provider"> <div class="social-provider">
<div class="providers-text">{t('Or continue with social network')}</div> <div class="providers-text">{t('Or continue with social network')}</div>
@ -297,25 +319,24 @@ export default (props: { code?: string; mode?: string }) => {
</div> </div>
</div> </div>
</Show> </Show>
<div class="auth-control"> <div class="auth-control">
<div classList={{ show: mode() === 'sign-up' }}> <div classList={{ show: mode() === 'sign-up' }}>
<span class="auth-link" onClick={() => setMode('sign-in')}> <span class="auth-link" onClick={() => changeMode('sign-in')}>
{t('I have an account')} {t('I have an account')}
</span> </span>
</div> </div>
<div classList={{ show: mode() === 'sign-in' }}> <div classList={{ show: mode() === 'sign-in' }}>
<span class="auth-link" onClick={() => setMode('sign-up')}> <span class="auth-link" onClick={() => changeMode('sign-up')}>
{t('I have no account yet')} {t('I have no account yet')}
</span> </span>
</div> </div>
<div classList={{ show: mode() === 'forget' }}> <div classList={{ show: mode() === 'forget' }}>
<span class="auth-link" onClick={() => setMode('sign-in')}> <span class="auth-link" onClick={() => changeMode('sign-in')}>
{t('I know the password')} {t('I know the password')}
</span> </span>
</div> </div>
<div classList={{ show: mode() === 'reset' }}> <div classList={{ show: mode() === 'reset' }}>
<span class="auth-link" onClick={() => setMode('resend')}> <span class="auth-link" onClick={() => changeMode('resend')}>
{t('Resend code')} {t('Resend code')}
</span> </span>
</div> </div>

View File

@ -6,8 +6,7 @@ import { Modal } from './Modal'
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 { useStore } from '@nanostores/solid' import { useAuthStore } from '../../stores/auth'
import { session as ssession } from '../../stores/auth'
import { handleClientRouteLinkClick, router, Routes, useRouter } from '../../stores/router' import { handleClientRouteLinkClick, router, Routes, useRouter } from '../../stores/router'
import './Header.scss' import './Header.scss'
import { getPagePath } from '@nanostores/router' import { getPagePath } from '@nanostores/router'
@ -38,7 +37,7 @@ export const Header = (props: Props) => {
const [visibleWarnings, setVisibleWarnings] = createSignal(false) const [visibleWarnings, setVisibleWarnings] = createSignal(false)
// stores // stores
const { getWarnings } = useWarningsStore() const { getWarnings } = useWarningsStore()
const session = useStore(ssession) const { session } = useAuthStore()
const { getModal } = useModalStore() const { getModal } = useModalStore()
const { getPage } = useRouter() const { getPage } = useRouter()

View File

@ -2,12 +2,11 @@ import type { Author } from '../../graphql/types.gen'
import Userpic from '../Author/Userpic' import Userpic from '../Author/Userpic'
import { Icon } from './Icon' import { Icon } from './Icon'
import './Private.scss' import './Private.scss'
import { session as sesstore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useStore } from '@nanostores/solid'
import { useRouter } from '../../stores/router' import { useRouter } from '../../stores/router'
export default () => { export default () => {
const session = useStore(sesstore) const { session } = useAuthStore()
const { getPage } = useRouter() const { getPage } = useRouter()
return ( return (

View File

@ -3,8 +3,7 @@ import { AuthorCard } from '../Author/Card'
import type { Author } from '../../graphql/types.gen' import type { Author } from '../../graphql/types.gen'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { hideModal } from '../../stores/ui' import { hideModal } from '../../stores/ui'
import { session, signOut } from '../../stores/auth' import { useAuthStore, signOut } from '../../stores/auth'
import { useStore } from '@nanostores/solid'
import { createMemo } from 'solid-js' import { createMemo } from 'solid-js'
const quit = () => { const quit = () => {
@ -13,29 +12,32 @@ const quit = () => {
} }
export default () => { export default () => {
const auth = useStore(session) const { session } = useAuthStore()
const author = createMemo(() => {
const a = { const author = createMemo<Author>(() => {
const a: Author = {
name: 'anonymous', name: 'anonymous',
userpic: '', userpic: '',
slug: '' slug: ''
} as Author }
if (auth()?.user?.slug) {
const u = auth().user if (session()?.user?.slug) {
const u = session().user
a.name = u.name a.name = u.name
a.slug = u.slug a.slug = u.slug
a.userpic = u.userpic a.userpic = u.userpic
} }
return a return a
}) })
// TODO: ProfileModal markup and styles // TODO: ProfileModal markup and styles
return ( return (
<div class="row view profile"> <div class="row view profile">
<h1>{auth()?.user?.username}</h1> <h1>{session()?.user?.username}</h1>
<AuthorCard author={author()} /> <AuthorCard author={author()} />
<div class="profile-bio">{auth()?.user?.bio || ''}</div> <div class="profile-bio">{session()?.user?.bio || ''}</div>
<For each={auth()?.user?.links || []}>{(l: string) => <a href={l}>{l}</a>}</For> <For each={session()?.user?.links || []}>{(l: string) => <a href={l}>{l}</a>}</For>
<span onClick={quit}>{t('Quit')}</span> <span onClick={quit}>{t('Quit')}</span>
</div> </div>
) )

View File

@ -42,7 +42,6 @@
} }
a:hover { a:hover {
font-weight: 500;
color: white; color: white;
} }
} }

View File

@ -6,7 +6,8 @@ import { t } from '../../utils/intl'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
export const NavTopics = (props: { topics: Topic[] }) => { export const NavTopics = (props: { topics: Topic[] }) => {
const tag = (t: Topic) => (/[ЁА-яё]/.test(t.title || '') && locale() !== 'ru' ? t.slug : t.title) const tag = (topic: Topic) =>
/[ЁА-яё]/.test(topic.title || '') && locale() !== 'ru' ? topic.slug : topic.title
// TODO: something about subtopics // TODO: something about subtopics
return ( return (
@ -14,10 +15,10 @@ export const NavTopics = (props: { topics: Topic[] }) => {
<ul class="topics"> <ul class="topics">
<Show when={props.topics.length > 0}> <Show when={props.topics.length > 0}>
<For each={props.topics}> <For each={props.topics}>
{(t: Topic) => ( {(topic) => (
<li class="item"> <li class="item">
<a href={`/topic/${t.slug}`}> <a href={`/topic/${topic.slug}`}>
<span>#{tag(t)}</span> <span>#{tag(topic)}</span>
</a> </a>
</li> </li>
)} )}

View File

@ -18,17 +18,17 @@ export const ArticlePage = (props: PageProps) => {
throw new Error('ts guard') throw new Error('ts guard')
} }
const { getArticleEntities } = useArticlesStore({ const { articleEntities } = useArticlesStore({
sortedArticles sortedArticles
}) })
const article = createMemo<Shout>(() => getArticleEntities()[page.params.slug]) const article = createMemo<Shout>(() => articleEntities()[page.params.slug])
onMount(() => { onMount(() => {
const slug = page.params.slug const slug = page.params.slug
const article = getArticleEntities()[slug] const articleValue = articleEntities()[slug]
if (!article || !article.body) { if (!articleValue || !articleValue.body) {
loadArticle({ slug }) loadArticle({ slug })
} }
}) })

View File

@ -3,7 +3,7 @@ import { MainLayout } from '../Layouts/MainLayout'
export const FourOuFourPage = () => { export const FourOuFourPage = () => {
return ( return (
<MainLayout isHeaderFixed={false}> <MainLayout isHeaderFixed={false} hideFooter={true}>
<FourOuFourView /> <FourOuFourView />
</MainLayout> </MainLayout>
) )

View File

@ -2,43 +2,49 @@
// import 'solid-devtools' // import 'solid-devtools'
import { setLocale } from '../stores/ui' import { setLocale } from '../stores/ui'
import { Component, createEffect, createMemo, lazy } from 'solid-js' import { Component, createEffect, createMemo } 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 { getLogger } from '../utils/logger'
import type { PageProps } from './types' import type { PageProps } from './types'
// do not remove import { HomePage } from './Pages/HomePage'
// for debugging, to disable lazy loading import { AllTopicsPage } from './Pages/AllTopicsPage'
// import HomePage from './Pages/HomePage' import { TopicPage } from './Pages/TopicPage'
// import AllTopicsPage from './Pages/AllTopicsPage' import { AllAuthorsPage } from './Pages/AllAuthorsPage'
// import TopicPage from './Pages/TopicPage' import { AuthorPage } from './Pages/AuthorPage'
// import AllAuthorsPage from './Pages/AllAuthorsPage' import { FeedPage } from './Pages/FeedPage'
// import AuthorPage from './Pages/AuthorPage' import { ArticlePage } from './Pages/ArticlePage'
// import FeedPage from './Pages/FeedPage' import { SearchPage } from './Pages/SearchPage'
// import ArticlePage from './Pages/ArticlePage' import { FourOuFourPage } from './Pages/FourOuFourPage'
// import SearchPage from './Pages/SearchPage' import { DogmaPage } from './Pages/about/DogmaPage'
// import FourOuFourPage from './Pages/FourOuFourPage' import { GuidePage } from './Pages/about/GuidePage'
import { HelpPage } from './Pages/about/HelpPage'
import { ManifestPage } from './Pages/about/ManifestPage'
import { PartnersPage } from './Pages/about/PartnersPage'
import { ProjectsPage } from './Pages/about/ProjectsPage'
import { TermsOfUsePage } from './Pages/about/TermsOfUsePage'
import { ThanksPage } from './Pages/about/ThanksPage'
const HomePage = lazy(() => import('./Pages/HomePage')) // TODO: lazy load
const AllTopicsPage = lazy(() => import('./Pages/AllTopicsPage')) // const HomePage = lazy(() => import('./Pages/HomePage'))
const TopicPage = lazy(() => import('./Pages/TopicPage')) // const AllTopicsPage = lazy(() => import('./Pages/AllTopicsPage'))
const AllAuthorsPage = lazy(() => import('./Pages/AllAuthorsPage')) // const TopicPage = lazy(() => import('./Pages/TopicPage'))
const AuthorPage = lazy(() => import('./Pages/AuthorPage')) // const AllAuthorsPage = lazy(() => import('./Pages/AllAuthorsPage'))
const FeedPage = lazy(() => import('./Pages/FeedPage')) // const AuthorPage = lazy(() => import('./Pages/AuthorPage'))
const ArticlePage = lazy(() => import('./Pages/ArticlePage')) // const FeedPage = lazy(() => import('./Pages/FeedPage'))
const SearchPage = lazy(() => import('./Pages/SearchPage')) // const ArticlePage = lazy(() => import('./Pages/ArticlePage'))
const FourOuFourPage = lazy(() => import('./Pages/FourOuFourPage')) // const SearchPage = lazy(() => import('./Pages/SearchPage'))
const DogmaPage = lazy(() => import('./Pages/about/DogmaPage')) // const FourOuFourPage = lazy(() => import('./Pages/FourOuFourPage'))
// const DogmaPage = lazy(() => import('./Pages/about/DogmaPage'))
const GuidePage = lazy(() => import('./Pages/about/GuidePage')) // const GuidePage = lazy(() => import('./Pages/about/GuidePage'))
const HelpPage = lazy(() => import('./Pages/about/HelpPage')) // const HelpPage = lazy(() => import('./Pages/about/HelpPage'))
const ManifestPage = lazy(() => import('./Pages/about/ManifestPage')) // const ManifestPage = lazy(() => import('./Pages/about/ManifestPage'))
const PartnersPage = lazy(() => import('./Pages/about/PartnersPage')) // const PartnersPage = lazy(() => import('./Pages/about/PartnersPage'))
const ProjectsPage = lazy(() => import('./Pages/about/ProjectsPage')) // const ProjectsPage = lazy(() => import('./Pages/about/ProjectsPage'))
const TermsOfUsePage = lazy(() => import('./Pages/about/TermsOfUsePage')) // const TermsOfUsePage = lazy(() => import('./Pages/about/TermsOfUsePage'))
const ThanksPage = lazy(() => import('./Pages/about/ThanksPage')) // const ThanksPage = lazy(() => import('./Pages/about/ThanksPage'))
const log = getLogger('root') const log = getLogger('root')

View File

@ -6,9 +6,11 @@ import type { Topic } from '../../graphql/types.gen'
import { FollowingEntity } from '../../graphql/types.gen' import { FollowingEntity } from '../../graphql/types.gen'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { useStore } from '@nanostores/solid' import { useAuthStore } from '../../stores/auth'
import { session } 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
@ -19,63 +21,73 @@ interface TopicProps {
} }
export const TopicCard = (props: TopicProps) => { export const TopicCard = (props: TopicProps) => {
const auth = useStore(session) const { session } = useAuthStore()
const topic = createMemo(() => props.topic)
const subscribed = createMemo(() => { const subscribed = createMemo(() => {
return Boolean(auth()?.user?.slug) && topic().slug ? topic().slug in auth().info.topics : false if (!session()?.user?.slug || !session()?.info?.topics) {
return false
}
return props.topic.slug in session().info.topics
}) })
// FIXME use store actions // FIXME use store actions
const subscribe = async (really = true) => { const subscribe = async (really = true) => {
if (really) { if (really) {
follow({ what: FollowingEntity.Topic, slug: topic().slug }) follow({ what: FollowingEntity.Topic, slug: props.topic.slug })
} else { } else {
unfollow({ what: FollowingEntity.Topic, slug: topic().slug }) unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug })
} }
} }
return ( return (
<div class="topic" classList={{ row: !props.compact && !props.subscribeButtonBottom }}> <div class="topic" classList={{ row: !props.compact && !props.subscribeButtonBottom }}>
<div classList={{ 'col-md-7': !props.compact && !props.subscribeButtonBottom }}> <div classList={{ 'col-md-7': !props.compact && !props.subscribeButtonBottom }}>
<Show when={topic().title}> <Show when={props.topic.title}>
<div class="topic-title"> <div class="topic-title">
<a href={`/topic/${topic().slug}`}>{capitalize(topic().title || '')}</a> <a href={`/topic/${props.topic.slug}`}>{capitalize(props.topic.title || '')}</a>
</div> </div>
</Show> </Show>
<Show when={topic().pic}> <Show when={props.topic.pic}>
<div class="topic__avatar"> <div class="topic__avatar">
<a href={topic().slug}> <a href={props.topic.slug}>
<img src={topic().pic} alt={topic().title} /> <img src={props.topic.pic} alt={props.topic.title} />
</a> </a>
</div> </div>
</Show> </Show>
<Show when={!props.compact && topic()?.body}> <Show when={!props.compact && props.topic?.body}>
<div class="topic-description" classList={{ 'topic-description--short': props.shortDescription }}> <div class="topic-description" classList={{ 'topic-description--short': props.shortDescription }}>
{topic().body} {props.topic.body}
</div> </div>
</Show> </Show>
<Show when={topic()?.stat}> <Show when={props.topic?.stat}>
<div class="topic-details"> <div class="topic-details">
<Show when={!props.compact}> <Show when={!props.compact}>
<span class="topic-details__item" classList={{ compact: props.compact }}> <span class="topic-details__item" classList={{ compact: props.compact }}>
{topic().stat?.shouts + {props.topic.stat?.shouts +
' ' + ' ' +
t('post') + t('post') +
plural(topic().stat?.shouts || 0, locale() === 'ru' ? ['ов', '', 'а'] : ['s', '', 's'])} plural(
props.topic.stat?.shouts || 0,
locale() === 'ru' ? ['ов', '', 'а'] : ['s', '', 's']
)}
</span> </span>
<span class="topic-details__item" classList={{ compact: props.compact }}> <span class="topic-details__item" classList={{ compact: props.compact }}>
{topic().stat?.authors + {props.topic.stat?.authors +
' ' + ' ' +
t('author') + t('author') +
plural(topic().stat?.authors || 0, locale() === 'ru' ? ['ов', '', 'а'] : ['s', '', 's'])} plural(
props.topic.stat?.authors || 0,
locale() === 'ru' ? ['ов', '', 'а'] : ['s', '', 's']
)}
</span> </span>
<span class="topic-details__item" classList={{ compact: props.compact }}> <span class="topic-details__item" classList={{ compact: props.compact }}>
{topic().stat?.followers + {props.topic.stat?.followers +
' ' + ' ' +
t('follower') + t('follower') +
plural( plural(
topic().stat?.followers || 0, props.topic.stat?.followers || 0,
locale() === 'ru' ? ['ов', '', 'а'] : ['s', '', 's'] locale() === 'ru' ? ['ов', '', 'а'] : ['s', '', 's']
)} )}
</span> </span>

View File

@ -3,8 +3,7 @@ import { Show } from 'solid-js/web'
import type { Topic } from '../../graphql/types.gen' import type { Topic } from '../../graphql/types.gen'
import { FollowingEntity } from '../../graphql/types.gen' import { FollowingEntity } from '../../graphql/types.gen'
import './Full.scss' import './Full.scss'
import { session } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useStore } from '@nanostores/solid'
import { follow, unfollow } from '../../stores/zine/common' import { follow, unfollow } from '../../stores/zine/common'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
@ -13,8 +12,9 @@ type Props = {
} }
export const FullTopic = (props: Props) => { export const FullTopic = (props: Props) => {
const auth = useStore(session) const { session } = useAuthStore()
const subscribed = createMemo(() => auth()?.info?.topics?.includes(props.topic?.slug))
const subscribed = createMemo(() => session()?.info?.topics?.includes(props.topic?.slug))
return ( return (
<div class="topic-full container"> <div class="topic-full container">
<div class="row"> <div class="row">

View File

@ -7,8 +7,7 @@ import { Icon } from '../Nav/Icon'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { useAuthorsStore } from '../../stores/zine/authors' import { useAuthorsStore } from '../../stores/zine/authors'
import { handleClientRouteLinkClick, useRouter } from '../../stores/router' import { handleClientRouteLinkClick, useRouter } from '../../stores/router'
import { session } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useStore } from '@nanostores/solid'
import '../../styles/AllTopics.scss' import '../../styles/AllTopics.scss'
type AllAuthorsPageSearchParams = { type AllAuthorsPageSearchParams = {
@ -20,19 +19,21 @@ type Props = {
} }
export const AllAuthorsView = (props: Props) => { export const AllAuthorsView = (props: Props) => {
const { getSortedAuthors: authorslist } = useAuthorsStore({ authors: props.authors }) const { sortedAuthors: authorList } = useAuthorsStore({ authors: props.authors })
const [sortedAuthors, setSortedAuthors] = createSignal<Author[]>([]) const [sortedAuthors, setSortedAuthors] = createSignal<Author[]>([])
const [sortedKeys, setSortedKeys] = createSignal<string[]>([]) const [sortedKeys, setSortedKeys] = createSignal<string[]>([])
const [abc, setAbc] = createSignal([]) const [abc, setAbc] = createSignal([])
const auth = useStore(session)
const subscribed = (s) => Boolean(auth()?.info?.authors && auth()?.info?.authors?.includes(s || '')) const { session } = useAuthStore()
const subscribed = (s) => Boolean(session()?.info?.authors && session()?.info?.authors?.includes(s || ''))
const { getSearchParams } = useRouter<AllAuthorsPageSearchParams>() const { getSearchParams } = useRouter<AllAuthorsPageSearchParams>()
createEffect(() => { createEffect(() => {
if ((!getSearchParams().by || getSearchParams().by === 'name') && abc().length === 0) { if ((!getSearchParams().by || getSearchParams().by === 'name') && abc().length === 0) {
console.log('[authors] default grouping by abc') console.log('[authors] default grouping by abc')
const grouped = { ...groupByName(authorslist()) } const grouped = { ...groupByName(authorList()) }
grouped['A-Z'] = sortBy(grouped['A-Z'], byFirstChar) grouped['A-Z'] = sortBy(grouped['A-Z'], byFirstChar)
setAbc(grouped) setAbc(grouped)
const keys = Object.keys(abc) const keys = Object.keys(abc)
@ -40,13 +41,13 @@ export const AllAuthorsView = (props: Props) => {
setSortedKeys(keys as string[]) setSortedKeys(keys as string[])
} else { } else {
console.log('[authors] sorting by ' + getSearchParams().by) console.log('[authors] sorting by ' + getSearchParams().by)
setSortedAuthors(sortBy(authorslist(), getSearchParams().by)) setSortedAuthors(sortBy(authorList(), getSearchParams().by))
} }
}, [authorslist(), getSearchParams().by]) }, [authorList(), getSearchParams().by])
return ( return (
<div class="all-topics-page"> <div class="all-topics-page">
<Show when={sortedAuthors()}> <Show when={sortedAuthors().length > 0}>
<div class="wide-container"> <div class="wide-container">
<div class="shift-content"> <div class="shift-content">
<div class="row"> <div class="row">

View File

@ -2,39 +2,42 @@ import { createEffect, For, Show } from 'solid-js'
import type { Topic } from '../../graphql/types.gen' import type { Topic } from '../../graphql/types.gen'
import { Icon } from '../Nav/Icon' import { Icon } from '../Nav/Icon'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { setSortAllTopicsBy, useTopicsStore } from '../../stores/zine/topics' import { setSortAllBy as setSortAllTopicsBy, useTopicsStore } from '../../stores/zine/topics'
import { handleClientRouteLinkClick, useRouter } from '../../stores/router' import { handleClientRouteLinkClick, useRouter } from '../../stores/router'
import { TopicCard } from '../Topic/Card' import { TopicCard } from '../Topic/Card'
import { session } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useStore } from '@nanostores/solid'
import '../../styles/AllTopics.scss' import '../../styles/AllTopics.scss'
import { getLogger } from '../../utils/logger'
const log = getLogger('AllTopicsView')
type AllTopicsPageSearchParams = { type AllTopicsPageSearchParams = {
by: 'shouts' | 'authors' | 'title' | '' by: 'shouts' | 'authors' | 'title' | ''
} }
type Props = { type AllTopicsViewProps = {
topics: Topic[] topics: Topic[]
} }
export const AllTopicsView = (props: Props) => { export const AllTopicsView = (props: AllTopicsViewProps) => {
const { getSearchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>() const { getSearchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>()
const { getSortedTopics } = useTopicsStore({ const { sortedTopics } = useTopicsStore({
topics: props.topics, topics: props.topics,
sortBy: getSearchParams().by || 'shouts' sortBy: getSearchParams().by || 'shouts'
}) })
const auth = useStore(session)
const { session } = useAuthStore()
createEffect(() => { createEffect(() => {
setSortAllTopicsBy(getSearchParams().by || 'shouts') setSortAllTopicsBy(getSearchParams().by || 'shouts')
}) })
const subscribed = (s) => Boolean(auth()?.info?.topics && auth()?.info?.topics?.includes(s || '')) const subscribed = (s) => Boolean(session()?.info?.topics && session()?.info?.topics?.includes(s || ''))
return ( return (
<div class="all-topics-page"> <div class="all-topics-page">
<Show when={getSortedTopics().length > 0}> <Show when={sortedTopics().length > 0}>
<div class="wide-container"> <div class="wide-container">
<div class="shift-content"> <div class="shift-content">
<div class="row"> <div class="row">
@ -78,7 +81,7 @@ export const AllTopicsView = (props: Props) => {
</ul> </ul>
<div class="stats"> <div class="stats">
<For each={getSortedTopics()}> <For each={sortedTopics()}>
{(topic) => ( {(topic) => (
<TopicCard topic={topic} compact={false} subscribed={subscribed(topic.slug)} /> <TopicCard topic={topic} compact={false} subscribed={subscribed(topic.slug)} />
)} )}

View File

@ -25,12 +25,12 @@ type AuthorPageSearchParams = {
} }
export const AuthorView = (props: AuthorProps) => { export const AuthorView = (props: AuthorProps) => {
const { getSortedArticles: articles } = useArticlesStore({ const { sortedArticles } = useArticlesStore({
sortedArticles: props.authorArticles sortedArticles: props.authorArticles
}) })
const { getAuthorEntities: authors } = useAuthorsStore({ authors: [props.author] }) const { authorEntities } = useAuthorsStore({ authors: [props.author] })
const author = createMemo(() => authors()[props.author.slug]) const author = createMemo(() => authorEntities()[props.author.slug])
const { getSearchParams, changeSearchParam } = useRouter<AuthorPageSearchParams>() const { getSearchParams, changeSearchParam } = useRouter<AuthorPageSearchParams>()
//const slug = createMemo(() => author().slug) //const slug = createMemo(() => author().slug)
@ -90,7 +90,7 @@ export const AuthorView = (props: AuthorProps) => {
<div class="floor"> <div class="floor">
<h3 class="col-12">{title()}</h3> <h3 class="col-12">{title()}</h3>
<div class="row"> <div class="row">
<Show when={articles()?.length > 0}> <Show when={sortedArticles().length > 0}>
{/*FIXME*/} {/*FIXME*/}
{/*<Beside*/} {/*<Beside*/}
{/* title={t('Topics which supported by author')}*/} {/* title={t('Topics which supported by author')}*/}
@ -99,10 +99,10 @@ export const AuthorView = (props: AuthorProps) => {
{/* wrapper={'topic'}*/} {/* wrapper={'topic'}*/}
{/* topicShortDescription={true}*/} {/* topicShortDescription={true}*/}
{/*/>*/} {/*/>*/}
<Row3 articles={articles().slice(1, 4)} /> <Row3 articles={sortedArticles().slice(1, 4)} />
<Row2 articles={articles().slice(4, 6)} /> <Row2 articles={sortedArticles().slice(4, 6)} />
<Row3 articles={articles().slice(10, 13)} /> <Row3 articles={sortedArticles().slice(10, 13)} />
<Row3 articles={articles().slice(13, 16)} /> <Row3 articles={sortedArticles().slice(13, 16)} />
</Show> </Show>
</div> </div>
</div> </div>

View File

@ -7,9 +7,8 @@ import { TopicCard } from '../Topic/Card'
import { ArticleCard } from '../Feed/Card' import { ArticleCard } from '../Feed/Card'
import { AuthorCard } from '../Author/Card' import { AuthorCard } from '../Author/Card'
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { useStore } from '@nanostores/solid'
import { FeedSidebar } from '../Feed/Sidebar' import { FeedSidebar } from '../Feed/Sidebar'
import { session } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import CommentCard from '../Article/Comment' import CommentCard from '../Article/Comment'
import { loadRecentArticles, useArticlesStore } from '../../stores/zine/articles' import { loadRecentArticles, useArticlesStore } from '../../stores/zine/articles'
import { useReactionsStore } from '../../stores/zine/reactions' import { useReactionsStore } from '../../stores/zine/reactions'
@ -31,13 +30,12 @@ interface FeedProps {
export const FeedView = (props: FeedProps) => { export const FeedView = (props: FeedProps) => {
// state // state
const { getSortedArticles: articles } = useArticlesStore({ sortedArticles: props.articles }) const { sortedArticles } = useArticlesStore({ sortedArticles: props.articles })
const reactions = useReactionsStore() const reactions = useReactionsStore()
const { getSortedAuthors: authors } = useAuthorsStore() const { sortedAuthors } = useAuthorsStore()
const { getTopTopics } = useTopicsStore() const { topTopics } = useTopicsStore()
const { getTopAuthors } = useTopAuthorsStore() const { topAuthors } = useTopAuthorsStore()
const { session } = useAuthStore()
const auth = useStore(session)
const topReactions = createMemo(() => sortBy(reactions(), byCreated)) const topReactions = createMemo(() => sortBy(reactions(), byCreated))
@ -66,12 +64,12 @@ export const FeedView = (props: FeedProps) => {
<div class="container feed"> <div class="container feed">
<div class="row"> <div class="row">
<div class="col-md-3 feed-navigation"> <div class="col-md-3 feed-navigation">
<FeedSidebar authors={authors()} /> <FeedSidebar authors={sortedAuthors()} />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<ul class="feed-filter"> <ul class="feed-filter">
<Show when={!!auth()?.user?.slug}> <Show when={!!session()?.user?.slug}>
<li class="selected"> <li class="selected">
<a href="/feed/my">{t('My feed')}</a> <a href="/feed/my">{t('My feed')}</a>
</li> </li>
@ -87,8 +85,8 @@ export const FeedView = (props: FeedProps) => {
</li> </li>
</ul> </ul>
<Show when={articles().length > 0}> <Show when={sortedArticles().length > 0}>
<For each={articles().slice(0, 4)}> <For each={sortedArticles().slice(0, 4)}>
{(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />} {(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />}
</For> </For>
@ -101,7 +99,7 @@ export const FeedView = (props: FeedProps) => {
</div> </div>
<ul class="beside-column"> <ul class="beside-column">
<For each={getTopAuthors().slice(0, 5)}> <For each={topAuthors().slice(0, 5)}>
{(author) => ( {(author) => (
<li> <li>
<AuthorCard author={author} compact={true} hasLink={true} /> <AuthorCard author={author} compact={true} hasLink={true} />
@ -110,7 +108,7 @@ export const FeedView = (props: FeedProps) => {
</For> </For>
</ul> </ul>
<For each={articles().slice(4)}> <For each={sortedArticles().slice(4)}>
{(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />} {(article) => <ArticleCard article={article} settings={{ isFeedMode: true }} />}
</For> </For>
</Show> </Show>
@ -127,10 +125,10 @@ export const FeedView = (props: FeedProps) => {
{(comment) => <CommentCard comment={comment} compact={true} />} {(comment) => <CommentCard comment={comment} compact={true} />}
</For> </For>
</section> </section>
<Show when={getTopTopics().length > 0}> <Show when={topTopics().length > 0}>
<section class="feed-topics"> <section class="feed-topics">
<h4>{t('Topics')}</h4> <h4>{t('Topics')}</h4>
<For each={getTopTopics().slice(0, 5)}> <For each={topTopics().slice(0, 5)}>
{(topic) => <TopicCard topic={topic} subscribeButtonBottom={true} />} {(topic) => <TopicCard topic={topic} subscribeButtonBottom={true} />}
</For> </For>
</section> </section>

View File

@ -1,7 +1,7 @@
import { t } from '../../utils/intl' import { t } from '../../utils/intl'
import { Icon } from '../Nav/Icon' import { Icon } from '../Nav/Icon'
import styles from '../../styles/FourOuFour.module.scss' import styles from '../../styles/FourOuFour.module.scss'
import clsx from 'clsx' import { clsx } from 'clsx'
export const FourOuFourView = (_props) => { export const FourOuFourView = (_props) => {
return ( return (

View File

@ -23,6 +23,7 @@ import {
} from '../../stores/zine/articles' } from '../../stores/zine/articles'
import { useTopAuthorsStore } from '../../stores/zine/topAuthors' import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
import { locale } from '../../stores/ui' import { locale } from '../../stores/ui'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
const log = getLogger('home view') const log = getLogger('home view')
@ -30,50 +31,51 @@ type HomeProps = {
randomTopics: Topic[] randomTopics: Topic[]
recentPublishedArticles: Shout[] recentPublishedArticles: Shout[]
} }
const PRERENDERED_ARTICLES_COUNT = 5
const CLIENT_LOAD_ARTICLES_COUNT = 30 const CLIENT_LOAD_ARTICLES_COUNT = 29
const LOAD_MORE_ARTICLES_COUNT = 30 const LOAD_MORE_PAGE_SIZE = 16 // Row1 + Row3 + Row2 + Beside (3 + 1) + Row1 + Row 2 + Row3
export const HomeView = (props: HomeProps) => { export const HomeView = (props: HomeProps) => {
const { const {
getSortedArticles, sortedArticles,
getTopArticles, topArticles,
getTopMonthArticles, topMonthArticles,
getTopViewedArticles, topViewedArticles,
getTopCommentedArticles, topCommentedArticles,
getArticlesByLayout articlesByLayout
} = useArticlesStore({ } = useArticlesStore({
sortedArticles: props.recentPublishedArticles sortedArticles: props.recentPublishedArticles
}) })
const { getRandomTopics, getTopTopics } = useTopicsStore({ const { randomTopics, topTopics } = useTopicsStore({
randomTopics: props.randomTopics randomTopics: props.randomTopics
}) })
const { getTopAuthors } = useTopAuthorsStore() const { topAuthors } = useTopAuthorsStore()
onMount(() => { onMount(() => {
loadTopArticles() loadTopArticles()
loadTopMonthArticles() loadTopMonthArticles()
loadPublishedArticles({ limit: CLIENT_LOAD_ARTICLES_COUNT, offset: getSortedArticles().length }) if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) {
loadPublishedArticles({ limit: CLIENT_LOAD_ARTICLES_COUNT, offset: sortedArticles().length })
}
}) })
const randomLayout = createMemo(() => { const randomLayout = createMemo(() => {
const articlesByLayout = getArticlesByLayout() const filledLayouts = Object.keys(articlesByLayout()).filter(
const filledLayouts = Object.keys(articlesByLayout).filter(
// FIXME: is 7 ok? or more complex logic needed? // FIXME: is 7 ok? or more complex logic needed?
(layout) => articlesByLayout[layout].length > 7 (layout) => articlesByLayout()[layout].length > 7
) )
const randomLayout = const selectedRandomLayout =
filledLayouts.length > 0 ? filledLayouts[Math.floor(Math.random() * filledLayouts.length)] : '' filledLayouts.length > 0 ? filledLayouts[Math.floor(Math.random() * filledLayouts.length)] : ''
return ( return (
<Show when={Boolean(randomLayout)}> <Show when={Boolean(selectedRandomLayout)}>
<Group <Group
articles={articlesByLayout[randomLayout]} articles={articlesByLayout()[selectedRandomLayout]}
header={ header={
<div class="layout-icon"> <div class="layout-icon">
<Icon name={randomLayout} /> <Icon name={selectedRandomLayout} />
</div> </div>
} }
/> />
@ -81,69 +83,95 @@ export const HomeView = (props: HomeProps) => {
) )
}) })
const loadMore = () => { const loadMore = async () => {
loadPublishedArticles({ limit: LOAD_MORE_ARTICLES_COUNT, offset: getSortedArticles().length }) saveScrollPosition()
await loadPublishedArticles({ limit: LOAD_MORE_PAGE_SIZE, offset: sortedArticles().length })
restoreScrollPosition()
} }
return ( const pages = createMemo<Shout[][]>(() => {
<Show when={locale() && getSortedArticles().at(0) !== undefined}> return sortedArticles()
<NavTopics topics={getRandomTopics()} /> .slice(PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT)
.reduce((acc, article, index) => {
if (index % LOAD_MORE_PAGE_SIZE === 0) {
acc.push([])
}
<Row5 articles={getSortedArticles().slice(0, 5)} /> acc[acc.length - 1].push(article)
return acc
}, [] as Shout[][])
})
return (
<Show when={locale()}>
<NavTopics topics={randomTopics()} />
<Row5 articles={sortedArticles().slice(0, 5)} />
<Hero /> <Hero />
<Beside <Show when={sortedArticles().length > 5}>
beside={getSortedArticles().slice(4, 5)[0]}
title={t('Top viewed')}
values={getTopViewedArticles().slice(0, 5)}
wrapper={'top-article'}
/>
<Row3 articles={getSortedArticles().slice(6, 9)} />
{/*FIXME: ?*/}
<Show when={getTopAuthors().length === 5}>
<Beside <Beside
beside={getSortedArticles().slice(8, 9)[0]} beside={sortedArticles()[5]}
title={t('Top viewed')}
values={topViewedArticles().slice(0, 5)}
wrapper={'top-article'}
/>
<Row3 articles={sortedArticles().slice(6, 9)} />
<Beside
beside={sortedArticles()[9]}
title={t('Top authors')} title={t('Top authors')}
values={getTopAuthors()} values={topAuthors()}
wrapper={'author'} wrapper={'author'}
/> />
<Slider title={t('Top month articles')} articles={topMonthArticles()} />
<Row2 articles={sortedArticles().slice(10, 12)} />
<RowShort articles={sortedArticles().slice(12, 16)} />
<Row1 article={sortedArticles()[16]} />
<Row3 articles={sortedArticles().slice(17, 20)} />
<Row3 articles={topCommentedArticles().slice(0, 3)} header={<h2>{t('Top commented')}</h2>} />
{randomLayout()}
<Slider title={t('Favorite')} articles={topArticles()} />
<Beside
beside={sortedArticles()[20]}
title={t('Top topics')}
values={topTopics().slice(0, 5)}
wrapper={'topic'}
isTopicCompact={true}
/>
<Row3 articles={sortedArticles().slice(21, 24)} />
<Banner />
<Row2 articles={sortedArticles().slice(24, 26)} />
<Row3 articles={sortedArticles().slice(26, 29)} />
<Row2 articles={sortedArticles().slice(29, 31)} />
<Row3 articles={sortedArticles().slice(31, 34)} />
</Show> </Show>
<Slider title={t('Top month articles')} articles={getTopMonthArticles()} /> <For each={pages()}>
{(page) => (
<Row2 articles={getSortedArticles().slice(10, 12)} /> <>
<Row1 article={page[0]} />
<RowShort articles={getSortedArticles().slice(12, 16)} /> <Row3 articles={page.slice(1, 4)} />
<Row2 articles={page.slice(4, 6)} />
<Row1 article={getSortedArticles().slice(15, 16)[0]} /> <Beside values={page.slice(6, 9)} beside={page[9]} wrapper="article" />
<Row3 articles={getSortedArticles().slice(17, 20)} /> <Row1 article={page[10]} />
<Row3 articles={getTopCommentedArticles()} header={<h2>{t('Top commented')}</h2>} /> <Row2 articles={page.slice(11, 13)} />
<Row3 articles={page.slice(13, 16)} />
{randomLayout()} </>
)}
<Slider title={t('Favorite')} articles={getTopArticles()} /> </For>
<Beside
beside={getSortedArticles().slice(19, 20)[0]}
title={t('Top topics')}
values={getTopTopics().slice(0, 5)}
wrapper={'topic'}
isTopicCompact={true}
/>
<Row3 articles={getSortedArticles().slice(21, 24)} />
<Banner />
<Row2 articles={getSortedArticles().slice(24, 26)} />
<Row3 articles={getSortedArticles().slice(26, 29)} />
<Row2 articles={getSortedArticles().slice(29, 31)} />
<Row3 articles={getSortedArticles().slice(31, 34)} />
<For each={getSortedArticles().slice(35)}>{(article) => <Row1 article={article} />}</For>
<p class="load-more-container"> <p class="load-more-container">
<button class="button" onClick={loadMore}> <button class="button" onClick={loadMore}>

View File

@ -16,7 +16,7 @@ type Props = {
} }
export const SearchView = (props: Props) => { export const SearchView = (props: Props) => {
const { getSortedArticles } = useArticlesStore({ sortedArticles: props.results }) const { sortedArticles } = useArticlesStore({ sortedArticles: props.results })
const [getQuery, setQuery] = createSignal(props.query) const [getQuery, setQuery] = createSignal(props.query)
const { getSearchParams } = useRouter<SearchPageSearchParams>() const { getSearchParams } = useRouter<SearchPageSearchParams>()
@ -66,12 +66,12 @@ export const SearchView = (props: Props) => {
</li> </li>
</ul> </ul>
<Show when={getSortedArticles().length > 0}> <Show when={sortedArticles().length > 0}>
<h3>{t('Publications')}</h3> <h3>{t('Publications')}</h3>
<div class="floor"> <div class="floor">
<div class="row"> <div class="row">
<For each={getSortedArticles()}> <For each={sortedArticles()}>
{(article) => ( {(article) => (
<div class="col-md-3"> <div class="col-md-3">
<ArticleCard article={article} /> <ArticleCard article={article} />

View File

@ -24,12 +24,12 @@ interface TopicProps {
export const TopicView = (props: TopicProps) => { export const TopicView = (props: TopicProps) => {
const { getSearchParams, changeSearchParam } = useRouter<TopicsPageSearchParams>() const { getSearchParams, changeSearchParam } = useRouter<TopicsPageSearchParams>()
const { getSortedArticles: sortedArticles } = useArticlesStore({ sortedArticles: props.topicArticles }) const { sortedArticles } = useArticlesStore({ sortedArticles: props.topicArticles })
const { getTopicEntities } = useTopicsStore({ topics: [props.topic] }) const { topicEntities } = useTopicsStore({ topics: [props.topic] })
const { getAuthorsByTopic } = useAuthorsStore() const { authorsByTopic } = useAuthorsStore()
const topic = createMemo(() => getTopicEntities()[props.topic.slug]) const topic = createMemo(() => topicEntities()[props.topic.slug])
/* /*
const slug = createMemo<string>(() => { const slug = createMemo<string>(() => {
@ -104,7 +104,7 @@ export const TopicView = (props: TopicProps) => {
<Show when={sortedArticles().length > 5}> <Show when={sortedArticles().length > 5}>
<Beside <Beside
title={t('Topic is supported by')} title={t('Topic is supported by')}
values={getAuthorsByTopic()[topic().slug].slice(0, 7)} values={authorsByTopic()[topic().slug].slice(0, 7)}
beside={sortedArticles()[6]} beside={sortedArticles()[6]}
wrapper={'author'} wrapper={'author'}
/> />

View File

@ -324,11 +324,11 @@ export type Query = {
getUserRoles: Array<Maybe<Role>> getUserRoles: Array<Maybe<Role>>
getUsersBySlugs: Array<Maybe<User>> getUsersBySlugs: Array<Maybe<User>>
isEmailUsed: Scalars['Boolean'] isEmailUsed: Scalars['Boolean']
myCandidates: Array<Maybe<Shout>>
reactionsByAuthor: Array<Maybe<Reaction>> reactionsByAuthor: Array<Maybe<Reaction>>
reactionsByShout: Array<Maybe<Reaction>> reactionsByShout: Array<Maybe<Reaction>>
reactionsForShouts: Array<Maybe<Reaction>> reactionsForShouts: Array<Maybe<Reaction>>
recentAll: Array<Maybe<Shout>> recentAll: Array<Maybe<Shout>>
recentCandidates: Array<Maybe<Shout>>
recentCommented: Array<Maybe<Shout>> recentCommented: Array<Maybe<Shout>>
recentPublished: Array<Maybe<Shout>> recentPublished: Array<Maybe<Shout>>
recentReacted: Array<Maybe<Shout>> recentReacted: Array<Maybe<Shout>>
@ -397,11 +397,6 @@ export type QueryIsEmailUsedArgs = {
email: Scalars['String'] email: Scalars['String']
} }
export type QueryMyCandidatesArgs = {
limit: Scalars['Int']
offset: Scalars['Int']
}
export type QueryReactionsByAuthorArgs = { export type QueryReactionsByAuthorArgs = {
limit: Scalars['Int'] limit: Scalars['Int']
offset: Scalars['Int'] offset: Scalars['Int']
@ -425,6 +420,11 @@ export type QueryRecentAllArgs = {
offset: Scalars['Int'] offset: Scalars['Int']
} }
export type QueryRecentCandidatesArgs = {
limit: Scalars['Int']
offset: Scalars['Int']
}
export type QueryRecentCommentedArgs = { export type QueryRecentCommentedArgs = {
limit: Scalars['Int'] limit: Scalars['Int']
offset: Scalars['Int'] offset: Scalars['Int']

View File

@ -39,7 +39,7 @@
"Feedback": "Обратная связь", "Feedback": "Обратная связь",
"Follow": "Подписаться", "Follow": "Подписаться",
"Follow the topic": "Подписаться на тему", "Follow the topic": "Подписаться на тему",
"Forget password?": "Забыли пароль?", "Forgot password?": "Забыли пароль?",
"Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Познакомитесь с выдающимися людьми нашего времени, участвуйте в редактировании и обсуждении статей, выступайте экспертом, оценивайте материалы других авторов со всего мира и определяйте, какие статьи будут опубликованы в журнале", "Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine": "Познакомитесь с выдающимися людьми нашего времени, участвуйте в редактировании и обсуждении статей, выступайте экспертом, оценивайте материалы других авторов со всего мира и определяйте, какие статьи будут опубликованы в журнале",
"Help to edit": "Помочь редактировать", "Help to edit": "Помочь редактировать",
"Horizontal collaborative journalistic platform": "Горизонтальная платформа для коллаборативной журналистики", "Horizontal collaborative journalistic platform": "Горизонтальная платформа для коллаборативной журналистики",
@ -52,7 +52,7 @@
"Join the community": "Присоединиться к сообществу", "Join the community": "Присоединиться к сообществу",
"Join the global community of authors!": "Присоединятесь к глобальному сообществу авторов со всего мира!", "Join the global community of authors!": "Присоединятесь к глобальному сообществу авторов со всего мира!",
"Knowledge base": "База знаний", "Knowledge base": "База знаний",
"Load more": "Загрузить ещё", "Load more": "Показать ещё",
"Loading": "Загрузка", "Loading": "Загрузка",
"Manifest": "Манифест", "Manifest": "Манифест",
"More": "Ещё", "More": "Ещё",
@ -141,5 +141,7 @@
"topics": "темы", "topics": "темы",
"user already exist": "пользователь уже существует", "user already exist": "пользователь уже существует",
"view": "просмотр", "view": "просмотр",
"zine": "журнал" "zine": "журнал",
"Please, confirm email": "Пожалуйста, подтвердите электронную почту",
"Something went wrong, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль"
} }

View File

@ -3,27 +3,28 @@ import type { AuthResult } from '../graphql/types.gen'
import { getLogger } from '../utils/logger' import { getLogger } from '../utils/logger'
import { resetToken, setToken } from '../graphql/privateGraphQLClient' import { resetToken, setToken } from '../graphql/privateGraphQLClient'
import { apiClient } from '../utils/apiClient' import { apiClient } from '../utils/apiClient'
import { createSignal } from 'solid-js'
const log = getLogger('auth-store') const log = getLogger('auth-store')
export const session = atom<AuthResult>() const [session, setSession] = createSignal<AuthResult | null>(null)
export const signIn = async (params) => { export const signIn = async (params) => {
const s = await apiClient.authLogin(params) const authResult = await apiClient.authLogin(params)
session.set(s) setSession(authResult)
setToken(s.token) setToken(authResult.token)
log.debug('signed in') log.debug('signed in')
} }
export const signUp = async (params) => { export const signUp = async (params) => {
const s = await apiClient.authRegiser(params) const authResult = await apiClient.authRegister(params)
session.set(s) setSession(authResult)
setToken(s.token) setToken(authResult.token)
log.debug('signed up') log.debug('signed up')
} }
export const signOut = () => { export const signOut = () => {
session.set(null) setSession(null)
resetToken() resetToken()
log.debug('signed out') log.debug('signed out')
} }
@ -36,6 +37,18 @@ export const signCheck = async (params) => {
export const resetCode = atom<string>() export const resetCode = atom<string>()
export const register = async ({ email, password }: { email: string; password: string }) => {
const authResult = await apiClient.authRegister({
email,
password
})
if (authResult && !authResult.error) {
log.debug('register session update', authResult)
setSession(authResult)
}
}
export const signSendLink = async (params) => { export const signSendLink = async (params) => {
await apiClient.authSendLink(params) // { email } await apiClient.authSendLink(params) // { email }
resetToken() resetToken()
@ -44,11 +57,15 @@ export const signSendLink = async (params) => {
export const signConfirm = async (params) => { export const signConfirm = async (params) => {
const auth = await apiClient.authConfirmCode(params) // { code } const auth = await apiClient.authConfirmCode(params) // { code }
setToken(auth.token) setToken(auth.token)
session.set(auth) setSession(auth)
} }
export const renewSession = async () => { export const renewSession = async () => {
const s = await apiClient.getSession() // token in header const authResult = await apiClient.getSession() // token in header
setToken(s.token) setToken(authResult.token)
session.set(s) setSession(authResult)
}
export const useAuthStore = () => {
return { session }
} }

View File

@ -69,6 +69,10 @@ export const handleClientRouteLinkClick = (event) => {
event.preventDefault() event.preventDefault()
// TODO: search params // TODO: search params
routerStore.open(url.pathname) routerStore.open(url.pathname)
window.scrollTo({
top: 0,
left: 0
})
} }
} }
} }

View File

@ -1,7 +1,4 @@
import { atom, computed, map, ReadableAtom } from 'nanostores'
import type { Author, Shout, Topic } from '../../graphql/types.gen' import type { Author, Shout, Topic } from '../../graphql/types.gen'
import type { WritableAtom } from 'nanostores'
import { useStore } from '@nanostores/solid'
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
import { addAuthorsByTopic } from './authors' import { addAuthorsByTopic } from './authors'
import { addTopicsByAuthor } from './topics' import { addTopicsByAuthor } from './topics'
@ -9,79 +6,65 @@ import { byStat } from '../../utils/sortby'
import { getLogger } from '../../utils/logger' import { getLogger } from '../../utils/logger'
import { createSignal } from 'solid-js' import { createSignal } from 'solid-js'
import { createLazyMemo } from '@solid-primitives/memo'
const log = getLogger('articles store') const log = getLogger('articles store')
let articleEntitiesStore: WritableAtom<{ [articleSlug: string]: Shout }> const [sortedArticles, setSortedArticles] = createSignal<Shout[]>([])
let articlesByAuthorsStore: ReadableAtom<{ [authorSlug: string]: Shout[] }> const [articleEntities, setArticleEntities] = createSignal<{ [articleSlug: string]: Shout }>({})
let articlesByLayoutStore: ReadableAtom<{ [layout: string]: Shout[] }>
let articlesByTopicsStore: ReadableAtom<{ [topicSlug: string]: Shout[] }>
let topViewedArticlesStore: ReadableAtom<Shout[]>
let topCommentedArticlesStore: ReadableAtom<Shout[]>
const [getSortedArticles, setSortedArticles] = createSignal<Shout[]>([]) const [topArticles, setTopArticles] = createSignal<Shout[]>([])
const [topMonthArticles, setTopMonthArticles] = createSignal<Shout[]>([])
const topArticlesStore = atom<Shout[]>() const articlesByAuthor = createLazyMemo(() => {
const topMonthArticlesStore = atom<Shout[]>() return Object.values(articleEntities()).reduce((acc, article) => {
article.authors.forEach((author) => {
const initStore = (initial?: Record<string, Shout>) => { if (!acc[author.slug]) {
log.debug('initStore') acc[author.slug] = []
if (articleEntitiesStore) {
throw new Error('articles store already initialized')
}
articleEntitiesStore = map(initial)
articlesByAuthorsStore = computed(articleEntitiesStore, (articleEntities) => {
return Object.values(articleEntities).reduce((acc, article) => {
article.authors.forEach((author) => {
if (!acc[author.slug]) {
acc[author.slug] = []
}
acc[author.slug].push(article)
})
return acc
}, {} as { [authorSlug: string]: Shout[] })
})
articlesByTopicsStore = computed(articleEntitiesStore, (articleEntities) => {
return Object.values(articleEntities).reduce((acc, article) => {
article.topics.forEach((topic) => {
if (!acc[topic.slug]) {
acc[topic.slug] = []
}
acc[topic.slug].push(article)
})
return acc
}, {} as { [authorSlug: string]: Shout[] })
})
articlesByLayoutStore = computed(articleEntitiesStore, (articleEntities) => {
return Object.values(articleEntities).reduce((acc, article) => {
if (!acc[article.layout]) {
acc[article.layout] = []
} }
acc[author.slug].push(article)
})
acc[article.layout].push(article) return acc
}, {} as { [authorSlug: string]: Shout[] })
})
return acc const articlesByTopic = createLazyMemo(() => {
}, {} as { [layout: string]: Shout[] }) return Object.values(articleEntities()).reduce((acc, article) => {
}) article.topics.forEach((topic) => {
if (!acc[topic.slug]) {
acc[topic.slug] = []
}
acc[topic.slug].push(article)
})
topViewedArticlesStore = computed(articleEntitiesStore, (articleEntities) => { return acc
const sortedArticles = Object.values(articleEntities) }, {} as { [authorSlug: string]: Shout[] })
sortedArticles.sort(byStat('viewed')) })
return sortedArticles
})
topCommentedArticlesStore = computed(articleEntitiesStore, (articleEntities) => { const articlesByLayout = createLazyMemo(() => {
const sortedArticles = Object.values(articleEntities) return Object.values(articleEntities()).reduce((acc, article) => {
sortedArticles.sort(byStat('commented')) if (!acc[article.layout]) {
return sortedArticles acc[article.layout] = []
}) }
}
acc[article.layout].push(article)
return acc
}, {} as { [layout: string]: Shout[] })
})
const topViewedArticles = createLazyMemo(() => {
const result = Object.values(articleEntities())
result.sort(byStat('viewed'))
return result
})
const topCommentedArticles = createLazyMemo(() => {
const result = Object.values(articleEntities())
result.sort(byStat('commented'))
return result
})
// eslint-disable-next-line sonarjs/cognitive-complexity // eslint-disable-next-line sonarjs/cognitive-complexity
const addArticles = (...args: Shout[][]) => { const addArticles = (...args: Shout[][]) => {
@ -92,14 +75,12 @@ const addArticles = (...args: Shout[][]) => {
return acc return acc
}, {} as { [articleSLug: string]: Shout }) }, {} as { [articleSLug: string]: Shout })
if (!articleEntitiesStore) { setArticleEntities((prevArticleEntities) => {
initStore(newArticleEntities) return {
} else { ...prevArticleEntities,
articleEntitiesStore.set({
...articleEntitiesStore.get(),
...newArticleEntities ...newArticleEntities
}) }
} })
const authorsByTopic = allArticles.reduce((acc, article) => { const authorsByTopic = allArticles.reduce((acc, article) => {
const { authors, topics } = article const { authors, topics } = article
@ -173,13 +154,13 @@ export const loadPublishedArticles = async ({
export const loadTopMonthArticles = async (): Promise<void> => { export const loadTopMonthArticles = async (): Promise<void> => {
const articles = await apiClient.getTopMonthArticles() const articles = await apiClient.getTopMonthArticles()
addArticles(articles) addArticles(articles)
topMonthArticlesStore.set(articles) setTopMonthArticles(articles)
} }
export const loadTopArticles = async (): Promise<void> => { export const loadTopArticles = async (): Promise<void> => {
const articles = await apiClient.getTopArticles() const articles = await apiClient.getTopArticles()
addArticles(articles) addArticles(articles)
topArticlesStore.set(articles) setTopArticles(articles)
} }
export const loadSearchResults = async ({ export const loadSearchResults = async ({
@ -217,32 +198,21 @@ type InitialState = {
} }
export const useArticlesStore = (initialState: InitialState = {}) => { export const useArticlesStore = (initialState: InitialState = {}) => {
addArticles(initialState.sortedArticles || []) addArticles([...(initialState.sortedArticles || [])])
if (initialState.sortedArticles) { if (initialState.sortedArticles) {
setSortedArticles([...initialState.sortedArticles]) setSortedArticles([...initialState.sortedArticles])
} }
const getArticleEntities = useStore(articleEntitiesStore)
const getTopArticles = useStore(topArticlesStore)
const getTopMonthArticles = useStore(topMonthArticlesStore)
const getArticlesByAuthor = useStore(articlesByAuthorsStore)
const getArticlesByTopic = useStore(articlesByTopicsStore)
const getArticlesByLayout = useStore(articlesByLayoutStore)
// TODO: get from server
const getTopViewedArticles = useStore(topViewedArticlesStore)
// TODO: get from server
const getTopCommentedArticles = useStore(topCommentedArticlesStore)
return { return {
getArticleEntities, articleEntities,
getSortedArticles, sortedArticles,
getArticlesByTopic, articlesByTopic,
getArticlesByAuthor, articlesByAuthor,
getTopArticles, topArticles,
getTopMonthArticles, topMonthArticles,
getTopViewedArticles, topViewedArticles,
getTopCommentedArticles, topCommentedArticles,
getArticlesByLayout articlesByLayout
} }
} }

View File

@ -1,50 +1,36 @@
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
import type { ReadableAtom, WritableAtom } from 'nanostores'
import { atom, computed } from 'nanostores'
import type { Author } from '../../graphql/types.gen' import type { Author } from '../../graphql/types.gen'
import { useStore } from '@nanostores/solid'
import { byCreated } from '../../utils/sortby' import { byCreated } from '../../utils/sortby'
import { getLogger } from '../../utils/logger' import { getLogger } from '../../utils/logger'
import { createSignal } from 'solid-js'
import { createLazyMemo } from '@solid-primitives/memo'
const log = getLogger('authors store') const log = getLogger('authors store')
export type AuthorsSortBy = 'created' | 'name' export type AuthorsSortBy = 'created' | 'name'
const sortAllByStore = atom<AuthorsSortBy>('created') const [sortAllBy, setSortAllBy] = createSignal<AuthorsSortBy>('created')
let authorEntitiesStore: WritableAtom<{ [authorSlug: string]: Author }> const [authorEntities, setAuthorEntities] = createSignal<{ [authorSlug: string]: Author }>({})
let authorsByTopicStore: WritableAtom<{ [topicSlug: string]: Author[] }> const [authorsByTopic, setAuthorsByTopic] = createSignal<{ [topicSlug: string]: Author[] }>({})
let sortedAuthorsStore: ReadableAtom<Author[]>
const initStore = (initial: { [authorSlug: string]: Author }) => { const sortedAuthors = createLazyMemo(() => {
if (authorEntitiesStore) { const authors = Object.values(authorEntities())
return switch (sortAllBy()) {
} case 'created': {
// log.debug('sorted by created')
authorEntitiesStore = atom(initial) authors.sort(byCreated)
break
sortedAuthorsStore = computed([authorEntitiesStore, sortAllByStore], (authorEntities, sortBy) => {
const authors = Object.values(authorEntities)
switch (sortBy) {
case 'created': {
// log.debug('sorted by created')
authors.sort(byCreated)
break
}
case 'name': {
// log.debug('sorted by name')
authors.sort((a, b) => a.name.localeCompare(b.name))
break
}
} }
return authors case 'name': {
}) // log.debug('sorted by name')
} authors.sort((a, b) => a.name.localeCompare(b.name))
break
export const setSortAllBy = (sortBy: AuthorsSortBy) => { }
sortAllByStore.set(sortBy) }
} return authors
})
const addAuthors = (authors: Author[]) => { const addAuthors = (authors: Author[]) => {
const newAuthorEntities = authors.reduce((acc, author) => { const newAuthorEntities = authors.reduce((acc, author) => {
@ -52,24 +38,20 @@ const addAuthors = (authors: Author[]) => {
return acc return acc
}, {} as Record<string, Author>) }, {} as Record<string, Author>)
if (!authorEntitiesStore) { setAuthorEntities((prevAuthorEntities) => {
initStore(newAuthorEntities) return {
} else { ...prevAuthorEntities,
authorEntitiesStore.set({
...authorEntitiesStore.get(),
...newAuthorEntities ...newAuthorEntities
}) }
} })
} }
export const addAuthorsByTopic = (authorsByTopic: { [topicSlug: string]: Author[] }) => { export const addAuthorsByTopic = (newAuthorsByTopic: { [topicSlug: string]: Author[] }) => {
const allAuthors = Object.values(authorsByTopic).flat() const allAuthors = Object.values(newAuthorsByTopic).flat()
addAuthors(allAuthors) addAuthors(allAuthors)
if (!authorsByTopicStore) { setAuthorsByTopic((prevAuthorsByTopic) => {
authorsByTopicStore = atom<{ [topicSlug: string]: Author[] }>(authorsByTopic) return Object.entries(newAuthorsByTopic).reduce((acc, [topicSlug, authors]) => {
} else {
const newState = Object.entries(authorsByTopic).reduce((acc, [topicSlug, authors]) => {
if (!acc[topicSlug]) { if (!acc[topicSlug]) {
acc[topicSlug] = [] acc[topicSlug] = []
} }
@ -81,10 +63,8 @@ export const addAuthorsByTopic = (authorsByTopic: { [topicSlug: string]: Author[
}) })
return acc return acc
}, authorsByTopicStore.get()) }, prevAuthorsByTopic)
})
authorsByTopicStore.set(newState)
}
} }
export const loadAllAuthors = async (): Promise<void> => { export const loadAllAuthors = async (): Promise<void> => {
@ -97,12 +77,7 @@ type InitialState = {
} }
export const useAuthorsStore = (initialState: InitialState = {}) => { export const useAuthorsStore = (initialState: InitialState = {}) => {
const authors = [...(initialState.authors || [])] addAuthors([...(initialState.authors || [])])
addAuthors(authors)
const getAuthorEntities = useStore(authorEntitiesStore) return { authorEntities, sortedAuthors, authorsByTopic }
const getSortedAuthors = useStore(sortedAuthorsStore)
const getAuthorsByTopic = useStore(authorsByTopicStore)
return { getAuthorEntities, getSortedAuthors, getAuthorsByTopic }
} }

View File

@ -40,8 +40,8 @@ export const loadReactions = async ({
limit: number limit: number
offset: number offset: number
}): Promise<void> => { }): Promise<void> => {
const reactions = await apiClient.getReactionsForShouts({ shoutSlugs, limit, offset }) const reactionsForShouts = await apiClient.getReactionsForShouts({ shoutSlugs, limit, offset })
reactionsOrdered.set(reactions) reactionsOrdered.set(reactionsForShouts)
} }
export const createReaction = async (reaction: Reaction) => export const createReaction = async (reaction: Reaction) =>

View File

@ -5,20 +5,17 @@ import { useAuthorsStore } from './authors'
const TOP_AUTHORS_COUNT = 5 const TOP_AUTHORS_COUNT = 5
export const useTopAuthorsStore = () => { export const useTopAuthorsStore = () => {
const { getArticlesByAuthor } = useArticlesStore() const { articlesByAuthor } = useArticlesStore()
const { getAuthorEntities } = useAuthorsStore() const { authorEntities } = useAuthorsStore()
const getTopAuthors = createMemo(() => { const topAuthors = createMemo(() => {
const articlesByAuthor = getArticlesByAuthor() return Object.keys(articlesByAuthor())
const authorEntities = getAuthorEntities()
return Object.keys(articlesByAuthor)
.sort((authorSlug1, authorSlug2) => { .sort((authorSlug1, authorSlug2) => {
const author1Rating = articlesByAuthor[authorSlug1].reduce( const author1Rating = articlesByAuthor()[authorSlug1].reduce(
(acc, article) => acc + article.stat?.rating, (acc, article) => acc + article.stat?.rating,
0 0
) )
const author2Rating = articlesByAuthor[authorSlug2].reduce( const author2Rating = articlesByAuthor()[authorSlug2].reduce(
(acc, article) => acc + article.stat?.rating, (acc, article) => acc + article.stat?.rating,
0 0
) )
@ -29,9 +26,9 @@ export const useTopAuthorsStore = () => {
return author1Rating > author2Rating ? 1 : -1 return author1Rating > author2Rating ? 1 : -1
}) })
.slice(0, TOP_AUTHORS_COUNT) .slice(0, TOP_AUTHORS_COUNT)
.map((authorSlug) => authorEntities[authorSlug]) .map((authorSlug) => authorEntities()[authorSlug])
.filter(Boolean) .filter(Boolean)
}) })
return { getTopAuthors } return { topAuthors }
} }

View File

@ -1,66 +1,53 @@
import { createMemo, createSignal } from 'solid-js'
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
import { map, MapStore, ReadableAtom, atom, computed } from 'nanostores'
import type { Topic } from '../../graphql/types.gen' import type { Topic } from '../../graphql/types.gen'
import { useStore } from '@nanostores/solid'
import { byCreated, byTopicStatDesc } from '../../utils/sortby' import { byCreated, byTopicStatDesc } from '../../utils/sortby'
import { getLogger } from '../../utils/logger' import { getLogger } from '../../utils/logger'
import { createSignal } from 'solid-js' import { createLazyMemo } from '@solid-primitives/memo'
const log = getLogger('topics store') const log = getLogger('topics store')
export type TopicsSortBy = 'created' | 'title' | 'authors' | 'shouts' export type TopicsSortBy = 'created' | 'title' | 'authors' | 'shouts'
const sortAllByStore = atom<TopicsSortBy>('shouts') const [sortAllBy, setSortAllBy] = createSignal<TopicsSortBy>('shouts')
let topicEntitiesStore: MapStore<Record<string, Topic>> export { setSortAllBy }
let sortedTopicsStore: ReadableAtom<Topic[]>
let topTopicsStore: ReadableAtom<Topic[]>
const [getRandomTopics, setRandomTopics] = createSignal<Topic[]>() const [topicEntities, setTopicEntities] = createSignal<{ [topicSlug: string]: Topic }>({})
let topicsByAuthorStore: MapStore<Record<string, Topic[]>> const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
const [topicsByAuthor, setTopicByAuthor] = createSignal<{ [authorSlug: string]: Topic[] }>({})
const initStore = (initial?: { [topicSlug: string]: Topic }) => { const sortedTopics = createLazyMemo<Topic[]>(() => {
if (topicEntitiesStore) { const topics = Object.values(topicEntities())
return const sortAllByValue = sortAllBy()
}
topicEntitiesStore = map<Record<string, Topic>>(initial) switch (sortAllByValue) {
case 'created': {
sortedTopicsStore = computed([topicEntitiesStore, sortAllByStore], (topicEntities, sortBy) => { // log.debug('sorted by created')
const topics = Object.values(topicEntities) topics.sort(byCreated)
switch (sortBy) { break
case 'created': {
// log.debug('sorted by created')
topics.sort(byCreated)
break
}
case 'shouts':
case 'authors':
// log.debug(`sorted by ${sortBy}`)
topics.sort(byTopicStatDesc(sortBy))
break
case 'title':
// log.debug('sorted by title')
topics.sort((a, b) => a.title.localeCompare(b.title))
break
default:
log.error(`Unknown sort: ${sortBy}`)
} }
return topics case 'shouts':
}) case 'authors':
// log.debug(`sorted by ${sortBy}`)
topTopicsStore = computed(topicEntitiesStore, (topicEntities) => { topics.sort(byTopicStatDesc(sortAllByValue))
const topics = Object.values(topicEntities) break
topics.sort(byTopicStatDesc('shouts')) case 'title':
return topics // log.debug('sorted by title')
}) topics.sort((a, b) => a.title.localeCompare(b.title))
} break
default:
export const setSortAllTopicsBy = (sortBy: TopicsSortBy) => { log.error(`Unknown sort: ${sortAllByValue}`)
if (sortAllByStore.get() !== sortBy) {
sortAllByStore.set(sortBy)
} }
}
return topics
})
const topTopics = createMemo(() => {
const topics = Object.values(topicEntities())
topics.sort(byTopicStatDesc('shouts'))
return topics
})
const addTopics = (...args: Topic[][]) => { const addTopics = (...args: Topic[][]) => {
const allTopics = args.flatMap((topics) => topics || []) const allTopics = args.flatMap((topics) => topics || [])
@ -70,24 +57,20 @@ const addTopics = (...args: Topic[][]) => {
return acc return acc
}, {} as Record<string, Topic>) }, {} as Record<string, Topic>)
if (!topicEntitiesStore) { setTopicEntities((prevTopicEntities) => {
initStore(newTopicEntities) return {
} else { ...prevTopicEntities,
topicEntitiesStore.set({
...topicEntitiesStore.get(),
...newTopicEntities ...newTopicEntities
}) }
} })
} }
export const addTopicsByAuthor = (topicsByAuthors: { [authorSlug: string]: Topic[] }) => { export const addTopicsByAuthor = (newTopicsByAuthors: { [authorSlug: string]: Topic[] }) => {
const allTopics = Object.values(topicsByAuthors).flat() const allTopics = Object.values(newTopicsByAuthors).flat()
addTopics(allTopics) addTopics(allTopics)
if (!topicsByAuthorStore) { setTopicByAuthor((prevTopicsByAuthor) => {
topicsByAuthorStore = map<Record<string, Topic[]>>(topicsByAuthors) return Object.entries(newTopicsByAuthors).reduce((acc, [authorSlug, topics]) => {
} else {
const newState = Object.entries(topicsByAuthors).reduce((acc, [authorSlug, topics]) => {
if (!acc[authorSlug]) { if (!acc[authorSlug]) {
acc[authorSlug] = [] acc[authorSlug] = []
} }
@ -99,10 +82,8 @@ export const addTopicsByAuthor = (topicsByAuthors: { [authorSlug: string]: Topic
}) })
return acc return acc
}, topicsByAuthorStore.get()) }, prevTopicsByAuthor)
})
topicsByAuthorStore.set(newState)
}
} }
export const loadAllTopics = async (): Promise<void> => { export const loadAllTopics = async (): Promise<void> => {
@ -117,24 +98,15 @@ type InitialState = {
} }
export const useTopicsStore = (initialState: InitialState = {}) => { export const useTopicsStore = (initialState: InitialState = {}) => {
const topics = [...(initialState.topics || [])]
const randomTopics = [...(initialState.randomTopics || [])]
if (initialState.sortBy) { if (initialState.sortBy) {
sortAllByStore.set(initialState.sortBy) setSortAllBy(initialState.sortBy)
} }
addTopics(topics, randomTopics) addTopics(initialState.topics, initialState.randomTopics)
if (randomTopics) { if (initialState.randomTopics) {
setRandomTopics(randomTopics) setRandomTopics(initialState.randomTopics)
} }
const getTopicEntities = useStore(topicEntitiesStore) return { topicEntities, sortedTopics, randomTopics, topTopics, topicsByAuthor }
const getSortedTopics = useStore(sortedTopicsStore)
const getTopTopics = useStore(topTopicsStore)
return { getTopicEntities, getSortedTopics, getRandomTopics, getTopTopics }
} }

View File

@ -1,11 +1,3 @@
header {
position: absolute;
}
footer {
display: none;
}
.main-logo { .main-logo {
height: 80px !important; height: 80px !important;
} }

View File

@ -26,7 +26,6 @@ html {
height: 100%; height: 100%;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overscroll-behavior-y: none; overscroll-behavior-y: none;
scroll-behavior: smooth;
} }
body { body {

View File

@ -1,4 +1,4 @@
import type { Reaction, Shout, FollowingEntity } from '../graphql/types.gen' import type { Reaction, Shout, FollowingEntity, AuthResult } from '../graphql/types.gen'
import { publicGraphQLClient } from '../graphql/publicGraphQLClient' import { publicGraphQLClient } from '../graphql/publicGraphQLClient'
import articleBySlug from '../graphql/query/article-by-slug' import articleBySlug from '../graphql/query/article-by-slug'
@ -36,15 +36,43 @@ const log = getLogger('api-client')
const FEED_SIZE = 50 const FEED_SIZE = 50
const REACTIONS_PAGE_SIZE = 100 const REACTIONS_PAGE_SIZE = 100
export const apiClient = { type ApiErrorCode = 'unknown' | 'email_not_confirmed' | 'user_not_found'
// auth
authLogin: async ({ email, password }) => { export class ApiError extends Error {
code: ApiErrorCode
constructor(code: ApiErrorCode, message?: string) {
super(message)
this.code = code
}
}
export const apiClient = {
authLogin: async ({ email, password }): Promise<AuthResult> => {
const response = await publicGraphQLClient.query(authLoginQuery, { email, password }).toPromise() const response = await publicGraphQLClient.query(authLoginQuery, { email, password }).toPromise()
// log.debug('authLogin', { response })
if (response.error) {
if (response.error.message === '[GraphQL] User not found') {
throw new ApiError('user_not_found')
}
throw new ApiError('unknown', response.error.message)
}
if (response.data.signIn.error) {
if (response.data.signIn.error === 'please, confirm email') {
throw new ApiError('email_not_confirmed')
}
throw new ApiError('unknown', response.data.signIn.error)
}
return response.data.signIn return response.data.signIn
}, },
authRegiser: async ({ email, password }) => { authRegister: async ({ email, password }): Promise<AuthResult> => {
const response = await publicGraphQLClient.query(authRegisterMutation, { email, password }).toPromise() const response = await publicGraphQLClient
.mutation(authRegisterMutation, { email, password })
.toPromise()
return response.data.registerUser return response.data.registerUser
}, },
authSignOut: async () => { authSignOut: async () => {
@ -87,9 +115,12 @@ export const apiClient = {
return response.data.recentPublished return response.data.recentPublished
}, },
getRandomTopics: async ({ amount }: { amount: number }) => { getRandomTopics: async ({ amount }: { amount: number }) => {
log.debug('getRandomTopics')
const response = await publicGraphQLClient.query(topicsRandomQuery, { amount }).toPromise() const response = await publicGraphQLClient.query(topicsRandomQuery, { amount }).toPromise()
if (!response.data) {
log.error('getRandomTopics', response.error)
}
return response.data.topicsRandom return response.data.topicsRandom
}, },
getSearchResults: async ({ getSearchResults: async ({
@ -177,7 +208,7 @@ export const apiClient = {
return response.data.unfollow return response.data.unfollow
}, },
getSession: async () => { getSession: async (): Promise<AuthResult> => {
// renew session with auth token in header (!) // renew session with auth token in header (!)
const response = await privateGraphQLClient.mutation(mySession, {}).toPromise() const response = await privateGraphQLClient.mutation(mySession, {}).toPromise()
return response.data.refreshSession return response.data.refreshSession

16
src/utils/scroll.ts Normal file
View File

@ -0,0 +1,16 @@
const scrollPosition = {
top: 0,
left: 0
}
export const saveScrollPosition = () => {
scrollPosition.top = window.scrollY
scrollPosition.left = window.scrollX
}
export const restoreScrollPosition = () => {
window.scroll({
top: scrollPosition.top,
left: scrollPosition.left
})
}

View File

@ -2733,6 +2733,14 @@
"@solid-primitives/rootless" "^1.1.3" "@solid-primitives/rootless" "^1.1.3"
"@solid-primitives/utils" "^3.0.2" "@solid-primitives/utils" "^3.0.2"
"@solid-primitives/memo@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@solid-primitives/memo/-/memo-1.0.2.tgz#7a33216e665a94ac85413be206dacf3f295221d0"
integrity sha512-I4BKJAItiRxjR1ngc+gWsdpiz3V79LQdgxxRlFPp3K+8Oi2dolXweDlLKKX5qec8cSuhV99gTfsxEoVBMkzNgQ==
dependencies:
"@solid-primitives/scheduled" "1.0.1"
"@solid-primitives/utils" "^3.0.2"
"@solid-primitives/platform@^0.0.101": "@solid-primitives/platform@^0.0.101":
version "0.0.101" version "0.0.101"
resolved "https://registry.yarnpkg.com/@solid-primitives/platform/-/platform-0.0.101.tgz#7bfa879152a59169589e2dc999aac8ceb63233c7" resolved "https://registry.yarnpkg.com/@solid-primitives/platform/-/platform-0.0.101.tgz#7bfa879152a59169589e2dc999aac8ceb63233c7"
@ -2763,6 +2771,11 @@
dependencies: dependencies:
"@solid-primitives/utils" "^3.0.2" "@solid-primitives/utils" "^3.0.2"
"@solid-primitives/scheduled@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@solid-primitives/scheduled/-/scheduled-1.0.1.tgz#e5b07452f39d27504c4ba1caa64d65020110c017"
integrity sha512-zRyW9L4nYdL0yZktvJz/Ye9kVNa6UW26L71sZEqzzHnxvDidbT+mln4np7jqFrAeGiWMwWnRDR/ZvM0FK85jMw==
"@solid-primitives/scheduled@^1.0.1": "@solid-primitives/scheduled@^1.0.1":
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/@solid-primitives/scheduled/-/scheduled-1.0.2.tgz#8c2e8511b9b361c22c13e78377dc4168cc9c0452" resolved "https://registry.yarnpkg.com/@solid-primitives/scheduled/-/scheduled-1.0.2.tgz#8c2e8511b9b361c22c13e78377dc4168cc9c0452"