author-feed+comments-paginate

This commit is contained in:
Untone 2024-07-16 03:14:08 +03:00
parent 82904bd1da
commit 8fbc85615c
6 changed files with 117 additions and 115 deletions

View File

@ -32,40 +32,40 @@
"@solid-primitives/pagination": "^0.3.0",
"@solid-primitives/script-loader": "^2.2.0",
"@solid-primitives/share": "^2.0.6",
"@solid-primitives/storage": "^3.7.1",
"@solid-primitives/storage": "^3.8.0",
"@solid-primitives/upload": "^0.0.117",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.13.6",
"@solidjs/start": "^1.0.4",
"@tiptap/core": "^2.4.0",
"@tiptap/extension-blockquote": "^2.4.0",
"@tiptap/extension-bold": "^2.4.0",
"@tiptap/extension-bubble-menu": "^2.4.0",
"@tiptap/extension-bullet-list": "^2.4.0",
"@tiptap/extension-character-count": "^2.4.0",
"@tiptap/extension-collaboration": "^2.4.0",
"@tiptap/extension-collaboration-cursor": "^2.4.0",
"@tiptap/extension-document": "^2.4.0",
"@tiptap/extension-dropcursor": "^2.4.0",
"@tiptap/extension-floating-menu": "^2.4.0",
"@tiptap/extension-focus": "^2.4.0",
"@tiptap/extension-gapcursor": "^2.4.0",
"@tiptap/extension-hard-break": "^2.4.0",
"@tiptap/extension-heading": "^2.4.0",
"@tiptap/extension-highlight": "^2.4.0",
"@tiptap/extension-history": "^2.4.0",
"@tiptap/extension-horizontal-rule": "^2.4.0",
"@tiptap/extension-image": "^2.4.0",
"@tiptap/extension-italic": "^2.4.0",
"@tiptap/extension-link": "^2.4.0",
"@tiptap/extension-list-item": "^2.4.0",
"@tiptap/extension-ordered-list": "^2.4.0",
"@tiptap/extension-paragraph": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/extension-strike": "^2.4.0",
"@tiptap/extension-text": "^2.4.0",
"@tiptap/extension-underline": "^2.4.0",
"@tiptap/extension-youtube": "^2.4.0",
"@tiptap/core": "^2.5.1",
"@tiptap/extension-blockquote": "^2.5.1",
"@tiptap/extension-bold": "^2.5.1",
"@tiptap/extension-bubble-menu": "^2.5.1",
"@tiptap/extension-bullet-list": "^2.5.1",
"@tiptap/extension-character-count": "^2.5.1",
"@tiptap/extension-collaboration": "^2.5.1",
"@tiptap/extension-collaboration-cursor": "^2.5.1",
"@tiptap/extension-document": "^2.5.1",
"@tiptap/extension-dropcursor": "^2.5.1",
"@tiptap/extension-floating-menu": "^2.5.1",
"@tiptap/extension-focus": "^2.5.1",
"@tiptap/extension-gapcursor": "^2.5.1",
"@tiptap/extension-hard-break": "^2.5.1",
"@tiptap/extension-heading": "^2.5.1",
"@tiptap/extension-highlight": "^2.5.1",
"@tiptap/extension-history": "^2.5.1",
"@tiptap/extension-horizontal-rule": "^2.5.1",
"@tiptap/extension-image": "^2.5.1",
"@tiptap/extension-italic": "^2.5.1",
"@tiptap/extension-link": "^2.5.1",
"@tiptap/extension-list-item": "^2.5.1",
"@tiptap/extension-ordered-list": "^2.5.1",
"@tiptap/extension-paragraph": "^2.5.1",
"@tiptap/extension-placeholder": "^2.5.1",
"@tiptap/extension-strike": "^2.5.1",
"@tiptap/extension-text": "^2.5.1",
"@tiptap/extension-underline": "^2.5.1",
"@tiptap/extension-youtube": "^2.5.1",
"@types/cookie": "^0.6.0",
"@types/cookie-signature": "^1.1.2",
"@types/node": "^20.14.10",
@ -79,7 +79,7 @@
"extended-eventsource": "^1.4.9",
"fast-deep-equal": "^3.1.3",
"graphql": "^16.9.0",
"i18next": "^23.11.5",
"i18next": "^23.12.1",
"i18next-http-backend": "^2.5.2",
"i18next-icu": "^2.3.0",
"intl-messageformat": "^10.5.14",
@ -99,7 +99,7 @@
"stylelint-config-standard-scss": "^13.1.0",
"stylelint-order": "^6.0.4",
"stylelint-scss": "^6.4.1",
"swiper": "^11.1.4",
"swiper": "^11.1.5",
"throttle-debounce": "^5.0.2",
"tslib": "^2.6.3",
"typescript": "^5.5.3",

View File

@ -8,14 +8,11 @@ import { useFollowing } from '~/context/following'
import { useGraphQL } from '~/context/graphql'
import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session'
import { loadReactions } from '~/graphql/api/public'
import loadShoutsQuery from '~/graphql/query/core/articles-load-by'
import getAuthorFollowersQuery from '~/graphql/query/core/author-followers'
import getAuthorFollowsQuery from '~/graphql/query/core/author-follows'
import type { Author, Reaction, Shout, Topic } from '~/graphql/schema/core.gen'
import { byCreated } from '~/lib/sort'
import { paginate } from '~/utils/paginate'
import { restoreScrollPosition, saveScrollPosition } from '~/utils/scroll'
import stylesArticle from '../../Article/Article.module.scss'
import { Comment } from '../../Article/Comment'
import { AuthorCard } from '../../Author/AuthorCard'
@ -48,7 +45,6 @@ export const AuthorView = (props: AuthorViewProps) => {
const { followers: myFollowers, follows: myFollows } = useFollowing()
// signals
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [isBioExpanded, setIsBioExpanded] = createSignal(false)
const [author, setAuthor] = createSignal<Author>()
const [followers, setFollowers] = createSignal<Author[]>([] as Author[])
@ -62,20 +58,6 @@ export const AuthorView = (props: AuthorViewProps) => {
paginate(sortedFeed(), PRERENDERED_ARTICLES_COUNT, LOAD_MORE_PAGE_SIZE)
)
// fx
// пагинация загрузки ленты постов
const loadMore = async () => {
saveScrollPosition()
const resp = await query(loadShoutsQuery, {
filters: { author: props.authorSlug },
limit: LOAD_MORE_PAGE_SIZE,
offset: sortedFeed().length
})
const hasMore = resp?.data?.load_shouts_by?.hasMore
setIsLoadMoreButtonVisible(hasMore)
restoreScrollPosition()
}
// 1 // проверяет не собственный ли это профиль, иначе - загружает
const [isFetching, setIsFetching] = createSignal(false)
createEffect(
@ -122,24 +104,6 @@ export const AuthorView = (props: AuthorViewProps) => {
)
)
// 3 // догружает ленту и комментарии
createEffect(
on(
() => author() as Author,
async (profile: Author) => {
if (!commented() && profile) {
await loadMore()
const commentsFetcher = loadReactions({
by: { comment: true, created_by: profile.id }
})
const ccc = await commentsFetcher()
if (ccc) setCommented((_) => ccc || [])
}
}
// { defer: true },
)
)
// event handlers
let bioContainerRef: HTMLDivElement
let bioWrapperRef: HTMLDivElement
@ -290,14 +254,6 @@ export const AuthorView = (props: AuthorViewProps) => {
</Match>
</Switch>
</Show>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</Show>
</Match>
</Switch>

View File

@ -9,6 +9,7 @@ export type LoadMoreItems = Shout[] | Author[] | Reaction[]
type LoadMoreProps = {
loadFunction: (offset?: number) => Promise<LoadMoreItems>
pageSize: number
hidden?: boolean
children: JSX.Element
}
@ -37,7 +38,7 @@ export const LoadMoreWrapper = (props: LoadMoreProps) => {
return (
<>
{props.children}
<Show when={isLoadMoreButtonVisible()}>
<Show when={isLoadMoreButtonVisible() && !props.hidden}>
<div class="load-more-container">
<Button
onClick={loadItems}

View File

@ -24,6 +24,7 @@ type ReactionsContextType = {
createReaction: (reaction: MutationCreate_ReactionArgs) => Promise<void>
updateReaction: (reaction: MutationUpdate_ReactionArgs) => Promise<Reaction>
deleteReaction: (id: number) => Promise<{ error: string } | null>
addReactions: (rrr: Reaction[]) => void
}
const ReactionsContext = createContext<ReactionsContextType>({} as ReactionsContextType)
@ -38,24 +39,27 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
const { t } = useLocalize()
const { showSnackbar } = useSnackbar()
const { mutation } = useGraphQL()
const loadReactionsBy = async (opts: QueryLoad_Reactions_ByArgs): Promise<Reaction[]> => {
!opts.by && console.warn('reactions provider got wrong opts')
const fetcher = await loadReactions(opts)
const result = (await fetcher()) || []
console.debug('[context.reactions] loaded', result)
const newReactionsByShout: Record<string, Reaction[]> = {}
const newReactionEntities = result.reduce(
const addReactions = (rrr: Reaction[]) => {
const newReactionsByShout: Record<string, Reaction[]> = { ...reactionsByShout }
const newReactionEntities = rrr.reduce(
(acc: { [reaction_id: number]: Reaction }, reaction: Reaction) => {
acc[reaction.id] = reaction
if (!newReactionsByShout[reaction.shout.slug]) newReactionsByShout[reaction.shout.slug] = []
newReactionsByShout[reaction.shout.slug].push(reaction)
return acc
},
{}
{ ...reactionEntities }
)
setReactionsByShout(newReactionsByShout)
setReactionEntities(newReactionEntities)
setReactionsByShout(newReactionsByShout)
}
const loadReactionsBy = async (opts: QueryLoad_Reactions_ByArgs): Promise<Reaction[]> => {
!opts.by && console.warn('reactions provider got wrong opts')
const fetcher = await loadReactions(opts)
const result = (await fetcher()) || []
console.debug('[context.reactions] loaded', result)
result && addReactions(result)
return result
}
@ -120,7 +124,8 @@ export const ReactionsProvider = (props: { children: JSX.Element }) => {
loadReactionsBy,
createReaction,
updateReaction,
deleteReaction
deleteReaction,
addReactions
}
const value: ReactionsContextType = { reactionEntities, reactionsByShout, ...actions }

View File

@ -1,13 +1,14 @@
import { RouteSectionProps, createAsync } from '@solidjs/router'
import { ErrorBoundary, Suspense, createEffect, createMemo } from 'solid-js'
import { ErrorBoundary, createEffect, createMemo } from 'solid-js'
import { AuthorView } from '~/components/Views/Author'
import { FourOuFourView } from '~/components/Views/FourOuFour'
import { Loading } from '~/components/_shared/Loading'
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
import { PageLayout } from '~/components/_shared/PageLayout'
import { useAuthors } from '~/context/authors'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { ReactionsProvider } from '~/context/reactions'
import { loadAuthors, loadShouts, loadTopics } from '~/graphql/api/public'
import { ReactionsProvider, useReactions } from '~/context/reactions'
import { loadAuthors, loadReactions, loadShouts, loadTopics } from '~/graphql/api/public'
import {
Author,
LoadShoutsOptions,
@ -38,10 +39,9 @@ const fetchAuthor = async (slug: string) => {
export const route = {
load: async ({ params, location: { query } }: RouteSectionProps<{ articles: Shout[] }>) => {
const offset: number = Number.parseInt(query.offset, 10)
const result = await fetchAuthorShouts(params.slug, offset)
return {
author: await fetchAuthor(params.slug),
shouts: result || [],
shouts: await fetchAuthorShouts(params.slug, offset),
topics: await fetchAllTopics()
}
}
@ -50,11 +50,14 @@ export const route = {
export type AuthorPageProps = { articles?: Shout[]; author?: Author; topics?: Topic[] }
export default function AuthorPage(props: RouteSectionProps<AuthorPageProps>) {
const { addAuthor } = useAuthors()
const { addAuthor, authorsEntities } = useAuthors()
const articles = createAsync(
async () => props.data.articles || (await fetchAuthorShouts(props.params.slug)) || []
)
const author = createAsync(async () => {
const loadedBefore = authorsEntities()[props.params.slug]
if (loadedBefore) return loadedBefore
const a = props.data.author || (await fetchAuthor(props.params.slug))
a && addAuthor(a)
return a
@ -80,12 +83,32 @@ export default function AuthorPage(props: RouteSectionProps<AuthorPageProps>) {
: getImageUrl('production/image/logo_image.png')
)
const selectedTab = createMemo(() =>
props.params.tab in ['followers', 'shouts'] ? props.params.tab : 'name'
)
const selectedTab = createMemo(() => (props.params.tab in ['comments', 'about'] ? props.params.tab : ''))
const { addReactions } = useReactions()
const loadMoreComments = async () => {
const commentsFetcher = loadReactions({
by: { comment: true, created_by: author()?.id }
})
const ccc = await commentsFetcher()
ccc && addReactions(ccc)
return ccc as LoadMoreItems
}
const { addFeed, feedByAuthor } = useFeed()
const loadMoreAuthorShouts = async () => {
const slug = author()?.slug
const offset = feedByAuthor()[props.params.slug].length
const shoutsFetcher = loadShouts({
filters: { author: slug },
offset,
limit: SHOUTS_PER_PAGE
})
const sss = await shoutsFetcher()
sss && addFeed(sss)
return sss as LoadMoreItems
}
return (
<ErrorBoundary fallback={(_err) => <FourOuFourView />}>
<Suspense fallback={<Loading />}>
<PageLayout
title={`${t('Discours')} :: ${title()}`}
headerTitle={author()?.name || ''}
@ -94,16 +117,21 @@ export default function AuthorPage(props: RouteSectionProps<AuthorPageProps>) {
cover={cover()}
>
<ReactionsProvider>
<LoadMoreWrapper
loadFunction={(selectedTab() === 'comments' ? loadMoreComments : loadMoreAuthorShouts)}
pageSize={SHOUTS_PER_PAGE}
hidden={selectedTab() !== '' || selectedTab() !== 'comments'}
>
<AuthorView
author={author() as Author}
selectedTab={selectedTab()}
authorSlug={props.params.slug}
shouts={articles() as Shout[]}
shouts={feedByAuthor()[props.params.slug] || articles() as Shout[]}
topics={topics()}
/>
</LoadMoreWrapper>
</ReactionsProvider>
</PageLayout>
</Suspense>
</ErrorBoundary>
)
}

View File

@ -1,10 +1,22 @@
/**
* Делит массив на меньшие массивы (страницы) заданного размера.
*
* @template T - Тип элементов в массиве.
* @param {T[]} arr - Массив, который нужно разделить на страницы.
* @param {number} startIndex - Индекс, с которого начинается пагинация.
* @param {number} pageSize - Размер каждой страницы.
* @returns {T[][]} - Массив массивов, где каждый подмассив является страницей заданного размера.
*/
export function paginate<T>(arr: T[], startIndex: number, pageSize: number): T[][] {
return arr.slice(startIndex).reduce((acc, item, index) => {
// Начинаем новую страницу, когда индекс кратен размеру страницы
if (index % pageSize === 0) {
acc.push([])
// Создаем новый подмассив (страницу)
acc.push([]);
}
acc?.at(-1)?.push(item)
return acc
}, [] as T[][])
// Добавляем текущий элемент на последнюю страницу
acc.at(-1)?.push(item);
return acc;
}, [] as T[][]); // Инициализируем аккумулятор как пустой массив массивов
}