home page tops fix, all authors, all topics fixes and more

This commit is contained in:
Igor Lobanov 2022-12-01 19:45:35 +01:00
parent c086a242b7
commit e61181392c
35 changed files with 369 additions and 282 deletions

View File

@ -2,14 +2,14 @@ import { capitalize, formatDate } from '../../utils'
import './Full.scss'
import { Icon } from '../_shared/Icon'
import { AuthorCard } from '../Author/Card'
import { createMemo, createSignal, For, Match, onMount, Show, Switch } from 'solid-js'
import { createMemo, For, Match, onMount, Show, Switch } from 'solid-js'
import type { Author, Shout } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
import MD from './MD'
import { SharePopup } from './SharePopup'
import stylesHeader from '../Nav/Header.module.scss'
import styles from '../../styles/Article.module.scss'
import RatingControl from './RatingControl'
import { RatingControl } from './RatingControl'
import { clsx } from 'clsx'
import { CommentsTree } from './CommentsTree'
import { useSession } from '../../context/session'

View File

@ -20,5 +20,3 @@ export const RatingControl = (props: RatingControlProps) => {
</div>
)
}
export default RatingControl

View File

@ -277,3 +277,7 @@
margin-top: -0.6em;
}
}
.isSubscribing {
color: transparent;
}

View File

@ -2,7 +2,7 @@ import type { Author } from '../../graphql/types.gen'
import Userpic from './Userpic'
import { Icon } from '../_shared/Icon'
import styles from './Card.module.scss'
import { createMemo, For, Show } from 'solid-js'
import { createMemo, createSignal, For, Show } from 'solid-js'
import { translit } from '../../utils/ru2en'
import { t } from '../../utils/intl'
import { locale } from '../../stores/ui'
@ -10,6 +10,8 @@ import { follow, unfollow } from '../../stores/zine/common'
import { clsx } from 'clsx'
import { useSession } from '../../context/session'
import { StatMetrics } from '../_shared/StatMetrics'
import { FollowingEntity } from '../../graphql/types.gen'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
interface AuthorCardProps {
caption?: string
@ -28,17 +30,32 @@ interface AuthorCardProps {
}
export const AuthorCard = (props: AuthorCardProps) => {
const { session } = useSession()
const {
session,
actions: { loadSession }
} = useSession()
const [isSubscribing, setIsSubscribing] = createSignal(false)
const subscribed = createMemo<boolean>(
() => session()?.news?.authors?.some((u) => u === props.author.slug) || false
)
const subscribe = async (really = true) => {
setIsSubscribing(true)
await (really
? follow({ what: FollowingEntity.Author, slug: props.author.slug })
: unfollow({ what: FollowingEntity.Author, slug: props.author.slug }))
await loadSession()
setIsSubscribing(false)
}
const canFollow = createMemo(() => !props.hideFollow && session()?.user?.slug !== props.author.slug)
const name = () => {
return props.author.name === 'Дискурс' && locale() !== 'ru'
? 'Discours'
: translit(props.author.name || '', locale() || 'ru')
return props.author.name === 'Дискурс' && locale() !== 'ru' ? 'Discours' : translit(props.author.name)
}
// TODO: reimplement AuthorCard
return (
@ -75,28 +92,31 @@ export const AuthorCard = (props: AuthorCardProps) => {
class={styles.authorAbout}
classList={{ 'text-truncate': props.truncateBio }}
innerHTML={props.author.bio}
></div>
/>
</Show>
<Show when={props.author.stat}>
<StatMetrics fields={['shouts', 'followers', 'comments']} stat={props.author.stat} />
</Show>
</div>
<ShowOnlyOnClient>
<Show when={session.state !== 'pending'}>
<Show when={canFollow()}>
<div class={styles.authorSubscribe}>
<Show
when={subscribed()}
fallback={
<button
onClick={() => follow}
onClick={() => subscribe(true)}
class={clsx('button', styles.button)}
classList={{
[styles.buttonSubscribe]: !props.isAuthorsList,
'button--subscribe': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.isAuthorsList
[styles.buttonWrite]: props.isAuthorsList,
[styles.isSubscribing]: isSubscribing()
}}
disabled={isSubscribing()}
>
<Show when={!props.isAuthorsList}>
<Icon name="circle-plus" iconClassName={styles.s24} class={styles.icon} />
@ -106,13 +126,16 @@ export const AuthorCard = (props: AuthorCardProps) => {
}
>
<button
onClick={() => unfollow}
onClick={() => subscribe(false)}
class={clsx('button', styles.button)}
classList={{
[styles.buttonSubscribe]: !props.isAuthorsList,
'button--subscribe': !props.isAuthorsList,
'button--subscribe-topic': props.isAuthorsList,
[styles.buttonWrite]: props.isAuthorsList
[styles.buttonWrite]: props.isAuthorsList,
[styles.isSubscribing]: isSubscribing()
}}
disabled={isSubscribing()}
>
<Show when={!props.isAuthorsList}>
<Icon name="author-unsubscribe" class={styles.icon} />
@ -141,6 +164,8 @@ export const AuthorCard = (props: AuthorCardProps) => {
</Show>
</div>
</Show>
</Show>
</ShowOnlyOnClient>
</div>
</div>
)

View File

@ -59,7 +59,7 @@ export const Editor = () => {
const handleSaveButtonClick = () => {
const article: ShoutInput = {
body: getHtml(editorViewRef.current.state),
community: 'discours', // ?
community: 1, // 'discours' ?
slug: 'new-' + Math.floor(Math.random() * 1000000)
}
createArticle({ article })

View File

@ -7,8 +7,8 @@ import { Icon } from '../_shared/Icon'
import styles from './Card.module.scss'
import { locale } from '../../stores/ui'
import { clsx } from 'clsx'
import CardTopic from './CardTopic'
import RatingControl from '../Article/RatingControl'
import { CardTopic } from './CardTopic'
import { RatingControl } from '../Article/RatingControl'
interface ArticleCardProps {
settings?: {
@ -107,7 +107,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
<Show when={!props.settings?.isGroup}>
<CardTopic
title={
locale() === 'ru' && mainTopic.title ? mainTopic.title : mainTopic.slug.replace('-', ' ')
locale() === 'ru' && mainTopic.title ? mainTopic.title : mainTopic?.slug?.replace('-', ' ')
}
slug={mainTopic.slug}
isFloorImportant={props.settings?.isFloorImportant}
@ -135,9 +135,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
<For each={authors}>
{(author, index) => {
const name =
author.name === 'Дискурс' && locale() !== 'ru'
? 'Discours'
: translit(author.name || '', locale() || 'ru')
author.name === 'Дискурс' && locale() !== 'ru' ? 'Discours' : translit(author.name)
return (
<>

View File

@ -6,7 +6,7 @@ interface CardTopicProps {
isFloorImportant?: boolean
}
export default (props: CardTopicProps) => {
export const CardTopic = (props: CardTopicProps) => {
return (
<div
class={style.shoutTopic}

View File

@ -20,7 +20,7 @@ type Props = {
const CreateModalContent = (props: Props) => {
const inviteUsers: inviteUser[] = props.users.map((user) => ({ ...user, selected: false }))
const [theme, setTheme] = createSignal<string>('')
const [slugs, setSlugs] = createSignal<string[]>([])
const [slugs, setSlugs] = createSignal<number[]>([])
const [collectionToInvite, setCollectionToInvite] = createSignal<inviteUser[]>(inviteUsers)
let textInput: HTMLInputElement
@ -37,7 +37,7 @@ const CreateModalContent = (props: Props) => {
return user.selected === true
})
.map((user) => {
return user['slug']
return user.id
})
})
if (slugs().length > 2 && theme().length === 0) {

View File

@ -61,14 +61,14 @@ export const RegisterForm = () => {
const newValidationErrors: ValidationErrors = {}
const clearName = name().trim()
const clearEmail = email().trim()
const cleanName = name().trim()
const cleanEmail = email().trim()
if (!clearName) {
if (!cleanName) {
newValidationErrors.name = t('Please enter a name to sign your comments and publication')
}
if (!clearEmail) {
if (!cleanEmail) {
newValidationErrors.email = t('Please enter email')
} else if (!isValidEmail(email())) {
newValidationErrors.email = t('Invalid email')
@ -80,7 +80,7 @@ export const RegisterForm = () => {
setValidationErrors(newValidationErrors)
const emailCheckResult = await checkEmail(clearEmail)
const emailCheckResult = await checkEmail(cleanEmail)
const isValid = Object.keys(newValidationErrors).length === 0 && !emailCheckResult
@ -92,8 +92,8 @@ export const RegisterForm = () => {
try {
await register({
name: clearName,
email: clearEmail,
name: cleanName,
email: cleanEmail,
password: password()
})
@ -123,13 +123,13 @@ export const RegisterForm = () => {
</Show>
<div class="pretty-form__item">
<input
name="name"
name="fullName"
type="text"
placeholder={t('Full name')}
autocomplete=""
onInput={(event) => handleNameInput(event.currentTarget.value)}
/>
<label for="name">{t('Full name')}</label>
<label for="fullName">{t('Full name')}</label>
</div>
<Show when={validationErrors().name}>
<div class={styles.validationError}>{validationErrors().name}</div>

View File

@ -3,7 +3,7 @@ import { clsx } from 'clsx'
import { useRouter } from '../../stores/router'
import { t } from '../../utils/intl'
import { Icon } from '../_shared/Icon'
import { createSignal, Show } from 'solid-js'
import { createEffect, createSignal, Show } from 'solid-js'
import Notifications from './Notifications'
import { ProfilePopup } from './ProfilePopup'
import Userpic from '../Author/Userpic'
@ -38,7 +38,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
return (
<ShowOnlyOnClient>
<Show when={!session.loading}>
<Show when={session.state !== 'pending'}>
<div class={styles.usernav}>
<div class={clsx(styles.userControl, styles.userControl, 'col')}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}>

View File

@ -23,15 +23,16 @@
position: absolute;
top: 1em;
cursor: pointer;
height: 0.8em;
height: 18px;
width: 16px;
opacity: 1;
padding: 0;
right: 0;
transition: opacity 0.3s;
width: 0.8em;
z-index: 1;
svg {
display: block;
pointer-events: none;
}
@ -55,6 +56,7 @@
@media (min-width: 800px) and (max-width: 991px) {
width: 80%;
}
.close {
right: 12px;
top: 12px;

View File

@ -116,3 +116,7 @@
.buttonCompact {
margin-top: 0.6rem;
}
.isSubscribing {
color: transparent;
}

View File

@ -1,6 +1,6 @@
import { capitalize } from '../../utils'
import styles from './Card.module.scss'
import { createMemo, Show } from 'solid-js'
import { createEffect, createMemo, createSignal, Show } from 'solid-js'
import type { Topic } from '../../graphql/types.gen'
import { FollowingEntity } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
@ -9,6 +9,7 @@ import { getLogger } from '../../utils/logger'
import { clsx } from 'clsx'
import { useSession } from '../../context/session'
import { StatMetrics } from '../_shared/StatMetrics'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
const log = getLogger('TopicCard')
@ -25,7 +26,12 @@ interface TopicProps {
}
export const TopicCard = (props: TopicProps) => {
const { session } = useSession()
const {
session,
actions: { loadSession }
} = useSession()
const [isSubscribing, setIsSubscribing] = createSignal(false)
const subscribed = createMemo(() => {
if (!session()?.user?.slug || !session()?.news?.topics) {
@ -35,14 +41,17 @@ export const TopicCard = (props: TopicProps) => {
return session()?.news.topics.includes(props.topic.slug)
})
// FIXME use store actions
const subscribe = async (really = true) => {
if (really) {
follow({ what: FollowingEntity.Topic, slug: props.topic.slug })
} else {
unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug })
}
setIsSubscribing(true)
await (really
? follow({ what: FollowingEntity.Topic, slug: props.topic.slug })
: unfollow({ what: FollowingEntity.Topic, slug: props.topic.slug }))
await loadSession()
setIsSubscribing(false)
}
return (
<div
class={styles.topic}
@ -79,32 +88,30 @@ export const TopicCard = (props: TopicProps) => {
class={styles.controlContainer}
classList={{ 'col-md-3': !props.compact && !props.subscribeButtonBottom }}
>
<Show
when={subscribed()}
fallback={
<ShowOnlyOnClient>
<Show when={session.state !== 'pending'}>
<button
onClick={() => subscribe(true)}
onClick={() => subscribe(!subscribed())}
class="button--light button--subscribe-topic"
classList={{
[styles.buttonCompact]: props.compact
[styles.buttonCompact]: props.compact,
[styles.isSubscribing]: isSubscribing()
}}
disabled={isSubscribing()}
>
<Show when={props.iconButton}>+</Show>
<Show when={!props.iconButton}>{t('Follow')}</Show>
</button>
}
>
<button
onClick={() => subscribe(false)}
class="button--light button--subscribe-topic"
classList={{
[styles.buttonCompact]: props.compact
}}
>
<Show when={props.iconButton}>-</Show>
<Show when={!props.iconButton}>{t('Unfollow')}</Show>
<Show when={props.iconButton}>
<Show when={subscribed()} fallback="+">
-
</Show>
</Show>
<Show when={!props.iconButton}>
<Show when={subscribed()} fallback={t('Follow')}>
{t('Unfollow')}
</Show>
</Show>
</button>
</Show>
</ShowOnlyOnClient>
</div>
</div>
)

View File

@ -1,7 +1,7 @@
import { createEffect, createMemo, createSignal, For, onMount, Show } from 'solid-js'
import type { Author } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
import { AuthorsSortBy, setAuthorsSort, useAuthorsStore } from '../../stores/zine/authors'
import { setAuthorsSort, useAuthorsStore } from '../../stores/zine/authors'
import { useRouter } from '../../stores/router'
import { AuthorCard } from '../Author/Card'
import { clsx } from 'clsx'
@ -22,36 +22,38 @@ type AllAuthorsViewProps = {
}
const PAGE_SIZE = 20
const ALPHABET = [...'@АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ']
const ALPHABET = [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@']
export const AllAuthorsView = (props: AllAuthorsViewProps) => {
const [limit, setLimit] = createSignal(PAGE_SIZE)
const { searchParams, changeSearchParam } = useRouter()
const [filterResults, setFilterResults] = createSignal<Author[]>([])
const { searchParams, changeSearchParam } = useRouter<AllAuthorsPageSearchParams>()
const { sortedAuthors } = useAuthorsStore({
authors: props.authors,
sortBy: (searchParams().by || 'shouts') as AuthorsSortBy
sortBy: searchParams().by || 'shouts'
})
const [searchQuery, setSearchQuery] = createSignal('')
const { session } = useSession()
onMount(() => {
if (!searchParams().by) {
setAuthorsSort('name')
changeSearchParam('by', 'name')
changeSearchParam('by', 'shouts')
}
})
createEffect(() => {
setAuthorsSort((searchParams().by || 'shouts') as AuthorsSortBy)
setFilterResults(sortedAuthors())
setLimit(PAGE_SIZE)
setAuthorsSort(searchParams().by || 'shouts')
})
const byLetter = createMemo<{ [letter: string]: Author[] }>(() => {
return sortedAuthors().reduce((acc, author) => {
let letter = author.name.trim().split(' ').pop().at(0).toUpperCase()
if (!/[А-я]/i.test(letter) && locale() === 'ru') letter = '@'
if (/[^ËА-яё]/.test(letter) && locale() === 'ru') letter = '@'
if (!acc[letter]) acc[letter] = []
acc[letter].push(author)
return acc
}, {} as { [letter: string]: Author[] })
@ -60,41 +62,34 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetter())
keys.sort()
keys.push(keys.shift())
return keys
})
const subscribed = (s) => Boolean(session()?.news?.authors && session()?.news?.authors?.includes(s || ''))
// eslint-disable-next-line sonarjs/cognitive-complexity
const filterAuthors = (value) => {
/* very stupid filter by string algorithm with no deps */
let q = value.toLowerCase()
if (q.length > 0) {
setFilterResults([])
if (locale() === 'ru') q = translit(q, 'ru')
const aaa: Author[] = sortedAuthors()
sortedAuthors().forEach((author) => {
let flag = false
author.slug.split('-').forEach((w) => {
if (w.startsWith(q)) flag = true
})
const filteredAuthors = createMemo(() => {
let q = searchQuery().toLowerCase()
if (!flag) {
let wrds: string = author.name.toLowerCase()
if (locale() === 'ru') wrds = translit(wrds, 'ru')
wrds.split(' ').forEach((w: string) => {
if (w.startsWith(q)) flag = true
})
if (q.length === 0) {
return sortedAuthors()
}
if (!flag && aaa.includes(author)) {
const idx = aaa.indexOf(author)
aaa.splice(idx, 1)
if (locale() === 'ru') q = translit(q)
return sortedAuthors().filter((author) => {
if (author.slug.split('-').some((w) => w.startsWith(q))) {
return true
}
let name = author.name.toLowerCase()
if (locale() === 'ru') {
name = translit(name)
}
return name.split(' ').some((word) => word.startsWith(q))
})
})
setFilterResults(aaa)
}
}
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
const AllAuthorsHead = () => (
@ -104,7 +99,7 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
<p>{t('Subscribe who you like to tune your personal feed')}</p>
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
<li classList={{ selected: searchParams().by === 'shouts' }}>
<li classList={{ selected: !searchParams().by || searchParams().by === 'shouts' }}>
<a href="/authors?by=shouts">{t('By shouts')}</a>
</li>
<li classList={{ selected: searchParams().by === 'followers' }}>
@ -115,7 +110,7 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
</li>
<Show when={searchParams().by !== 'name'}>
<li class="view-switcher__search">
<SearchField onChange={filterAuthors} />
<SearchField onChange={(value) => setSearchQuery(value)} />
</li>
</Show>
</ul>
@ -139,7 +134,10 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
<Show when={letter in byLetter()} fallback={letter}>
<a
href={`/authors?by=name#letter-${index()}`}
onClick={() => scrollHandler(`letter-${index()}`)}
onClick={(event) => {
event.preventDefault()
scrollHandler(`letter-${index()}`)
}}
>
{letter}
</a>
@ -152,9 +150,9 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
</div>
<For each={sortedKeys()}>
{(letter, index) => (
{(letter) => (
<div class={clsx(styles.group, 'group')}>
<h2 id={`letter-${index()}`}>{letter}</h2>
<h2 id={`letter-${ALPHABET.indexOf(letter)}`}>{letter}</h2>
<div class="container">
<div class="row">
<div class="col-lg-10">
@ -178,8 +176,8 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
</For>
</Show>
<Show when={searchParams().by && searchParams().by !== 'title'}>
<For each={filterResults().slice(0, limit())}>
<Show when={searchParams().by && searchParams().by !== 'name'}>
<For each={filteredAuthors().slice(0, limit())}>
{(author) => (
<div class="row">
<div class="col-lg-10 col-xl-9">
@ -197,7 +195,7 @@ export const AllAuthorsView = (props: AllAuthorsViewProps) => {
</For>
</Show>
<Show when={sortedAuthors().length > limit() && searchParams().by !== 'name'}>
<Show when={filteredAuthors().length > limit() && searchParams().by !== 'name'}>
<div class="row">
<div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10')}>
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>

View File

@ -22,7 +22,7 @@ type AllTopicsViewProps = {
}
const PAGE_SIZE = 20
const ALPHABET = [...'#АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ']
const ALPHABET = [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ#']
export const AllTopicsView = (props: AllTopicsViewProps) => {
const { searchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>()
@ -37,20 +37,18 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
onMount(() => {
if (!searchParams().by) {
setTopicsSort('shouts')
changeSearchParam('by', 'shouts')
}
})
createEffect(() => {
setTopicsSort(searchParams().by || 'shouts')
setFilterResults(sortedTopics())
setLimit(PAGE_SIZE)
})
const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => {
return sortedTopics().reduce((acc, topic) => {
let letter = topic.title[0].toUpperCase()
if (!/[А-я]/i.test(letter) && locale() === 'ru') letter = '#'
if (/[Аё]/.test(letter) && locale() === 'ru') letter = '#'
if (!acc[letter]) acc[letter] = []
acc[letter].push(topic)
return acc
@ -60,6 +58,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetter())
keys.sort()
keys.push(keys.shift())
return keys
})
@ -67,37 +66,32 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
const showMore = () => setLimit((oldLimit) => oldLimit + PAGE_SIZE)
const [filterResults, setFilterResults] = createSignal<Topic[]>([])
// eslint-disable-next-line sonarjs/cognitive-complexity
const filterTopics = (value) => {
const [searchQuery, setSearchQuery] = createSignal('')
const filteredResults = createMemo(() => {
/* very stupid filter by string algorithm with no deps */
let q = value.toLowerCase()
if (q.length > 0) {
setFilterResults([])
if (locale() === 'ru') q = translit(q, 'ru')
const ttt: Topic[] = sortedTopics()
sortedTopics().forEach((topic) => {
let flag = false
topic.slug.split('-').forEach((w) => {
if (w.startsWith(q)) flag = true
})
if (!flag) {
let wrds: string = topic.title.toLowerCase()
if (locale() === 'ru') wrds = translit(wrds, 'ru')
wrds.split(' ').forEach((w: string) => {
if (w.startsWith(q)) flag = true
})
let q = searchQuery().toLowerCase()
if (q.length === 0) {
return sortedTopics()
}
if (!flag && ttt.includes(topic)) {
const idx = ttt.indexOf(topic)
ttt.splice(idx, 1)
if (locale() === 'ru') {
q = translit(q)
}
return sortedTopics().filter((topic) => {
if (topic.slug.split('-').some((w) => w.startsWith(q))) {
return true
}
let title = topic.title.toLowerCase()
if (locale() === 'ru') {
title = translit(title)
}
return title.split(' ').some((word) => word.startsWith(q))
})
})
setFilterResults(ttt)
}
}
const AllTopicsHead = () => (
<div class="row">
@ -117,7 +111,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</li>
<Show when={searchParams().by !== 'title'}>
<li class="view-switcher__search">
<SearchField onChange={filterTopics} />
<SearchField onChange={(value) => setSearchQuery(value)} />
</li>
</Show>
</ul>
@ -130,7 +124,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
<div class="shift-content">
<AllTopicsHead />
<Show when={filterResults().length > 0}>
<Show when={filteredResults().length > 0}>
<Show when={searchParams().by === 'title'}>
<div class="col-lg-10 col-xl-9">
<ul class={clsx('nodash', styles.alphabet)}>
@ -140,7 +134,10 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
<Show when={letter in byLetter()} fallback={letter}>
<a
href={`/topics?by=title#letter-${index()}`}
onClick={() => scrollHandler(`letter-${index()}`)}
onClick={(event) => {
event.preventDefault()
scrollHandler(`letter-${index()}`)
}}
>
{letter}
</a>
@ -152,9 +149,9 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</div>
<For each={sortedKeys()}>
{(letter, index) => (
{(letter) => (
<div class={clsx(styles.group, 'group')}>
<h2 id={`letter-${index()}`}>{letter}</h2>
<h2 id={`letter-${ALPHABET.indexOf(letter)}`}>{letter}</h2>
<div class="container">
<div class="row">
<div class="col-lg-10">
@ -177,7 +174,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</Show>
<Show when={searchParams().by && searchParams().by !== 'title'}>
<For each={filterResults().slice(0, limit())}>
<For each={filteredResults().slice(0, limit())}>
{(topic) => (
<>
<TopicCard
@ -192,7 +189,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</For>
</Show>
<Show when={sortedTopics().length > limit() && searchParams().by !== 'title'}>
<Show when={filteredResults().length > limit() && searchParams().by !== 'title'}>
<div class={clsx(styles.loadMoreContainer, 'col-12 col-md-10 offset-md-1')}>
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
{t('Load more')}

View File

@ -13,7 +13,12 @@ import Group from '../Feed/Group'
import type { Shout, Topic } from '../../graphql/types.gen'
import { t } from '../../utils/intl'
import { useTopicsStore } from '../../stores/zine/topics'
import { loadShouts, useArticlesStore } from '../../stores/zine/articles'
import {
loadShouts,
loadTopArticles,
loadTopMonthArticles,
useArticlesStore
} from '../../stores/zine/articles'
import { useTopAuthorsStore } from '../../stores/zine/topAuthors'
import { locale } from '../../stores/ui'
import { restoreScrollPosition, saveScrollPosition } from '../../utils/scroll'
@ -48,6 +53,8 @@ export const HomeView = (props: HomeProps) => {
const { topAuthors } = useTopAuthorsStore()
onMount(async () => {
loadTopArticles()
loadTopMonthArticles()
if (sortedArticles().length < PRERENDERED_ARTICLES_COUNT + CLIENT_LOAD_ARTICLES_COUNT) {
const { hasMore } = await loadShouts({
filters: { visibility: 'public' },

View File

@ -149,7 +149,8 @@ export const TopicView = (props: TopicProps) => {
<Row2 articles={sortedArticles().slice(13, 15)} isEqual={true} />
<Row1 article={sortedArticles()[15]} />
<Slider slidesPerView={3} title={title()}>
<Show when={sortedArticles().length > 15}>
<Slider slidesPerView={3}>
<For each={sortedArticles().slice(16, 22)}>
{(a: Shout) => (
<ArticleCard
@ -167,6 +168,7 @@ export const TopicView = (props: TopicProps) => {
<Row3 articles={sortedArticles().slice(23, 26)} />
<Row2 articles={sortedArticles().slice(26, 28)} />
</Show>
<For each={pages()}>
{(page) => (

View File

@ -7,7 +7,7 @@ import { createStore } from 'solid-js/store'
type InboxContextType = {
chatEntities: { [chatId: string]: Message[] }
actions: {
createChat: (members: string[], title: string) => Promise<void>
createChat: (members: number[], title: string) => Promise<void>
}
}
@ -20,7 +20,7 @@ export function useInbox() {
export const InboxProvider = (props: { children: JSX.Element }) => {
const [chatEntities, setChatEntities] = createStore({})
const createChat = async (members: string[], title: string) => {
const createChat = async (members: number[], title: string) => {
const chat = await apiClient.createChat({ members, title })
setChatEntities((s) => {
s[chat.id] = chat

View File

@ -1,15 +1,15 @@
import type { Accessor, InitializedResource, JSX } from 'solid-js'
import type { Accessor, JSX, Resource } from 'solid-js'
import { createContext, createMemo, createResource, onMount, useContext } from 'solid-js'
import type { AuthResult } from '../graphql/types.gen'
import { apiClient } from '../utils/apiClient'
import { resetToken, setToken } from '../graphql/privateGraphQLClient'
type SessionContextType = {
session: InitializedResource<AuthResult>
session: Resource<AuthResult>
userSlug: Accessor<string>
isAuthenticated: Accessor<boolean>
actions: {
getSession: () => AuthResult | Promise<AuthResult>
loadSession: () => AuthResult | Promise<AuthResult>
signIn: ({ email, password }: { email: string; password: string }) => Promise<void>
signOut: () => Promise<void>
confirmEmail: (token: string) => Promise<void>
@ -27,7 +27,7 @@ const getSession = async (): Promise<AuthResult> => {
setToken(authResult.token)
return authResult
} catch (error) {
console.error('renewSession error:', error)
console.error('getSession error:', error)
resetToken()
return null
}
@ -38,10 +38,7 @@ export function useSession() {
}
export const SessionProvider = (props: { children: JSX.Element }) => {
const [session, { refetch: refetchSession, mutate }] = createResource<AuthResult>(getSession, {
ssrLoadFrom: 'initial',
initialValue: null
})
const [session, { refetch: loadSession, mutate }] = createResource<AuthResult>(getSession)
const userSlug = createMemo(() => session()?.user?.slug)
@ -68,7 +65,7 @@ export const SessionProvider = (props: { children: JSX.Element }) => {
}
const actions = {
getSession: refetchSession,
loadSession,
signIn,
signOut,
confirmEmail
@ -77,7 +74,7 @@ export const SessionProvider = (props: { children: JSX.Element }) => {
const value: SessionContextType = { session, userSlug, isAuthenticated, actions }
onMount(() => {
refetchSession()
loadSession()
})
return <SessionContext.Provider value={value}>{props.children}</SessionContext.Provider>

View File

@ -11,12 +11,12 @@ export default gql`
subtitle
body
topics {
_id: slug
id
title
slug
}
authors {
_id: slug
id
name
slug
userpic

View File

@ -12,13 +12,13 @@ export default gql`
image
body
topics {
_id: slug
id
title
slug
image
}
authors {
_id: slug
id
name
slug
userpic

View File

@ -1,7 +1,7 @@
import { gql } from '@urql/core'
export default gql`
mutation FollowQuery($what: String!, $slug: String!) {
mutation FollowMutation($what: FollowingEntity!, $slug: String!) {
follow(what: $what, slug: $slug) {
error
}

View File

@ -1,7 +1,7 @@
import { gql } from '@urql/core'
export default gql`
mutation UnfollowQuery($what: String!, $slug: String!) {
mutation UnfollowMutation($what: FollowingEntity!, $slug: String!) {
unfollow(what: $what, slug: $slug) {
error
}

View File

@ -16,6 +16,10 @@ export const getToken = (): string => {
}
export const setToken = (token: string) => {
if (!token) {
console.error('[privateGraphQLClient] setToken: token is null!')
}
localStorage.setItem(TOKEN_LOCAL_STORAGE_KEY, token)
}
@ -28,11 +32,12 @@ const options: ClientOptions = {
maskTypename: true,
requestPolicy: 'cache-and-network',
fetchOptions: () => {
// пока источником правды для значения токена будет локальное хранилище
// меняем через setToken, например при получении значения с сервера
// скорее всего придумаем что-нибудь получше со временем
// localStorage is the source of truth for now
// to change token call setToken, for example after login
const token = localStorage.getItem(TOKEN_LOCAL_STORAGE_KEY)
if (token === null) alert('token is null')
if (!token) {
console.error('[privateGraphQLClient] fetchOptions: token is null!')
}
const headers = { Authorization: token }
return { headers }
},

View File

@ -14,6 +14,7 @@ export default gql`
# community
mainTopic
topics {
id
title
body
slug
@ -25,7 +26,7 @@ export default gql`
}
}
authors {
_id: slug
id
name
slug
userpic

View File

@ -12,6 +12,7 @@ export default gql`
# community
mainTopic
topics {
id
title
body
slug
@ -23,7 +24,7 @@ export default gql`
}
}
authors {
_id: slug
id
name
slug
userpic

View File

@ -14,6 +14,7 @@ export default gql`
# community
mainTopic
topics {
id
title
body
slug
@ -25,7 +26,7 @@ export default gql`
}
}
authors {
_id: slug
id
name
slug
userpic

View File

@ -4,6 +4,7 @@ export default gql`
query GetCollabsQuery {
getCollabs {
authors {
id
slug
name
pic

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core'
export default gql`
query TopicsRandomQuery {
topicsRandom {
query TopicsRandomQuery($amount: Int) {
topicsRandom(amount: $amount) {
_id: slug
title
body

View File

@ -84,10 +84,10 @@ export type ChatMember = {
export type Collab = {
authors: Array<Maybe<Scalars['String']>>
body?: Maybe<Scalars['String']>
createdAt: Scalars['DateTime']
chat?: Maybe<Chat>
createdAt: Scalars['Int']
invites?: Maybe<Array<Maybe<Scalars['String']>>>
title?: Maybe<Scalars['String']>
shout?: Maybe<Shout>
}
export type Collection = {
@ -164,6 +164,7 @@ export type MessagesBy = {
}
export type Mutation = {
acceptCoauthor: Result
confirmEmail: AuthResult
createChat: Result
createMessage: Result
@ -177,11 +178,11 @@ export type Mutation = {
destroyTopic: Result
follow: Result
getSession: AuthResult
inviteAuthor: Result
inviteCoauthor: Result
markAsRead: Result
rateUser: Result
registerUser: AuthResult
removeAuthor: Result
removeCoauthor: Result
sendLink: Result
unfollow: Result
updateChat: Result
@ -193,19 +194,23 @@ export type Mutation = {
updateTopic: Result
}
export type MutationAcceptCoauthorArgs = {
shout: Scalars['Int']
}
export type MutationConfirmEmailArgs = {
token: Scalars['String']
}
export type MutationCreateChatArgs = {
members: Array<InputMaybe<Scalars['String']>>
members: Array<InputMaybe<Scalars['Int']>>
title?: InputMaybe<Scalars['String']>
}
export type MutationCreateMessageArgs = {
body: Scalars['String']
chat: Scalars['String']
replyTo?: InputMaybe<Scalars['String']>
replyTo?: InputMaybe<Scalars['Int']>
}
export type MutationCreateReactionArgs = {
@ -213,7 +218,7 @@ export type MutationCreateReactionArgs = {
}
export type MutationCreateShoutArgs = {
input: ShoutInput
inp: ShoutInput
}
export type MutationCreateTopicArgs = {
@ -246,9 +251,9 @@ export type MutationFollowArgs = {
what: FollowingEntity
}
export type MutationInviteAuthorArgs = {
export type MutationInviteCoauthorArgs = {
author: Scalars['String']
shout: Scalars['String']
shout: Scalars['Int']
}
export type MutationMarkAsReadArgs = {
@ -267,14 +272,15 @@ export type MutationRegisterUserArgs = {
password?: InputMaybe<Scalars['String']>
}
export type MutationRemoveAuthorArgs = {
export type MutationRemoveCoauthorArgs = {
author: Scalars['String']
shout: Scalars['String']
shout: Scalars['Int']
}
export type MutationSendLinkArgs = {
email: Scalars['String']
lang?: InputMaybe<Scalars['String']>
template?: InputMaybe<Scalars['String']>
}
export type MutationUnfollowArgs = {
@ -301,7 +307,7 @@ export type MutationUpdateReactionArgs = {
}
export type MutationUpdateShoutArgs = {
input: ShoutInput
inp: ShoutInput
}
export type MutationUpdateTopicArgs = {
@ -320,14 +326,16 @@ export type Operation = {
}
export type Permission = {
operation_id: Scalars['Int']
resource_id: Scalars['Int']
operation: Scalars['Int']
resource: Scalars['Int']
}
export type ProfileInput = {
about?: InputMaybe<Scalars['String']>
bio?: InputMaybe<Scalars['String']>
links?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
name?: InputMaybe<Scalars['String']>
slug?: InputMaybe<Scalars['String']>
userpic?: InputMaybe<Scalars['String']>
}
@ -461,7 +469,7 @@ export type Reaction = {
old_id?: Maybe<Scalars['String']>
old_thread?: Maybe<Scalars['String']>
range?: Maybe<Scalars['String']>
replyTo?: Maybe<Reaction>
replyTo?: Maybe<Scalars['Int']>
shout: Shout
stat?: Maybe<Stat>
updatedAt?: Maybe<Scalars['DateTime']>
@ -576,13 +584,14 @@ export type Shout = {
}
export type ShoutInput = {
authors?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
body: Scalars['String']
community: Scalars['String']
community?: InputMaybe<Scalars['Int']>
mainTopic?: InputMaybe<Scalars['String']>
slug: Scalars['String']
slug?: InputMaybe<Scalars['String']>
subtitle?: InputMaybe<Scalars['String']>
title?: InputMaybe<Scalars['String']>
topic_slugs?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
topics?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
versionOf?: InputMaybe<Scalars['String']>
visibleForRoles?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
visibleForUsers?: InputMaybe<Array<InputMaybe<Scalars['String']>>>

View File

@ -162,6 +162,36 @@ type InitialState = {
shouts?: Shout[]
}
const TOP_MONTH_ARTICLES_COUNT = 10
export const loadTopMonthArticles = async (): Promise<void> => {
const articles = await apiClient.getShouts({
filters: {
visibility: 'public',
// TODO: replace with from, to
days: 30
},
order_by: 'rating_stat',
limit: TOP_MONTH_ARTICLES_COUNT
})
addArticles(articles)
setTopMonthArticles(articles)
}
const TOP_ARTICLES_COUNT = 10
export const loadTopArticles = async (): Promise<void> => {
const articles = await apiClient.getShouts({
filters: {
visibility: 'public'
},
order_by: 'rating_stat',
limit: TOP_ARTICLES_COUNT
})
addArticles(articles)
setTopArticles(articles)
}
export const useArticlesStore = (initialState: InitialState = {}) => {
addArticles([...(initialState.shouts || [])])

View File

@ -3,10 +3,7 @@ import { apiClient } from '../../utils/apiClient'
export const follow = async ({ what, slug }: { what: FollowingEntity; slug: string }) => {
await apiClient.follow({ what, slug })
// refresh session
// TODO: _store update code
}
export const unfollow = async ({ what, slug }: { what: FollowingEntity; slug: string }) => {
await apiClient.unfollow({ what, slug })
// TODO: store update
}

View File

@ -168,11 +168,11 @@ export const apiClient = {
// subscribe
follow: async ({ what, slug }: { what: FollowingEntity; slug: string }) => {
const response = await privateGraphQLClient.query(followMutation, { what, slug }).toPromise()
const response = await privateGraphQLClient.mutation(followMutation, { what, slug }).toPromise()
return response.data.follow
},
unfollow: async ({ what, slug }: { what: FollowingEntity; slug: string }) => {
const response = await privateGraphQLClient.query(unfollowMutation, { what, slug }).toPromise()
const response = await privateGraphQLClient.mutation(unfollowMutation, { what, slug }).toPromise()
return response.data.unfollow
},
@ -210,7 +210,7 @@ export const apiClient = {
},
getAuthor: async ({ slug }: { slug: string }): Promise<Author> => {
const response = await publicGraphQLClient.query(authorBySlug, { slug }).toPromise()
console.debug('getAuthor', response)
// console.debug('getAuthor', response)
return response.data.getAuthor
},
getTopic: async ({ slug }: { slug: string }): Promise<Topic> => {

View File

@ -65,13 +65,16 @@ const ru2en: { [key: string]: string } = {
я: 'ya'
}
export const translit = (x: string, l?: string) => {
let n
n = [...(x || '')]
const isCyrillic = /[ЁА-яё]/.test(x || '')
if (l !== 'ru' && isCyrillic) n = n.map((c: string) => ru2en[c] || c)
return n.join('')
export const translit = (str: string) => {
if (!str) {
return ''
}
const isCyrillic = /[ЁА-яё]/.test(str)
if (!isCyrillic) {
return str
}
return [...str].map((c) => ru2en[c] || c).join('')
}

View File

@ -15,12 +15,12 @@ export const restoreScrollPosition = () => {
})
}
export const scrollHandler = (elemId) => {
export const scrollHandler = (elemId: string, offset = -100) => {
const anchor = document.querySelector('#' + elemId)
// console.debug(elemId)
if (anchor) {
window.scrollTo({
top: anchor.getBoundingClientRect().top - 100,
top: anchor.getBoundingClientRect().top + offset,
behavior: 'smooth'
})
}