Feature/all authors order (#410)

Load Authors by btn click
---------

Co-authored-by: Untone <anton.rewin@gmail.com>
This commit is contained in:
Ilya Y 2024-02-22 10:29:52 +03:00 committed by GitHub
parent 01a4b558bd
commit 4e931a39c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 368 additions and 272 deletions

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run pre-commit

View File

@ -291,6 +291,7 @@
"Profile": "Profile",
"Publications": "Publications",
"PublicationsWithCount": "{count, plural, =0 {no publications} one {{count} publication} other {{count} publications}}",
"FollowersWithCount": "{count, plural, =0 {no followers} one {{count} follower} other {{count} followers}}",
"Publish Album": "Publish Album",
"Publish Settings": "Publish Settings",
"Published": "Published",

View File

@ -309,9 +309,10 @@
"Publication settings": "Настройки публикации",
"Publications": "Публикации",
"PublicationsWithCount": "{count, plural, =0 {нет публикаций} one {{count} публикация} few {{count} публикации} other {{count} публикаций}}",
"FollowersWithCount": "{count, plural, =0 {нет подписчиков} one {{count} подписчик} few {{count} подписчика} other {{count} подписчиков}}",
"Publish": "Опубликовать",
"Publish Album": "Опубликовать альбом",
"Publish Settings": "Настройки публикации",
"Publish": "Опубликовать",
"Published": "Опубликованные",
"Punchline": "Панчлайн",
"Quit": "Выйти",

View File

@ -58,6 +58,11 @@
}
.bio {
@include font-size(1.2rem);
display: flex;
flex-direction: row;
gap: 1rem;
color: var(--black-400);
font-weight: 500;
}

View File

@ -118,12 +118,17 @@ export const AuthorBadge = (props: Props) => {
<Match when={props.author.bio}>
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio} />
</Match>
<Match when={props.author?.stat && props.author?.stat.shouts > 0}>
<div class={styles.bio}>
{t('PublicationsWithCount', { count: props.author?.stat.shouts ?? 0 })}
</div>
</Match>
</Switch>
<Show when={props.author?.stat}>
<div class={styles.bio}>
<Show when={props.author?.stat.shouts > 0}>
<div>{t('PublicationsWithCount', { count: props.author.stat?.shouts ?? 0 })}</div>
</Show>
<Show when={props.author?.stat.followers > 0}>
<div>{t('FollowersWithCount', { count: props.author.stat?.followers ?? 0 })}</div>
</Show>
</div>
</Show>
</Show>
</ConditionalWrapper>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,20 @@
import { clsx } from 'clsx'
import { useLocalize } from '../../context/localize'
import { Loading } from '../_shared/Loading'
import styles from './InlineLoader.module.scss'
type Props = {
class?: string
}
export const InlineLoader = (props: Props) => {
const { t } = useLocalize()
return (
<div class={styles.InlineLoader}>
<div class={styles.icon}>
<Loading size="tiny" />
</div>
<div>{t('Loading')}</div>
</div>
)
}

View File

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

View File

@ -1,234 +0,0 @@
import type { Author } from '../../graphql/schema/core.gen'
import { Meta } from '@solidjs/meta'
import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'
import { useFollowing } from '../../context/following'
import { useLocalize } from '../../context/localize'
import { useRouter } from '../../stores/router'
import { loadAuthors, setAuthorsSort, useAuthorsStore } from '../../stores/zine/authors'
import { dummyFilter } from '../../utils/dummyFilter'
import { getImageUrl } from '../../utils/getImageUrl'
import { scrollHandler } from '../../utils/scroll'
import { authorLetterReduce, translateAuthor } from '../../utils/translate'
import { AuthorBadge } from '../Author/AuthorBadge'
import { Loading } from '../_shared/Loading'
import { SearchField } from '../_shared/SearchField'
import styles from './AllAuthors.module.scss'
type AllAuthorsPageSearchParams = {
by: '' | 'name' | 'shouts' | 'followers'
}
type Props = {
authors: Author[]
isLoaded: boolean
}
const PAGE_SIZE = 20
export const AllAuthorsView = (props: Props) => {
const { t, lang } = useLocalize()
const ALPHABET =
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ@']
const [offsetByShouts, setOffsetByShouts] = createSignal(0)
const [offsetByFollowers, setOffsetByFollowers] = createSignal(0)
const { searchParams, changeSearchParams } = useRouter<AllAuthorsPageSearchParams>()
const { sortedAuthors } = useAuthorsStore({
authors: props.authors,
sortBy: searchParams().by || 'name',
})
const [searchQuery, setSearchQuery] = createSignal('')
const offset = searchParams()?.by === 'shouts' ? offsetByShouts : offsetByFollowers
createEffect(() => {
let by = searchParams().by
if (by) {
setAuthorsSort(by)
} else {
by = 'name'
changeSearchParams({ by })
}
})
const loadMoreByShouts = async () => {
await loadAuthors({ by: { order: 'shouts_stat' }, limit: PAGE_SIZE, offset: offsetByShouts() })
setOffsetByShouts((o) => o + PAGE_SIZE)
}
const loadMoreByFollowers = async () => {
await loadAuthors({ by: { order: 'followers_stat' }, limit: PAGE_SIZE, offset: offsetByFollowers() })
setOffsetByFollowers((o) => o + PAGE_SIZE)
}
const isStatsLoaded = createMemo(() => sortedAuthors()?.some((author) => author.stat))
createEffect(async () => {
if (!isStatsLoaded()) {
await loadMoreByShouts()
await loadMoreByFollowers()
}
})
const showMore = async () =>
await {
shouts: loadMoreByShouts,
followers: loadMoreByFollowers,
}[searchParams().by]()
const byLetter = createMemo<{ [letter: string]: Author[] }>(() => {
return sortedAuthors().reduce(
(acc, author) => authorLetterReduce(acc, author, lang()),
{} as { [letter: string]: Author[] },
)
})
const { isOwnerSubscribed } = useFollowing()
const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetter())
keys.sort()
keys.push(keys.shift())
return keys
})
const filteredAuthors = createMemo(() => {
return dummyFilter(sortedAuthors(), searchQuery(), lang())
})
const ogImage = getImageUrl('production/image/logo_image.png')
const ogTitle = t('Authors')
const description = t('List of authors of the open editorial community')
return (
<div class={clsx(styles.allAuthorsPage, 'wide-container')}>
<Meta name="descprition" content={description} />
<Meta name="keywords" content={t('keywords')} />
<Meta name="og:type" content="article" />
<Meta name="og:title" content={ogTitle} />
<Meta name="og:image" content={ogImage} />
<Meta name="twitter:image" content={ogImage} />
<Meta name="og:description" content={description} />
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={ogTitle} />
<Meta name="twitter:description" content={description} />
<Show when={props.isLoaded} fallback={<Loading />}>
<div class="offset-md-5">
<div class="row">
<div class="col-lg-20 col-xl-18">
<h1>{t('Authors')}</h1>
<p>{t('Subscribe who you like to tune your personal feed')}</p>
<Show when={isStatsLoaded()}>
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
<li
classList={{
'view-switcher__item--selected': !searchParams().by || searchParams().by === 'shouts',
}}
>
<a href="/authors?by=shouts">{t('By shouts')}</a>
</li>
<li classList={{ 'view-switcher__item--selected': searchParams().by === 'followers' }}>
<a href="/authors?by=followers">{t('By popularity')}</a>
</li>
<li classList={{ 'view-switcher__item--selected': searchParams().by === 'name' }}>
<a href="/authors?by=name">{t('By name')}</a>
</li>
<Show when={searchParams().by !== 'name'}>
<li class="view-switcher__search">
<SearchField onChange={(value) => setSearchQuery(value)} />
</li>
</Show>
</ul>
</Show>
</div>
</div>
<Show when={sortedAuthors().length > 0}>
<Show when={searchParams().by === 'name'}>
<div class="row">
<div class="col-lg-20 col-xl-18">
<ul class={clsx('nodash', styles.alphabet)}>
<For each={ALPHABET}>
{(letter, index) => (
<li>
<Show when={letter in byLetter()} fallback={letter}>
<a
href={`/authors?by=name#letter-${index()}`}
onClick={(event) => {
event.preventDefault()
scrollHandler(`letter-${index()}`)
}}
>
{letter}
</a>
</Show>
</li>
)}
</For>
</ul>
</div>
</div>
<For each={sortedKeys()}>
{(letter) => (
<div class={clsx(styles.group, 'group')}>
<h2 id={`letter-${ALPHABET.indexOf(letter)}`}>{letter}</h2>
<div class="container">
<div class="row">
<div class="col-lg-20">
<div class="row">
<For each={byLetter()[letter]}>
{(author) => (
<div class={clsx(styles.topic, 'topic col-sm-12 col-md-8')}>
<div class="topic-title">
<a href={`/author/${author.slug}`}>{translateAuthor(author, lang())}</a>
<Show when={author.stat}>
<span class={styles.articlesCounter}>{author.stat.shouts}</span>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
</div>
</div>
</div>
)}
</For>
</Show>
<Show when={searchParams().by && searchParams().by !== 'name'}>
<For each={filteredAuthors().slice(0, PAGE_SIZE)}>
{(author) => (
<div class="row">
<div class="col-lg-20 col-xl-18">
<AuthorBadge
author={author as Author}
isFollowed={{
loaded: Boolean(filteredAuthors()),
value: isOwnerSubscribed(author.id),
}}
/>
</div>
</div>
)}
</For>
</Show>
<Show when={filteredAuthors().length > PAGE_SIZE + offset() && searchParams().by !== 'name'}>
<div class="row">
<div class={clsx(styles.loadMoreContainer, 'col-24 col-md-20')}>
<button class={clsx('button', styles.loadMoreButton)} onClick={showMore}>
{t('Load more')}
</button>
</div>
</div>
</Show>
</Show>
</div>
</Show>
</div>
)
}

View File

@ -0,0 +1,177 @@
import type { Author } from '../../../graphql/schema/core.gen'
import { Meta } from '@solidjs/meta'
import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useRouter } from '../../../stores/router'
import { setAuthorsSort, useAuthorsStore } from '../../../stores/zine/authors'
import { getImageUrl } from '../../../utils/getImageUrl'
import { scrollHandler } from '../../../utils/scroll'
import { authorLetterReduce, translateAuthor } from '../../../utils/translate'
import { AuthorsList } from '../../AuthorsList'
import { Loading } from '../../_shared/Loading'
import { SearchField } from '../../_shared/SearchField'
import styles from './AllAuthors.module.scss'
type AllAuthorsPageSearchParams = {
by: '' | 'name' | 'shouts' | 'followers'
}
type Props = {
authors: Author[]
isLoaded: boolean
}
export const AllAuthors = (props: Props) => {
const { t, lang } = useLocalize()
const ALPHABET =
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ@']
const { searchParams, changeSearchParams } = useRouter<AllAuthorsPageSearchParams>()
const { sortedAuthors } = useAuthorsStore({
authors: props.authors,
sortBy: searchParams().by || 'name',
})
const [searchQuery, setSearchQuery] = createSignal('')
createEffect(() => {
let by = searchParams().by
if (by) {
setAuthorsSort(by)
} else {
by = 'name'
changeSearchParams({ by })
}
})
const byLetter = createMemo<{ [letter: string]: Author[] }>(() => {
return sortedAuthors().reduce(
(acc, author) => authorLetterReduce(acc, author, lang()),
{} as { [letter: string]: Author[] },
)
})
const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetter())
keys.sort()
keys.push(keys.shift())
return keys
})
const ogImage = getImageUrl('production/image/logo_image.png')
const ogTitle = t('Authors')
const description = t('List of authors of the open editorial community')
return (
<div class={clsx(styles.allAuthorsPage, 'wide-container')}>
<Meta name="descprition" content={description} />
<Meta name="keywords" content={t('keywords')} />
<Meta name="og:type" content="article" />
<Meta name="og:title" content={ogTitle} />
<Meta name="og:image" content={ogImage} />
<Meta name="twitter:image" content={ogImage} />
<Meta name="og:description" content={description} />
<Meta name="twitter:card" content="summary_large_image" />
<Meta name="twitter:title" content={ogTitle} />
<Meta name="twitter:description" content={description} />
<Show when={props.isLoaded} fallback={<Loading />}>
<div class="offset-md-5">
<div class="row">
<div class="col-lg-20 col-xl-18">
<h1>{t('Authors')}</h1>
<p>{t('Subscribe who you like to tune your personal feed')}</p>
<ul class={clsx(styles.viewSwitcher, 'view-switcher')}>
<li
class={clsx({
['view-switcher__item--selected']: !searchParams().by || searchParams().by === 'shouts',
})}
>
<a href="/authors?by=shouts">{t('By shouts')}</a>
</li>
<li
class={clsx({
['view-switcher__item--selected']: searchParams().by === 'followers',
})}
>
<a href="/authors?by=followers">{t('By popularity')}</a>
</li>
<li
class={clsx({
['view-switcher__item--selected']: searchParams().by === 'name',
})}
>
<a href="/authors?by=name">{t('By name')}</a>
</li>
<Show when={searchParams().by !== 'name'}>
<li class="view-switcher__search">
<SearchField onChange={(value) => setSearchQuery(value)} />
</li>
</Show>
</ul>
</div>
</div>
<Show when={searchParams().by === 'name'}>
<div class="row">
<div class="col-lg-20 col-xl-18">
<ul class={clsx('nodash', styles.alphabet)}>
<For each={ALPHABET}>
{(letter, index) => (
<li>
<Show when={letter in byLetter()} fallback={letter}>
<a
href={`/authors?by=name#letter-${index()}`}
onClick={(event) => {
event.preventDefault()
scrollHandler(`letter-${index()}`)
}}
>
{letter}
</a>
</Show>
</li>
)}
</For>
</ul>
</div>
</div>
<For each={sortedKeys()}>
{(letter) => (
<div class={clsx(styles.group, 'group')}>
<h2 id={`letter-${ALPHABET.indexOf(letter)}`}>{letter}</h2>
<div class="container">
<div class="row">
<div class="col-lg-20">
<div class="row">
<For each={byLetter()[letter]}>
{(author) => (
<div class={clsx(styles.topic, 'topic col-sm-12 col-md-8')}>
<div class="topic-title">
<a href={`/author/${author.slug}`}>{translateAuthor(author, lang())}</a>
<Show when={author.stat}>
<span class={styles.articlesCounter}>{author.stat.shouts}</span>
</Show>
</div>
</div>
)}
</For>
</div>
</div>
</div>
</div>
</div>
)}
</For>
</Show>
<Show when={searchParams().by !== 'name' && props.isLoaded} fallback={<Loading />}>
<AuthorsList query={searchParams().by === 'shouts' ? 'shouts' : 'followers'} />
</Show>
</div>
</Show>
</div>
)
}

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import { Button } from '../Button'
import { DropdownSelect } from '../DropdownSelect'
import { Loading } from '../Loading'
import { InlineLoader } from '../../InlineLoader'
import styles from './InviteMembers.module.scss'
type InviteAuthor = Author & { selected: boolean }
@ -62,7 +63,7 @@ export const InviteMembers = (props: Props) => {
return authors?.slice(start, end)
}
const [pages, _infiniteScrollLoader, { end }] = createInfiniteScroll(fetcher)
const [pages, setEl, { end }] = createInfiniteScroll(fetcher)
createEffect(
on(
@ -158,11 +159,8 @@ export const InviteMembers = (props: Props) => {
)}
</For>
<Show when={!end()}>
<div use:infiniteScrollLoader class={styles.loading}>
<div class={styles.icon}>
<Loading size="tiny" />
</div>
<div>{t('Loading')}</div>
<div ref={setEl as (e: HTMLDivElement) => void}>
<InlineLoader />
</div>
</Show>
</div>

View File

@ -1,8 +1,8 @@
import type { PageProps } from './types'
import { createSignal, onMount } from 'solid-js'
import { createEffect, createSignal, onMount } from 'solid-js'
import { AllAuthorsView } from '../components/Views/AllAuthors'
import { AllAuthors } from '../components/Views/AllAuthors/'
import { PageLayout } from '../components/_shared/PageLayout'
import { useLocalize } from '../context/localize'
import { loadAllAuthors } from '../stores/zine/authors'
@ -23,7 +23,7 @@ export const AllAuthorsPage = (props: PageProps) => {
return (
<PageLayout title={t('Authors')}>
<AllAuthorsView isLoaded={isLoaded()} authors={props.allAuthors} />
<AllAuthors isLoaded={isLoaded()} authors={props.allAuthors} />
</PageLayout>
)
}

View File

@ -6,6 +6,7 @@ import { Author, QueryLoad_Authors_ByArgs } from '../../graphql/schema/core.gen'
import { byStat } from '../../utils/sortby'
export type AuthorsSortBy = 'shouts' | 'name' | 'followers'
type SortedAuthorsSetter = (prev: Author[]) => Author[]
const [sortAllBy, setSortAllBy] = createSignal<AuthorsSortBy>('name')
@ -13,6 +14,11 @@ export const setAuthorsSort = (sortBy: AuthorsSortBy) => setSortAllBy(sortBy)
const [authorEntities, setAuthorEntities] = createSignal<{ [authorSlug: string]: Author }>({})
const [authorsByTopic, setAuthorsByTopic] = createSignal<{ [topicSlug: string]: Author[] }>({})
const [authorsByShouts, setSortedAuthorsByShout] = createSignal<Author[]>([])
const [authorsByFollowers, setSortedAuthorsByFollowers] = createSignal<Author[]>([])
export const setAuthorsByShouts = (authors: SortedAuthorsSetter) => setSortedAuthorsByShout(authors)
export const setAuthorsByFollowers = (authors: SortedAuthorsSetter) => setSortedAuthorsByFollowers(authors)
const sortedAuthors = createLazyMemo(() => {
const authors = Object.values(authorEntities())
@ -108,5 +114,5 @@ export const useAuthorsStore = (initialState: InitialState = {}) => {
}
addAuthors([...(initialState.authors || [])])
return { authorEntities, sortedAuthors, authorsByTopic }
return { authorEntities, sortedAuthors, authorsByTopic, authorsByShouts, authorsByFollowers }
}