Feature/profile reorganise (#196)

* Profile reorganise
This commit is contained in:
Ilya Y 2023-09-01 17:28:50 +03:00 committed by GitHub
parent 3a61460af1
commit 6ae2fb07fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 245 additions and 282 deletions

View File

@ -393,5 +393,12 @@
"user already exist": "user already exists", "user already exist": "user already exists",
"video": "video", "video": "video",
"view": "view", "view": "view",
"zine": "zine" "zine": "zine",
"subscriber": "subscriber",
"subscriber_rp": "subscriber",
"subscribers": "subscribers",
"subscription": "subscription",
"subscription_rp": "subscription",
"subscriptions": "subscriptions",
"Users": "Users"
} }

View File

@ -300,6 +300,7 @@
"Subscribe what you like to tune your personal feed": "Подпишитесь на интересующие вас темы, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях", "Subscribe what you like to tune your personal feed": "Подпишитесь на интересующие вас темы, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
"Subscribe who you like to tune your personal feed": "Подпишитесь на интересующих вас авторов, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях", "Subscribe who you like to tune your personal feed": "Подпишитесь на интересующих вас авторов, чтобы настроить вашу персональную ленту и моментально узнавать о новых публикациях и обсуждениях",
"Subscription": "Подписка", "Subscription": "Подписка",
"subscription": "подписка",
"Subscriptions": "Подписки", "Subscriptions": "Подписки",
"Substrate": "Подложка", "Substrate": "Подложка",
"Success": "Успешно", "Success": "Успешно",
@ -387,7 +388,6 @@
"email not confirmed": "email не подтвержден", "email not confirmed": "email не подтвержден",
"enter": "войдите", "enter": "войдите",
"feed": "лента", "feed": "лента",
"follower": "подписчик",
"general feed": "Общая лента", "general feed": "Общая лента",
"header 1": "заголовок 1", "header 1": "заголовок 1",
"header 2": "заголовок 2", "header 2": "заголовок 2",
@ -419,5 +419,12 @@
"video": "видео", "video": "видео",
"view": "просмотр", "view": "просмотр",
"zine": "журнал", "zine": "журнал",
"Enter footnote text": "Введите текст сноски" "Enter footnote text": "Введите текст сноски",
"follower": "подписчик",
"subscriber": "подписчик",
"subscriber_rp": "подписчика",
"subscribers": "подписчиков",
"subscription_rp": "подписки",
"subscriptions": "подписок",
"Users": "Пользователи"
} }

View File

@ -35,7 +35,8 @@ export const AudioPlayer = (props: Props) => {
() => currentTrackIndex(), () => currentTrackIndex(),
() => { () => {
setCurrentTrackDuration(0) setCurrentTrackDuration(0)
} },
{ defer: true }
) )
) )

View File

@ -441,3 +441,25 @@
width: 1.6rem; width: 1.6rem;
} }
} }
.subscribers {
cursor: pointer;
display: inline-flex;
vertical-align: top;
align-items: center;
.userpic {
background: var(--background-color);
box-shadow: 0 0 0 2px var(--background-color);
vertical-align: top;
&:not(:first-child) {
margin-left: -2.2rem;
}
}
}
.listWrapper {
overflow: auto;
max-height: 70vh;
}

View File

@ -1,20 +1,25 @@
import type { Author } from '../../graphql/types.gen' import type { Author } from '../../../graphql/types.gen'
import { Userpic } from './Userpic' import { Userpic } from '../Userpic'
import { Icon } from '../_shared/Icon' import { Icon } from '../../_shared/Icon'
import styles from './AuthorCard.module.scss' import styles from './AuthorCard.module.scss'
import { createMemo, createSignal, For, Show } from 'solid-js' import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'
import { translit } from '../../utils/ru2en' import { translit } from '../../../utils/ru2en'
import { follow, unfollow } from '../../stores/zine/common' import { follow, unfollow } from '../../../stores/zine/common'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useSession } from '../../context/session' import { useSession } from '../../../context/session'
import { StatMetrics } from '../_shared/StatMetrics' import { ShowOnlyOnClient } from '../../_shared/ShowOnlyOnClient'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient' import { FollowingEntity, Topic } from '../../../graphql/types.gen'
import { FollowingEntity } from '../../graphql/types.gen' import { router, useRouter } from '../../../stores/router'
import { router, useRouter } from '../../stores/router'
import { openPage } from '@nanostores/router' import { openPage } from '@nanostores/router'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../../context/localize'
import { ConditionalWrapper } from '../../_shared/ConditionalWrapper'
import { Modal } from '../../Nav/Modal'
import { showModal } from '../../../stores/ui'
import { TopicCard } from '../../Topic/Card'
import { getNumeralsDeclension } from '../../../utils/getNumeralsDeclension'
interface AuthorCardProps { type SubscriptionFilter = 'all' | 'users' | 'topics'
type AuthorCardProps = {
caption?: string caption?: string
hideWriteButton?: boolean hideWriteButton?: boolean
hideDescription?: boolean hideDescription?: boolean
@ -32,6 +37,12 @@ interface AuthorCardProps {
isFeedMode?: boolean isFeedMode?: boolean
isNowrap?: boolean isNowrap?: boolean
class?: string class?: string
followers?: Author[]
subscriptions?: Array<Author | Topic>
}
function isAuthor(value: Author | Topic): value is Author {
return 'name' in value
} }
export const AuthorCard = (props: AuthorCardProps) => { export const AuthorCard = (props: AuthorCardProps) => {
@ -44,6 +55,8 @@ export const AuthorCard = (props: AuthorCardProps) => {
} = useSession() } = useSession()
const [isSubscribing, setIsSubscribing] = createSignal(false) const [isSubscribing, setIsSubscribing] = createSignal(false)
const [subscriptions, setSubscriptions] = createSignal<Array<Author | Topic>>(props.subscriptions)
const [subscriptionFilter, setSubscriptionFilter] = createSignal<SubscriptionFilter>('all')
const subscribed = createMemo<boolean>(() => { const subscribed = createMemo<boolean>(() => {
return session()?.news?.authors?.some((u) => u === props.author.slug) || false return session()?.news?.authors?.some((u) => u === props.author.slug) || false
@ -89,6 +102,18 @@ export const AuthorCard = (props: AuthorCardProps) => {
}, 'subscribe') }, 'subscribe')
} }
createEffect(() => {
if (props.subscriptions) {
if (subscriptionFilter() === 'users') {
setSubscriptions(props.subscriptions.filter((s) => 'name' in s))
} else if (subscriptionFilter() === 'topics') {
setSubscriptions(props.subscriptions.filter((s) => 'title' in s))
} else {
setSubscriptions(props.subscriptions)
}
}
})
return ( return (
<div <div
class={clsx(styles.author, props.class)} class={clsx(styles.author, props.class)}
@ -136,19 +161,20 @@ export const AuthorCard = (props: AuthorCardProps) => {
}} }}
> >
<div class={styles.authorDetailsWrapper}> <div class={styles.authorDetailsWrapper}>
<Show when={props.hasLink}>
<div class={styles.authorNameContainer}> <div class={styles.authorNameContainer}>
<ConditionalWrapper
condition={props.hasLink}
wrapper={(children) => (
<a class={styles.authorName} href={`/author/${props.author.slug}`}> <a class={styles.authorName} href={`/author/${props.author.slug}`}>
{name()} {children}
</a> </a>
)}
>
<span class={clsx({ [styles.authorName]: !props.hasLink })}>{name()}</span>
</ConditionalWrapper>
</div> </div>
</Show>
<Show when={!props.hasLink}>
<div class={styles.authorName}>{name()}</div>
</Show>
<Show when={!props.hideDescription && props.author.bio}> <Show when={props.author.bio}>
{props.isAuthorsList}
<div <div
class={styles.authorAbout} class={styles.authorAbout}
classList={{ 'text-truncate': props.truncateBio }} classList={{ 'text-truncate': props.truncateBio }}
@ -156,8 +182,44 @@ export const AuthorCard = (props: AuthorCardProps) => {
/> />
</Show> </Show>
<Show when={props.author.stat}> <Show when={props.followers && props.followers.length > 0}>
<StatMetrics fields={['shouts', 'followers', 'comments']} stat={props.author.stat} /> <div class={styles.subscribers} onClick={() => showModal('followers')}>
<For each={props.followers.slice(0, 3)}>
{(f) => <Userpic name={f.name} userpic={f.userpic} class={styles.userpic} />}
</For>
<div>
{props.followers.length}&nbsp;
{getNumeralsDeclension(props.followers.length, [
t('subscriber'),
t('subscriber_rp'),
t('subscribers')
])}
</div>
</div>
</Show>
<Show when={props.subscriptions && props.subscriptions.length > 0}>
<div>
<div class={styles.subscribers} onClick={() => showModal('subscriptions')}>
<For each={props.subscriptions.slice(0, 3)}>
{(f) => {
if ('name' in f) {
return <Userpic name={f.name} userpic={f.userpic} class={styles.userpic} />
} else if ('title' in f) {
return <Userpic name={f.title} userpic={f.pic} class={styles.userpic} />
}
return null
}}
</For>
<div>
{props.subscriptions.length}&nbsp;
{getNumeralsDeclension(props.subscriptions.length, [
t('subscription'),
t('subscription_rp'),
t('subscriptions')
])}
</div>
</div>
</div>
</Show> </Show>
</div> </div>
<ShowOnlyOnClient> <ShowOnlyOnClient>
@ -256,6 +318,68 @@ export const AuthorCard = (props: AuthorCardProps) => {
</Show> </Show>
</ShowOnlyOnClient> </ShowOnlyOnClient>
</div> </div>
<Show when={props.followers}>
<Modal variant="wide" name="followers">
<>
<h2>{t('Followers')}</h2>
<div class={styles.listWrapper}>
<div class="row">
<For each={props.followers}>
{(follower: Author) => (
<div class="col-xs-12">
<AuthorCard author={follower} hideWriteButton={true} hasLink={true} />
</div>
)}
</For>
</div>
</div>
</>
</Modal>
</Show>
<Show when={props.subscriptions}>
<Modal variant="wide" name="subscriptions">
<>
<h2>{t('Subscriptions')}</h2>
<ul class="view-switcher">
<li class={clsx({ 'view-switcher__item--selected': true })}>
<button type="button" onClick={() => setSubscriptionFilter('all')}>
{t('All')} {props.subscriptions.length}
</button>
</li>
<li class={clsx({ 'view-switcher__item--selected': false })}>
<button type="button" onClick={() => setSubscriptionFilter('users')}>
{t('Users')} {props.subscriptions.filter((s) => 'name' in s).length}
</button>
</li>
<li class={clsx({ 'view-switcher__item--selected': false })}>
<button type="button" onClick={() => setSubscriptionFilter('topics')}>
{t('Topics')} {props.subscriptions.filter((s) => 'title' in s).length}
</button>
</li>
</ul>
<div class={styles.listWrapper}>
<div class="row">
<For each={subscriptions()}>
{(subscription: Author | Topic) => (
<div class="col-xs-12">
{isAuthor(subscription) ? (
<AuthorCard
author={subscription}
hideWriteButton={true}
hasLink={true}
isTextButton={true}
/>
) : (
<TopicCard compact isTopicInRow showDescription topic={subscription} />
)}
</div>
)}
</For>
</div>
</div>
</>
</Modal>
</Show>
</div> </div>
) )
} }

View File

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

View File

@ -1,9 +0,0 @@
.userDetails {
border-bottom: 2px solid #000;
margin: 0 0 3.6rem;
padding-bottom: 3.6rem;
@include media-breakpoint-down(md) {
text-align: center;
}
}

View File

@ -1,12 +0,0 @@
import type { Author } from '../../graphql/types.gen'
import { AuthorCard } from './AuthorCard'
import styles from './Full.module.scss'
import clsx from 'clsx'
export const AuthorFull = (props: { author: Author }) => {
return (
<div class={clsx(styles.userDetails)}>
<AuthorCard author={props.author} isAuthorPage={true} />
</div>
)
}

View File

@ -34,56 +34,6 @@
} }
} }
.userpic {
background: #fff;
box-shadow: 0 0 0 2px #fff;
display: inline-block;
margin-right: -1.2rem;
vertical-align: top;
}
.subscribers {
cursor: pointer;
display: inline-block;
margin: -0.4rem 2em 0 0;
vertical-align: top;
}
.subscribersCounter {
@include font-size(1rem);
background: #fff;
border: 2px solid #000;
border-radius: 100%;
font-weight: bold;
height: 32px;
line-height: 30px;
position: relative;
text-align: center;
width: 32px;
z-index: 1;
}
.subscribersList {
max-height: 15em;
overflow: auto;
position: relative;
.subscriber {
white-space: nowrap;
display: flex;
flex-direction: row;
align-items: center;
margin: 0;
border-radius: 4px;
padding: 8px 4px;
transition: background 0.2s ease-in-out;
&:hover {
background: #f7f7f7;
}
}
}
.loadingWrapper { .loadingWrapper {
position: relative; position: relative;
min-height: 40vh; min-height: 40vh;

View File

@ -3,7 +3,6 @@ import type { Author, Shout, Topic } from '../../../graphql/types.gen'
import { Row1 } from '../../Feed/Row1' import { Row1 } from '../../Feed/Row1'
import { Row2 } from '../../Feed/Row2' import { Row2 } from '../../Feed/Row2'
import { Row3 } from '../../Feed/Row3' import { Row3 } from '../../Feed/Row3'
import { AuthorFull } from '../../Author/Full'
import { useAuthorsStore } from '../../../stores/zine/authors' import { useAuthorsStore } from '../../../stores/zine/authors'
import { loadShouts, useArticlesStore } from '../../../stores/zine/articles' import { loadShouts, useArticlesStore } from '../../../stores/zine/articles'
@ -13,8 +12,6 @@ import { splitToPages } from '../../../utils/splitToPages'
import styles from './Author.module.scss' import styles from './Author.module.scss'
import stylesArticle from '../../Article/Article.module.scss' import stylesArticle from '../../Article/Article.module.scss'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { Userpic } from '../../Author/Userpic'
import { Popup } from '../../_shared/Popup'
import { AuthorCard } from '../../Author/AuthorCard' import { AuthorCard } from '../../Author/AuthorCard'
import { apiClient } from '../../../utils/apiClient' import { apiClient } from '../../../utils/apiClient'
import { Comment } from '../../Article/Comment' import { Comment } from '../../Article/Comment'
@ -22,6 +19,7 @@ import { useLocalize } from '../../../context/localize'
import { AuthorRatingControl } from '../../Author/AuthorRatingControl' import { AuthorRatingControl } from '../../Author/AuthorRatingControl'
import { TopicCard } from '../../Topic/Card' import { TopicCard } from '../../Topic/Card'
import { Loading } from '../../_shared/Loading' import { Loading } from '../../_shared/Loading'
import { hideModal } from '../../../stores/ui'
type AuthorProps = { type AuthorProps = {
shouts: Shout[] shouts: Shout[]
@ -30,24 +28,12 @@ type AuthorProps = {
} }
export type AuthorPageSearchParams = { export type AuthorPageSearchParams = {
by: by: '' | 'viewed' | 'rating' | 'commented' | 'recent' | 'about' | 'popular'
| ''
| 'viewed'
| 'rating'
| 'commented'
| 'recent'
| 'subscriptions'
| 'followers'
| 'about'
| 'popular'
} }
export const PRERENDERED_ARTICLES_COUNT = 12 export const PRERENDERED_ARTICLES_COUNT = 12
const LOAD_MORE_PAGE_SIZE = 9 const LOAD_MORE_PAGE_SIZE = 9
function isAuthor(value: Author | Topic): value is Author {
return 'name' in value
}
export const AuthorView = (props: AuthorProps) => { export const AuthorView = (props: AuthorProps) => {
const { t } = useLocalize() const { t } = useLocalize()
const { sortedArticles } = useArticlesStore({ shouts: props.shouts }) const { sortedArticles } = useArticlesStore({ shouts: props.shouts })
@ -59,7 +45,6 @@ export const AuthorView = (props: AuthorProps) => {
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false) const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [followers, setFollowers] = createSignal<Author[]>([]) const [followers, setFollowers] = createSignal<Author[]>([])
const [subscriptions, setSubscriptions] = createSignal<Array<Author | Topic>>([]) const [subscriptions, setSubscriptions] = createSignal<Array<Author | Topic>>([])
const [isLoaded, setIsLoaded] = createSignal<boolean>()
const fetchSubscriptions = async (): Promise<{ authors: Author[]; topics: Topic[] }> => { const fetchSubscriptions = async (): Promise<{ authors: Author[]; topics: Topic[] }> => {
try { try {
@ -77,6 +62,7 @@ export const AuthorView = (props: AuthorProps) => {
} }
onMount(async () => { onMount(async () => {
hideModal()
try { try {
const userSubscribers = await apiClient.getAuthorFollowers({ slug: props.authorSlug }) const userSubscribers = await apiClient.getAuthorFollowers({ slug: props.authorSlug })
setFollowers(userSubscribers) setFollowers(userSubscribers)
@ -89,6 +75,8 @@ export const AuthorView = (props: AuthorProps) => {
if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) { if (sortedArticles().length === PRERENDERED_ARTICLES_COUNT) {
await loadMore() await loadMore()
} }
const { authors, topics } = await fetchSubscriptions()
setSubscriptions([...authors, ...topics])
}) })
const loadMore = async () => { const loadMore = async () => {
@ -118,13 +106,6 @@ export const AuthorView = (props: AuthorProps) => {
const [commented, setCommented] = createSignal([]) const [commented, setCommented] = createSignal([])
createEffect(async () => { createEffect(async () => {
if (searchParams().by === 'subscriptions') {
setIsLoaded(false)
const { authors, topics } = await fetchSubscriptions()
setSubscriptions([...authors, ...topics])
setIsLoaded(true)
}
if (searchParams().by === 'commented') { if (searchParams().by === 'commented') {
try { try {
const data = await apiClient.getReactionsBy({ const data = await apiClient.getReactionsBy({
@ -140,7 +121,12 @@ export const AuthorView = (props: AuthorProps) => {
<div class={styles.authorPage}> <div class={styles.authorPage}>
<div class="wide-container"> <div class="wide-container">
<Show when={author()}> <Show when={author()}>
<AuthorFull author={author()} /> <AuthorCard
author={author()}
isAuthorPage={true}
followers={followers()}
subscriptions={subscriptions()}
/>
</Show> </Show>
<div class={clsx(styles.groupControls, 'row')}> <div class={clsx(styles.groupControls, 'row')}>
<div class="col-md-16"> <div class="col-md-16">
@ -150,16 +136,6 @@ export const AuthorView = (props: AuthorProps) => {
{t('Publications')} {t('Publications')}
</button> </button>
</li> </li>
<li classList={{ 'view-switcher__item--selected': searchParams().by === 'followers' }}>
<button type="button" onClick={() => changeSearchParam('by', 'followers')}>
{t('Followers')}
</button>
</li>
<li classList={{ 'view-switcher__item--selected': searchParams().by === 'subscriptions' }}>
<button type="button" onClick={() => changeSearchParam('by', 'subscriptions')}>
{t('Subscriptions')}
</button>
</li>
<li classList={{ 'view-switcher__item--selected': searchParams().by === 'commented' }}> <li classList={{ 'view-switcher__item--selected': searchParams().by === 'commented' }}>
<button type="button" onClick={() => changeSearchParam('by', 'commented')}> <button type="button" onClick={() => changeSearchParam('by', 'commented')}>
{t('Comments')} {t('Comments')}
@ -173,45 +149,6 @@ export const AuthorView = (props: AuthorProps) => {
</ul> </ul>
</div> </div>
<div class={clsx('col-md-8', styles.additionalControls)}> <div class={clsx('col-md-8', styles.additionalControls)}>
<Popup
trigger={
<div class={styles.subscribers}>
<Switch>
<Match when={followers().length <= 3}>
<For each={followers().slice(0, 3)}>
{(f) => <Userpic name={f.name} userpic={f.userpic} class={styles.userpic} />}
</For>
</Match>
<Match when={followers().length > 3}>
<For each={followers().slice(0, 2)}>
{(f) => <Userpic name={f.name} userpic={f.userpic} class={styles.userpic} />}
</For>
<div class={clsx(styles.userpic, styles.subscribersCounter)}>
{followers().length}
</div>
</Match>
</Switch>
</div>
}
variant="tiny"
>
<ul class={clsx('nodash', styles.subscribersList)}>
<For each={followers()}>
{(item: Author) => (
<li class={styles.subscriber}>
<AuthorCard
author={item}
isNowrap={true}
hideDescription={true}
hideFollow={true}
hasLink={true}
/>
</li>
)}
</For>
</ul>
</Popup>
<div class={styles.ratingContainer}> <div class={styles.ratingContainer}>
{t('Karma')} {t('Karma')}
<AuthorRatingControl author={props.author} class={styles.ratingControl} /> <AuthorRatingControl author={props.author} class={styles.ratingControl} />
@ -239,52 +176,7 @@ export const AuthorView = (props: AuthorProps) => {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={searchParams().by === 'followers'}>
<div class="wide-container">
<div class="row">
<For each={followers()}>
{(follower: Author) => (
<div class="col-md-6 col-lg-4">
<AuthorCard author={follower} hideWriteButton={true} hasLink={true} />
</div>
)}
</For>
</div>
</div>
</Match>
<Match when={searchParams().by === 'subscriptions'}>
<div class={clsx('wide-container', styles.subscriptions)}>
<div class="row position-relative">
<Show
when={isLoaded()}
fallback={
<div class={styles.loadingWrapper}>
<Loading />
</div>
}
>
<For each={subscriptions()}>
{(subscription: Author | Topic) => (
<div class="col-md-20 col-lg-18">
{isAuthor(subscription) ? (
<div class={styles.authorContainer}>
<AuthorCard
author={subscription}
hideWriteButton={true}
hasLink={true}
isTextButton={true}
/>
</div>
) : (
<TopicCard compact isTopicInRow showDescription topic={subscription} />
)}
</div>
)}
</For>
</Show>
</div>
</div>
</Match>
<Match when={searchParams().by === 'rating'}> <Match when={searchParams().by === 'rating'}>
<Show when={sortedArticles().length === 1}> <Show when={sortedArticles().length === 1}>
<Row1 article={sortedArticles()[0]} noAuthorLink={true} /> <Row1 article={sortedArticles()[0]} noAuthorLink={true} />

View File

@ -55,13 +55,18 @@ export const FeedView = () => {
actions: { loadReactionsBy } actions: { loadReactionsBy }
} = useReactions() } = useReactions()
onMount(() => {
loadMore()
})
createEffect( createEffect(
on( on(
() => page().route + searchParams().by, () => page().route + searchParams().by,
() => { () => {
resetSortedArticles() resetSortedArticles()
loadMore() loadMore()
} },
{ defer: true }
) )
) )

View File

@ -63,7 +63,8 @@ export const SolidSwiper = (props: Props) => {
() => { () => {
mainSwipeRef.current?.swiper.update() mainSwipeRef.current?.swiper.update()
thumbSwipeRef.current?.swiper.update() thumbSwipeRef.current?.swiper.update()
} },
{ defer: true }
) )
) )
@ -95,7 +96,7 @@ export const SolidSwiper = (props: Props) => {
const results: UploadedFile[] = [] const results: UploadedFile[] = []
for (const file of selectedFiles) { for (const file of selectedFiles) {
const result = await handleFileUpload(file) const result = await handleFileUpload(file)
results.push(result.url) results.push(result)
} }
props.onImagesAdd(composeMediaItems(results)) props.onImagesAdd(composeMediaItems(results))
setLoading(false) setLoading(false)

View File

@ -17,14 +17,15 @@ export const AuthorPage = (props: PageProps) => {
Boolean(props.authorShouts) && Boolean(props.author) && props.author.slug === slug() Boolean(props.authorShouts) && Boolean(props.author) && props.author.slug === slug()
) )
const preload = () => const preload = () => {
Promise.all([ return Promise.all([
loadShouts({ loadShouts({
filters: { author: slug(), visibility: 'community' }, filters: { author: slug(), visibility: 'community' },
limit: PRERENDERED_ARTICLES_COUNT limit: PRERENDERED_ARTICLES_COUNT
}), }),
loadAuthor({ slug: slug() }) loadAuthor({ slug: slug() })
]) ])
}
onMount(async () => { onMount(async () => {
if (isLoaded()) { if (isLoaded()) {
@ -44,7 +45,8 @@ export const AuthorPage = (props: PageProps) => {
resetSortedArticles() resetSortedArticles()
await preload() await preload()
setIsLoaded(true) setIsLoaded(true)
} },
{ defer: true }
) )
) )

View File

@ -41,7 +41,8 @@ export const TopicPage = (props: PageProps) => {
resetSortedArticles() resetSortedArticles()
await preload() await preload()
setIsLoaded(true) setIsLoaded(true)
} },
{ defer: true }
) )
) )

View File

@ -20,6 +20,8 @@ export type ModalType =
| 'uploadCoverImage' | 'uploadCoverImage'
| 'editorInsertLink' | 'editorInsertLink'
| 'simplifiedEditorInsertLink' | 'simplifiedEditorInsertLink'
| 'followers'
| 'subscriptions'
type WarnKind = 'error' | 'warn' | 'info' type WarnKind = 'error' | 'warn' | 'info'
@ -41,7 +43,9 @@ export const MODALS: Record<ModalType, ModalType> = {
simplifiedEditorUploadImage: 'simplifiedEditorUploadImage', simplifiedEditorUploadImage: 'simplifiedEditorUploadImage',
uploadCoverImage: 'uploadCoverImage', uploadCoverImage: 'uploadCoverImage',
editorInsertLink: 'editorInsertLink', editorInsertLink: 'editorInsertLink',
simplifiedEditorInsertLink: 'simplifiedEditorInsertLink' simplifiedEditorInsertLink: 'simplifiedEditorInsertLink',
followers: 'followers',
subscriptions: 'subscriptions'
} }
const [modal, setModal] = createSignal<ModalType | null>(null) const [modal, setModal] = createSignal<ModalType | null>(null)

View File

@ -1,7 +1,6 @@
import type { Author, Shout, ShoutInput, Topic, LoadShoutsOptions } from '../../graphql/types.gen' import type { Author, Shout, ShoutInput, Topic, LoadShoutsOptions } from '../../graphql/types.gen'
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
import { addAuthorsByTopic } from './authors' import { addAuthorsByTopic } from './authors'
import { addTopicsByAuthor } from './topics'
import { byStat } from '../../utils/sortby' import { byStat } from '../../utils/sortby'
import { createSignal } from 'solid-js' import { createSignal } from 'solid-js'
import { createLazyMemo } from '@solid-primitives/memo' import { createLazyMemo } from '@solid-primitives/memo'
@ -97,26 +96,6 @@ const addArticles = (...args: Shout[][]) => {
}, {} as { [topicSlug: string]: Author[] }) }, {} as { [topicSlug: string]: Author[] })
addAuthorsByTopic(authorsByTopic) addAuthorsByTopic(authorsByTopic)
const topicsByAuthor = allArticles.reduce((acc, article) => {
const { authors, topics } = article
authors.forEach((author) => {
if (!acc[author.slug]) {
acc[author.slug] = []
}
topics.forEach((topic) => {
if (!acc[author.slug].some((t) => t.slug === topic.slug)) {
acc[author.slug].push(topic)
}
})
})
return acc
}, {} as { [authorSlug: string]: Topic[] })
addTopicsByAuthor(topicsByAuthor)
} }
const addSortedArticles = (articles: Shout[]) => { const addSortedArticles = (articles: Shout[]) => {

View File

@ -1,6 +1,6 @@
import { apiClient } from '../../utils/apiClient' import { apiClient } from '../../utils/apiClient'
import type { Author } from '../../graphql/types.gen' import type { Author } from '../../graphql/types.gen'
import { createSignal } from 'solid-js' import { createEffect, createSignal } from 'solid-js'
import { createLazyMemo } from '@solid-primitives/memo' import { createLazyMemo } from '@solid-primitives/memo'
import { byStat } from '../../utils/sortby' import { byStat } from '../../utils/sortby'
@ -38,12 +38,18 @@ const addAuthors = (authors: Author[]) => {
return acc return acc
}, {} as Record<string, Author>) }, {} as Record<string, Author>)
setAuthorEntities((prevAuthorEntities) => { setAuthorEntities((prevAuthorEntities) =>
return { Object.keys(newAuthorEntities).reduce(
...prevAuthorEntities, (acc, authorSlug) => {
...newAuthorEntities acc[authorSlug] = {
...acc[authorSlug],
...newAuthorEntities[authorSlug]
} }
}) return acc
},
{ ...prevAuthorEntities }
)
)
} }
export const loadAuthor = async ({ slug }: { slug: string }): Promise<void> => { export const loadAuthor = async ({ slug }: { slug: string }): Promise<void> => {

View File

@ -12,7 +12,6 @@ export const setTopicsSort = (sortBy: TopicsSortBy) => setSortAllBy(sortBy)
const [topicEntities, setTopicEntities] = createSignal<{ [topicSlug: string]: Topic }>({}) const [topicEntities, setTopicEntities] = createSignal<{ [topicSlug: string]: Topic }>({})
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([]) const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])
const [topicsByAuthor, setTopicByAuthor] = createSignal<{ [authorSlug: string]: Topic[] }>({})
const sortedTopics = createLazyMemo<Topic[]>(() => { const sortedTopics = createLazyMemo<Topic[]>(() => {
const topics = Object.values(topicEntities()) const topics = Object.values(topicEntities())
@ -68,27 +67,6 @@ const addTopics = (...args: Topic[][]) => {
}) })
} }
export const addTopicsByAuthor = (newTopicsByAuthors: { [authorSlug: string]: Topic[] }) => {
const allTopics = Object.values(newTopicsByAuthors).flat()
addTopics(allTopics)
setTopicByAuthor((prevTopicsByAuthor) => {
return Object.entries(newTopicsByAuthors).reduce((acc, [authorSlug, topics]) => {
if (!acc[authorSlug]) {
acc[authorSlug] = []
}
topics.forEach((topic) => {
if (!acc[authorSlug].some((t) => t.slug === topic.slug)) {
acc[authorSlug].push(topic)
}
})
return acc
}, prevTopicsByAuthor)
})
}
export const loadAllTopics = async (): Promise<void> => { export const loadAllTopics = async (): Promise<void> => {
const topics = await apiClient.getAllTopics() const topics = await apiClient.getAllTopics()
addTopics(topics) addTopics(topics)
@ -121,5 +99,5 @@ export const useTopicsStore = (initialState: InitialState = {}) => {
setRandomTopics(initialState.randomTopics) setRandomTopics(initialState.randomTopics)
} }
return { topicEntities, sortedTopics, randomTopics, topTopics, topicsByAuthor } return { topicEntities, sortedTopics, randomTopics, topTopics }
} }

View File

@ -0,0 +1,3 @@
// Usage in tsx: {getNumeralsDeclension(NUMBER, ['яблоко', 'яблока', 'яблок'])}
export const getNumeralsDeclension = (number: number, words: string[], cases = [2, 0, 1, 1, 1, 2]) =>
words[number % 100 > 4 && number % 100 < 20 ? 2 : cases[number % 10 < 5 ? number % 10 : 5]]

View File

@ -1,9 +1,10 @@
import { UploadFile } from '@solid-primitives/upload' import { UploadFile } from '@solid-primitives/upload'
import { apiBaseUrl } from './config' import { apiBaseUrl } from './config'
import { UploadedFile } from '../pages/types'
const apiUrl = `${apiBaseUrl}/upload` const apiUrl = `${apiBaseUrl}/upload`
export const handleFileUpload = async (uploadFile: UploadFile) => { export const handleFileUpload = async (uploadFile: UploadFile): Promise<UploadedFile> => {
const formData = new FormData() const formData = new FormData()
formData.append('file', uploadFile.file, uploadFile.name) formData.append('file', uploadFile.file, uploadFile.name)
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {