diff --git a/angular-primeng-app/src/app/components/posts/posts.component.html b/angular-primeng-app/src/app/components/posts/posts.component.html index 93c7649..cb80b9d 100644 --- a/angular-primeng-app/src/app/components/posts/posts.component.html +++ b/angular-primeng-app/src/app/components/posts/posts.component.html @@ -1,13 +1,19 @@ -
+
- @for (post of posts$ | async; track post.id) { - - - - - - - + @for (post of posts; track post) { + + + + + + + }
+ + @if (paginationInfo.hasNextPage && !isHiddenLoadMore) { + + }
diff --git a/angular-primeng-app/src/app/components/posts/posts.component.scss b/angular-primeng-app/src/app/components/posts/posts.component.scss index 033efd7..28ff44e 100644 --- a/angular-primeng-app/src/app/components/posts/posts.component.scss +++ b/angular-primeng-app/src/app/components/posts/posts.component.scss @@ -13,6 +13,12 @@ display: flex; } } + + .load-more-posts { + display: flex; + justify-content: center; + margin-bottom: 2rem; + } } diff --git a/angular-primeng-app/src/app/components/posts/posts.component.ts b/angular-primeng-app/src/app/components/posts/posts.component.ts index 3454d3a..097aeb2 100644 --- a/angular-primeng-app/src/app/components/posts/posts.component.ts +++ b/angular-primeng-app/src/app/components/posts/posts.component.ts @@ -1,25 +1,53 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { BlogService } from '../../services/blog.service'; import { RouterLink } from '@angular/router'; -import { Observable } from 'rxjs'; import { AsyncPipe } from '@angular/common'; import { CardModule } from 'primeng/card'; -import { Post } from '../../models/post'; +import { PageInfo, Post } from '../../models/post'; +import { InfiniteScrollDirective } from "../../directives/infinite-scroll.directive"; +import { ButtonModule } from "primeng/button"; @Component({ selector: 'app-posts', standalone: true, - imports: [AsyncPipe, RouterLink, CardModule], + imports: [AsyncPipe, RouterLink, CardModule, InfiniteScrollDirective, ButtonModule], templateUrl: './posts.component.html', styleUrl: './posts.component.scss' }) export class PostsComponent implements OnInit { blogURL!: string; - posts$!: Observable; + posts!: Post[]; + paginationInfo: PageInfo = { hasNextPage: true, endCursor: ''}; + isHiddenLoadMore: boolean = true; + isActiveInfiniteScroll: boolean = false; + private blogService = inject(BlogService); ngOnInit() { this.blogURL = this.blogService.getBlogURL(); - this.posts$ = this.blogService.getPosts(this.blogURL); + this.loadPosts(); } + + private loadPosts(): void { + this.blogService.getPosts(this.blogURL, this.paginationInfo.endCursor).subscribe(postsPageInfo => { + this.paginationInfo = postsPageInfo.pagination; + this.isHiddenLoadMore = !postsPageInfo.pagination.hasNextPage; + this.posts = postsPageInfo.posts; + }); + } + + loadMorePosts(): void { + if (!this.paginationInfo.hasNextPage) { + return; + } + + this.isHiddenLoadMore = true; + + this.blogService.getPosts(this.blogURL, this.paginationInfo.endCursor) + .subscribe(newPosts => { + this.isActiveInfiniteScroll = true; + this.paginationInfo = newPosts.pagination; + this.posts = this.posts.concat(newPosts.posts); + }); + } } diff --git a/angular-primeng-app/src/app/components/series/series.component.html b/angular-primeng-app/src/app/components/series/series.component.html index 56f4420..3607db7 100644 --- a/angular-primeng-app/src/app/components/series/series.component.html +++ b/angular-primeng-app/src/app/components/series/series.component.html @@ -1,6 +1,7 @@ -
+
- @for (post of postsInSeries$ | async; track post.id) { + @for (post of postsInSeries; track post) { @@ -10,4 +11,10 @@ }
+ + @if (paginationInfo.hasNextPage && !isHiddenLoadMore) { + + }
diff --git a/angular-primeng-app/src/app/components/series/series.component.scss b/angular-primeng-app/src/app/components/series/series.component.scss index e3bcaae..49c2d71 100644 --- a/angular-primeng-app/src/app/components/series/series.component.scss +++ b/angular-primeng-app/src/app/components/series/series.component.scss @@ -13,4 +13,10 @@ display: flex; } } + + .load-more-posts { + display: flex; + justify-content: center; + margin-bottom: 2rem; + } } diff --git a/angular-primeng-app/src/app/components/series/series.component.ts b/angular-primeng-app/src/app/components/series/series.component.ts index ba43733..137a460 100644 --- a/angular-primeng-app/src/app/components/series/series.component.ts +++ b/angular-primeng-app/src/app/components/series/series.component.ts @@ -1,32 +1,54 @@ import { Component, inject } from '@angular/core'; -import { ActivatedRoute, Params, RouterLink } from '@angular/router'; -import { Observable, switchMap } from 'rxjs'; -import { Post } from '../../models/post'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { PageInfo, Post } from '../../models/post'; import { AsyncPipe } from "@angular/common"; import { BlogService } from '../../services/blog.service'; import { CardModule } from 'primeng/card'; +import { InfiniteScrollDirective } from "../../directives/infinite-scroll.directive"; +import { ButtonModule } from "primeng/button"; @Component({ selector: 'app-series', standalone: true, - imports: [RouterLink, AsyncPipe, CardModule], + imports: [RouterLink, AsyncPipe, CardModule, InfiniteScrollDirective, ButtonModule], templateUrl: './series.component.html', styleUrl: './series.component.scss' }) export class SeriesComponent { blogURL!: string; slug: string = ""; - postsInSeries$!: Observable; + postsInSeries: Post[] = []; + paginationInfo: PageInfo = { hasNextPage: true, endCursor: '' }; + isHiddenLoadMore: boolean = true; + isActiveInfiniteScroll: boolean = false; + blogService: BlogService = inject(BlogService); route: ActivatedRoute = inject(ActivatedRoute); ngOnInit(): void { this.blogURL = this.blogService.getBlogURL(); - this.postsInSeries$ = this.route.params.pipe( - switchMap((params: Params) => { - this.slug = params["slug"]; - return this.blogService.getPostsInSeries(this.blogURL, this.slug); - }) - ); + this.route.params.subscribe(params => { + this.slug = params['slug']; + this.loadPostsInSeries(); + }) } + + private loadPostsInSeries():void{ + this.blogService.getPostsInSeries(this.blogURL, this.slug).subscribe(seriesPageInfo => { + this.paginationInfo = seriesPageInfo.pagination; + this.isHiddenLoadMore = !seriesPageInfo.pagination.hasNextPage; + this.postsInSeries = seriesPageInfo.posts; + }) + } + + loadMorePostsFromSeries():void { + if (!this.paginationInfo.hasNextPage) return; + this.isHiddenLoadMore = true; + this.blogService.getPostsInSeries(this.blogURL, this.slug, this.paginationInfo.endCursor).pipe( + ).subscribe(newPosts => { + this.isActiveInfiniteScroll = true; + this.paginationInfo = newPosts.pagination; + this.postsInSeries = this.postsInSeries.concat(newPosts.posts); + }); + } } diff --git a/angular-primeng-app/src/app/directives/infinite-scroll.directive.spec.ts b/angular-primeng-app/src/app/directives/infinite-scroll.directive.spec.ts new file mode 100644 index 0000000..2721ef5 --- /dev/null +++ b/angular-primeng-app/src/app/directives/infinite-scroll.directive.spec.ts @@ -0,0 +1,8 @@ +import { InfiniteScrollDirective } from './infinite-scroll.directive'; + +describe('InfiniteScrollDirective', () => { + it('should create an instance', () => { + const directive = new InfiniteScrollDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/angular-primeng-app/src/app/directives/infinite-scroll.directive.ts b/angular-primeng-app/src/app/directives/infinite-scroll.directive.ts new file mode 100644 index 0000000..41cc01a --- /dev/null +++ b/angular-primeng-app/src/app/directives/infinite-scroll.directive.ts @@ -0,0 +1,55 @@ +import { Directive, effect, EventEmitter, input, Input, InputSignal, OnDestroy, Output } from '@angular/core'; +import { fromEvent, Subject, takeUntil } from 'rxjs'; + +@Directive({ + selector: '[infiniteScroll]', + standalone: true +}) +export class InfiniteScrollDirective implements OnDestroy { + isActiveInfiniteScroll: InputSignal = input(false); + @Input() infiniteScrollDistance: number = 20; + + @Output() scrolled = new EventEmitter; + + private unsubscribe: Subject = new Subject(); + + constructor() { + this._listenSignals(); + } + + ngOnDestroy(): void { + this.unsubscribe.next(); + this.unsubscribe.complete(); + } + + private listenScrollWindow(): void { + let isActivePercentageBeforeEnd = false; + fromEvent(window, 'scroll').pipe( + takeUntil(this.unsubscribe)) + .subscribe(() => { + let percentageBeforeEnd = this.calculatePositionScroll(); + if (!isActivePercentageBeforeEnd && percentageBeforeEnd <= this.infiniteScrollDistance) { + isActivePercentageBeforeEnd = true; + this.scrolled.emit(); + } else if (percentageBeforeEnd >= this.infiniteScrollDistance) { + isActivePercentageBeforeEnd = false; + } + }); + } + + private calculatePositionScroll(): number { + let scrollHeight = document.documentElement.scrollHeight; + let innerHeight = window.innerHeight; + let scrollY = window.scrollY || 0; + return (scrollHeight - scrollY - innerHeight) / scrollHeight * 100; + } + + private _listenSignals(): void { + effect(() => { + this.unsubscribe.next(); + if (this.isActiveInfiniteScroll()) { + this.listenScrollWindow(); + } + }); + } +} diff --git a/angular-primeng-app/src/app/graphql.operations.ts b/angular-primeng-app/src/app/graphql.operations.ts index c3a3157..9b76f93 100644 --- a/angular-primeng-app/src/app/graphql.operations.ts +++ b/angular-primeng-app/src/app/graphql.operations.ts @@ -48,12 +48,12 @@ export const GET_AUTHOR_INFO = gql` `; export const GET_POSTS = gql` - query Publication($host: String!) { + query Publication($host: String!, $after: String!) { publication(host: $host) { id isTeam title - posts(first: 10) { + posts(first: 10, after: $after) { edges { node { id @@ -68,6 +68,10 @@ export const GET_POSTS = gql` } } } + pageInfo { + endCursor + hasNextPage + } } } } @@ -92,13 +96,13 @@ export const GET_SERIES_LIST = gql` `; export const GET_POSTS_IN_SERIES = gql` - query Publication($host: String!, $slug: String!) { + query Publication($host: String!, $slug: String!, $after: String!) { publication(host: $host) { id isTeam title series(slug: $slug) { - posts(first: 10) { + posts(first: 10, after: $after) { edges { node { id @@ -109,6 +113,10 @@ export const GET_POSTS_IN_SERIES = gql` } } } + pageInfo { + endCursor + hasNextPage + } } } } diff --git a/angular-primeng-app/src/app/models/post.ts b/angular-primeng-app/src/app/models/post.ts index 076f3d0..ea8b9b2 100644 --- a/angular-primeng-app/src/app/models/post.ts +++ b/angular-primeng-app/src/app/models/post.ts @@ -47,3 +47,12 @@ export interface Content { html: string; } +export interface PostsPageInfo { + posts: Post[]; + pagination: PageInfo; +} + +export interface PageInfo { + hasNextPage: boolean; + endCursor: string; +} diff --git a/angular-primeng-app/src/app/services/blog.service.ts b/angular-primeng-app/src/app/services/blog.service.ts index b74c943..ddb899e 100644 --- a/angular-primeng-app/src/app/services/blog.service.ts +++ b/angular-primeng-app/src/app/services/blog.service.ts @@ -10,7 +10,7 @@ import { GET_SINGLE_POST, SEARCH_POSTS, } from "../graphql.operations"; -import { Author, Post, SeriesList } from "../models/post"; +import { Author, Post, PostsPageInfo, SeriesList } from "../models/post"; import { BlogInfo } from "../models/blog-info"; import { isPlatformBrowser } from "@angular/common"; @@ -70,19 +70,23 @@ export class BlogService { .valueChanges.pipe(map(({ data }) => data.publication.author)); } - getPosts(host: string): Observable { + getPosts(host: string, after: string): Observable { return this.apollo .watchQuery({ query: GET_POSTS, variables: { host: host, + after: after }, }) .valueChanges.pipe( - map(({ data }) => - data.publication.posts.edges.map((edge: { node: any }) => edge.node) - ) - ); + map(({ data }) => { + const { edges, pageInfo } = data.publication.posts; + return { + posts: edges.map((edge: { node: any }) => edge.node), + pagination: pageInfo + } + })); } getSeriesList(host: string): Observable { @@ -102,23 +106,24 @@ export class BlogService { ); } - getPostsInSeries(host: string, slug: string): Observable { - return this.apollo - .watchQuery({ - query: GET_POSTS_IN_SERIES, - variables: { - host: host, - slug: slug, - }, - }) - .valueChanges.pipe( - map(({ data }) => - data.publication.series.posts.edges.map( - (edge: { node: any }) => edge.node - ) - ) - ); - } + getPostsInSeries(host: string, slug: string, after: string = ""): Observable { + return this.apollo + .watchQuery({ + query: GET_POSTS_IN_SERIES, + variables: { + host: host, + slug: slug, + after: after + }, + }).valueChanges.pipe( + map(({ data }) => { + const { edges, pageInfo } = data.publication.series.posts; + return { + posts: edges.map((edge: { node: any; }) => edge.node), + pagination: pageInfo + }; + })); + } getSinglePost(host: string, slug: string): Observable { return this.apollo