diff --git a/api/src/main/kotlin/com/gmtkgamejam/models/posts/dtos/PostsDTO.kt b/api/src/main/kotlin/com/gmtkgamejam/models/posts/dtos/PostsDTO.kt new file mode 100644 index 0000000..a067aca --- /dev/null +++ b/api/src/main/kotlin/com/gmtkgamejam/models/posts/dtos/PostsDTO.kt @@ -0,0 +1,10 @@ +package com.gmtkgamejam.models.posts.dtos + +import com.gmtkgamejam.models.posts.PostItem +import kotlinx.serialization.Serializable + +@Serializable +data class PostsDTO( + val posts: List, + val pagination: Map +) \ No newline at end of file diff --git a/api/src/main/kotlin/com/gmtkgamejam/repositories/PostRepository.kt b/api/src/main/kotlin/com/gmtkgamejam/repositories/PostRepository.kt index bad26b7..d308dd8 100644 --- a/api/src/main/kotlin/com/gmtkgamejam/repositories/PostRepository.kt +++ b/api/src/main/kotlin/com/gmtkgamejam/repositories/PostRepository.kt @@ -11,13 +11,18 @@ import java.time.format.DateTimeFormatter interface PostRepository { fun createPost(postItem: PostItem) - fun getPosts(filter: Bson, sort: Bson): List + fun getPosts(filter: Bson, sort: Bson, page: Int): List fun getPost(id: String) : PostItem? fun getPostByAuthorId(authorId: String, ignoreDeletion: Boolean = false) : PostItem? fun updatePost(postItem: PostItem) fun deletePost(postItem: PostItem) fun addQueryView(postItem: PostItem) fun addFullPageView(postItem: PostItem) + fun getPostCount(): Int + + companion object { + const val PAGE_SIZE = 36 + } } open class PostRepositoryImpl(val client: MongoClient) : PostRepository { @@ -39,8 +44,9 @@ open class PostRepositoryImpl(val client: MongoClient) : PostRepository { } // Un-paginated version should be used for Admin endpoints - override fun getPosts(filter: Bson, sort: Bson): List { - return col.find(filter).sort(sort).toList() + override fun getPosts(filter: Bson, sort: Bson, page: Int): List { + val pageSize = PostRepository.PAGE_SIZE + return col.find(filter).sort(sort).limit(pageSize).skip(pageSize * (page - 1)).toList() } override fun getPost(id: String) : PostItem? { @@ -56,6 +62,10 @@ open class PostRepositoryImpl(val client: MongoClient) : PostRepository { return col.findOne(filter) } + override fun getPostCount(): Int { + return col.countDocuments(PostItem::deletedAt eq null).toInt(); + } + override fun updatePost(postItem: PostItem) { if (bannedUsersCol.findOne(BannedUser::discordId eq postItem.authorId) != null) { throw Exception("User is banned, cannot perform action!") diff --git a/api/src/main/kotlin/com/gmtkgamejam/routing/AdminRoutes.kt b/api/src/main/kotlin/com/gmtkgamejam/routing/AdminRoutes.kt index 016c16e..00d76be 100644 --- a/api/src/main/kotlin/com/gmtkgamejam/routing/AdminRoutes.kt +++ b/api/src/main/kotlin/com/gmtkgamejam/routing/AdminRoutes.kt @@ -1,10 +1,10 @@ package com.gmtkgamejam.routing -import com.gmtkgamejam.models.admin.dtos.BanUnbanUserDto import com.gmtkgamejam.models.admin.BannedUser -import com.gmtkgamejam.models.posts.PostItem +import com.gmtkgamejam.models.admin.dtos.BanUnbanUserDto import com.gmtkgamejam.models.admin.dtos.DeletePostDto import com.gmtkgamejam.models.admin.dtos.ReportedUsersClearDto +import com.gmtkgamejam.models.posts.PostItem import com.gmtkgamejam.respondJSON import com.gmtkgamejam.services.AdminService import com.gmtkgamejam.services.PostService @@ -30,7 +30,7 @@ fun Application.configureAdminRouting() { route("/reports") { get { val filters = mutableListOf(PostItem::deletedAt eq null, PostItem::reportCount gt 0) - call.respond(service.getPosts(and(filters), descending(PostItem::reportCount))) + call.respond(service.getPosts(and(filters), descending(PostItem::reportCount), 1)) } post("/clear") { val data = call.receive() diff --git a/api/src/main/kotlin/com/gmtkgamejam/routing/PostRoutes.kt b/api/src/main/kotlin/com/gmtkgamejam/routing/PostRoutes.kt index 30d615b..44a6e4e 100644 --- a/api/src/main/kotlin/com/gmtkgamejam/routing/PostRoutes.kt +++ b/api/src/main/kotlin/com/gmtkgamejam/routing/PostRoutes.kt @@ -6,10 +6,8 @@ import com.gmtkgamejam.models.posts.Availability import com.gmtkgamejam.models.posts.PostItem import com.gmtkgamejam.models.posts.Skills import com.gmtkgamejam.models.posts.Tools -import com.gmtkgamejam.models.posts.dtos.PostItemCreateDto -import com.gmtkgamejam.models.posts.dtos.PostItemReportDto -import com.gmtkgamejam.models.posts.dtos.PostItemUnableToContactReportDto -import com.gmtkgamejam.models.posts.dtos.PostItemUpdateDto +import com.gmtkgamejam.models.posts.dtos.* +import com.gmtkgamejam.repositories.PostRepository import com.gmtkgamejam.respondJSON import com.gmtkgamejam.services.AnalyticsService import com.gmtkgamejam.services.AuthService @@ -27,6 +25,7 @@ import org.bson.conversions.Bson import org.litote.kmongo.* import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import kotlin.math.ceil import kotlin.math.min import kotlin.reflect.full.memberProperties import kotlin.text.Regex.Companion.escape @@ -42,8 +41,13 @@ fun Application.configurePostRouting() { route("/posts") { get { val params = call.parameters + val page = params["page"]?.toInt() ?: 1 - val posts = service.getPosts(and(getFilterFromParameters(params)), getSortFromParameters(params)) + val posts = service.getPosts( + and(getFilterFromParameters(params)), + getSortFromParameters(params), + page + ) // Set isFavourite on posts for this user if they're logged in call.request.header("Authorization")?.substring(7) @@ -54,7 +58,17 @@ fun Application.configurePostRouting() { posts.map { it.isFavourite = favouritesList.postIds.contains(it.id) } } - call.respond(posts) + val pagination = mapOf( + "current" to page, + "total" to ceil(service.getPostCount() / PostRepository.PAGE_SIZE.toDouble()).toInt() + ) + + call.respond( + PostsDTO( + posts, + pagination + ) + ) launch { analyticsService.trackQuery(params.toMap().toSortedMap()) @@ -131,7 +145,8 @@ fun Application.configurePostRouting() { or(favouritesFilters), and(getFilterFromParameters(params)) ), - getSortFromParameters(params) + getSortFromParameters(params), + params["page"]?.toInt() ?: 1 ) posts.map { post -> post.isFavourite = true } diff --git a/api/src/main/kotlin/com/gmtkgamejam/services/PostService.kt b/api/src/main/kotlin/com/gmtkgamejam/services/PostService.kt index 5f11e1c..3bc12d8 100644 --- a/api/src/main/kotlin/com/gmtkgamejam/services/PostService.kt +++ b/api/src/main/kotlin/com/gmtkgamejam/services/PostService.kt @@ -14,8 +14,8 @@ class PostService : KoinComponent { repository.createPost(postItem) } - fun getPosts(filter: Bson, sort: Bson): List { - return repository.getPosts(filter, sort) + fun getPosts(filter: Bson, sort: Bson, page: Int): List { + return repository.getPosts(filter, sort, page) } fun getPost(id: String) : PostItem? { @@ -26,6 +26,10 @@ class PostService : KoinComponent { return repository.getPostByAuthorId(authorId, ignoreDeletion) } + fun getPostCount(): Int { + return repository.getPostCount() + } + fun updatePost(postItem: PostItem) { repository.updatePost(postItem) } diff --git a/ui/src/api/bot.ts b/ui/src/api/bot.ts index f768a3e..f7b0a6c 100644 --- a/ui/src/api/bot.ts +++ b/ui/src/api/bot.ts @@ -6,7 +6,7 @@ import { } from "react-query"; import { Post, - PostApiResult, + PostResponseDTO, postFromApiResult, } from "../common/models/post"; import { useApiRequest } from "./apiRequest"; @@ -25,7 +25,7 @@ export function useCreateBotDmMutation( return useMutation({ ...opts, mutationFn: async (variables) => { - const result = await apiRequest("/bot/dm", { + const result = await apiRequest("/bot/dm", { method: "POST", body: variables, }); diff --git a/ui/src/api/myPost.ts b/ui/src/api/myPost.ts index 8bbf4d5..4e5aa87 100644 --- a/ui/src/api/myPost.ts +++ b/ui/src/api/myPost.ts @@ -9,7 +9,7 @@ import { } from "react-query"; import { Post, - PostApiResult, + PostResponseDTO, postFromApiResult, } from "../common/models/post"; import { expectNotFound, useApiRequest } from "./apiRequest"; @@ -32,7 +32,7 @@ export function useMyPostQuery( return useQuery( MY_POST_QUERY_KEY, () => - expectNotFound(apiRequest("/posts/mine")).then( + expectNotFound(apiRequest("/posts/mine")).then( (result) => result && postFromApiResult(result) ), { @@ -62,13 +62,13 @@ export function useMyPostMutation( return useMutation({ ...opts, mutationFn: async (variables) => { - const existing = await queryClient.fetchQuery( + const existing = await queryClient.fetchQuery( MY_POST_QUERY_KEY ); let result; if (existing) { - result = await apiRequest("/posts/mine", { + result = await apiRequest("/posts/mine", { method: "PUT", body: { ...variables, @@ -76,7 +76,7 @@ export function useMyPostMutation( }, }); } else { - result = await apiRequest("/posts", { + result = await apiRequest("/posts", { method: "POST", body: { ...variables, @@ -106,7 +106,7 @@ export function useDeleteMyPostMutation( return useMutation({ ...opts, mutationFn: async (variables) => { - const result = await apiRequest("/posts/mine", { + const result = await apiRequest("/posts/mine", { method: "DELETE", body: variables, }); diff --git a/ui/src/api/post.ts b/ui/src/api/post.ts index 9d9ec06..b4f4ed4 100644 --- a/ui/src/api/post.ts +++ b/ui/src/api/post.ts @@ -7,11 +7,11 @@ import { UseQueryResult } from 'react-query'; import {useApiRequest} from "./apiRequest.ts"; -import {Post, PostApiResult, postFromApiResult} from "../common/models/post.ts"; +import {Post, PostResponseDTO, postFromApiResult, PostResponse, PostDTO} from '../common/models/post.ts'; import {useAuth} from './AuthContext.tsx'; import {useSearchParams} from 'react-router-dom'; -export function usePosts(): UseQueryResult { +export function usePosts(): UseQueryResult { const [searchParams, _] = useSearchParams(); const { token } = useAuth() ?? {}; const apiRequest = useApiRequest(); @@ -23,10 +23,15 @@ export function usePosts(): UseQueryResult { return useQuery( ["posts", "list", searchParams.toString() ?? ""], () => { - return apiRequest(url, {method: "GET", authToken: token}); + return apiRequest(url, {method: "GET", authToken: token}); }, { - select: (posts: PostApiResult[]) => posts.map(postFromApiResult), + select: (result: PostResponseDTO) => { + return { + posts: result.posts.map(postFromApiResult), + pagination: result.pagination + } + }, } ); } @@ -105,7 +110,7 @@ export function useFavouritePostMutation( const method = variables.isFavourite ? "POST" : "DELETE"; delete variables.isFavourite; // Don't submit this field, it's only used in the UI - const result = await apiRequest("/favourites", { + const result = await apiRequest("/favourites", { method: method, body: variables, }); diff --git a/ui/src/common/models/post.ts b/ui/src/common/models/post.ts index 8b12693..aa1b263 100644 --- a/ui/src/common/models/post.ts +++ b/ui/src/common/models/post.ts @@ -18,13 +18,23 @@ export interface Post { deletedAt?: Date; } -export type PostApiResult = Omit & { +export type PostDTO = Omit & { createdAt: string; updatedAt?: string; deletedAt?: string; }; -export function postFromApiResult(input: PostApiResult): Post { +export type PostResponseDTO = { + posts: PostDTO[]; + pagination: {current: number, total: number}; +} + +export type PostResponse = { + posts: Post[]; + pagination: {current: number, total: number}; +} + +export function postFromApiResult(input: PostDTO): Post { return { ...input, createdAt: new Date(input.createdAt), diff --git a/ui/src/pages/home/Home.tsx b/ui/src/pages/home/Home.tsx index 05d617d..4a1e2d7 100644 --- a/ui/src/pages/home/Home.tsx +++ b/ui/src/pages/home/Home.tsx @@ -7,6 +7,7 @@ import {Onboarding} from "./components/Onboarding.tsx"; import {SiteIntro} from "./components/SiteIntro.tsx"; import {Post} from '../../common/models/post.ts'; import {usePosts} from '../../api/post.ts'; +import {iiicon} from '../../common/utils/iiicon.tsx'; export const Home: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); @@ -25,28 +26,94 @@ export const Home: React.FC = () => { - {posts?.data?.length - ? - : + {posts?.data?.pagination + ? <> +
+

Page {posts.data.pagination.current}

+ + : <> } + +
+ {posts?.data?.posts?.length + ? + : + } +

 

+
+ + {posts?.data?.pagination ? : <>} ) } +const PaginationButtons: React.FC<{ + pagination: { + current: number; + total: number; + } +}> = ({pagination}) => { + const [_, setSearchParams] = useSearchParams(); + + const currentPage = pagination.current + const maxPage = pagination.total + + const movePage = (diff: number) => { + setTimeout(() => { + document.getElementById('search-results')!.scrollIntoView({behavior: 'smooth'}) + }, 100) + console.log("Smooth scroll!") + + setSearchParams(params => { + const newPage = currentPage <= maxPage ? Math.max(1, currentPage + diff) : maxPage; + params.set("page", newPage.toString()) + return params + }) + } + + const buttonClass = "w-[140px] h-[60px] py-2 border-2 border-theme-l-7 disabled:border-gray-500 rounded-xl font-bold text-center cursor-pointer" + return ( +
+ + + +
+ ) +} + const PostsToDisplay: React.FC<{posts: Post[]}> = ({posts}) => { return ( -
{posts.map(post => )}
+ <> +
{posts.map(post => )}
+ ) } -const NoPostsToDisplay: React.FC<{isViewingBookmarks: boolean}> = ({isViewingBookmarks}) => { +const NoPostsToDisplay: React.FC<{ + isLoading: boolean; + isViewingBookmarks: boolean; +}> = ({isLoading, isViewingBookmarks}) => { + if (isLoading) { + return ( +

Loading, please wait...

+ ) + } + if (isViewingBookmarks) { return ( -

You don't have any bookmarked posts

+

No bookmarked posts available.

) } return ( -

Please wait...

+

No posts available.

) } \ No newline at end of file diff --git a/ui/src/pages/home/components/SearchForm.tsx b/ui/src/pages/home/components/SearchForm.tsx index 3f21ce0..e922f02 100644 --- a/ui/src/pages/home/components/SearchForm.tsx +++ b/ui/src/pages/home/components/SearchForm.tsx @@ -92,7 +92,7 @@ export const SearchForm: React.FC<{
-

Search results

+

Search results

diff --git a/ui/src/pages/home/components/SearchFormWrapper.tsx b/ui/src/pages/home/components/SearchFormWrapper.tsx index f792679..5989229 100644 --- a/ui/src/pages/home/components/SearchFormWrapper.tsx +++ b/ui/src/pages/home/components/SearchFormWrapper.tsx @@ -23,6 +23,10 @@ export const SearchFormWrapper: React.FC<{ delete formattedValues.timezoneEnd } + // When the form changes, reset the pagination back to page=1 + // Otherwise adding filtering leaves you on page=lots with no posts to view + formattedValues.page = "1" + // @ts-ignore setSearchParams(formattedValues) } diff --git a/ui/src/pages/home/models/SearchParameters.ts b/ui/src/pages/home/models/SearchParameters.ts index 5d0fa12..4c5866c 100644 --- a/ui/src/pages/home/models/SearchParameters.ts +++ b/ui/src/pages/home/models/SearchParameters.ts @@ -8,31 +8,34 @@ export type SearchParameters = { timezoneEnd: string | undefined; sortBy: string; sortDir: string; + page: string; } export const blankSearchParameters: SearchParameters = { - description: "", + description: '', skillsPossessed: [], skillsSought: [], tools: [], languages: [], timezoneStart: undefined, timezoneEnd: undefined, - sortBy: "", - sortDir: "", + sortBy: '', + sortDir: '', + page: '1', } export const searchParametersFromQueryString = (queryParams: URLSearchParams): SearchParameters => { return { ...blankSearchParameters, - description: queryParams.get("description"), - skillsPossessed: queryParams.get('skillsPossessed')?.split(","), - skillsSought: queryParams.get('skillsSought')?.split(","), - tools: queryParams.get('tools')?.split(","), - languages: queryParams.get('languages')?.split(","), - timezoneStart: queryParams.get("timezoneStart"), - timezoneEnd: queryParams.get("timezoneEnd"), + description: queryParams.get('description'), + skillsPossessed: queryParams.get('skillsPossessed')?.split(','), + skillsSought: queryParams.get('skillsSought')?.split(','), + tools: queryParams.get('tools')?.split(','), + languages: queryParams.get('languages')?.split(','), + timezoneStart: queryParams.get('timezoneStart'), + timezoneEnd: queryParams.get('timezoneEnd'), sortBy: queryParams.get('sortBy'), sortDir: queryParams.get('sortDir'), + page: queryParams.get('page'), } as SearchParameters } \ No newline at end of file