Social site with React and  Pocketbase

Social site with React and Pocketbase

·

8 min read

building the timeline, replies and nested replies

Authentication

In this first part we'll implement user authentication with the pocketbase social OAUTH providers I'll use google and GitHub but they support a dozen more.

  1. Obtaining client id and client secret from the providers

    1. setting up GitHub OAUTH

    2. setting up google OAUTH

then enable the respective providers in the pocketbase admin dashboard

enabling GitHub OAUTH

enabling Google OAUTH

  1. frontend integration using the pocketbase client
const authData = await pb.collection('devs').authWithOAuth2(
    'google',
    'CODE',
    'VERIFIER',
    'REDIRECT_URL',
    // optional data that will be used for the new account on OAuth2 sign-up
    {
      'name': 'test',
    },
);

to get the required arguments we need to fetch the enabled providers

the start function

first we get some icons for the respective providers

import { TheIcon } from '@denniskinuthia/tiny-pkgs';
import { FaGithub,FaGoogle } from 'react-icons/fa'

const providerIcons={
github:FaGithub,
google:FaGoogle
}

then

const providers = await client.collection("devs").listAuthMethods()

initiate login function using:

const startLogin = (prov:ProvType) => {
   localStorage.setItem("provider",JSON.stringify(prov));
  const redirectUrl = "http://localhost:3000/redirect";
  const url = prov.authUrl + redirectUrl;
      // console.log("prov in button === ", prov)
      // console.log("combined url ==== >>>>>>  ",url)

    if (typeof window !== "undefined") {
      window.location.href = url;
    }
  };

note: the redirect URL should match what you provided in the setup process: once you've hosted your website you can use your actual domain instead of localhost

then we'll map over them and render out a button for each provider


    <div className="w-full h-fit md:h-full flex flex-wrap items-center justify-center gap-2 ">

      {provs &&
        provs?.map((item:any) => {
          return (
            <div
              key={item.name}
              onClick={() => startLogin(item)} 
              className="p-2 w-[50%] md:w-[30%] cursor-pointer
               bg-slate-600 rounded-lg hover:bg-slate-800 
             capitalize text-xl font-bold flex items-center justify-center gap-2"
            >
            <TheIcon
            iconstyle="" 
            Icon={providerIcons[item.name as keyof typeof providerIcons]}
            size={'30'}
            />
              {item.name}
            </div>
          );
        })}
    </div>

finally the redirect component

remember to define a route for it in your react router config

Note: I used client.autoCancellation(false) to avoid the OAUTH request getting auto cancelled in dev mode because of react strict mode

finally we can put in place route AUTH guards , I prefer to do it at the root layout level inside which every other route is nested

Click to expand Redirect.tsx

Redirect.tsx

import React, { useEffect } from

Note: I used client.autoCancellation(false) to avoid the OAUTH request getting auto canceled in dev mode because of react strict mode

finally, we can put in place route AUTH guards, I prefer to do it at the root layout level inside which every other route is nested

Click to expand RootLayout.tsx

RootLayout.tsx

import React from

complete code AUTH guarding

the timeline

extending pocketbase

useful references

requirements for this part:

First, we'll set up our pocketbase using the admin dashboard

this can also be done using migrations, pocketbase is introducing JavaScript migrations using GOJA , but current migrations documentation is not very good so I didn't spend too much time on them and opted for direct SQL commands instead I'll leave the repo link to the directory where I put all the experiments in .

using the pocketbase JS SDK we can query the posts collection to populate our timeline

import PocketBase from 'pocketbase';

const pb = new PocketBase('http://127.0.0.1:8090');

...

// fetch a paginated records list
const resultList = await pb.collection('posts').getList(1, 50, {
    filter: 'created >= "2022-01-01 00:00:00" && someFiled1 != someField2',
});

// you can also fetch all records at once via getFullList
const records = await pb.collection('posts').getFullList(200 /* batch size */, {
    sort: '-created',
});

// or fetch only the first record that matches the specified filter
const record = await pb.collection('posts').getFirstListItem('someField="test"', {
    expand: 'relField1,relField2.subRelField',
});

pocketbase admin panel has an API preview feature which is one of the best in the baas offerings

pocketbase admin panel

pocketbase admin panel

but the return data doesn't give us all the data at once

    {
      "id": "RECORD_ID",
      "collectionId": "vbse1l0qet8z4hu",
      "collectionName": "posts",
      "created": "2022-01-01 01:00:00.123Z",
      "updated": "2022-01-01 23:59:59.456Z",
      "title": "test",
      "body": "test",
      "media": "filename.jpg",
      "user": "RELATION_RECORD_ID"
    }

we do want to have the like count and information on whether the logged-in user like the current post on first glance before clicking on the post to see the tweet details

unfortunately, server-side aggregation isn't currently supported in pocketbase but is in the pipeline discussion

pocketbase does support expanding relations in an inner-join kind of way

expanding relations

but that still won't satisfy our needs so we'll have to go option 3 and open up pocketbase and use some of its exposed APIs

custom posts endpoint

btw that's the thunder client VSCODE extension that am using as my REST client

The initial request requires user: the logged-in user id and created: the latest date the rest can be sent as empty strings

const currentdate = dayjs(new Date()).format("[YYYYescape] YYYY-MM-DDTHH:mm:ssZ[Z]")

the subsequent requests will need id: the last record id in the previous request

At this point we can run

go build  *.go -o pocketbase

and get our custom backend in one executable executable

you can also use a build script

to build a Linux and windows executable

now that we have an endpoint we can create a timeline route and useInfiniteQuery to get our data

custom hook

import dayjs from "dayjs";
import { pb_url } from "../../utils/env";
import { PBUser } from "../../utils/types/types";
import { UseInfiniteQueryOptions, useInfiniteQuery } from "@tanstack/react-query";

interface PaginationDeps {
    pageParam: {
        created: string;
        id: string;
    };
}

export const useInfiniteCustom = <T>(
    key: string,
    user: PBUser,
    options?:
        | Omit<UseInfiniteQueryOptions<T[], unknown, T[], T[], string[]>, "queryKey" | "queryFn">
        | undefined
) => {
    // custom-posts uses a where clause to paginate and needs the current
    //date formatted in sqlite date format as the starting point
    const currentdate = dayjs(new Date()).format("[YYYYescape] YYYY-MM-DDTHH:mm:ssZ[Z]");

    const fetchPosts = async (deps?: Partial<PaginationDeps>) => {
        // console.log("page params dependaces === ", deps, deps.pageParam?.id)
        const url = `${pb_url}/custom_posts/?id=${deps?.pageParam?.id ?? ""}&user=${
        user?.id ?? ""}&created=${deps?.pageParam?.created ?? currentdate}`;
        let headersList = {
            Accept: "*/*"
        };
        try {
            const response = await fetch(url, {
                method: "GET",
                headers: headersList
            });
            const data = await response.json();
            console.log("response === ", data);
            if (data.code === 400) {
                throw new Error(data.message);
            }
            return data;
        } catch (e: any) {
            console.log("error fetching custom ", e);
            throw new Error(e.message);
        }
    };

   return useInfiniteQuery<T[], unknown, T[], string[]>(
        [key],
        fetchPosts,
        options
    );


};

then call it on our timeline component

import React from 'react'
import { CustomPostType, PBUser } from '../../utils/types/types';
import { useInView } from 'react-intersection-observer'
import { useInfiniteCustom } from '../../shared/hooks/useInfiniteCustom';
import { QueryStateWrapper } from './../../shared/wrappers/QueryStateWrapper';
import { FaPlus } from 'react-icons/fa';
import { TheIcon } from '../../shared/wrappers/TheIcon';
import { PostsCard } from './../../components/timeline/PostCard';
import { PostForm } from './../../components/timeline/PostForm';
import { ReactModalWrapper } from './../../shared/wrappers/ReactModalWrapper';
interface TimelineProps {
    user: PBUser
}

export const Timeline = ({user}: TimelineProps) => {
const { ref, inView } = useInView()
const [isOpen, setIsOpen] = React.useState(false);

const customPostsQuery = useInfiniteCustom<CustomPostType>('custom-posts',user,{
    getNextPageParam: (lastPage, allPages) => {
        // console.log("last page ==== ",lastPage,allPages)
        if (lastPage && lastPage[lastPage.length - 1]) {
            return {
             created: lastPage[lastPage?.length - 1]?.created_at,
             id: lastPage[lastPage?.length - 1]?.post_id
            };
        }
        return;
    }
})

 React.useEffect(() => {
    if (inView) {
        customPostsQuery.fetchNextPage()
    }
}, [inView])

const data = customPostsQuery.data
// console.log("custom query === ",data)
return (
<QueryStateWrapper query={customPostsQuery}>
    <div className='w-full min-h-full  flex flex-col gap-2 items-center justify-center'>
        <div className='w-[95%] h-full flex flex-col items-center justify-center gap-2 py-2'>
            {data?.pages.map((page) => {
                    // console.log("page=== ",page)
                    return page.map((item) => {
                        return <PostsCard item={item} key={item.post_id} user={user} />
                    })

                })
                }
        </div>

    <div className='w-fit h-fit p-2 bg-slate-500 text-white rounded-full fixed bottom-[10%] right-[5%]'>
            <TheIcon Icon={FaPlus} size={'40'} iconAction={() => setIsOpen(true)} />
        </div>

            <ReactModalWrapper
                child={
                <PostForm user={user} setIsOpen={setIsOpen} />}
                closeModal={() => setIsOpen(false)}
                delay={2}
                isOpen={isOpen}
                styles={{
                    overlay_top: '0%',
                    overlay_right: '0%',
                    overlay_left: '0%',
                    overlay_bottom: '0%',
                    content_bottom: '2%',
                    content_right: '2%',
                    content_left: '2%',
                    content_top: '2%'

                }}/>

            <div>
        <button ref={ref}
            onClick={() => customPostsQuery.fetchNextPage()}
            disabled={!customPostsQuery.hasNextPage || customPostsQuery.isFetchingNextPage}>
                {customPostsQuery.isFetchingNextPage ? 'Loading more...': customPostsQuery.hasNextPage ? 'Load More'
                : !customPostsQuery.isLoading ? 'Nothing more to load' : null}</button>
            </div>

        </div>
    </QueryStateWrapper>
);
}

The initial request requires user: the logged in user id and created: the latest date the rest can be sent as empty strings

const currentdate = dayjs(new Date()).format("[YYYYescape] YYYY-MM-DDTHH:mm:ssZ[Z]")

useful references

replies and nested sub replies

After changing up the backend layout and removing the replies table and just saving everything in the posts table with different depth levels to differentiate original posts from replies and also easily represent all the levels of nested replies

the initial request n the timeline will have null parent field and depth as 0

http://127.0.0.1:8090/custom_posts?id=undefined&depth=0&parent=&user=v7o41qltt4ttyy&created=YYYYescape+2023-01-16T06:47:26+03:00Z

and the replies /sub replies will require a parent: the id of the post/reply t fetch replies for

http://127.0.0.1:8090/custom_replies?id=undefined&depth=1&parent=1o599tvtue3ghwa&user=6wdrg4lavbr&created=YYYYescape+2023-01-16T06:47:26+03:00Z

we can now covert the routing strategy in this app is to have this little slice be responsible for all the posts , replies and sub replies

To like/comment on the post without triggering the navigate event add a stop propagation to the parent with the click event to stop the event from propagating to the parent component

<div  onClick={(event) => event.stopPropagation()}>
        ....
</div>

The Post.tsx component will fetch the posts and fetch all associated replies

the depth searchParam and id queryParam will allow us to use this component recursively and open any clicked on reply as a post

instinctively I tried using

    navigate({
    pathname: 'post/' + item.post_id,
    search: createSearchParams({
    depth:(item.post_depth===""?0:item.post_depth).toString()
    }).toString()
    })

but in a result in a URL that looks like

http://localhost:3000/post/0radtgd42swe3n8?depth=0/post/0radtdtdtdtn8?depth=1

so we have to hop back a route and pass in a new id into the post id param to get it to work

navigate({
pathname: '../' + item.post_id,
search: createSearchParams({
   depth: (item.post_depth === "" ? 0 : item.post_depth).toString()
   }).toString(),
   },
   )

To get

http://localhost:3000/post/0radtdtdtdtn8?depth=1

helpful resources

react router v6 features