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

View File

@ -8,14 +8,11 @@ import { useFollowing } from '~/context/following'
import { useGraphQL } from '~/context/graphql' import { useGraphQL } from '~/context/graphql'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' 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 getAuthorFollowersQuery from '~/graphql/query/core/author-followers'
import getAuthorFollowsQuery from '~/graphql/query/core/author-follows' import getAuthorFollowsQuery from '~/graphql/query/core/author-follows'
import type { Author, Reaction, Shout, Topic } from '~/graphql/schema/core.gen' import type { Author, Reaction, Shout, Topic } from '~/graphql/schema/core.gen'
import { byCreated } from '~/lib/sort' import { byCreated } from '~/lib/sort'
import { paginate } from '~/utils/paginate' import { paginate } from '~/utils/paginate'
import { restoreScrollPosition, saveScrollPosition } from '~/utils/scroll'
import stylesArticle from '../../Article/Article.module.scss' import stylesArticle from '../../Article/Article.module.scss'
import { Comment } from '../../Article/Comment' import { Comment } from '../../Article/Comment'
import { AuthorCard } from '../../Author/AuthorCard' import { AuthorCard } from '../../Author/AuthorCard'
@ -48,7 +45,6 @@ export const AuthorView = (props: AuthorViewProps) => {
const { followers: myFollowers, follows: myFollows } = useFollowing() const { followers: myFollowers, follows: myFollows } = useFollowing()
// signals // signals
const [isLoadMoreButtonVisible, setIsLoadMoreButtonVisible] = createSignal(false)
const [isBioExpanded, setIsBioExpanded] = createSignal(false) const [isBioExpanded, setIsBioExpanded] = createSignal(false)
const [author, setAuthor] = createSignal<Author>() const [author, setAuthor] = createSignal<Author>()
const [followers, setFollowers] = createSignal<Author[]>([] as 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) 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 // проверяет не собственный ли это профиль, иначе - загружает // 1 // проверяет не собственный ли это профиль, иначе - загружает
const [isFetching, setIsFetching] = createSignal(false) const [isFetching, setIsFetching] = createSignal(false)
createEffect( 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 // event handlers
let bioContainerRef: HTMLDivElement let bioContainerRef: HTMLDivElement
let bioWrapperRef: HTMLDivElement let bioWrapperRef: HTMLDivElement
@ -290,14 +254,6 @@ export const AuthorView = (props: AuthorViewProps) => {
</Match> </Match>
</Switch> </Switch>
</Show> </Show>
<Show when={isLoadMoreButtonVisible()}>
<p class="load-more-container">
<button class="button" onClick={loadMore}>
{t('Load more')}
</button>
</p>
</Show>
</Show> </Show>
</Match> </Match>
</Switch> </Switch>

View File

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

View File

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

View File

@ -1,13 +1,14 @@
import { RouteSectionProps, createAsync } from '@solidjs/router' 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 { AuthorView } from '~/components/Views/Author'
import { FourOuFourView } from '~/components/Views/FourOuFour' 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 { PageLayout } from '~/components/_shared/PageLayout'
import { useAuthors } from '~/context/authors' import { useAuthors } from '~/context/authors'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { ReactionsProvider } from '~/context/reactions' import { ReactionsProvider, useReactions } from '~/context/reactions'
import { loadAuthors, loadShouts, loadTopics } from '~/graphql/api/public' import { loadAuthors, loadReactions, loadShouts, loadTopics } from '~/graphql/api/public'
import { import {
Author, Author,
LoadShoutsOptions, LoadShoutsOptions,
@ -38,10 +39,9 @@ const fetchAuthor = async (slug: string) => {
export const route = { export const route = {
load: async ({ params, location: { query } }: RouteSectionProps<{ articles: Shout[] }>) => { load: async ({ params, location: { query } }: RouteSectionProps<{ articles: Shout[] }>) => {
const offset: number = Number.parseInt(query.offset, 10) const offset: number = Number.parseInt(query.offset, 10)
const result = await fetchAuthorShouts(params.slug, offset)
return { return {
author: await fetchAuthor(params.slug), author: await fetchAuthor(params.slug),
shouts: result || [], shouts: await fetchAuthorShouts(params.slug, offset),
topics: await fetchAllTopics() topics: await fetchAllTopics()
} }
} }
@ -50,11 +50,14 @@ export const route = {
export type AuthorPageProps = { articles?: Shout[]; author?: Author; topics?: Topic[] } export type AuthorPageProps = { articles?: Shout[]; author?: Author; topics?: Topic[] }
export default function AuthorPage(props: RouteSectionProps<AuthorPageProps>) { export default function AuthorPage(props: RouteSectionProps<AuthorPageProps>) {
const { addAuthor } = useAuthors() const { addAuthor, authorsEntities } = useAuthors()
const articles = createAsync( const articles = createAsync(
async () => props.data.articles || (await fetchAuthorShouts(props.params.slug)) || [] async () => props.data.articles || (await fetchAuthorShouts(props.params.slug)) || []
) )
const author = createAsync(async () => { const author = createAsync(async () => {
const loadedBefore = authorsEntities()[props.params.slug]
if (loadedBefore) return loadedBefore
const a = props.data.author || (await fetchAuthor(props.params.slug)) const a = props.data.author || (await fetchAuthor(props.params.slug))
a && addAuthor(a) a && addAuthor(a)
return a return a
@ -80,30 +83,55 @@ export default function AuthorPage(props: RouteSectionProps<AuthorPageProps>) {
: getImageUrl('production/image/logo_image.png') : getImageUrl('production/image/logo_image.png')
) )
const selectedTab = createMemo(() => const selectedTab = createMemo(() => (props.params.tab in ['comments', 'about'] ? props.params.tab : ''))
props.params.tab in ['followers', 'shouts'] ? props.params.tab : 'name' 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 ( return (
<ErrorBoundary fallback={(_err) => <FourOuFourView />}> <ErrorBoundary fallback={(_err) => <FourOuFourView />}>
<Suspense fallback={<Loading />}> <PageLayout
<PageLayout title={`${t('Discours')} :: ${title()}`}
title={`${t('Discours')} :: ${title()}`} headerTitle={author()?.name || ''}
headerTitle={author()?.name || ''} slug={author()?.slug}
slug={author()?.slug} desc={author()?.about || author()?.bio || ''}
desc={author()?.about || author()?.bio || ''} cover={cover()}
cover={cover()} >
> <ReactionsProvider>
<ReactionsProvider> <LoadMoreWrapper
loadFunction={(selectedTab() === 'comments' ? loadMoreComments : loadMoreAuthorShouts)}
pageSize={SHOUTS_PER_PAGE}
hidden={selectedTab() !== '' || selectedTab() !== 'comments'}
>
<AuthorView <AuthorView
author={author() as Author} author={author() as Author}
selectedTab={selectedTab()} selectedTab={selectedTab()}
authorSlug={props.params.slug} authorSlug={props.params.slug}
shouts={articles() as Shout[]} shouts={feedByAuthor()[props.params.slug] || articles() as Shout[]}
topics={topics()} topics={topics()}
/> />
</ReactionsProvider> </LoadMoreWrapper>
</PageLayout> </ReactionsProvider>
</Suspense> </PageLayout>
</ErrorBoundary> </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[][] { export function paginate<T>(arr: T[], startIndex: number, pageSize: number): T[][] {
return arr.slice(startIndex).reduce((acc, item, index) => { return arr.slice(startIndex).reduce((acc, item, index) => {
// Начинаем новую страницу, когда индекс кратен размеру страницы
if (index % pageSize === 0) { if (index % pageSize === 0) {
acc.push([]) // Создаем новый подмассив (страницу)
acc.push([]);
} }
acc?.at(-1)?.push(item) // Добавляем текущий элемент на последнюю страницу
return acc acc.at(-1)?.push(item);
}, [] as T[][]) return acc;
}, [] as T[][]); // Инициализируем аккумулятор как пустой массив массивов
} }