Merge branch 'dev' into hotfix/posting-author

This commit is contained in:
Tony 2024-03-03 17:36:14 +03:00 committed by GitHub
commit b43ba41f9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 677 additions and 536 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", "Profile": "Profile",
"Publications": "Publications", "Publications": "Publications",
"PublicationsWithCount": "{count, plural, =0 {no publications} one {{count} publication} other {{count} 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 Album": "Publish Album",
"Publish Settings": "Publish Settings", "Publish Settings": "Publish Settings",
"Published": "Published", "Published": "Published",

View File

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

View File

@ -94,10 +94,6 @@ export const App = (props: Props) => {
const is404 = createMemo(() => props.is404) const is404 = createMemo(() => props.is404)
createEffect(() => { createEffect(() => {
if (!searchParams().m) {
hideModal()
}
const modal = MODALS[searchParams().m] const modal = MODALS[searchParams().m]
if (modal) { if (modal) {
showModal(modal) showModal(modal)

View File

@ -38,17 +38,21 @@ export const Comment = (props: Props) => {
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [editMode, setEditMode] = createSignal(false) const [editMode, setEditMode] = createSignal(false)
const [clearEditor, setClearEditor] = createSignal(false) const [clearEditor, setClearEditor] = createSignal(false)
const { author } = useSession() const { author, session } = useSession()
const { createReaction, deleteReaction, updateReaction } = useReactions() const { createReaction, deleteReaction, updateReaction } = useReactions()
const { showConfirm } = useConfirm() const { showConfirm } = useConfirm()
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const isCommentAuthor = createMemo(() => props.comment.created_by?.slug === author()?.slug) const canEdit = createMemo(
const comment = createMemo(() => props.comment) () =>
const body = createMemo(() => (comment().body || '').trim()) Boolean(author()?.id) &&
(props.comment?.created_by?.slug === author().slug || session()?.user?.roles.includes('editor')),
)
const body = createMemo(() => (props.comment.body || '').trim())
const remove = async () => { const remove = async () => {
if (comment()?.id) { if (props.comment?.id) {
try { try {
const isConfirmed = await showConfirm({ const isConfirmed = await showConfirm({
confirmBody: t('Are you sure you want to delete this comment?'), confirmBody: t('Are you sure you want to delete this comment?'),
@ -58,7 +62,7 @@ export const Comment = (props: Props) => {
}) })
if (isConfirmed) { if (isConfirmed) {
await deleteReaction(comment().id) await deleteReaction(props.comment.id)
await showSnackbar({ body: t('Comment successfully deleted') }) await showSnackbar({ body: t('Comment successfully deleted') })
} }
@ -93,7 +97,8 @@ export const Comment = (props: Props) => {
const handleUpdate = async (value) => { const handleUpdate = async (value) => {
setLoading(true) setLoading(true)
try { try {
await updateReaction(props.comment.id, { await updateReaction({
id: props.comment.id,
kind: ReactionKind.Comment, kind: ReactionKind.Comment,
body: value, body: value,
shout: props.comment.shout.id, shout: props.comment.shout.id,
@ -107,9 +112,9 @@ export const Comment = (props: Props) => {
return ( return (
<li <li
id={`comment_${comment().id}`} id={`comment_${props.comment.id}`}
class={clsx(styles.comment, props.class, { class={clsx(styles.comment, props.class, {
[styles.isNew]: !isCommentAuthor() && comment()?.created_at > props.lastSeen, [styles.isNew]: props.comment?.created_at > props.lastSeen,
})} })}
> >
<Show when={!!body()}> <Show when={!!body()}>
@ -119,21 +124,21 @@ export const Comment = (props: Props) => {
fallback={ fallback={
<div> <div>
<Userpic <Userpic
name={comment().created_by.name} name={props.comment.created_by.name}
userpic={comment().created_by.pic} userpic={props.comment.created_by.pic}
class={clsx({ class={clsx({
[styles.compactUserpic]: props.compact, [styles.compactUserpic]: props.compact,
})} })}
/> />
<small> <small>
<a href={`#comment_${comment()?.id}`}>{comment()?.shout.title || ''}</a> <a href={`#comment_${props.comment?.id}`}>{props.comment?.shout.title || ''}</a>
</small> </small>
</div> </div>
} }
> >
<div class={styles.commentDetails}> <div class={styles.commentDetails}>
<div class={styles.commentAuthor}> <div class={styles.commentAuthor}>
<AuthorLink author={comment()?.created_by as Author} /> <AuthorLink author={props.comment?.created_by as Author} />
</div> </div>
<Show when={props.isArticleAuthor}> <Show when={props.isArticleAuthor}>
@ -144,23 +149,23 @@ export const Comment = (props: Props) => {
<div class={styles.articleLink}> <div class={styles.articleLink}>
<Icon name="arrow-right" class={styles.articleLinkIcon} /> <Icon name="arrow-right" class={styles.articleLinkIcon} />
<a <a
href={`${getPagePath(router, 'article', { slug: comment().shout.slug })}?commentId=${ href={`${getPagePath(router, 'article', {
comment().id slug: props.comment.shout.slug,
}`} })}?commentId=${props.comment.id}`}
> >
{comment().shout.title} {props.comment.shout.title}
</a> </a>
</div> </div>
</Show> </Show>
<CommentDate showOnHover={true} comment={comment()} isShort={true} /> <CommentDate showOnHover={true} comment={props.comment} isShort={true} />
<CommentRatingControl comment={comment()} /> <CommentRatingControl comment={props.comment} />
</div> </div>
</Show> </Show>
<div class={styles.commentBody}> <div class={styles.commentBody}>
<Show when={editMode()} fallback={<div innerHTML={body()} />}> <Show when={editMode()} fallback={<div innerHTML={body()} />}>
<Suspense fallback={<p>{t('Loading')}</p>}> <Suspense fallback={<p>{t('Loading')}</p>}>
<SimplifiedEditor <SimplifiedEditor
initialContent={comment().body} initialContent={props.comment.body}
submitButtonText={t('Save')} submitButtonText={t('Save')}
quoteEnabled={true} quoteEnabled={true}
imageEnabled={true} imageEnabled={true}
@ -189,7 +194,7 @@ export const Comment = (props: Props) => {
{loading() ? t('Loading') : t('Reply')} {loading() ? t('Loading') : t('Reply')}
</button> </button>
</ShowIfAuthenticated> </ShowIfAuthenticated>
<Show when={isCommentAuthor()}> <Show when={canEdit()}>
<button <button
class={clsx(styles.commentControl, styles.commentControlEdit)} class={clsx(styles.commentControl, styles.commentControlEdit)}
onClick={toggleEditMode} onClick={toggleEditMode}

View File

@ -2,15 +2,17 @@
@include font-size(1.2rem); @include font-size(1.2rem);
color: var(--secondary-color); color: var(--secondary-color);
align-items: center;
align-self: center; // align-self: center;
display: flex; display: flex;
align-items: flex-start;
justify-content: flex-start;
flex-direction: column;
gap: .5rem;
flex: 1; flex: 1;
flex-wrap: wrap; flex-wrap: wrap;
font-size: 1.2rem; font-size: 1.2rem;
justify-content: flex-start; margin-bottom: .5rem;
margin: 0 1rem;
height: 1.6rem;
.date { .date {
font-weight: 500; font-weight: 500;

View File

@ -4,8 +4,8 @@ import { For, Show, createMemo, createSignal, lazy, onMount } from 'solid-js'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { useReactions } from '../../context/reactions' import { useReactions } from '../../context/reactions'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
import { Author, Reaction, ReactionKind } from '../../graphql/schema/core.gen' import { Author, Reaction, ReactionKind, ReactionSort } from '../../graphql/schema/core.gen'
import { byCreated } from '../../utils/sortby' import { byCreated, byStat } from '../../utils/sortby'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated' import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
@ -15,27 +15,6 @@ import styles from './Article.module.scss'
const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor')) const SimplifiedEditor = lazy(() => import('../Editor/SimplifiedEditor'))
type CommentsOrder = 'createdAt' | 'rating' | 'newOnly'
const sortCommentsByRating = (a: Reaction, b: Reaction): -1 | 0 | 1 => {
if (a.reply_to && b.reply_to) {
return 0
}
const x = a.stat?.rating || 0
const y = b.stat?.rating || 0
if (x > y) {
return 1
}
if (x < y) {
return -1
}
return 0
}
type Props = { type Props = {
articleAuthors: Author[] articleAuthors: Author[]
shoutSlug: string shoutSlug: string
@ -45,7 +24,8 @@ type Props = {
export const CommentsTree = (props: Props) => { export const CommentsTree = (props: Props) => {
const { author } = useSession() const { author } = useSession()
const { t } = useLocalize() const { t } = useLocalize()
const [commentsOrder, setCommentsOrder] = createSignal<CommentsOrder>('createdAt') const [commentsOrder, setCommentsOrder] = createSignal<ReactionSort>(ReactionSort.Newest)
const [onlyNew, setOnlyNew] = createSignal(false)
const [newReactions, setNewReactions] = createSignal<Reaction[]>([]) const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
const [clearEditor, setClearEditor] = createSignal(false) const [clearEditor, setClearEditor] = createSignal(false)
const [clickedReplyId, setClickedReplyId] = createSignal<number>() const [clickedReplyId, setClickedReplyId] = createSignal<number>()
@ -59,16 +39,13 @@ export const CommentsTree = (props: Props) => {
let newSortedComments = [...comments()] let newSortedComments = [...comments()]
newSortedComments = newSortedComments.sort(byCreated) newSortedComments = newSortedComments.sort(byCreated)
if (commentsOrder() === 'newOnly') { if (onlyNew()) {
return newReactions().reverse() return newReactions().sort(byCreated).reverse()
} }
if (commentsOrder() === 'rating') { if (commentsOrder() === ReactionSort.Like) {
newSortedComments = newSortedComments.sort(sortCommentsByRating) newSortedComments = newSortedComments.sort(byStat('rating'))
} }
newSortedComments.reverse()
return newSortedComments return newSortedComments
}) })
@ -91,7 +68,7 @@ export const CommentsTree = (props: Props) => {
setCookie() setCookie()
} }
}) })
const handleSubmitComment = async (value) => { const handleSubmitComment = async (value: string) => {
try { try {
await createReaction({ await createReaction({
kind: ReactionKind.Comment, kind: ReactionKind.Comment,
@ -117,31 +94,25 @@ export const CommentsTree = (props: Props) => {
<Show when={comments().length > 0}> <Show when={comments().length > 0}>
<ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}> <ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}>
<Show when={newReactions().length > 0}> <Show when={newReactions().length > 0}>
<li classList={{ 'view-switcher__item--selected': commentsOrder() === 'newOnly' }}> <li classList={{ 'view-switcher__item--selected': onlyNew() }}>
<Button <Button variant="light" value={t('New only')} onClick={() => setOnlyNew(!onlyNew())} />
variant="light"
value={t('New only')}
onClick={() => {
setCommentsOrder('newOnly')
}}
/>
</li> </li>
</Show> </Show>
<li classList={{ 'view-switcher__item--selected': commentsOrder() === 'createdAt' }}> <li classList={{ 'view-switcher__item--selected': commentsOrder() === ReactionSort.Newest }}>
<Button <Button
variant="light" variant="light"
value={t('By time')} value={t('By time')}
onClick={() => { onClick={() => {
setCommentsOrder('createdAt') setCommentsOrder(ReactionSort.Newest)
}} }}
/> />
</li> </li>
<li classList={{ 'view-switcher__item--selected': commentsOrder() === 'rating' }}> <li classList={{ 'view-switcher__item--selected': commentsOrder() === ReactionSort.Like }}>
<Button <Button
variant="light" variant="light"
value={t('By rating')} value={t('By rating')}
onClick={() => { onClick={() => {
setCommentsOrder('rating') setCommentsOrder(ReactionSort.Like)
}} }}
/> />
</li> </li>

View File

@ -58,9 +58,8 @@ export type ArticlePageSearchParams = {
const scrollTo = (el: HTMLElement) => { const scrollTo = (el: HTMLElement) => {
const { top } = el.getBoundingClientRect() const { top } = el.getBoundingClientRect()
window.scrollTo({ window.scrollTo({
top: top + window.scrollY - DEFAULT_HEADER_OFFSET, top: top - DEFAULT_HEADER_OFFSET,
left: 0, left: 0,
behavior: 'smooth', behavior: 'smooth',
}) })
@ -75,10 +74,17 @@ export const FullArticle = (props: Props) => {
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false) const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false) const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const { t, formatDate, lang } = useLocalize() const { t, formatDate, lang } = useLocalize()
const { author, isAuthenticated, requireAuthentication } = useSession() const { author, session, isAuthenticated, requireAuthentication } = useSession()
const formattedDate = createMemo(() => formatDate(new Date((props.article?.published_at || 0) * 1000))) const formattedDate = createMemo(() => formatDate(new Date(props.article.published_at * 1000)))
const canEdit = () => props.article.authors?.some((a) => Boolean(a) && a?.slug === author()?.slug)
const canEdit = createMemo(
() =>
Boolean(author()?.id) &&
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
props.article?.created_by?.id === author().id ||
session()?.user?.roles.includes('editor')),
)
const mainTopic = createMemo(() => { const mainTopic = createMemo(() => {
const mainTopicSlug = props.article.topics.length > 0 ? props.article.main_topic : null const mainTopicSlug = props.article.topics.length > 0 ? props.article.main_topic : null
@ -145,22 +151,16 @@ export const FullArticle = (props: Props) => {
current: HTMLDivElement current: HTMLDivElement
} = { current: null } } = { current: null }
const scrollToComments = () => {
scrollTo(commentsRef.current)
}
createEffect(() => { createEffect(() => {
if (props.scrollToComments) { if (props.scrollToComments) {
scrollToComments() scrollTo(commentsRef.current)
} }
}) })
createEffect(() => { createEffect(() => {
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) { if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
scrollToComments() requestAnimationFrame(() => scrollTo(commentsRef.current))
changeSearchParams({ changeSearchParams({ scrollTo: null })
scrollTo: null,
})
} }
}) })
@ -170,10 +170,8 @@ export const FullArticle = (props: Props) => {
`[id='comment_${searchParams().commentId}']`, `[id='comment_${searchParams().commentId}']`,
) )
changeSearchParams({ commentId: null })
if (commentElement) { if (commentElement) {
scrollTo(commentElement) requestAnimationFrame(() => scrollTo(commentElement))
} }
} }
}) })
@ -466,7 +464,11 @@ export const FullArticle = (props: Props) => {
<Popover content={t('Comment')} disabled={isActionPopupActive()}> <Popover content={t('Comment')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el) => void) => (
<div class={clsx(styles.shoutStatsItem)} ref={triggerRef} onClick={scrollToComments}> <div
class={clsx(styles.shoutStatsItem)}
ref={triggerRef}
onClick={() => scrollTo(commentsRef.current)}
>
<Icon name="comment" class={styles.icon} /> <Icon name="comment" class={styles.icon} />
<Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} /> <Icon name="comment-hover" class={clsx(styles.icon, styles.iconHover)} />
<Show <Show
@ -544,7 +546,7 @@ export const FullArticle = (props: Props) => {
</Show> </Show>
<FeedArticlePopup <FeedArticlePopup
isOwner={canEdit()} canEdit={canEdit()}
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)} containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
onShareClick={() => showModal('share')} onShareClick={() => showModal('share')}
onInviteClick={() => showModal('inviteMembers')} onInviteClick={() => showModal('inviteMembers')}

View File

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

View File

@ -118,12 +118,17 @@ export const AuthorBadge = (props: Props) => {
<Match when={props.author.bio}> <Match when={props.author.bio}>
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio} /> <div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio} />
</Match> </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> </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> </Show>
</ConditionalWrapper> </ConditionalWrapper>
</div> </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,111 @@
import { clsx } from 'clsx'
import { For, Show, createEffect, createSignal, on, onMount } from 'solid-js'
import { useFollowing } from '../../context/following'
import { useLocalize } from '../../context/localize'
import { apiClient } from '../../graphql/client/core'
import { Author } from '../../graphql/schema/core.gen'
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: 'followers' | 'shouts'
searchQuery?: string
allAuthorsLength?: number
}
const PAGE_SIZE = 20
export const AuthorsList = (props: Props) => {
const { t } = useLocalize()
const { isOwnerSubscribed } = useFollowing()
const { authorsByShouts, authorsByFollowers } = useAuthorsStore()
const [loading, setLoading] = createSignal(false)
const [currentPage, setCurrentPage] = createSignal({ shouts: 0, followers: 0 })
const [allLoaded, setAllLoaded] = createSignal(false)
const fetchAuthors = async (queryType: Props['query'], page: number) => {
setLoading(true)
const offset = PAGE_SIZE * page
const result = await apiClient.loadAuthorsBy({
by: { order: queryType },
limit: PAGE_SIZE,
offset,
})
if (queryType === 'shouts') {
setAuthorsByShouts((prev) => [...prev, ...result])
} else {
setAuthorsByFollowers((prev) => [...prev, ...result])
}
setLoading(false)
}
const loadMoreAuthors = () => {
const nextPage = currentPage()[props.query] + 1
fetchAuthors(props.query, nextPage).then(() =>
setCurrentPage({ ...currentPage(), [props.query]: nextPage }),
)
}
createEffect(
on(
() => props.query,
(query) => {
const authorsList = query === 'shouts' ? authorsByShouts() : authorsByFollowers()
if (authorsList.length === 0 && currentPage()[query] === 0) {
setCurrentPage((prev) => ({ ...prev, [query]: 0 }))
fetchAuthors(query, 0).then(() => setCurrentPage((prev) => ({ ...prev, [query]: 1 })))
}
},
),
)
const authorsList = () => (props.query === 'shouts' ? authorsByShouts() : authorsByFollowers())
// TODO: do it with backend
// createEffect(() => {
// if (props.searchQuery) {
// // search logic
// }
// })
createEffect(() => {
setAllLoaded(props.allAuthorsLength === authorsList.length)
})
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="row">
<div class="col-lg-20 col-xl-18">
<div class={styles.action}>
<Show when={!loading() && authorsList().length > 0 && !allLoaded()}>
<Button value={t('Load more')} onClick={loadMoreAuthors} />
</Show>
<Show when={loading() && !allLoaded()}>
<InlineLoader />
</Show>
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@ -106,7 +106,7 @@ const LAYOUT_ASPECT = {
export const ArticleCard = (props: ArticleCardProps) => { export const ArticleCard = (props: ArticleCardProps) => {
const { t, lang, formatDate } = useLocalize() const { t, lang, formatDate } = useLocalize()
const { author } = useSession() const { author, session } = useSession()
const { changeSearchParams } = useRouter() const { changeSearchParams } = useRouter()
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false) const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false) const [isCoverImageLoadError, setIsCoverImageLoadError] = createSignal(false)
@ -120,9 +120,13 @@ export const ArticleCard = (props: ArticleCardProps) => {
props.article?.published_at ? formatDate(new Date(props.article.published_at * 1000)) : '', props.article?.published_at ? formatDate(new Date(props.article.published_at * 1000)) : '',
) )
const canEdit = () => const canEdit = createMemo(
props.article.authors?.some((a) => a && a?.slug === author()?.slug) || () =>
props.article.created_by?.id === author()?.id Boolean(author()?.id) &&
(props.article?.authors?.some((a) => Boolean(a) && a?.id === author().id) ||
props.article?.created_by?.id === author().id ||
session()?.user?.roles.includes('editor')),
)
const scrollToComments = (event) => { const scrollToComments = (event) => {
event.preventDefault() event.preventDefault()
@ -376,7 +380,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
<div class={styles.shoutCardDetailsItem}> <div class={styles.shoutCardDetailsItem}>
<FeedArticlePopup <FeedArticlePopup
isOwner={canEdit()} canEdit={canEdit()}
containerCssClass={stylesHeader.control} containerCssClass={stylesHeader.control}
onShareClick={() => props.onShare(props.article)} onShareClick={() => props.onShare(props.article)}
onInviteClick={props.onInvite} onInviteClick={props.onInvite}

View File

@ -10,7 +10,7 @@ import { SoonChip } from '../../_shared/SoonChip'
import styles from './FeedArticlePopup.module.scss' import styles from './FeedArticlePopup.module.scss'
type Props = { type Props = {
isOwner: boolean canEdit: boolean
onInviteClick: () => void onInviteClick: () => void
onShareClick: () => void onShareClick: () => void
} & Omit<PopupProps, 'children'> } & Omit<PopupProps, 'children'>
@ -41,7 +41,7 @@ export const FeedArticlePopup = (props: Props) => {
{t('Share')} {t('Share')}
</button> </button>
</li> </li>
<Show when={!props.isOwner}> <Show when={!props.canEdit}>
<li> <li>
<button <button
class={styles.action} class={styles.action}
@ -67,7 +67,7 @@ export const FeedArticlePopup = (props: Props) => {
{t('Invite experts')} {t('Invite experts')}
</button> </button>
</li> </li>
<Show when={!props.isOwner}> <Show when={!props.canEdit}>
<li> <li>
<button class={clsx(styles.action, styles.soon)} role="button"> <button class={clsx(styles.action, styles.soon)} role="button">
{t('Subscribe to comments')} <SoonChip /> {t('Subscribe to comments')} <SoonChip />
@ -79,7 +79,7 @@ export const FeedArticlePopup = (props: Props) => {
{t('Add to bookmarks')} <SoonChip /> {t('Add to bookmarks')} <SoonChip />
</button> </button>
</li> </li>
{/*<Show when={!props.isOwner}>*/} {/*<Show when={!props.canEdit}>*/}
{/* <li>*/} {/* <li>*/}
{/* <button*/} {/* <button*/}
{/* class={styles.action}*/} {/* class={styles.action}*/}

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

@ -53,7 +53,6 @@ export const RegisterForm = () => {
const handleSubmit = async (event: Event) => { const handleSubmit = async (event: Event) => {
event.preventDefault() event.preventDefault()
if (passwordError()) { if (passwordError()) {
setValidationErrors((errors) => ({ ...errors, password: passwordError() })) setValidationErrors((errors) => ({ ...errors, password: passwordError() }))
} else { } else {
@ -102,7 +101,7 @@ export const RegisterForm = () => {
redirect_uri: window.location.origin, redirect_uri: window.location.origin,
} }
const { errors } = await signUp(opts) const { errors } = await signUp(opts)
if (errors) return if (errors.length > 0) return
setIsSuccess(true) setIsSuccess(true)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -134,7 +133,6 @@ export const RegisterForm = () => {
), ),
})) }))
break break
case 'verified': case 'verified':
setValidationErrors((prev) => ({ setValidationErrors((prev) => ({
email: ( email: (

View File

@ -102,7 +102,6 @@ export const SendResetLinkForm = () => {
placeholder={t('Email')} placeholder={t('Email')}
onChange={(event) => handleEmailInput(event.currentTarget.value)} onChange={(event) => handleEmailInput(event.currentTarget.value)}
/> />
<label for="email">{t('Email')}</label> <label for="email">{t('Email')}</label>
<Show when={isUserNotFound()}> <Show when={isUserNotFound()}>
<div class={styles.validationError}> <div class={styles.validationError}>

View File

@ -142,10 +142,8 @@ export const Header = (props: Props) => {
} }
onMount(async () => { onMount(async () => {
if (window.location.pathname === '/' || window.location.pathname === '') { const topics = await apiClient.getRandomTopics({ amount: RANDOM_TOPICS_COUNT })
const topics = await apiClient.getRandomTopics({ amount: RANDOM_TOPICS_COUNT }) setRandomTopics(topics)
setRandomTopics(topics)
}
}) })
const handleToggleMenuByLink = (event: MouseEvent, route: keyof typeof ROUTES) => { const handleToggleMenuByLink = (event: MouseEvent, route: keyof typeof ROUTES) => {

View File

@ -75,7 +75,7 @@ export const TopicBadge = (props: Props) => {
when={props.topic.body} when={props.topic.body}
fallback={ fallback={
<div class={styles.description}> <div class={styles.description}>
{t('PublicationsWithCount', { count: props.topic.stat.shouts ?? 0 })} {t('PublicationsWithCount', { count: props.topic?.stat?.shouts ?? 0 })}
</div> </div>
} }
> >

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

@ -81,3 +81,5 @@
overflow-x: auto; overflow-x: auto;
} }
} }

View File

@ -0,0 +1,179 @@
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[]
topFollowedAuthors?: Author[]
topWritingAuthors?: Author[]
isLoaded: boolean
}
export const AllAuthors = (props: Props) => {
const { t, lang } = useLocalize()
const [searchQuery, setSearchQuery] = createSignal('')
const ALPHABET =
lang() === 'ru' ? [...'АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ@'] : [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ@']
const { searchParams, changeSearchParams } = useRouter<AllAuthorsPageSearchParams>()
const { sortedAuthors } = useAuthorsStore({
authors: props.authors,
sortBy: searchParams().by || 'name',
})
const filteredAuthors = createMemo(() => {
const query = searchQuery().toLowerCase()
return sortedAuthors().filter((author) => {
return author.name.toLowerCase().includes(query) // Предполагаем, что у автора есть свойство name
})
})
const byLetterFiltered = createMemo<{ [letter: string]: Author[] }>(() => {
return filteredAuthors().reduce(
(acc, author) => authorLetterReduce(acc, author, lang()),
{} as { [letter: string]: Author[] },
)
})
const sortedKeys = createMemo<string[]>(() => {
const keys = Object.keys(byLetterFiltered())
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 byLetterFiltered()} 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={byLetterFiltered()[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}>
<AuthorsList
allAuthorsLength={sortedAuthors()?.length}
searchQuery={searchQuery()}
query={searchParams().by === 'followers' ? 'followers' : 'shouts'}
/>
</Show>
</div>
</Show>
</div>
)
}

View File

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

View File

@ -28,7 +28,7 @@ type Props = {
isLoaded: boolean isLoaded: boolean
} }
const PAGE_SIZE = 20 export const PAGE_SIZE = 20
export const AllTopics = (props: Props) => { export const AllTopics = (props: Props) => {
const { t, lang } = useLocalize() const { t, lang } = useLocalize()

View File

@ -23,6 +23,7 @@ import { Row2 } from '../../Feed/Row2'
import { Row3 } from '../../Feed/Row3' import { Row3 } from '../../Feed/Row3'
import { Loading } from '../../_shared/Loading' import { Loading } from '../../_shared/Loading'
import { byCreated } from '../../../utils/sortby'
import stylesArticle from '../../Article/Article.module.scss' import stylesArticle from '../../Article/Article.module.scss'
import styles from './Author.module.scss' import styles from './Author.module.scss'
@ -58,9 +59,9 @@ export const AuthorView = (props: Props) => {
} }
}) })
createEffect(() => { createEffect(async () => {
if (author()?.id && !author().stat) { if (author()?.id && !author().stat) {
const a = loadAuthor({ slug: '', author_id: author().id }) const a = await loadAuthor({ slug: '', author_id: author().id })
console.debug('[AuthorView] loaded author:', a) console.debug('[AuthorView] loaded author:', a)
} }
}) })
@ -71,13 +72,7 @@ export const AuthorView = (props: Props) => {
const fetchData = async (slug) => { const fetchData = async (slug) => {
try { try {
const [subscriptionsResult, followersResult] = await Promise.all([ const [subscriptionsResult, followersResult] = await Promise.all([
(async () => { apiClient.getAuthorFollows({ slug }),
const [getAuthors, getTopics] = await Promise.all([
apiClient.getAuthorFollowingAuthors({ slug }),
apiClient.getAuthorFollowingTopics({ slug }),
])
return { authors: getAuthors, topics: getTopics }
})(),
apiClient.getAuthorFollowers({ slug }), apiClient.getAuthorFollowers({ slug }),
]) ])
@ -181,7 +176,7 @@ export const AuthorView = (props: Props) => {
{t('Comments')} {t('Comments')}
</a> </a>
<Show when={author().stat}> <Show when={author().stat}>
<span class="view-switcher__counter">{author().stat.commented}</span> <span class="view-switcher__counter">{author().stat.comments}</span>
</Show> </Show>
</li> </li>
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorAbout' }}> <li classList={{ 'view-switcher__item--selected': getPage().route === 'authorAbout' }}>
@ -237,7 +232,7 @@ export const AuthorView = (props: Props) => {
<div class="row"> <div class="row">
<div class="col-md-20 col-lg-18"> <div class="col-md-20 col-lg-18">
<ul class={stylesArticle.comments}> <ul class={stylesArticle.comments}>
<For each={commented()}> <For each={commented()?.sort(byCreated).reverse()}>
{(comment) => <Comment comment={comment} class={styles.comment} showArticleLink />} {(comment) => <Comment comment={comment} class={styles.comment} showArticleLink />}
</For> </For>
</ul> </ul>

View File

@ -18,7 +18,7 @@ export const DraftsView = () => {
const loadDrafts = async () => { const loadDrafts = async () => {
if (apiClient.private) { if (apiClient.private) {
const loadedDrafts = await apiClient.getDrafts() const loadedDrafts = await apiClient.getDrafts()
setDrafts(loadedDrafts || []) setDrafts(loadedDrafts.reverse() || [])
} }
} }

View File

@ -14,6 +14,7 @@ import { resetSortedArticles, useArticlesStore } from '../../../stores/zine/arti
import { useTopAuthorsStore } from '../../../stores/zine/topAuthors' import { useTopAuthorsStore } from '../../../stores/zine/topAuthors'
import { useTopicsStore } from '../../../stores/zine/topics' import { useTopicsStore } from '../../../stores/zine/topics'
import { getImageUrl } from '../../../utils/getImageUrl' import { getImageUrl } from '../../../utils/getImageUrl'
import { byCreated } from '../../../utils/sortby'
import { CommentDate } from '../../Article/CommentDate' import { CommentDate } from '../../Article/CommentDate'
import { getShareUrl } from '../../Article/SharePopup' import { getShareUrl } from '../../Article/SharePopup'
import { AuthorBadge } from '../../Author/AuthorBadge' import { AuthorBadge } from '../../Author/AuthorBadge'
@ -48,23 +49,11 @@ type VisibilityItem = {
} }
type FeedSearchParams = { type FeedSearchParams = {
by: 'publish_date' | 'likes_stat' | 'rating' | 'last_comment' by: 'publish_date' | 'likes' | 'comments'
period: FeedPeriod period: FeedPeriod
visibility: VisibilityMode visibility: VisibilityMode
} }
const getOrderBy = (by: FeedSearchParams['by']) => {
if (by === 'likes_stat' || by === 'rating') {
return 'likes_stat'
}
if (by === 'last_comment') {
return 'last_comment'
}
return ''
}
const getFromDate = (period: FeedPeriod): number => { const getFromDate = (period: FeedPeriod): number => {
const now = new Date() const now = new Date()
let d: Date = now let d: Date = now
@ -145,8 +134,8 @@ export const FeedView = (props: Props) => {
} }
const loadTopComments = async () => { const loadTopComments = async () => {
const comments = await loadReactionsBy({ by: { comment: true }, limit: 5 }) const comments = await loadReactionsBy({ by: { comment: true }, limit: 50 })
setTopComments(comments) setTopComments(comments.sort(byCreated).reverse())
} }
onMount(() => { onMount(() => {
@ -178,9 +167,8 @@ export const FeedView = (props: Props) => {
offset: sortedArticles().length, offset: sortedArticles().length,
} }
const orderBy = getOrderBy(searchParams().by) if (searchParams()?.by) {
if (orderBy) { options.order_by = searchParams().by
options.order_by = orderBy
} }
const visibilityMode = searchParams().visibility const visibilityMode = searchParams().visibility
@ -222,7 +210,7 @@ export const FeedView = (props: Props) => {
const ogTitle = t('Feed') const ogTitle = t('Feed')
const [shareData, setShareData] = createSignal<Shout | undefined>() const [shareData, setShareData] = createSignal<Shout | undefined>()
const handleShare = (shared) => { const handleShare = (shared: Shout | undefined) => {
showModal('share') showModal('share')
setShareData(shared) setShareData(shared)
} }
@ -260,19 +248,19 @@ export const FeedView = (props: Props) => {
{/*</li>*/} {/*</li>*/}
<li <li
class={clsx({ class={clsx({
'view-switcher__item--selected': searchParams().by === 'rating', 'view-switcher__item--selected': searchParams().by === 'likes',
})} })}
> >
<span class="link" onClick={() => changeSearchParams({ by: 'rating' })}> <span class="link" onClick={() => changeSearchParams({ by: 'likes' })}>
{t('Top rated')} {t('Top rated')}
</span> </span>
</li> </li>
<li <li
class={clsx({ class={clsx({
'view-switcher__item--selected': searchParams().by === 'last_comment', 'view-switcher__item--selected': searchParams().by === 'comments',
})} })}
> >
<span class="link" onClick={() => changeSearchParams({ by: 'last_comment' })}> <span class="link" onClick={() => changeSearchParams({ by: 'comments' })}>
{t('Most commented')} {t('Most commented')}
</span> </span>
</li> </li>

View File

@ -69,10 +69,12 @@ export const HomeView = (props: Props) => {
} }
const result = await apiClient.getRandomTopicShouts(RANDOM_TOPIC_SHOUTS_COUNT) const result = await apiClient.getRandomTopicShouts(RANDOM_TOPIC_SHOUTS_COUNT)
if (!result) console.warn('[apiClient.getRandomTopicShouts] failed') if (!result || result.error) console.warn('[apiClient.getRandomTopicShouts] failed')
batch(() => { batch(() => {
if (result?.topic) setRandomTopic(result.topic) if (!result?.error) {
if (result?.shouts) setRandomTopicArticles(result.shouts) if (result?.topic) setRandomTopic(result.topic)
if (result?.shouts) setRandomTopicArticles(result.shouts)
}
}) })
}) })

View File

@ -29,12 +29,9 @@ export const ProfileSubscriptions = () => {
const fetchSubscriptions = async () => { const fetchSubscriptions = async () => {
try { try {
const slug = author()?.slug const slug = author()?.slug
const [getAuthors, getTopics] = await Promise.all([ const authorFollows = await apiClient.getAuthorFollows({ slug })
apiClient.getAuthorFollowingAuthors({ slug }), setFollowing([...authorFollows['authors']])
apiClient.getAuthorFollowingTopics({ slug }), setFiltered([...authorFollows['authors'], ...authorFollows['topics']])
])
setFollowing([...getAuthors, ...getTopics])
setFiltered([...getAuthors, ...getTopics])
} catch (error) { } catch (error) {
console.error('[fetchSubscriptions] :', error) console.error('[fetchSubscriptions] :', error)
throw error throw error

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 { .teaser {
min-height: 300px; min-height: 300px;
display: flex; display: flex;

View File

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

View File

@ -2,20 +2,14 @@ import { Accessor, JSX, createContext, createEffect, createSignal, useContext }
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { Author, Community, FollowingEntity, Topic } from '../graphql/schema/core.gen' import { AuthorFollows, FollowingEntity } from '../graphql/schema/core.gen'
import { useSession } from './session' import { useSession } from './session'
type SubscriptionsData = {
topics?: Topic[]
authors?: Author[]
communities?: Community[]
}
interface FollowingContextType { interface FollowingContextType {
loading: Accessor<boolean> loading: Accessor<boolean>
subscriptions: SubscriptionsData subscriptions: AuthorFollows
setSubscriptions: (subscriptions: SubscriptionsData) => void setSubscriptions: (subscriptions: AuthorFollows) => void
setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void
loadSubscriptions: () => void loadSubscriptions: () => void
follow: (what: FollowingEntity, slug: string) => Promise<void> follow: (what: FollowingEntity, slug: string) => Promise<void>
@ -29,7 +23,7 @@ export function useFollowing() {
return useContext(FollowingContext) return useContext(FollowingContext)
} }
const EMPTY_SUBSCRIPTIONS = { const EMPTY_SUBSCRIPTIONS: AuthorFollows = {
topics: [], topics: [],
authors: [], authors: [],
communities: [], communities: [],
@ -37,15 +31,15 @@ const EMPTY_SUBSCRIPTIONS = {
export const FollowingProvider = (props: { children: JSX.Element }) => { export const FollowingProvider = (props: { children: JSX.Element }) => {
const [loading, setLoading] = createSignal<boolean>(false) const [loading, setLoading] = createSignal<boolean>(false)
const [subscriptions, setSubscriptions] = createStore<SubscriptionsData>(EMPTY_SUBSCRIPTIONS) const [subscriptions, setSubscriptions] = createStore<AuthorFollows>(EMPTY_SUBSCRIPTIONS)
const { author } = useSession() const { author, session } = useSession()
const fetchData = async () => { const fetchData = async () => {
setLoading(true) setLoading(true)
try { try {
if (apiClient.private) { if (apiClient.private) {
console.debug('[context.following] fetching subs data...') console.debug('[context.following] fetching subs data...')
const result = await apiClient.getMySubscriptions() const result = await apiClient.getAuthorFollows({ user: session()?.user.id })
setSubscriptions(result || EMPTY_SUBSCRIPTIONS) setSubscriptions(result || EMPTY_SUBSCRIPTIONS)
console.info('[context.following] subs:', subscriptions) console.info('[context.following] subs:', subscriptions)
} }

View File

@ -18,7 +18,7 @@ type ReactionsContextType = {
offset?: number offset?: number
}) => Promise<Reaction[]> }) => Promise<Reaction[]>
createReaction: (reaction: ReactionInput) => Promise<void> createReaction: (reaction: ReactionInput) => Promise<void>
updateReaction: (id: number, reaction: ReactionInput) => Promise<void> updateReaction: (reaction: ReactionInput) => Promise<void>
deleteReaction: (id: number) => Promise<void> deleteReaction: (id: number) => Promise<void>
} }
@ -88,8 +88,8 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
} }
} }
const updateReaction = async (id: number, input: ReactionInput): Promise<void> => { const updateReaction = async (input: ReactionInput): Promise<void> => {
const reaction = await apiClient.updateReaction(id, input) const reaction = await apiClient.updateReaction(input)
setReactionEntities(reaction.id, reaction) setReactionEntities(reaction.id, reaction)
} }

View File

@ -92,33 +92,43 @@ export const SessionProvider = (props: {
const authorizer = createMemo(() => new Authorizer(config())) const authorizer = createMemo(() => new Authorizer(config()))
const [oauthState, setOauthState] = createSignal<string>() const [oauthState, setOauthState] = createSignal<string>()
// handle callback's redirect_uri // handle auth state callback
createEffect(() => { createEffect(
// oauth on(
const state = searchParams()?.state () => searchParams()?.state,
if (state) { (state) => {
setOauthState((_s) => state) if (state) {
const scope = searchParams()?.scope setOauthState((_s) => state)
? searchParams()?.scope?.toString().split(' ') const scope = searchParams()?.scope
: ['openid', 'profile', 'email'] ? searchParams()?.scope?.toString().split(' ')
if (scope) console.info(`[context.session] scope: ${scope}`) : ['openid', 'profile', 'email']
const url = searchParams()?.redirect_uri || searchParams()?.redirectURL || window.location.href if (scope) console.info(`[context.session] scope: ${scope}`)
setConfig((c: ConfigType) => ({ ...c, redirectURL: url.split('?')[0] })) const url = searchParams()?.redirect_uri || searchParams()?.redirectURL || window.location.href
changeSearchParams({ mode: 'confirm-email', modal: 'auth' }, true) setConfig((c: ConfigType) => ({ ...c, redirectURL: url.split('?')[0] }))
} changeSearchParams({ mode: 'confirm-email', m: 'auth' }, true)
}) }
},
{ defer: true },
),
)
// handle email confirm // handle token confirm
createEffect(() => { createEffect(() => {
const token = searchParams()?.token const token = searchParams()?.token
const access_token = searchParams()?.access_token const access_token = searchParams()?.access_token
if (access_token) if (access_token)
changeSearchParams({ changeSearchParams({
mode: 'confirm-email', mode: 'confirm-email',
modal: 'auth', m: 'auth',
access_token, access_token,
}) })
else if (token) changeSearchParams({ mode: 'change-password', modal: 'auth', token }) else if (token) {
changeSearchParams({
mode: 'change-password',
m: 'auth',
token,
})
}
}) })
// load // load
@ -203,7 +213,6 @@ export const SessionProvider = (props: {
if (session()) { if (session()) {
const token = session()?.access_token const token = session()?.access_token
if (token) { if (token) {
// console.log('[context.session] token observer got token', token)
if (!inboxClient.private) { if (!inboxClient.private) {
apiClient.connect(token) apiClient.connect(token)
notifierClient.connect(token) notifierClient.connect(token)
@ -329,7 +338,6 @@ export const SessionProvider = (props: {
const response = await authorizer().graphqlQuery({ const response = await authorizer().graphqlQuery({
query: `query { is_registered(email: "${email}") { message }}`, query: `query { is_registered(email: "${email}") { message }}`,
}) })
// console.log(response)
return response?.data?.is_registered?.message return response?.data?.is_registered?.message
} catch (error) { } catch (error) {
console.warn(error) console.warn(error)

View File

@ -1,7 +1,7 @@
import type { import type {
Author, Author,
AuthorFollows,
CommonResult, CommonResult,
Community,
FollowingEntity, FollowingEntity,
LoadShoutsOptions, LoadShoutsOptions,
MutationDelete_ShoutArgs, MutationDelete_ShoutArgs,
@ -37,16 +37,14 @@ import shoutsLoadSearch from '../query/core/articles-load-search'
import loadShoutsUnrated from '../query/core/articles-load-unrated' import loadShoutsUnrated from '../query/core/articles-load-unrated'
import authorBy from '../query/core/author-by' import authorBy from '../query/core/author-by'
import authorFollowers from '../query/core/author-followers' import authorFollowers from '../query/core/author-followers'
import authorFollows from '../query/core/author-follows'
import authorId from '../query/core/author-id' import authorId from '../query/core/author-id'
import authorsAll from '../query/core/authors-all' import authorsAll from '../query/core/authors-all'
import authorFollowedAuthors from '../query/core/authors-followed-by'
import authorsLoadBy from '../query/core/authors-load-by' import authorsLoadBy from '../query/core/authors-load-by'
import authorFollowedCommunities from '../query/core/communities-followed-by'
import mySubscriptions from '../query/core/my-followed' import mySubscriptions from '../query/core/my-followed'
import reactionsLoadBy from '../query/core/reactions-load-by' import reactionsLoadBy from '../query/core/reactions-load-by'
import topicBySlug from '../query/core/topic-by-slug' import topicBySlug from '../query/core/topic-by-slug'
import topicsAll from '../query/core/topics-all' import topicsAll from '../query/core/topics-all'
import authorFollowedTopics from '../query/core/topics-followed-by'
import topicsRandomQuery from '../query/core/topics-random' import topicsRandomQuery from '../query/core/topics-random'
const publicGraphQLClient = createGraphQLClient('core') const publicGraphQLClient = createGraphQLClient('core')
@ -86,7 +84,7 @@ export const apiClient = {
return response.data.get_topics_random return response.data.get_topics_random
}, },
getRandomTopicShouts: async (limit: number): Promise<{ topic: Topic; shouts: Shout[] }> => { getRandomTopicShouts: async (limit: number): Promise<CommonResult> => {
const resp = await publicGraphQLClient.query(articlesLoadRandomTopic, { limit }).toPromise() const resp = await publicGraphQLClient.query(articlesLoadRandomTopic, { limit }).toPromise()
if (!resp.data) console.error('[graphql.client.core] load_shouts_random_topic', resp) if (!resp.data) console.error('[graphql.client.core] load_shouts_random_topic', resp)
return resp.data.load_shouts_random_topic return resp.data.load_shouts_random_topic
@ -96,6 +94,7 @@ export const apiClient = {
const response = await apiClient.private.mutation(followMutation, { what, slug }).toPromise() const response = await apiClient.private.mutation(followMutation, { what, slug }).toPromise()
return response.data.follow return response.data.follow
}, },
unfollow: async ({ what, slug }: { what: FollowingEntity; slug: string }) => { unfollow: async ({ what, slug }: { what: FollowingEntity; slug: string }) => {
const response = await apiClient.private.mutation(unfollowMutation, { what, slug }).toPromise() const response = await apiClient.private.mutation(unfollowMutation, { what, slug }).toPromise()
return response.data.unfollow return response.data.unfollow
@ -107,48 +106,53 @@ export const apiClient = {
return response.data.get_topics_all return response.data.get_topics_all
}, },
getAllAuthors: async () => { getAllAuthors: async () => {
const response = await publicGraphQLClient.query(authorsAll, {}).toPromise() const response = await publicGraphQLClient.query(authorsAll, {}).toPromise()
if (!response.data) console.error('[graphql.client.core] getAllAuthors', response) if (!response.data) console.error('[graphql.client.core] getAllAuthors', response)
return response.data.get_authors_all return response.data.get_authors_all
}, },
getAuthor: async (params: { slug?: string; author_id?: number }): Promise<Author> => { getAuthor: async (params: { slug?: string; author_id?: number }): Promise<Author> => {
const response = await publicGraphQLClient.query(authorBy, params).toPromise() const response = await publicGraphQLClient.query(authorBy, params).toPromise()
return response.data.get_author return response.data.get_author
}, },
getAuthorId: async (params: { user: string }): Promise<Author> => { getAuthorId: async (params: { user: string }): Promise<Author> => {
const response = await publicGraphQLClient.query(authorId, params).toPromise() const response = await publicGraphQLClient.query(authorId, params).toPromise()
return response.data.get_author_id return response.data.get_author_id
}, },
getAuthorFollowers: async ({ slug }: { slug: string }): Promise<Author[]> => { getAuthorFollowers: async ({ slug }: { slug: string }): Promise<Author[]> => {
const response = await publicGraphQLClient.query(authorFollowers, { slug }).toPromise() const response = await publicGraphQLClient.query(authorFollowers, { slug }).toPromise()
return response.data.get_author_followers return response.data.get_author_followers
}, },
getAuthorFollowingAuthors: async ({ slug }: { slug: string }): Promise<Author[]> => {
const response = await publicGraphQLClient.query(authorFollowedAuthors, { slug }).toPromise() getAuthorFollows: async (params: {
return response.data.get_author_followed slug?: string
}, author_id?: number
getAuthorFollowingTopics: async ({ slug }: { slug: string }): Promise<Topic[]> => { user?: string
const response = await publicGraphQLClient.query(authorFollowedTopics, { slug }).toPromise() }): Promise<AuthorFollows> => {
return response.data.get_topics_by_author const response = await publicGraphQLClient.query(authorFollows, params).toPromise()
}, return response.data.get_author_follows
getAuthorFollowingCommunities: async ({ slug }: { slug: string }): Promise<Community[]> => {
const response = await publicGraphQLClient.query(authorFollowedCommunities, { slug }).toPromise()
return response.data.get_communities_by_author
}, },
updateAuthor: async (input: ProfileInput) => { updateAuthor: async (input: ProfileInput) => {
const response = await apiClient.private.mutation(updateAuthor, { profile: input }).toPromise() const response = await apiClient.private.mutation(updateAuthor, { profile: input }).toPromise()
return response.data.update_author return response.data.update_author
}, },
getTopic: async ({ slug }: { slug: string }): Promise<Topic> => { getTopic: async ({ slug }: { slug: string }): Promise<Topic> => {
const response = await publicGraphQLClient.query(topicBySlug, { slug }).toPromise() const response = await publicGraphQLClient.query(topicBySlug, { slug }).toPromise()
return response.data.get_topic return response.data.get_topic
}, },
createArticle: async ({ article }: { article: ShoutInput }): Promise<Shout> => { createArticle: async ({ article }: { article: ShoutInput }): Promise<Shout> => {
const response = await apiClient.private.mutation(createArticle, { shout: article }).toPromise() const response = await apiClient.private.mutation(createArticle, { shout: article }).toPromise()
return response.data.create_shout.shout return response.data.create_shout.shout
}, },
updateArticle: async ({ updateArticle: async ({
shout_id, shout_id,
shout_input, shout_input,
@ -164,10 +168,12 @@ export const apiClient = {
console.debug('[graphql.client.core] updateArticle:', response.data) console.debug('[graphql.client.core] updateArticle:', response.data)
return response.data.update_shout return response.data.update_shout
}, },
deleteShout: async (params: MutationDelete_ShoutArgs): Promise<void> => { deleteShout: async (params: MutationDelete_ShoutArgs): Promise<void> => {
const response = await apiClient.private.mutation(deleteShout, params).toPromise() const response = await apiClient.private.mutation(deleteShout, params).toPromise()
console.debug('[graphql.client.core] deleteShout:', response) console.debug('[graphql.client.core] deleteShout:', response)
}, },
getDrafts: async (): Promise<Shout[]> => { getDrafts: async (): Promise<Shout[]> => {
const response = await apiClient.private.query(draftsLoad, {}).toPromise() const response = await apiClient.private.query(draftsLoad, {}).toPromise()
console.debug('[graphql.client.core] getDrafts:', response) console.debug('[graphql.client.core] getDrafts:', response)
@ -178,15 +184,13 @@ export const apiClient = {
console.debug('[graphql.client.core] createReaction:', response) console.debug('[graphql.client.core] createReaction:', response)
return response.data.create_reaction.reaction return response.data.create_reaction.reaction
}, },
destroyReaction: async (id: number) => { destroyReaction: async (reaction_id: number) => {
const response = await apiClient.private.mutation(reactionDestroy, { id: id }).toPromise() const response = await apiClient.private.mutation(reactionDestroy, { reaction_id }).toPromise()
console.debug('[graphql.client.core] destroyReaction:', response) console.debug('[graphql.client.core] destroyReaction:', response)
return response.data.delete_reaction.reaction return response.data.delete_reaction.reaction
}, },
updateReaction: async (id: number, input: ReactionInput) => { updateReaction: async (reaction: ReactionInput) => {
const response = await apiClient.private const response = await apiClient.private.mutation(reactionUpdate, { reaction }).toPromise()
.mutation(reactionUpdate, { id: id, reaction: input })
.toPromise()
console.debug('[graphql.client.core] updateReaction:', response) console.debug('[graphql.client.core] updateReaction:', response)
return response.data.update_reaction.reaction return response.data.update_reaction.reaction
}, },
@ -233,9 +237,4 @@ export const apiClient = {
.toPromise() .toPromise()
return resp.data.load_reactions_by return resp.data.load_reactions_by
}, },
getMySubscriptions: async (): Promise<CommonResult> => {
const resp = await apiClient.private.query(mySubscriptions, {}).toPromise()
return resp.data.get_my_followed
},
} }

View File

@ -1,8 +1,8 @@
import { gql } from '@urql/core' import { gql } from '@urql/core'
export default gql` export default gql`
mutation UpdateReactionMutation($id: Int!, $reaction: ReactionInput!) { mutation UpdateReactionMutation($reaction: ReactionInput!) {
update_reaction(id: $id, reaction: $reaction) { update_reaction(reaction: $reaction) {
error error
reaction { reaction {
id id

View File

@ -53,7 +53,6 @@ export default gql`
featured_at featured_at
stat { stat {
viewed viewed
rating rating
commented commented
} }

View File

@ -15,10 +15,10 @@ export default gql`
last_seen last_seen
stat { stat {
shouts shouts
authors
followers followers
followings
rating rating
commented comments
} }
} }
} }

View File

@ -0,0 +1,19 @@
import { gql } from '@urql/core'
export default gql`
query GetAuthorFollowsAuthors($slug: String, $user: String, $author_id: Int) {
get_author_follows_authors(slug: $slug, user: $user, author_id: $author_id) {
id
slug
name
pic
bio
created_at
stat {
shouts
authors
followers
}
}
}
`

View File

@ -0,0 +1,16 @@
import { gql } from '@urql/core'
export default gql`
query GetAuthorFollowsTopics($slug: String, $user: String, $author_id: Int) {
get_author_follows_topics(slug: $slug, user: $user, author_id: $author_id) {
id
slug
title
stat {
shouts
authors
followers
}
}
}
`

View File

@ -0,0 +1,37 @@
import { gql } from '@urql/core'
export default gql`
query GetAuthorFollows($slug: String, $user: String, $author_id: Int) {
get_author_follows(slug: $slug, user: $user, author_id: $author_id) {
authors {
id
slug
name
pic
bio
created_at
stat {
shouts
authors
followers
}
}
topics {
id
slug
title
stat {
shouts
authors
followers
}
}
#communities {
# id
# slug
# name
# pic
#}
}
}
`

View File

@ -14,10 +14,10 @@ export default gql`
last_seen last_seen
stat { stat {
shouts shouts
comments: commented authors
followers followers
followings
rating rating
comments
rating_shouts rating_shouts
rating_comments rating_comments
} }

View File

@ -1,17 +0,0 @@
import { gql } from '@urql/core'
export default gql`
query AuthorsFollowedByQuery($slug: String, $user: String, $author_id: Int) {
get_author_followed(slug: $slug, user: $user, author_id: $author_id) {
id
slug
name
pic
bio
created_at
stat {
shouts
}
}
}
`

View File

@ -11,10 +11,10 @@ export default gql`
created_at created_at
stat { stat {
shouts shouts
comments: commented authors
followers followers
followings
rating rating
comments
rating_shouts rating_shouts
rating_comments rating_comments
} }

View File

@ -1,12 +1,22 @@
import type { PageContext } from '../renderer/types' import type { PageContext } from '../renderer/types'
import type { PageProps } from './types' import type { PageProps } from './types'
import { PAGE_SIZE } from '../components/Views/AllTopics/AllTopics'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
export const onBeforeRender = async (_pageContext: PageContext) => { export const onBeforeRender = async (_pageContext: PageContext) => {
const allAuthors = await apiClient.getAllAuthors() const allAuthors = await apiClient.getAllAuthors()
const topWritingAuthors = await apiClient.loadAuthorsBy({
const pageProps: PageProps = { allAuthors, seo: { title: '' } } by: { order: 'shouts' },
limit: PAGE_SIZE,
offset: 0,
})
const topFollowedAuthors = await apiClient.loadAuthorsBy({
by: { order: 'followers' },
limit: PAGE_SIZE,
offset: 0,
})
const pageProps: PageProps = { allAuthors, seo: { title: '' }, topWritingAuthors, topFollowedAuthors }
return { return {
pageContext: { pageContext: {

View File

@ -1,14 +1,17 @@
import type { PageProps } from './types' 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 { PAGE_SIZE } from '../components/Views/AllTopics/AllTopics'
import { PageLayout } from '../components/_shared/PageLayout' import { PageLayout } from '../components/_shared/PageLayout'
import { useLocalize } from '../context/localize' import { useLocalize } from '../context/localize'
import { loadAllAuthors } from '../stores/zine/authors' import { loadAllAuthors, loadAuthors } from '../stores/zine/authors'
export const AllAuthorsPage = (props: PageProps) => { export const AllAuthorsPage = (props: PageProps) => {
const [isLoaded, setIsLoaded] = createSignal<boolean>(Boolean(props.allAuthors)) const [isLoaded, setIsLoaded] = createSignal<boolean>(
Boolean(props.allAuthors && props.topFollowedAuthors && props.topWritingAuthors),
)
const { t } = useLocalize() const { t } = useLocalize()
@ -18,12 +21,19 @@ export const AllAuthorsPage = (props: PageProps) => {
} }
await loadAllAuthors() await loadAllAuthors()
await loadAuthors({ by: { order: 'shouts' }, limit: PAGE_SIZE, offset: 0 })
await loadAuthors({ by: { order: 'followers' }, limit: PAGE_SIZE, offset: 0 })
setIsLoaded(true) setIsLoaded(true)
}) })
return ( return (
<PageLayout title={t('Authors')}> <PageLayout title={t('Authors')}>
<AllAuthorsView isLoaded={isLoaded()} authors={props.allAuthors} /> <AllAuthors
isLoaded={isLoaded()}
authors={props.allAuthors}
topWritingAuthors={props.topWritingAuthors}
topFollowedAuthors={props.topFollowedAuthors}
/>
</PageLayout> </PageLayout>
) )
} }

View File

@ -10,6 +10,8 @@ export type PageProps = {
homeShouts?: Shout[] homeShouts?: Shout[]
author?: Author author?: Author
allAuthors?: Author[] allAuthors?: Author[]
topWritingAuthors?: Author[]
topFollowedAuthors?: Author[]
topic?: Topic topic?: Topic
allTopics?: Topic[] allTopics?: Topic[]
searchQuery?: string searchQuery?: string
@ -25,6 +27,7 @@ export type PageProps = {
export type RootSearchParams = { export type RootSearchParams = {
m: string // modal m: string // modal
lang: string lang: string
token: string
} }
export type LayoutType = 'article' | 'audio' | 'video' | 'image' | 'literature' export type LayoutType = 'article' | 'audio' | 'video' | 'image' | 'literature'

View File

@ -3,9 +3,9 @@ import { createSignal } from 'solid-js'
import { apiClient } from '../../graphql/client/core' import { apiClient } from '../../graphql/client/core'
import { Author, QueryLoad_Authors_ByArgs } from '../../graphql/schema/core.gen' import { Author, QueryLoad_Authors_ByArgs } from '../../graphql/schema/core.gen'
import { byStat } from '../../utils/sortby'
export type AuthorsSortBy = 'shouts' | 'name' | 'followers' export type AuthorsSortBy = 'shouts' | 'name' | 'followers'
type SortedAuthorsSetter = (prev: Author[]) => Author[]
const [sortAllBy, setSortAllBy] = createSignal<AuthorsSortBy>('name') const [sortAllBy, setSortAllBy] = createSignal<AuthorsSortBy>('name')
@ -13,21 +13,14 @@ export const setAuthorsSort = (sortBy: AuthorsSortBy) => setSortAllBy(sortBy)
const [authorEntities, setAuthorEntities] = createSignal<{ [authorSlug: string]: Author }>({}) const [authorEntities, setAuthorEntities] = createSignal<{ [authorSlug: string]: Author }>({})
const [authorsByTopic, setAuthorsByTopic] = createSignal<{ [topicSlug: 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 sortedAuthors = createLazyMemo(() => {
const authors = Object.values(authorEntities()) return Object.values(authorEntities())
switch (sortAllBy()) {
case 'followers': {
return authors.sort(byStat('followers'))
}
case 'shouts': {
return authors.sort(byStat('shouts'))
}
case 'name': {
return authors.sort((a, b) => a.name.localeCompare(b.name))
}
}
return authors
}) })
export const addAuthors = (authors: Author[]) => { export const addAuthors = (authors: Author[]) => {
@ -108,5 +101,5 @@ export const useAuthorsStore = (initialState: InitialState = {}) => {
} }
addAuthors([...(initialState.authors || [])]) addAuthors([...(initialState.authors || [])])
return { authorEntities, sortedAuthors, authorsByTopic } return { authorEntities, sortedAuthors, authorsByTopic, authorsByShouts, authorsByFollowers }
} }

View File

@ -204,7 +204,7 @@ a:hover,
a:visited, a:visited,
a:link, a:link,
.link { .link {
border-bottom: 2px solid rgb(0 0 0 / 30%); border-bottom: 2px solid var(--link-color);
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
} }