Merge branch 'dev' of github.com:Discours/discoursio-webapp into hotfix/posting-author
This commit is contained in:
commit
0160dec607
|
@ -1,4 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npm run pre-commit
|
27
codegen.yml
27
codegen.yml
|
@ -25,33 +25,6 @@ generates:
|
|||
useTypeImports: true
|
||||
outputPath: './src/graphql/types/core.gen.ts'
|
||||
# namingConvention: change-case#CamelCase # for generated types
|
||||
|
||||
# Generate types for notifier
|
||||
src/graphql/schema/notifier.gen.ts:
|
||||
schema: 'https://notifier.discours.io'
|
||||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
- 'typescript-urql'
|
||||
config:
|
||||
skipTypename: true
|
||||
useTypeImports: true
|
||||
outputPath: './src/graphql/types/notifier.gen.ts'
|
||||
# namingConvention: change-case#CamelCase # for generated types
|
||||
|
||||
# internal types for auth
|
||||
# src/graphql/schema/auth.gen.ts:
|
||||
# schema: 'https://auth.discours.io/graphql'
|
||||
# plugins:
|
||||
# - 'typescript'
|
||||
# - 'typescript-operations'
|
||||
# - 'typescript-urql'
|
||||
# config:
|
||||
# skipTypename: true
|
||||
# useTypeImports: true
|
||||
# outputPath: './src/graphql/types/auth.gen.ts'
|
||||
# namingConvention: change-case#CamelCase # for generated types
|
||||
|
||||
hooks:
|
||||
afterAllFileWrite:
|
||||
- prettier --ignore-path .gitignore --write --plugin-search-dir=. src/graphql/schema/*.gen.ts
|
||||
|
|
|
@ -291,6 +291,7 @@
|
|||
"Profile": "Profile",
|
||||
"Publications": "Publications",
|
||||
"PublicationsWithCount": "{count, plural, =0 {no publications} one {{count} publication} other {{count} publications}}",
|
||||
"FollowersWithCount": "{count, plural, =0 {no followers} one {{count} follower} other {{count} followers}}",
|
||||
"Publish Album": "Publish Album",
|
||||
"Publish Settings": "Publish Settings",
|
||||
"Published": "Published",
|
||||
|
|
|
@ -136,7 +136,6 @@
|
|||
"Each image must be no larger than 5 MB.": "Каждое изображение должно быть размером не больше 5 мб.",
|
||||
"Edit profile": "Редактировать профиль",
|
||||
"Edit": "Редактировать",
|
||||
"Edited": "Отредактирован",
|
||||
"Editing": "Редактирование",
|
||||
"Editor": "Редактор",
|
||||
"Email": "Почта",
|
||||
|
@ -309,9 +308,10 @@
|
|||
"Publication settings": "Настройки публикации",
|
||||
"Publications": "Публикации",
|
||||
"PublicationsWithCount": "{count, plural, =0 {нет публикаций} one {{count} публикация} few {{count} публикации} other {{count} публикаций}}",
|
||||
"FollowersWithCount": "{count, plural, =0 {нет подписчиков} one {{count} подписчик} few {{count} подписчика} other {{count} подписчиков}}",
|
||||
"Publish": "Опубликовать",
|
||||
"Publish Album": "Опубликовать альбом",
|
||||
"Publish Settings": "Настройки публикации",
|
||||
"Publish": "Опубликовать",
|
||||
"Published": "Опубликованные",
|
||||
"Punchline": "Панчлайн",
|
||||
"Quit": "Выйти",
|
||||
|
|
|
@ -94,10 +94,6 @@ export const App = (props: Props) => {
|
|||
const is404 = createMemo(() => props.is404)
|
||||
|
||||
createEffect(() => {
|
||||
if (!searchParams().m) {
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const modal = MODALS[searchParams().m]
|
||||
if (modal) {
|
||||
showModal(modal)
|
||||
|
|
|
@ -38,17 +38,22 @@ export const Comment = (props: Props) => {
|
|||
const [loading, setLoading] = createSignal(false)
|
||||
const [editMode, setEditMode] = createSignal(false)
|
||||
const [clearEditor, setClearEditor] = createSignal(false)
|
||||
const { author } = useSession()
|
||||
const [editedBody, setEditedBody] = createSignal<string>()
|
||||
const { author, session } = useSession()
|
||||
const { createReaction, deleteReaction, updateReaction } = useReactions()
|
||||
const { showConfirm } = useConfirm()
|
||||
const { showSnackbar } = useSnackbar()
|
||||
|
||||
const isCommentAuthor = createMemo(() => props.comment.created_by?.slug === author()?.slug)
|
||||
const comment = createMemo(() => props.comment)
|
||||
const body = createMemo(() => (comment().body || '').trim())
|
||||
const canEdit = createMemo(
|
||||
() =>
|
||||
Boolean(author()?.id) &&
|
||||
(props.comment?.created_by?.slug === author().slug || session()?.user?.roles.includes('editor')),
|
||||
)
|
||||
|
||||
const body = createMemo(() => (editedBody() ? editedBody().trim() : props.comment.body.trim() || ''))
|
||||
|
||||
const remove = async () => {
|
||||
if (comment()?.id) {
|
||||
if (props.comment?.id) {
|
||||
try {
|
||||
const isConfirmed = await showConfirm({
|
||||
confirmBody: t('Are you sure you want to delete this comment?'),
|
||||
|
@ -58,7 +63,7 @@ export const Comment = (props: Props) => {
|
|||
})
|
||||
|
||||
if (isConfirmed) {
|
||||
await deleteReaction(comment().id)
|
||||
await deleteReaction(props.comment.id)
|
||||
|
||||
await showSnackbar({ body: t('Comment successfully deleted') })
|
||||
}
|
||||
|
@ -93,11 +98,15 @@ export const Comment = (props: Props) => {
|
|||
const handleUpdate = async (value) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await updateReaction(props.comment.id, {
|
||||
const reaction = await updateReaction({
|
||||
id: props.comment.id,
|
||||
kind: ReactionKind.Comment,
|
||||
body: value,
|
||||
shout: props.comment.shout.id,
|
||||
})
|
||||
if (reaction) {
|
||||
setEditedBody(value)
|
||||
}
|
||||
setEditMode(false)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
|
@ -107,9 +116,9 @@ export const Comment = (props: Props) => {
|
|||
|
||||
return (
|
||||
<li
|
||||
id={`comment_${comment().id}`}
|
||||
id={`comment_${props.comment.id}`}
|
||||
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()}>
|
||||
|
@ -119,21 +128,21 @@ export const Comment = (props: Props) => {
|
|||
fallback={
|
||||
<div>
|
||||
<Userpic
|
||||
name={comment().created_by.name}
|
||||
userpic={comment().created_by.pic}
|
||||
name={props.comment.created_by.name}
|
||||
userpic={props.comment.created_by.pic}
|
||||
class={clsx({
|
||||
[styles.compactUserpic]: props.compact,
|
||||
})}
|
||||
/>
|
||||
<small>
|
||||
<a href={`#comment_${comment()?.id}`}>{comment()?.shout.title || ''}</a>
|
||||
<a href={`#comment_${props.comment?.id}`}>{props.comment?.shout.title || ''}</a>
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class={styles.commentDetails}>
|
||||
<div class={styles.commentAuthor}>
|
||||
<AuthorLink author={comment()?.created_by as Author} />
|
||||
<AuthorLink author={props.comment?.created_by as Author} />
|
||||
</div>
|
||||
|
||||
<Show when={props.isArticleAuthor}>
|
||||
|
@ -144,23 +153,23 @@ export const Comment = (props: Props) => {
|
|||
<div class={styles.articleLink}>
|
||||
<Icon name="arrow-right" class={styles.articleLinkIcon} />
|
||||
<a
|
||||
href={`${getPagePath(router, 'article', { slug: comment().shout.slug })}?commentId=${
|
||||
comment().id
|
||||
}`}
|
||||
href={`${getPagePath(router, 'article', {
|
||||
slug: props.comment.shout.slug,
|
||||
})}?commentId=${props.comment.id}`}
|
||||
>
|
||||
{comment().shout.title}
|
||||
{props.comment.shout.title}
|
||||
</a>
|
||||
</div>
|
||||
</Show>
|
||||
<CommentDate showOnHover={true} comment={comment()} isShort={true} />
|
||||
<CommentRatingControl comment={comment()} />
|
||||
<CommentDate showOnHover={true} comment={props.comment} isShort={true} />
|
||||
<CommentRatingControl comment={props.comment} />
|
||||
</div>
|
||||
</Show>
|
||||
<div class={styles.commentBody}>
|
||||
<Show when={editMode()} fallback={<div innerHTML={body()} />}>
|
||||
<Suspense fallback={<p>{t('Loading')}</p>}>
|
||||
<SimplifiedEditor
|
||||
initialContent={comment().body}
|
||||
initialContent={editedBody() || props.comment.body}
|
||||
submitButtonText={t('Save')}
|
||||
quoteEnabled={true}
|
||||
imageEnabled={true}
|
||||
|
@ -189,7 +198,7 @@ export const Comment = (props: Props) => {
|
|||
{loading() ? t('Loading') : t('Reply')}
|
||||
</button>
|
||||
</ShowIfAuthenticated>
|
||||
<Show when={isCommentAuthor()}>
|
||||
<Show when={canEdit()}>
|
||||
<button
|
||||
class={clsx(styles.commentControl, styles.commentControlEdit)}
|
||||
onClick={toggleEditMode}
|
||||
|
|
|
@ -2,29 +2,22 @@
|
|||
@include font-size(1.2rem);
|
||||
|
||||
color: var(--secondary-color);
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
|
||||
// align-self: center;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
font-size: 1.2rem;
|
||||
justify-content: flex-start;
|
||||
margin: 0 1rem;
|
||||
height: 1.6rem;
|
||||
margin-bottom: .5rem;
|
||||
|
||||
.date {
|
||||
font-weight: 500;
|
||||
margin-right: 1rem;
|
||||
position: relative;
|
||||
|
||||
.icon {
|
||||
line-height: 1;
|
||||
width: 1rem;
|
||||
display: inline-block;
|
||||
opacity: 0.6;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
&.showOnHover {
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import type { Reaction } from '../../../graphql/schema/core.gen'
|
||||
|
||||
import { clsx } from 'clsx'
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { Icon } from '../../_shared/Icon'
|
||||
|
||||
import styles from './CommentDate.module.scss'
|
||||
|
||||
|
@ -34,14 +32,6 @@ export const CommentDate = (props: Props) => {
|
|||
})}
|
||||
>
|
||||
<time class={styles.date}>{formattedDate(props.comment.created_at * 1000)}</time>
|
||||
<Show when={props.comment.updated_at}>
|
||||
<time class={styles.date}>
|
||||
<Icon name="edit" class={styles.icon} />
|
||||
<span class={styles.text}>
|
||||
{t('Edited')} {formattedDate(props.comment.updated_at * 1000)}
|
||||
</span>
|
||||
</time>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,8 +4,8 @@ import { For, Show, createMemo, createSignal, lazy, onMount } from 'solid-js'
|
|||
import { useLocalize } from '../../context/localize'
|
||||
import { useReactions } from '../../context/reactions'
|
||||
import { useSession } from '../../context/session'
|
||||
import { Author, Reaction, ReactionKind } from '../../graphql/schema/core.gen'
|
||||
import { byCreated } from '../../utils/sortby'
|
||||
import { Author, Reaction, ReactionKind, ReactionSort } from '../../graphql/schema/core.gen'
|
||||
import { byCreated, byStat } from '../../utils/sortby'
|
||||
import { Button } from '../_shared/Button'
|
||||
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
|
||||
|
||||
|
@ -15,27 +15,6 @@ import styles from './Article.module.scss'
|
|||
|
||||
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 = {
|
||||
articleAuthors: Author[]
|
||||
shoutSlug: string
|
||||
|
@ -45,7 +24,8 @@ type Props = {
|
|||
export const CommentsTree = (props: Props) => {
|
||||
const { author } = useSession()
|
||||
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 [clearEditor, setClearEditor] = createSignal(false)
|
||||
const [clickedReplyId, setClickedReplyId] = createSignal<number>()
|
||||
|
@ -59,16 +39,13 @@ export const CommentsTree = (props: Props) => {
|
|||
let newSortedComments = [...comments()]
|
||||
newSortedComments = newSortedComments.sort(byCreated)
|
||||
|
||||
if (commentsOrder() === 'newOnly') {
|
||||
return newReactions().reverse()
|
||||
if (onlyNew()) {
|
||||
return newReactions().sort(byCreated).reverse()
|
||||
}
|
||||
|
||||
if (commentsOrder() === 'rating') {
|
||||
newSortedComments = newSortedComments.sort(sortCommentsByRating)
|
||||
if (commentsOrder() === ReactionSort.Like) {
|
||||
newSortedComments = newSortedComments.sort(byStat('rating'))
|
||||
}
|
||||
|
||||
newSortedComments.reverse()
|
||||
|
||||
return newSortedComments
|
||||
})
|
||||
|
||||
|
@ -91,7 +68,7 @@ export const CommentsTree = (props: Props) => {
|
|||
setCookie()
|
||||
}
|
||||
})
|
||||
const handleSubmitComment = async (value) => {
|
||||
const handleSubmitComment = async (value: string) => {
|
||||
try {
|
||||
await createReaction({
|
||||
kind: ReactionKind.Comment,
|
||||
|
@ -117,31 +94,25 @@ export const CommentsTree = (props: Props) => {
|
|||
<Show when={comments().length > 0}>
|
||||
<ul class={clsx(styles.commentsViewSwitcher, 'view-switcher')}>
|
||||
<Show when={newReactions().length > 0}>
|
||||
<li classList={{ 'view-switcher__item--selected': commentsOrder() === 'newOnly' }}>
|
||||
<Button
|
||||
variant="light"
|
||||
value={t('New only')}
|
||||
onClick={() => {
|
||||
setCommentsOrder('newOnly')
|
||||
}}
|
||||
/>
|
||||
<li classList={{ 'view-switcher__item--selected': onlyNew() }}>
|
||||
<Button variant="light" value={t('New only')} onClick={() => setOnlyNew(!onlyNew())} />
|
||||
</li>
|
||||
</Show>
|
||||
<li classList={{ 'view-switcher__item--selected': commentsOrder() === 'createdAt' }}>
|
||||
<li classList={{ 'view-switcher__item--selected': commentsOrder() === ReactionSort.Newest }}>
|
||||
<Button
|
||||
variant="light"
|
||||
value={t('By time')}
|
||||
onClick={() => {
|
||||
setCommentsOrder('createdAt')
|
||||
setCommentsOrder(ReactionSort.Newest)
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
<li classList={{ 'view-switcher__item--selected': commentsOrder() === 'rating' }}>
|
||||
<li classList={{ 'view-switcher__item--selected': commentsOrder() === ReactionSort.Like }}>
|
||||
<Button
|
||||
variant="light"
|
||||
value={t('By rating')}
|
||||
onClick={() => {
|
||||
setCommentsOrder('rating')
|
||||
setCommentsOrder(ReactionSort.Like)
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
|
|
|
@ -58,9 +58,8 @@ export type ArticlePageSearchParams = {
|
|||
|
||||
const scrollTo = (el: HTMLElement) => {
|
||||
const { top } = el.getBoundingClientRect()
|
||||
|
||||
window.scrollTo({
|
||||
top: top + window.scrollY - DEFAULT_HEADER_OFFSET,
|
||||
top: top - DEFAULT_HEADER_OFFSET,
|
||||
left: 0,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
|
@ -75,10 +74,17 @@ export const FullArticle = (props: Props) => {
|
|||
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
|
||||
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
|
||||
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 canEdit = () => props.article.authors?.some((a) => Boolean(a) && a?.slug === author()?.slug)
|
||||
const formattedDate = createMemo(() => formatDate(new Date(props.article.published_at * 1000)))
|
||||
|
||||
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 mainTopicSlug = props.article.topics.length > 0 ? props.article.main_topic : null
|
||||
|
@ -145,22 +151,16 @@ export const FullArticle = (props: Props) => {
|
|||
current: HTMLDivElement
|
||||
} = { current: null }
|
||||
|
||||
const scrollToComments = () => {
|
||||
scrollTo(commentsRef.current)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.scrollToComments) {
|
||||
scrollToComments()
|
||||
scrollTo(commentsRef.current)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (searchParams()?.scrollTo === 'comments' && commentsRef.current) {
|
||||
scrollToComments()
|
||||
changeSearchParams({
|
||||
scrollTo: null,
|
||||
})
|
||||
requestAnimationFrame(() => scrollTo(commentsRef.current))
|
||||
changeSearchParams({ scrollTo: null })
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -170,10 +170,8 @@ export const FullArticle = (props: Props) => {
|
|||
`[id='comment_${searchParams().commentId}']`,
|
||||
)
|
||||
|
||||
changeSearchParams({ commentId: null })
|
||||
|
||||
if (commentElement) {
|
||||
scrollTo(commentElement)
|
||||
requestAnimationFrame(() => scrollTo(commentElement))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -466,7 +464,11 @@ export const FullArticle = (props: Props) => {
|
|||
|
||||
<Popover content={t('Comment')} disabled={isActionPopupActive()}>
|
||||
{(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-hover" class={clsx(styles.icon, styles.iconHover)} />
|
||||
<Show
|
||||
|
@ -544,7 +546,7 @@ export const FullArticle = (props: Props) => {
|
|||
</Show>
|
||||
|
||||
<FeedArticlePopup
|
||||
isOwner={canEdit()}
|
||||
canEdit={canEdit()}
|
||||
containerCssClass={clsx(stylesHeader.control, styles.articlePopupOpener)}
|
||||
onShareClick={() => showModal('share')}
|
||||
onInviteClick={() => showModal('inviteMembers')}
|
||||
|
|
|
@ -58,6 +58,11 @@
|
|||
}
|
||||
|
||||
.bio {
|
||||
@include font-size(1.2rem);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
color: var(--black-400);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
|
@ -118,12 +118,17 @@ export const AuthorBadge = (props: Props) => {
|
|||
<Match when={props.author.bio}>
|
||||
<div class={clsx('text-truncate', styles.bio)} innerHTML={props.author.bio} />
|
||||
</Match>
|
||||
<Match when={props.author?.stat && props.author?.stat.shouts > 0}>
|
||||
<div class={styles.bio}>
|
||||
{t('PublicationsWithCount', { count: props.author.stat?.shouts ?? 0 })}
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={props.author?.stat}>
|
||||
<div class={styles.bio}>
|
||||
<Show when={props.author?.stat.shouts > 0}>
|
||||
<div>{t('PublicationsWithCount', { count: props.author.stat?.shouts ?? 0 })}</div>
|
||||
</Show>
|
||||
<Show when={props.author?.stat.followers > 0}>
|
||||
<div>{t('FollowersWithCount', { count: props.author.stat?.followers ?? 0 })}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</ConditionalWrapper>
|
||||
</div>
|
||||
|
|
26
src/components/AuthorsList/AuthorsList.module.scss
Normal file
26
src/components/AuthorsList/AuthorsList.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
111
src/components/AuthorsList/AuthorsList.tsx
Normal file
111
src/components/AuthorsList/AuthorsList.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
src/components/AuthorsList/index.ts
Normal file
1
src/components/AuthorsList/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { AuthorsList } from './AuthorsList'
|
|
@ -106,7 +106,7 @@ const LAYOUT_ASPECT = {
|
|||
|
||||
export const ArticleCard = (props: ArticleCardProps) => {
|
||||
const { t, lang, formatDate } = useLocalize()
|
||||
const { author } = useSession()
|
||||
const { author, session } = useSession()
|
||||
const { changeSearchParams } = useRouter()
|
||||
const [isActionPopupActive, setIsActionPopupActive] = 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)) : '',
|
||||
)
|
||||
|
||||
const canEdit = () =>
|
||||
props.article.authors?.some((a) => a && a?.slug === author()?.slug) ||
|
||||
props.article.created_by?.id === author()?.id
|
||||
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 scrollToComments = (event) => {
|
||||
event.preventDefault()
|
||||
|
@ -376,7 +380,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
|
|||
|
||||
<div class={styles.shoutCardDetailsItem}>
|
||||
<FeedArticlePopup
|
||||
isOwner={canEdit()}
|
||||
canEdit={canEdit()}
|
||||
containerCssClass={stylesHeader.control}
|
||||
onShareClick={() => props.onShare(props.article)}
|
||||
onInviteClick={props.onInvite}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { SoonChip } from '../../_shared/SoonChip'
|
|||
import styles from './FeedArticlePopup.module.scss'
|
||||
|
||||
type Props = {
|
||||
isOwner: boolean
|
||||
canEdit: boolean
|
||||
onInviteClick: () => void
|
||||
onShareClick: () => void
|
||||
} & Omit<PopupProps, 'children'>
|
||||
|
@ -41,7 +41,7 @@ export const FeedArticlePopup = (props: Props) => {
|
|||
{t('Share')}
|
||||
</button>
|
||||
</li>
|
||||
<Show when={!props.isOwner}>
|
||||
<Show when={!props.canEdit}>
|
||||
<li>
|
||||
<button
|
||||
class={styles.action}
|
||||
|
@ -67,7 +67,7 @@ export const FeedArticlePopup = (props: Props) => {
|
|||
{t('Invite experts')}
|
||||
</button>
|
||||
</li>
|
||||
<Show when={!props.isOwner}>
|
||||
<Show when={!props.canEdit}>
|
||||
<li>
|
||||
<button class={clsx(styles.action, styles.soon)} role="button">
|
||||
{t('Subscribe to comments')} <SoonChip />
|
||||
|
@ -79,7 +79,7 @@ export const FeedArticlePopup = (props: Props) => {
|
|||
{t('Add to bookmarks')} <SoonChip />
|
||||
</button>
|
||||
</li>
|
||||
{/*<Show when={!props.isOwner}>*/}
|
||||
{/*<Show when={!props.canEdit}>*/}
|
||||
{/* <li>*/}
|
||||
{/* <button*/}
|
||||
{/* class={styles.action}*/}
|
||||
|
|
18
src/components/InlineLoader/InlineLoader.module.scss
Normal file
18
src/components/InlineLoader/InlineLoader.module.scss
Normal 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;
|
||||
}
|
||||
|
||||
}
|
20
src/components/InlineLoader/InlineLoader.tsx
Normal file
20
src/components/InlineLoader/InlineLoader.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
src/components/InlineLoader/index.ts
Normal file
1
src/components/InlineLoader/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { InlineLoader } from './InlineLoader'
|
|
@ -53,7 +53,6 @@ export const RegisterForm = () => {
|
|||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (passwordError()) {
|
||||
setValidationErrors((errors) => ({ ...errors, password: passwordError() }))
|
||||
} else {
|
||||
|
@ -102,7 +101,7 @@ export const RegisterForm = () => {
|
|||
redirect_uri: window.location.origin,
|
||||
}
|
||||
const { errors } = await signUp(opts)
|
||||
if (errors) return
|
||||
if (errors.length > 0) return
|
||||
setIsSuccess(true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
@ -134,7 +133,6 @@ export const RegisterForm = () => {
|
|||
),
|
||||
}))
|
||||
break
|
||||
|
||||
case 'verified':
|
||||
setValidationErrors((prev) => ({
|
||||
email: (
|
||||
|
|
|
@ -102,7 +102,6 @@ export const SendResetLinkForm = () => {
|
|||
placeholder={t('Email')}
|
||||
onChange={(event) => handleEmailInput(event.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<label for="email">{t('Email')}</label>
|
||||
<Show when={isUserNotFound()}>
|
||||
<div class={styles.validationError}>
|
||||
|
|
|
@ -142,10 +142,8 @@ export const Header = (props: Props) => {
|
|||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (window.location.pathname === '/' || window.location.pathname === '') {
|
||||
const topics = await apiClient.getRandomTopics({ amount: RANDOM_TOPICS_COUNT })
|
||||
setRandomTopics(topics)
|
||||
}
|
||||
})
|
||||
|
||||
const handleToggleMenuByLink = (event: MouseEvent, route: keyof typeof ROUTES) => {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { For, Show } from 'solid-js'
|
|||
|
||||
import { useLocalize } from '../../../context/localize'
|
||||
import { useNotifications } from '../../../context/notifications'
|
||||
import { NotificationGroup as Group } from '../../../graphql/schema/notifier.gen'
|
||||
import { NotificationGroup as Group } from '../../../graphql/schema/core.gen'
|
||||
import { router, useRouter } from '../../../stores/router'
|
||||
import { ArticlePageSearchParams } from '../../Article/FullArticle'
|
||||
import { GroupAvatar } from '../../_shared/GroupAvatar'
|
||||
|
@ -39,8 +39,8 @@ const getTitle = (title: string) => {
|
|||
return shoutTitle
|
||||
}
|
||||
|
||||
const reactionsCaption = (threadId: string) =>
|
||||
threadId.includes('::') ? 'Some new replies to your comment' : 'Some new comments to your publication'
|
||||
const threadCaption = (threadId: string) =>
|
||||
threadId.includes(':') ? 'Some new replies to your comment' : 'Some new comments to your publication'
|
||||
|
||||
export const NotificationGroup = (props: NotificationGroupProps) => {
|
||||
const { t, formatTime, formatDate } = useLocalize()
|
||||
|
@ -63,12 +63,12 @@ export const NotificationGroup = (props: NotificationGroupProps) => {
|
|||
return (
|
||||
<>
|
||||
<For each={props.notifications}>
|
||||
{(n: Group) => (
|
||||
{(n: Group, index) => (
|
||||
<>
|
||||
{t(reactionsCaption(n.id), { commentsCount: n.reactions.length })}{' '}
|
||||
{t(threadCaption(n.thread), { commentsCount: n.reactions.length })}{' '}
|
||||
<div
|
||||
class={clsx(styles.NotificationView, props.class, { [styles.seen]: n.seen })}
|
||||
onClick={(_) => handleClick(n.id)}
|
||||
onClick={(_) => handleClick(n.thread)}
|
||||
>
|
||||
<div class={styles.userpic}>
|
||||
<GroupAvatar authors={n.authors} />
|
||||
|
|
|
@ -75,7 +75,7 @@ export const TopicBadge = (props: Props) => {
|
|||
when={props.topic.body}
|
||||
fallback={
|
||||
<div class={styles.description}>
|
||||
{t('PublicationsWithCount', { count: props.topic.stat.shouts ?? 0 })}
|
||||
{t('PublicationsWithCount', { count: props.topic?.stat?.shouts ?? 0 })}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -81,3 +81,5 @@
|
|||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
179
src/components/Views/AllAuthors/AllAuthors.tsx
Normal file
179
src/components/Views/AllAuthors/AllAuthors.tsx
Normal 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>
|
||||
)
|
||||
}
|
1
src/components/Views/AllAuthors/index.ts
Normal file
1
src/components/Views/AllAuthors/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { AllAuthors } from './AllAuthors'
|
|
@ -28,7 +28,7 @@ type Props = {
|
|||
isLoaded: boolean
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
export const PAGE_SIZE = 20
|
||||
|
||||
export const AllTopics = (props: Props) => {
|
||||
const { t, lang } = useLocalize()
|
||||
|
|
|
@ -23,6 +23,7 @@ import { Row2 } from '../../Feed/Row2'
|
|||
import { Row3 } from '../../Feed/Row3'
|
||||
import { Loading } from '../../_shared/Loading'
|
||||
|
||||
import { byCreated } from '../../../utils/sortby'
|
||||
import stylesArticle from '../../Article/Article.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) {
|
||||
const a = loadAuthor({ slug: '', author_id: author().id })
|
||||
const a = await loadAuthor({ slug: '', author_id: author().id })
|
||||
console.debug('[AuthorView] loaded author:', a)
|
||||
}
|
||||
})
|
||||
|
@ -71,13 +72,7 @@ export const AuthorView = (props: Props) => {
|
|||
const fetchData = async (slug) => {
|
||||
try {
|
||||
const [subscriptionsResult, followersResult] = await Promise.all([
|
||||
(async () => {
|
||||
const [getAuthors, getTopics] = await Promise.all([
|
||||
apiClient.getAuthorFollowingAuthors({ slug }),
|
||||
apiClient.getAuthorFollowingTopics({ slug }),
|
||||
])
|
||||
return { authors: getAuthors, topics: getTopics }
|
||||
})(),
|
||||
apiClient.getAuthorFollows({ slug }),
|
||||
apiClient.getAuthorFollowers({ slug }),
|
||||
])
|
||||
|
||||
|
@ -181,7 +176,7 @@ export const AuthorView = (props: Props) => {
|
|||
{t('Comments')}
|
||||
</a>
|
||||
<Show when={author().stat}>
|
||||
<span class="view-switcher__counter">{author().stat.commented}</span>
|
||||
<span class="view-switcher__counter">{author().stat.comments}</span>
|
||||
</Show>
|
||||
</li>
|
||||
<li classList={{ 'view-switcher__item--selected': getPage().route === 'authorAbout' }}>
|
||||
|
@ -237,7 +232,7 @@ export const AuthorView = (props: Props) => {
|
|||
<div class="row">
|
||||
<div class="col-md-20 col-lg-18">
|
||||
<ul class={stylesArticle.comments}>
|
||||
<For each={commented()}>
|
||||
<For each={commented()?.sort(byCreated).reverse()}>
|
||||
{(comment) => <Comment comment={comment} class={styles.comment} showArticleLink />}
|
||||
</For>
|
||||
</ul>
|
||||
|
|
|
@ -18,7 +18,7 @@ export const DraftsView = () => {
|
|||
const loadDrafts = async () => {
|
||||
if (apiClient.private) {
|
||||
const loadedDrafts = await apiClient.getDrafts()
|
||||
setDrafts(loadedDrafts || [])
|
||||
setDrafts(loadedDrafts.reverse() || [])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ import { resetSortedArticles, useArticlesStore } from '../../../stores/zine/arti
|
|||
import { useTopAuthorsStore } from '../../../stores/zine/topAuthors'
|
||||
import { useTopicsStore } from '../../../stores/zine/topics'
|
||||
import { getImageUrl } from '../../../utils/getImageUrl'
|
||||
import { byCreated } from '../../../utils/sortby'
|
||||
import { CommentDate } from '../../Article/CommentDate'
|
||||
import { getShareUrl } from '../../Article/SharePopup'
|
||||
import { AuthorBadge } from '../../Author/AuthorBadge'
|
||||
|
@ -48,23 +49,11 @@ type VisibilityItem = {
|
|||
}
|
||||
|
||||
type FeedSearchParams = {
|
||||
by: 'publish_date' | 'likes_stat' | 'rating' | 'last_comment'
|
||||
by: 'publish_date' | 'likes' | 'comments'
|
||||
period: FeedPeriod
|
||||
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 now = new Date()
|
||||
let d: Date = now
|
||||
|
@ -145,8 +134,8 @@ export const FeedView = (props: Props) => {
|
|||
}
|
||||
|
||||
const loadTopComments = async () => {
|
||||
const comments = await loadReactionsBy({ by: { comment: true }, limit: 5 })
|
||||
setTopComments(comments)
|
||||
const comments = await loadReactionsBy({ by: { comment: true }, limit: 50 })
|
||||
setTopComments(comments.sort(byCreated).reverse())
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
@ -178,9 +167,8 @@ export const FeedView = (props: Props) => {
|
|||
offset: sortedArticles().length,
|
||||
}
|
||||
|
||||
const orderBy = getOrderBy(searchParams().by)
|
||||
if (orderBy) {
|
||||
options.order_by = orderBy
|
||||
if (searchParams()?.by) {
|
||||
options.order_by = searchParams().by
|
||||
}
|
||||
|
||||
const visibilityMode = searchParams().visibility
|
||||
|
@ -222,7 +210,7 @@ export const FeedView = (props: Props) => {
|
|||
const ogTitle = t('Feed')
|
||||
|
||||
const [shareData, setShareData] = createSignal<Shout | undefined>()
|
||||
const handleShare = (shared) => {
|
||||
const handleShare = (shared: Shout | undefined) => {
|
||||
showModal('share')
|
||||
setShareData(shared)
|
||||
}
|
||||
|
@ -260,19 +248,19 @@ export const FeedView = (props: Props) => {
|
|||
{/*</li>*/}
|
||||
<li
|
||||
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')}
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
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')}
|
||||
</span>
|
||||
</li>
|
||||
|
|
|
@ -69,10 +69,12 @@ export const HomeView = (props: Props) => {
|
|||
}
|
||||
|
||||
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(() => {
|
||||
if (!result?.error) {
|
||||
if (result?.topic) setRandomTopic(result.topic)
|
||||
if (result?.shouts) setRandomTopicArticles(result.shouts)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -29,12 +29,9 @@ export const ProfileSubscriptions = () => {
|
|||
const fetchSubscriptions = async () => {
|
||||
try {
|
||||
const slug = author()?.slug
|
||||
const [getAuthors, getTopics] = await Promise.all([
|
||||
apiClient.getAuthorFollowingAuthors({ slug }),
|
||||
apiClient.getAuthorFollowingTopics({ slug }),
|
||||
])
|
||||
setFollowing([...getAuthors, ...getTopics])
|
||||
setFiltered([...getAuthors, ...getTopics])
|
||||
const authorFollows = await apiClient.getAuthorFollows({ slug })
|
||||
setFollowing([...authorFollows['authors']])
|
||||
setFiltered([...authorFollows['authors'], ...authorFollows['topics']])
|
||||
} catch (error) {
|
||||
console.error('[fetchSubscriptions] :', error)
|
||||
throw error
|
||||
|
|
|
@ -2,14 +2,13 @@ import { clsx } from 'clsx'
|
|||
import { For } from 'solid-js'
|
||||
|
||||
import { Author } from '../../../graphql/schema/core.gen'
|
||||
import { NotificationAuthor } from '../../../graphql/schema/notifier.gen'
|
||||
import { Userpic } from '../../Author/Userpic'
|
||||
|
||||
import styles from './GroupAvatar.module.scss'
|
||||
|
||||
type Props = {
|
||||
class?: string
|
||||
authors: Author[] | NotificationAuthor[]
|
||||
authors: Author[]
|
||||
}
|
||||
|
||||
export const GroupAvatar = (props: Props) => {
|
||||
|
|
|
@ -50,24 +50,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
@include font-size(1.4rem);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
opacity: 0.5;
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.teaser {
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
|
|
|
@ -12,6 +12,7 @@ import { Button } from '../Button'
|
|||
import { DropdownSelect } from '../DropdownSelect'
|
||||
import { Loading } from '../Loading'
|
||||
|
||||
import { InlineLoader } from '../../InlineLoader'
|
||||
import styles from './InviteMembers.module.scss'
|
||||
|
||||
type InviteAuthor = Author & { selected: boolean }
|
||||
|
@ -62,7 +63,7 @@ export const InviteMembers = (props: Props) => {
|
|||
return authors?.slice(start, end)
|
||||
}
|
||||
|
||||
const [pages, _infiniteScrollLoader, { end }] = createInfiniteScroll(fetcher)
|
||||
const [pages, setEl, { end }] = createInfiniteScroll(fetcher)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
|
@ -158,11 +159,8 @@ export const InviteMembers = (props: Props) => {
|
|||
)}
|
||||
</For>
|
||||
<Show when={!end()}>
|
||||
<div use:infiniteScrollLoader class={styles.loading}>
|
||||
<div class={styles.icon}>
|
||||
<Loading size="tiny" />
|
||||
</div>
|
||||
<div>{t('Loading')}</div>
|
||||
<div ref={setEl as (e: HTMLDivElement) => void}>
|
||||
<InlineLoader />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
@ -2,20 +2,14 @@ import { Accessor, JSX, createContext, createEffect, createSignal, useContext }
|
|||
import { createStore } from 'solid-js/store'
|
||||
|
||||
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'
|
||||
|
||||
type SubscriptionsData = {
|
||||
topics?: Topic[]
|
||||
authors?: Author[]
|
||||
communities?: Community[]
|
||||
}
|
||||
|
||||
interface FollowingContextType {
|
||||
loading: Accessor<boolean>
|
||||
subscriptions: SubscriptionsData
|
||||
setSubscriptions: (subscriptions: SubscriptionsData) => void
|
||||
subscriptions: AuthorFollows
|
||||
setSubscriptions: (subscriptions: AuthorFollows) => void
|
||||
setFollowing: (what: FollowingEntity, slug: string, value: boolean) => void
|
||||
loadSubscriptions: () => void
|
||||
follow: (what: FollowingEntity, slug: string) => Promise<void>
|
||||
|
@ -29,7 +23,7 @@ export function useFollowing() {
|
|||
return useContext(FollowingContext)
|
||||
}
|
||||
|
||||
const EMPTY_SUBSCRIPTIONS = {
|
||||
const EMPTY_SUBSCRIPTIONS: AuthorFollows = {
|
||||
topics: [],
|
||||
authors: [],
|
||||
communities: [],
|
||||
|
@ -37,15 +31,15 @@ const EMPTY_SUBSCRIPTIONS = {
|
|||
|
||||
export const FollowingProvider = (props: { children: JSX.Element }) => {
|
||||
const [loading, setLoading] = createSignal<boolean>(false)
|
||||
const [subscriptions, setSubscriptions] = createStore<SubscriptionsData>(EMPTY_SUBSCRIPTIONS)
|
||||
const { author } = useSession()
|
||||
const [subscriptions, setSubscriptions] = createStore<AuthorFollows>(EMPTY_SUBSCRIPTIONS)
|
||||
const { author, session } = useSession()
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (apiClient.private) {
|
||||
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)
|
||||
console.info('[context.following] subs:', subscriptions)
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { Portal } from 'solid-js/web'
|
|||
import { NotificationsPanel } from '../components/NotificationsPanel'
|
||||
import { ShowIfAuthenticated } from '../components/_shared/ShowIfAuthenticated'
|
||||
import { notifierClient } from '../graphql/client/notifier'
|
||||
import { NotificationGroup, QueryLoad_NotificationsArgs } from '../graphql/schema/notifier.gen'
|
||||
import { NotificationGroup, QueryLoad_NotificationsArgs } from '../graphql/schema/core.gen'
|
||||
|
||||
import { SSEMessage, useConnect } from './connect'
|
||||
import { useSession } from './session'
|
||||
|
@ -51,7 +51,7 @@ export const NotificationsProvider = (props: { children: JSX.Element }) => {
|
|||
const unread = notificationsResult?.unread || 0
|
||||
|
||||
const newGroupsEntries = groups.reduce((acc, group: NotificationGroup) => {
|
||||
acc[group.id] = group
|
||||
acc[group.thread] = group
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ type ReactionsContextType = {
|
|||
offset?: number
|
||||
}) => Promise<Reaction[]>
|
||||
createReaction: (reaction: ReactionInput) => Promise<void>
|
||||
updateReaction: (id: number, reaction: ReactionInput) => Promise<void>
|
||||
updateReaction: (reaction: ReactionInput) => Promise<Reaction>
|
||||
deleteReaction: (id: number) => Promise<void>
|
||||
}
|
||||
|
||||
|
@ -88,9 +88,10 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const updateReaction = async (id: number, input: ReactionInput): Promise<void> => {
|
||||
const reaction = await apiClient.updateReaction(id, input)
|
||||
const updateReaction = async (input: ReactionInput): Promise<Reaction> => {
|
||||
const reaction = await apiClient.updateReaction(input)
|
||||
setReactionEntities(reaction.id, reaction)
|
||||
return reaction
|
||||
}
|
||||
|
||||
onCleanup(() => setReactionEntities(reconcile({})))
|
||||
|
|
|
@ -92,10 +92,11 @@ export const SessionProvider = (props: {
|
|||
const authorizer = createMemo(() => new Authorizer(config()))
|
||||
const [oauthState, setOauthState] = createSignal<string>()
|
||||
|
||||
// handle callback's redirect_uri
|
||||
createEffect(() => {
|
||||
// oauth
|
||||
const state = searchParams()?.state
|
||||
// handle auth state callback
|
||||
createEffect(
|
||||
on(
|
||||
() => searchParams()?.state,
|
||||
(state) => {
|
||||
if (state) {
|
||||
setOauthState((_s) => state)
|
||||
const scope = searchParams()?.scope
|
||||
|
@ -104,21 +105,30 @@ export const SessionProvider = (props: {
|
|||
if (scope) console.info(`[context.session] scope: ${scope}`)
|
||||
const url = searchParams()?.redirect_uri || searchParams()?.redirectURL || window.location.href
|
||||
setConfig((c: ConfigType) => ({ ...c, redirectURL: url.split('?')[0] }))
|
||||
changeSearchParams({ mode: 'confirm-email', modal: 'auth' }, true)
|
||||
changeSearchParams({ mode: 'confirm-email', m: 'auth' }, true)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
// handle email confirm
|
||||
// handle token confirm
|
||||
createEffect(() => {
|
||||
const token = searchParams()?.token
|
||||
const access_token = searchParams()?.access_token
|
||||
if (access_token)
|
||||
changeSearchParams({
|
||||
mode: 'confirm-email',
|
||||
modal: 'auth',
|
||||
m: 'auth',
|
||||
access_token,
|
||||
})
|
||||
else if (token) changeSearchParams({ mode: 'change-password', modal: 'auth', token })
|
||||
else if (token) {
|
||||
changeSearchParams({
|
||||
mode: 'change-password',
|
||||
m: 'auth',
|
||||
token,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// load
|
||||
|
@ -203,10 +213,8 @@ export const SessionProvider = (props: {
|
|||
if (session()) {
|
||||
const token = session()?.access_token
|
||||
if (token) {
|
||||
// console.log('[context.session] token observer got token', token)
|
||||
if (!inboxClient.private) {
|
||||
apiClient.connect(token)
|
||||
notifierClient.connect(token)
|
||||
inboxClient.connect(token)
|
||||
}
|
||||
if (!author()) loadAuthor()
|
||||
|
@ -329,7 +337,6 @@ export const SessionProvider = (props: {
|
|||
const response = await authorizer().graphqlQuery({
|
||||
query: `query { is_registered(email: "${email}") { message }}`,
|
||||
})
|
||||
// console.log(response)
|
||||
return response?.data?.is_registered?.message
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type {
|
||||
Author,
|
||||
AuthorFollows,
|
||||
CommonResult,
|
||||
Community,
|
||||
FollowingEntity,
|
||||
LoadShoutsOptions,
|
||||
MutationDelete_ShoutArgs,
|
||||
|
@ -37,16 +37,14 @@ import shoutsLoadSearch from '../query/core/articles-load-search'
|
|||
import loadShoutsUnrated from '../query/core/articles-load-unrated'
|
||||
import authorBy from '../query/core/author-by'
|
||||
import authorFollowers from '../query/core/author-followers'
|
||||
import authorFollows from '../query/core/author-follows'
|
||||
import authorId from '../query/core/author-id'
|
||||
import authorsAll from '../query/core/authors-all'
|
||||
import authorFollowedAuthors from '../query/core/authors-followed-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 reactionsLoadBy from '../query/core/reactions-load-by'
|
||||
import topicBySlug from '../query/core/topic-by-slug'
|
||||
import topicsAll from '../query/core/topics-all'
|
||||
import authorFollowedTopics from '../query/core/topics-followed-by'
|
||||
import topicsRandomQuery from '../query/core/topics-random'
|
||||
|
||||
const publicGraphQLClient = createGraphQLClient('core')
|
||||
|
@ -86,7 +84,7 @@ export const apiClient = {
|
|||
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()
|
||||
if (!resp.data) console.error('[graphql.client.core] load_shouts_random_topic', resp)
|
||||
return resp.data.load_shouts_random_topic
|
||||
|
@ -96,6 +94,7 @@ export const apiClient = {
|
|||
const response = await apiClient.private.mutation(followMutation, { what, slug }).toPromise()
|
||||
return response.data.follow
|
||||
},
|
||||
|
||||
unfollow: async ({ what, slug }: { what: FollowingEntity; slug: string }) => {
|
||||
const response = await apiClient.private.mutation(unfollowMutation, { what, slug }).toPromise()
|
||||
return response.data.unfollow
|
||||
|
@ -107,48 +106,53 @@ export const apiClient = {
|
|||
|
||||
return response.data.get_topics_all
|
||||
},
|
||||
|
||||
getAllAuthors: async () => {
|
||||
const response = await publicGraphQLClient.query(authorsAll, {}).toPromise()
|
||||
if (!response.data) console.error('[graphql.client.core] getAllAuthors', response)
|
||||
|
||||
return response.data.get_authors_all
|
||||
},
|
||||
|
||||
getAuthor: async (params: { slug?: string; author_id?: number }): Promise<Author> => {
|
||||
const response = await publicGraphQLClient.query(authorBy, params).toPromise()
|
||||
return response.data.get_author
|
||||
},
|
||||
|
||||
getAuthorId: async (params: { user: string }): Promise<Author> => {
|
||||
const response = await publicGraphQLClient.query(authorId, params).toPromise()
|
||||
return response.data.get_author_id
|
||||
},
|
||||
|
||||
getAuthorFollowers: async ({ slug }: { slug: string }): Promise<Author[]> => {
|
||||
const response = await publicGraphQLClient.query(authorFollowers, { slug }).toPromise()
|
||||
return response.data.get_author_followers
|
||||
},
|
||||
getAuthorFollowingAuthors: async ({ slug }: { slug: string }): Promise<Author[]> => {
|
||||
const response = await publicGraphQLClient.query(authorFollowedAuthors, { slug }).toPromise()
|
||||
return response.data.get_author_followed
|
||||
},
|
||||
getAuthorFollowingTopics: async ({ slug }: { slug: string }): Promise<Topic[]> => {
|
||||
const response = await publicGraphQLClient.query(authorFollowedTopics, { slug }).toPromise()
|
||||
return response.data.get_topics_by_author
|
||||
},
|
||||
getAuthorFollowingCommunities: async ({ slug }: { slug: string }): Promise<Community[]> => {
|
||||
const response = await publicGraphQLClient.query(authorFollowedCommunities, { slug }).toPromise()
|
||||
return response.data.get_communities_by_author
|
||||
|
||||
getAuthorFollows: async (params: {
|
||||
slug?: string
|
||||
author_id?: number
|
||||
user?: string
|
||||
}): Promise<AuthorFollows> => {
|
||||
const response = await publicGraphQLClient.query(authorFollows, params).toPromise()
|
||||
return response.data.get_author_follows
|
||||
},
|
||||
|
||||
updateAuthor: async (input: ProfileInput) => {
|
||||
const response = await apiClient.private.mutation(updateAuthor, { profile: input }).toPromise()
|
||||
return response.data.update_author
|
||||
},
|
||||
|
||||
getTopic: async ({ slug }: { slug: string }): Promise<Topic> => {
|
||||
const response = await publicGraphQLClient.query(topicBySlug, { slug }).toPromise()
|
||||
return response.data.get_topic
|
||||
},
|
||||
|
||||
createArticle: async ({ article }: { article: ShoutInput }): Promise<Shout> => {
|
||||
const response = await apiClient.private.mutation(createArticle, { shout: article }).toPromise()
|
||||
return response.data.create_shout.shout
|
||||
},
|
||||
|
||||
updateArticle: async ({
|
||||
shout_id,
|
||||
shout_input,
|
||||
|
@ -164,10 +168,12 @@ export const apiClient = {
|
|||
console.debug('[graphql.client.core] updateArticle:', response.data)
|
||||
return response.data.update_shout
|
||||
},
|
||||
|
||||
deleteShout: async (params: MutationDelete_ShoutArgs): Promise<void> => {
|
||||
const response = await apiClient.private.mutation(deleteShout, params).toPromise()
|
||||
console.debug('[graphql.client.core] deleteShout:', response)
|
||||
},
|
||||
|
||||
getDrafts: async (): Promise<Shout[]> => {
|
||||
const response = await apiClient.private.query(draftsLoad, {}).toPromise()
|
||||
console.debug('[graphql.client.core] getDrafts:', response)
|
||||
|
@ -178,15 +184,13 @@ export const apiClient = {
|
|||
console.debug('[graphql.client.core] createReaction:', response)
|
||||
return response.data.create_reaction.reaction
|
||||
},
|
||||
destroyReaction: async (id: number) => {
|
||||
const response = await apiClient.private.mutation(reactionDestroy, { id: id }).toPromise()
|
||||
destroyReaction: async (reaction_id: number) => {
|
||||
const response = await apiClient.private.mutation(reactionDestroy, { reaction_id }).toPromise()
|
||||
console.debug('[graphql.client.core] destroyReaction:', response)
|
||||
return response.data.delete_reaction.reaction
|
||||
},
|
||||
updateReaction: async (id: number, input: ReactionInput) => {
|
||||
const response = await apiClient.private
|
||||
.mutation(reactionUpdate, { id: id, reaction: input })
|
||||
.toPromise()
|
||||
updateReaction: async (reaction: ReactionInput) => {
|
||||
const response = await apiClient.private.mutation(reactionUpdate, { reaction }).toPromise()
|
||||
console.debug('[graphql.client.core] updateReaction:', response)
|
||||
return response.data.update_reaction.reaction
|
||||
},
|
||||
|
@ -233,9 +237,4 @@ export const apiClient = {
|
|||
.toPromise()
|
||||
return resp.data.load_reactions_by
|
||||
},
|
||||
getMySubscriptions: async (): Promise<CommonResult> => {
|
||||
const resp = await apiClient.private.query(mySubscriptions, {}).toPromise()
|
||||
|
||||
return resp.data.get_my_followed
|
||||
},
|
||||
}
|
||||
|
|
|
@ -4,15 +4,14 @@ import markSeenAfterMutation from '../mutation/notifier/mark-seen-after'
|
|||
import markThreadSeenMutation from '../mutation/notifier/mark-seen-thread'
|
||||
import loadNotifications from '../query/notifier/notifications-load'
|
||||
import {
|
||||
MutationMark_Seen_AfterArgs,
|
||||
MutationNotifications_Seen_AfterArgs,
|
||||
NotificationsResult,
|
||||
QueryLoad_NotificationsArgs,
|
||||
} from '../schema/notifier.gen'
|
||||
} from '../schema/core.gen'
|
||||
import { apiClient } from './core'
|
||||
|
||||
export const notifierClient = {
|
||||
private: null,
|
||||
connect: (token: string) => (notifierClient.private = createGraphQLClient('notifier', token)),
|
||||
|
||||
private: apiClient.private,
|
||||
getNotifications: async (params: QueryLoad_NotificationsArgs): Promise<NotificationsResult> => {
|
||||
const resp = await notifierClient.private.query(loadNotifications, params).toPromise()
|
||||
return resp.data?.load_notifications
|
||||
|
@ -23,7 +22,7 @@ export const notifierClient = {
|
|||
await notifierClient.private.mutation(markSeenMutation, { notification_id }).toPromise()
|
||||
},
|
||||
|
||||
markSeenAfter: async (options: MutationMark_Seen_AfterArgs): Promise<void> => {
|
||||
markSeenAfter: async (options: MutationNotifications_Seen_AfterArgs): Promise<void> => {
|
||||
// call when 'mark all as seen' cliecked
|
||||
await notifierClient.private.mutation(markSeenAfterMutation, options).toPromise()
|
||||
},
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { gql } from '@urql/core'
|
||||
|
||||
export default gql`
|
||||
mutation UpdateReactionMutation($id: Int!, $reaction: ReactionInput!) {
|
||||
update_reaction(id: $id, reaction: $reaction) {
|
||||
mutation UpdateReactionMutation($reaction: ReactionInput!) {
|
||||
update_reaction(reaction: $reaction) {
|
||||
error
|
||||
reaction {
|
||||
id
|
||||
|
|
|
@ -53,7 +53,6 @@ export default gql`
|
|||
featured_at
|
||||
stat {
|
||||
viewed
|
||||
|
||||
rating
|
||||
commented
|
||||
}
|
||||
|
|
|
@ -15,10 +15,10 @@ export default gql`
|
|||
last_seen
|
||||
stat {
|
||||
shouts
|
||||
authors
|
||||
followers
|
||||
followings
|
||||
rating
|
||||
commented
|
||||
comments
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
19
src/graphql/query/core/author-follows-authors.ts
Normal file
19
src/graphql/query/core/author-follows-authors.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
16
src/graphql/query/core/author-follows-topics.ts
Normal file
16
src/graphql/query/core/author-follows-topics.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
37
src/graphql/query/core/author-follows.ts
Normal file
37
src/graphql/query/core/author-follows.ts
Normal 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
|
||||
#}
|
||||
}
|
||||
}
|
||||
`
|
|
@ -14,10 +14,10 @@ export default gql`
|
|||
last_seen
|
||||
stat {
|
||||
shouts
|
||||
comments: commented
|
||||
authors
|
||||
followers
|
||||
followings
|
||||
rating
|
||||
comments
|
||||
rating_shouts
|
||||
rating_comments
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
|
@ -11,10 +11,10 @@ export default gql`
|
|||
created_at
|
||||
stat {
|
||||
shouts
|
||||
comments: commented
|
||||
authors
|
||||
followers
|
||||
followings
|
||||
rating
|
||||
comments
|
||||
rating_shouts
|
||||
rating_comments
|
||||
}
|
||||
|
|
|
@ -1,12 +1,22 @@
|
|||
import type { PageContext } from '../renderer/types'
|
||||
import type { PageProps } from './types'
|
||||
|
||||
import { PAGE_SIZE } from '../components/Views/AllTopics/AllTopics'
|
||||
import { apiClient } from '../graphql/client/core'
|
||||
|
||||
export const onBeforeRender = async (_pageContext: PageContext) => {
|
||||
const allAuthors = await apiClient.getAllAuthors()
|
||||
|
||||
const pageProps: PageProps = { allAuthors, seo: { title: '' } }
|
||||
const topWritingAuthors = await apiClient.loadAuthorsBy({
|
||||
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 {
|
||||
pageContext: {
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
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 { useLocalize } from '../context/localize'
|
||||
import { loadAllAuthors } from '../stores/zine/authors'
|
||||
import { loadAllAuthors, loadAuthors } from '../stores/zine/authors'
|
||||
|
||||
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()
|
||||
|
||||
|
@ -18,12 +21,19 @@ export const AllAuthorsPage = (props: PageProps) => {
|
|||
}
|
||||
|
||||
await loadAllAuthors()
|
||||
await loadAuthors({ by: { order: 'shouts' }, limit: PAGE_SIZE, offset: 0 })
|
||||
await loadAuthors({ by: { order: 'followers' }, limit: PAGE_SIZE, offset: 0 })
|
||||
setIsLoaded(true)
|
||||
})
|
||||
|
||||
return (
|
||||
<PageLayout title={t('Authors')}>
|
||||
<AllAuthorsView isLoaded={isLoaded()} authors={props.allAuthors} />
|
||||
<AllAuthors
|
||||
isLoaded={isLoaded()}
|
||||
authors={props.allAuthors}
|
||||
topWritingAuthors={props.topWritingAuthors}
|
||||
topFollowedAuthors={props.topFollowedAuthors}
|
||||
/>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ export type PageProps = {
|
|||
homeShouts?: Shout[]
|
||||
author?: Author
|
||||
allAuthors?: Author[]
|
||||
topWritingAuthors?: Author[]
|
||||
topFollowedAuthors?: Author[]
|
||||
topic?: Topic
|
||||
allTopics?: Topic[]
|
||||
searchQuery?: string
|
||||
|
@ -25,6 +27,7 @@ export type PageProps = {
|
|||
export type RootSearchParams = {
|
||||
m: string // modal
|
||||
lang: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export type LayoutType = 'article' | 'audio' | 'video' | 'image' | 'literature'
|
||||
|
|
|
@ -3,9 +3,9 @@ import { createSignal } from 'solid-js'
|
|||
|
||||
import { apiClient } from '../../graphql/client/core'
|
||||
import { Author, QueryLoad_Authors_ByArgs } from '../../graphql/schema/core.gen'
|
||||
import { byStat } from '../../utils/sortby'
|
||||
|
||||
export type AuthorsSortBy = 'shouts' | 'name' | 'followers'
|
||||
type SortedAuthorsSetter = (prev: Author[]) => Author[]
|
||||
|
||||
const [sortAllBy, setSortAllBy] = createSignal<AuthorsSortBy>('name')
|
||||
|
||||
|
@ -13,21 +13,14 @@ export const setAuthorsSort = (sortBy: AuthorsSortBy) => setSortAllBy(sortBy)
|
|||
|
||||
const [authorEntities, setAuthorEntities] = createSignal<{ [authorSlug: string]: Author }>({})
|
||||
const [authorsByTopic, setAuthorsByTopic] = createSignal<{ [topicSlug: string]: Author[] }>({})
|
||||
const [authorsByShouts, setSortedAuthorsByShout] = createSignal<Author[]>([])
|
||||
const [authorsByFollowers, setSortedAuthorsByFollowers] = createSignal<Author[]>([])
|
||||
|
||||
export const setAuthorsByShouts = (authors: SortedAuthorsSetter) => setSortedAuthorsByShout(authors)
|
||||
export const setAuthorsByFollowers = (authors: SortedAuthorsSetter) => setSortedAuthorsByFollowers(authors)
|
||||
|
||||
const sortedAuthors = createLazyMemo(() => {
|
||||
const authors = Object.values(authorEntities())
|
||||
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
|
||||
return Object.values(authorEntities())
|
||||
})
|
||||
|
||||
export const addAuthors = (authors: Author[]) => {
|
||||
|
@ -108,5 +101,5 @@ export const useAuthorsStore = (initialState: InitialState = {}) => {
|
|||
}
|
||||
addAuthors([...(initialState.authors || [])])
|
||||
|
||||
return { authorEntities, sortedAuthors, authorsByTopic }
|
||||
return { authorEntities, sortedAuthors, authorsByTopic, authorsByShouts, authorsByFollowers }
|
||||
}
|
||||
|
|
|
@ -204,7 +204,7 @@ a:hover,
|
|||
a:visited,
|
||||
a:link,
|
||||
.link {
|
||||
border-bottom: 2px solid rgb(0 0 0 / 30%);
|
||||
border-bottom: 2px solid var(--link-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user