diff --git a/gymboard-app/README.md b/gymboard-app/README.md index 84dec1e..cc70479 100644 --- a/gymboard-app/README.md +++ b/gymboard-app/README.md @@ -1,8 +1,12 @@ -# Gymboard App (gymboard-app) +# Gymboard App -Web app for Gymboard +The responsive web application for Gymboard. This is a Vue 3 + Quasar project that's written in Typescript. -## Install the dependencies +## Developing + +In order to develop this app, you'll most likely want to start a local instance of each Gymboard service that it relies on. Each service should expose a `./start-dev.sh` script that you can call to quickly boot it up, but refer to each service's README for more detailed information, like generating sample data. + +### Install the dependencies ```bash yarn @@ -41,3 +45,12 @@ quasar build ### Customize the configuration See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js). + +## Structure + +Within `src/`, there are a few key directories whose functions are listed below: + +- `src/api/` contains a Typescript client implementation for consuming the various APIs from Gymboard services such as the CDN, Search, and main API. The main API (`src/api/main/index.ts`) can be imported with `import api from 'src/api/main';`. While the other APIs may differ based on their usage, generally the main API is structured as a hierarchy of classes with `readonly` properties to act as namespaces. +- `src/components/` contains auxiliary Vue components that help in building pages. +- `src/pages/` contains all the pages as Vue components. +- `src/i18n` contains translation files as JSON objects for all supported languages. diff --git a/gymboard-app/src/api/infinite-page-loader.ts b/gymboard-app/src/api/infinite-page-loader.ts new file mode 100644 index 0000000..437a911 --- /dev/null +++ b/gymboard-app/src/api/infinite-page-loader.ts @@ -0,0 +1,73 @@ +import {defaultPaginationOptions, Page, PaginationOptions} from 'src/api/main/models'; +import {isScrolledToBottom} from 'src/utils'; +import {Ref} from 'vue'; + +export type PageFetcherFunction = (paginationOptions: PaginationOptions) => Promise | undefined>; + +/** + * A class that manages an "infinite loading" list of elements from a paginated + * API endpoint. The loader will use a supplied function to load more pages + * when the user reaches the bottom of the page or if pagination options are + * updated. + * + * This loader should be given a reference to an array of elements that can be + * updated, to update a reactive Vue display. + */ +export default class InfinitePageLoader { + private paginationOptions: PaginationOptions = defaultPaginationOptions(); + private latestPage: Page | null = null; + private readonly elements: Ref; + private readonly pageFetcher: PageFetcherFunction; + private fetching = false; // Internal flag used to make sure only one fetch operation happens at a time. + + /** + * Constructs the loader with a reference to a list of elements to manage, + * and a fetcher function for fetching pages. + * @param elements A reference to a reactive list of elements to manage. + * @param pageFetcher A function for fetching pages of elements. + */ + public constructor(elements: Ref, pageFetcher: PageFetcherFunction) { + this.elements = elements; + this.pageFetcher = pageFetcher; + } + + /** + * Sets the pagination options for this loader. Doing so will reset the + * loader's state and means that it'll reload elements from the start. + * @param opts The pagination options to apply. + */ + public async setPagination(opts: PaginationOptions) { + this.paginationOptions = opts; + this.elements.value.length = 0; + while (this.shouldFetchNextPage()) { + await this.loadNextPage(); + } + } + + /** + * Registers a window scroll listener that will try to fetch more elements + * when the user has scrolled to the bottom of the page. + */ + public registerWindowScrollListener() { + window.addEventListener('scroll', async () => { + if (this.shouldFetchNextPage()) { + await this.loadNextPage(); + } + }); + } + + private shouldFetchNextPage(): boolean { + return !this.fetching && isScrolledToBottom(10) && (!this.latestPage || !this.latestPage.last); + } + + private async loadNextPage() { + this.fetching = true; + const page = await this.pageFetcher(this.paginationOptions); + if (page) { + this.latestPage = page; + this.elements.value.push(...this.latestPage.content); + this.paginationOptions.page++; + } + this.fetching = false; + } +}; diff --git a/gymboard-app/src/api/main/auth.ts b/gymboard-app/src/api/main/auth.ts index 17e7686..079dd14 100644 --- a/gymboard-app/src/api/main/auth.ts +++ b/gymboard-app/src/api/main/auth.ts @@ -2,6 +2,7 @@ import { api } from 'src/api/main/index'; import { AuthStoreType } from 'stores/auth-store'; import Timeout = NodeJS.Timeout; import { WeightUnit } from 'src/api/main/submission'; +import {Page, PaginationOptions, toQueryParams} from "src/api/main/models"; export interface User { id: string; @@ -228,27 +229,23 @@ class AuthModule { public async getFollowers( userId: string, authStore: AuthStoreType, - page: number, - count: number - ): Promise { - const response = await api.get( - `/auth/users/${userId}/followers?page=${page}&count=${count}`, - authStore.axiosConfig - ); - return response.data.content; + paginationOptions: PaginationOptions + ): Promise> { + const config = structuredClone(authStore.axiosConfig); + config.params = toQueryParams(paginationOptions); + const response = await api.get(`/auth/users/${userId}/followers`, config); + return response.data; } public async getFollowing( userId: string, authStore: AuthStoreType, - page: number, - count: number - ): Promise { - const response = await api.get( - `/auth/users/${userId}/following?page=${page}&count=${count}`, - authStore.axiosConfig - ); - return response.data.content; + paginationOptions: PaginationOptions + ): Promise> { + const config = structuredClone(authStore.axiosConfig); + config.params = toQueryParams(paginationOptions); + const response = await api.get(`/auth/users/${userId}/following`, config); + return response.data; } public async getRelationshipTo( @@ -283,12 +280,12 @@ class AuthModule { userId: string, reason: string, description: string | null, - authStore?: AuthStoreType + authStore: AuthStoreType ) { await api.post( `/auth/users/${userId}/reports`, { reason, description }, - authStore?.axiosConfig + authStore.axiosConfig ); } } diff --git a/gymboard-app/src/api/main/models.ts b/gymboard-app/src/api/main/models.ts index 812ef79..95ed83a 100644 --- a/gymboard-app/src/api/main/models.ts +++ b/gymboard-app/src/api/main/models.ts @@ -52,3 +52,19 @@ export enum PaginationSortDir { ASC = 'asc', DESC = 'desc' } + +export class PaginationHelpers { + static sortedBy(prop: string, dir: PaginationSortDir): PaginationOptions { + const opts = defaultPaginationOptions(); + opts.sort = { propertyName: prop, sortDir: dir }; + return opts; + } + + static sortedAscBy(prop: string): PaginationOptions { + return PaginationHelpers.sortedBy(prop, PaginationSortDir.ASC); + } + + static sortedDescBy(prop: string): PaginationOptions { + return PaginationHelpers.sortedBy(prop, PaginationSortDir.DESC); + } +} diff --git a/gymboard-app/src/api/main/users.ts b/gymboard-app/src/api/main/users.ts index fca5d10..b121a1f 100644 --- a/gymboard-app/src/api/main/users.ts +++ b/gymboard-app/src/api/main/users.ts @@ -12,7 +12,7 @@ class UsersModule { public async getSubmissions(userId: string, authStore: AuthStoreType, paginationOptions: PaginationOptions = defaultPaginationOptions()): Promise> { const config = structuredClone(authStore.axiosConfig); config.params = toQueryParams(paginationOptions); - const response = await api.get(`/users/${userId}/submissions`, {...toQueryParams(paginationOptions), ...authStore.axiosConfig}); + const response = await api.get(`/users/${userId}/submissions`, config); response.data.content = response.data.content.map(parseSubmission); return response.data; } diff --git a/gymboard-app/src/pages/gym/GymPage.vue b/gymboard-app/src/pages/gym/GymPage.vue index 4af5ab3..315f61b 100644 --- a/gymboard-app/src/pages/gym/GymPage.vue +++ b/gymboard-app/src/pages/gym/GymPage.vue @@ -16,7 +16,7 @@ diff --git a/gymboard-app/src/pages/user/UserFollowingPage.vue b/gymboard-app/src/pages/user/UserFollowingPage.vue index 3fc4720..8ba7e9e 100644 --- a/gymboard-app/src/pages/user/UserFollowingPage.vue +++ b/gymboard-app/src/pages/user/UserFollowingPage.vue @@ -13,6 +13,8 @@ import {User} from 'src/api/main/auth'; import {useAuthStore} from 'stores/auth-store'; import {onMounted, ref, Ref} from 'vue'; import api from 'src/api/main'; +import InfinitePageLoader from 'src/api/infinite-page-loader'; +import {defaultPaginationOptions} from 'src/api/main/models'; interface Props { userId: string; @@ -20,14 +22,16 @@ interface Props { const props = defineProps(); const authStore = useAuthStore(); const following: Ref = ref([]); - +const loader = new InfinitePageLoader(following, async paginationOptions => { + try { + return await api.auth.getFollowing(props.userId, authStore, paginationOptions); + } catch (error) { + console.log(error); + } +}); onMounted(async () => { - following.value = await api.auth.getFollowing( - props.userId, - authStore, - 0, - 10 - ); + loader.registerWindowScrollListener(); + await loader.setPagination(defaultPaginationOptions()); }); diff --git a/gymboard-app/src/pages/user/UserPage.vue b/gymboard-app/src/pages/user/UserPage.vue index 58efbe2..7d7db8b 100644 --- a/gymboard-app/src/pages/user/UserPage.vue +++ b/gymboard-app/src/pages/user/UserPage.vue @@ -1,3 +1,12 @@ +