diff --git a/src/app/(site)/app/components/feed/index.tsx b/src/app/(site)/app/components/feed/index.tsx index c9e812f..c33a748 100644 --- a/src/app/(site)/app/components/feed/index.tsx +++ b/src/app/(site)/app/components/feed/index.tsx @@ -1,43 +1,85 @@ -import { getSuapabaseServerComponent } from "@/supabase/models/index.models"; +"use client"; + import { Heading } from "@/components/ui/typography/heading"; -import { PostsGrid } from "@/components/feature/posts-grid"; -import { Suspense } from "react"; -import { Skeleton } from "@/components/ui/skeleton"; +import { useEffect, useState } from "react"; +import { useSupabase } from "@/hooks/use-supabase"; +import { Database } from "@/supabase/types"; +import { PostsGrid } from "@/components/feature/posts-grid/posts-grid.component"; +import { PostsGridSkeleton } from "@/components/feature/posts-grid/components/posts-grid-skeleton"; + +export function Feed() { + const { supabase } = useSupabase(); + + const [posts, setPosts] = useState< + Database["public"]["Tables"]["posts"]["Row"][] + >([]); + + const [error, setError] = useState<boolean>(false); + + const [isLoading, setIsLoading] = useState<boolean>(true); + const [isFetching, setIsFetching] = useState<boolean>(false); + + const [lastPostIndex, setLastPostIndex] = useState<number>(1); + + useEffect(() => { + const controller = new AbortController(); + + try { + setIsLoading(true); + setIsFetching(true); + + supabase + .from("posts") + .select("*") + .order("id", { ascending: false }) + .limit(32) + .range(lastPostIndex, lastPostIndex + 32) + .abortSignal(controller.signal) + .then(({ data: posts, error }) => { + if (error) return; + + if (!posts) return; + + setPosts((prev) => [...prev, ...posts]); + }); + } catch (error: unknown) { + if ((error as Error).name === "AbortError") return; + + setError(true); + } finally { + setIsLoading(false); + setIsFetching(false); + } -export async function Feed() { - const supabase = await getSuapabaseServerComponent(); + return () => { + controller.abort(); + }; - const { data: posts, error } = await supabase - .from("posts") - .select("*") - .order("id", { ascending: false }) - .limit(24); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastPostIndex]); - const doesPostsExist = posts && posts.length > 0 && !error; + function handleScroll() { + setLastPostIndex((prev) => prev + 32); + } return ( - <> - {doesPostsExist ? ( - <main className="w-full h-full px-2"> - <Suspense - fallback={ - <ul className="break-inside-avoid gap-2 px-2 [column-count:3] md:[column-count:3]"> - {Array(16) - .fill(" ") - .map((_, i) => ( - <Skeleton key={i} className="w-full h-96 mb-2" /> - ))} - </ul> - } - > - <PostsGrid posts={posts} /> - </Suspense> - </main> - ) : ( - <article className="flex items-center justify-center w-full max-h-96 py-16 text-center border-y border-neutral-300"> - <Heading level={7}>Something wen wrong, reload the page</Heading> - </article> + <main className="w-full h-full flex flex-col gap-2"> + {isLoading && <PostsGridSkeleton cuantity={32} />} + {!isLoading && !isFetching && ( + <> + {!error ? ( + <div> + <PostsGrid posts={posts} onFetchNewPosts={handleScroll} /> + </div> + ) : ( + <article className="flex items-center justify-center w-full max-h-96 py-32 text-center border-y border-neutral-300"> + <Heading level={10}> + Something wen wrong, please reload the page + </Heading> + </article> + )} + </> )} - </> + </main> ); } diff --git a/src/app/(site)/app/layout.tsx b/src/app/(site)/app/layout.tsx index 0593fc6..2c82e52 100644 --- a/src/app/(site)/app/layout.tsx +++ b/src/app/(site)/app/layout.tsx @@ -13,7 +13,7 @@ export default function AppLayout({ children }: { children: ReactNode }) { <div className="md:hidden w-full flex fixed left-0 bottom-0 z-50"> <MobileNavMenu /> </div> - <div className="h-full w-full min-h-screen border-x border-neutral-300 dark:border-cm-lighter-gray"> + <div className="h-full px-2 w-full min-h-screen border-x border-neutral-300 dark:border-cm-lighter-gray"> {children} </div> <SyncTheme /> diff --git a/src/app/(site)/app/page.tsx b/src/app/(site)/app/page.tsx index 870165c..270338d 100644 --- a/src/app/(site)/app/page.tsx +++ b/src/app/(site)/app/page.tsx @@ -21,22 +21,8 @@ export default function AppPage() { function DesktopLayout() { return ( - <main className="w-full h-full flex flex-col justify-start py-2"> - <Suspense - fallback={ - <ul className="w-full flex flex-wrap gap-2"> - {Array(8) - .fill("") - .map((_, i) => ( - <li key={i}> - <Skeleton className="w-full bg-red-500 rounded-md h-[500px]" /> - </li> - ))} - </ul> - } - > - <Feed /> - </Suspense> + <main className="w-full h-full flex flex-col justify-start"> + <Feed /> </main> ); } diff --git a/src/app/(site)/app/post/[postid]/components/post/components/options/index.tsx b/src/app/(site)/app/post/[postid]/components/post/components/options/index.tsx index c36b1fc..f4c24e1 100644 --- a/src/app/(site)/app/post/[postid]/components/post/components/options/index.tsx +++ b/src/app/(site)/app/post/[postid]/components/post/components/options/index.tsx @@ -45,7 +45,6 @@ export function PostOptions({ return router.push("/app"); // Navigate to '/app' if no history } } - router.refresh(); } catch (e) { console.log(e); } diff --git a/src/app/(site)/app/post/[postid]/components/post/index.tsx b/src/app/(site)/app/post/[postid]/components/post/index.tsx index e594e6e..b09c154 100644 --- a/src/app/(site)/app/post/[postid]/components/post/index.tsx +++ b/src/app/(site)/app/post/[postid]/components/post/index.tsx @@ -22,48 +22,47 @@ export async function Post({ .single(); return ( - <article className="w-full h-full flex lg:gap-4 gap-2 flex-col-reverse lg:flex-row-reverse items-start justify-end bg-neutral-200 dark:bg-neutral-900 overflow-hidden"> - <header className="flex items-center justify-between w-96"> - <section className="w-full flex flex-col items-start justify-center gap-4"> - <PostOptions - post_id={post.id} - doesUserOwnPost={doesUserOwnPost} - image_url={post.asset_url} - /> - <h3 className="w-full text-neutral-800 dark:text-neutral-300 font-bold text-2xl"> - {title} - </h3> - {postOwnerProfile && ( - <Link - href={`/app/profile/${postOwnerProfile.username}`} - className="w-full" - > - <section className="flex flex-none gap-4 w-full items-center justify-start"> - {postOwnerProfile.avatar_url ? ( - <LazyImage - src={postOwnerProfile.avatar_url} - alt={post.title} - className="w-12 h-12 rounded-full object-cover object-center flex-0" - skeletonClassName="w-12 h-12 rounded-full" - width={48} - height={48} - /> - ) : ( - <div className="h-12 w-12 rounded-full bg-neutral-300" /> - )} - <h3 className="text-neutral-800 dark:text-neutral-300 font-semibold text-xl"> - {postOwnerProfile.name} - </h3> - </section> - </Link> - )} - </section> + <article className="w-full h-full flex flex-col-reverse gap-2 items-start justify-end bg-neutral-200 dark:bg-neutral-900"> + <header className="flex flex-col items-start justify-center gap-2 w-max xl:min-w-64 min-w-full"> + <h3 className="w-full text-neutral-800 dark:text-neutral-300 font-bold text-2xl"> + {title} + </h3> + {postOwnerProfile && ( + <Link + href={`/app/profile/${postOwnerProfile.username}`} + className="w-full" + > + <section className="flex gap-2 w-full justify-start items-center"> + {postOwnerProfile.avatar_url ? ( + <LazyImage + src={postOwnerProfile.avatar_url} + alt={post.title} + className="w-12 h-12 rounded-full object-cover object-center flex-0" + skeletonClassName="w-12 h-12 rounded-full" + width={48} + height={48} + /> + ) : ( + <div className="h-12 w-12 rounded-full bg-neutral-300" /> + )} + <h3 className="text-neutral-800 dark:text-neutral-300 font-semibold text-xl"> + {postOwnerProfile.name} + </h3> + </section> + </Link> + )} + <PostOptions + post_id={post.id} + doesUserOwnPost={doesUserOwnPost} + image_url={post.asset_url} + /> </header> <LazyImage src={asset_url} alt={title} - className="w-full h-full max-h-[100vh] lg:max-h-[80vh] object-cover object-center rounded-md" - skeletonClassName="w-full" + className="w-full h-full max-h-[80vh] object-cover object-center rounded-md" + skeletonClassName="w-full h-96 rounded-md" + containerClassname="w-full" skeletonBgColor={post.asset_color || undefined} /> </article> diff --git a/src/app/(site)/app/post/[postid]/components/recent-posts/index.ts b/src/app/(site)/app/post/[postid]/components/recent-posts/index.ts new file mode 100644 index 0000000..04da9a5 --- /dev/null +++ b/src/app/(site)/app/post/[postid]/components/recent-posts/index.ts @@ -0,0 +1 @@ +export * from "./recent-pots.component"; diff --git a/src/app/(site)/app/post/[postid]/components/recent-posts/recent-pots.component.tsx b/src/app/(site)/app/post/[postid]/components/recent-posts/recent-pots.component.tsx new file mode 100644 index 0000000..9902630 --- /dev/null +++ b/src/app/(site)/app/post/[postid]/components/recent-posts/recent-pots.component.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { PostsGridSkeleton } from "@/components/feature/posts-grid/components/posts-grid-skeleton"; +import { PostsGrid } from "@/components/feature/posts-grid/posts-grid.component"; +import { Heading } from "@/components/ui/typography/heading"; +import { useSupabase } from "@/hooks/use-supabase"; +import { Database } from "@/supabase/types"; +import { useEffect, useState } from "react"; + +export function RecentPosts({ + excludedPostId, +}: { + excludedPostId: Database["public"]["Tables"]["posts"]["Row"]["id"]; +}) { + const [posts, setPosts] = useState< + Database["public"]["Tables"]["posts"]["Row"][] + >([]); + + const [isLoading, setIsLoading] = useState<boolean>(true); + const [isFetching, setIsFetching] = useState<boolean>(false); + + const [lastPostIndex, setLastPostIndex] = useState<number>(1); + const [error, setError] = useState<boolean>(false); + + const { supabase } = useSupabase(); + + useEffect(() => { + const controller = new AbortController(); + + try { + setIsLoading(true); + setIsFetching(true); + + supabase + .from("posts") + .select("*") + .order("created_at", { ascending: false }) + .limit(32) + .range(lastPostIndex, lastPostIndex + 32) + .abortSignal(controller.signal) + .then(({ data: posts, error }) => { + if (error) return; + + if (!posts) return; + + setPosts((prev) => [ + ...prev, + ...posts.filter((post) => post.id !== excludedPostId), + ]); + }); + } catch (error: unknown) { + if ((error as Error).name === "AbortError") return; + + setError(true); + } finally { + setIsLoading(false); + setIsFetching(false); + } + + return () => { + controller.abort(); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastPostIndex]); + + function handleScroll() { + if (isLoading || isFetching) return; + + setLastPostIndex((prev) => prev + 32); + } + + return ( + <section> + {isLoading && <PostsGridSkeleton cuantity={32} />} + {!isLoading && !isFetching && ( + <> + {!error ? ( + <div> + <PostsGrid posts={posts} onFetchNewPosts={handleScroll} /> + </div> + ) : ( + <article className="flex items-center justify-center w-full max-h-96 py-32 text-center border-y border-neutral-300"> + <Heading level={10}> + Something wen wrong, please reload the page + </Heading> + </article> + )} + </> + )} + </section> + ); +} diff --git a/src/app/(site)/app/post/[postid]/page.tsx b/src/app/(site)/app/post/[postid]/page.tsx index b35b0d1..898380d 100644 --- a/src/app/(site)/app/post/[postid]/page.tsx +++ b/src/app/(site)/app/post/[postid]/page.tsx @@ -1,9 +1,8 @@ import { BackwardsNav } from "@/components/feature/nav/backwards"; import { Heading } from "@/components/ui/typography/heading"; import { getSuapabaseServerComponent } from "@/supabase/models/index.models"; -import { PostsGrid } from "@/components/feature/posts-grid"; -import { Database } from "@/supabase/types"; import { Post } from "./components/post"; +import { RecentPosts } from "./components/recent-posts"; export default async function PostPage({ params: { postid }, @@ -28,15 +27,15 @@ export default async function PostPage({ ); return ( - <div className="flex flex-col gap-2 px-2 pb-10"> - <nav className="w-full py-2 flex items-center gap-4 border-b border-neutral-300 dark:border-cm-lighter-gray"> + <div className="flex flex-col py-2 gap-2 pb-10"> + <nav className="w-full flex items-center gap-4 pb-2 border-b border-neutral-300 dark:border-cm-lighter-gray"> <BackwardsNav catchHref="/app" /> <Heading level={9}>Back</Heading> </nav> {postData && !postError ? ( - <section className="p-y2 flex flex-col gap-2"> + <section className="flex flex-col gap-2"> <Post post={postData} doesUserOwnPost={doesUserOwnPost} /> - <RecentPosts excludePost={postData} /> + <RecentPosts excludedPostId={postData.id} /> </section> ) : ( <Error404Box /> @@ -45,31 +44,6 @@ export default async function PostPage({ ); } -async function RecentPosts({ - excludePost, -}: { - excludePost: Database["public"]["Tables"]["posts"]["Row"]; -}) { - const supabase = await getSuapabaseServerComponent(); - - const { data: posts, error } = await supabase - .from("posts") - .select("*") - .order("created_at", { ascending: false }) - .limit(9); - - if (!posts || posts.length === 0) return; - - const filteredPosts = posts.filter((post) => post.id !== excludePost.id); - return ( - <> - {!error && - filteredPosts.length !== undefined && - filteredPosts.length > 0 && <PostsGrid posts={filteredPosts} />} - </> - ); -} - function Error404Box() { return ( <div className="w-full flex flex-col gap-2 items-center justify-center py-32 border-y border-neutral-300 dark:border-cm-lighter-gray"> diff --git a/src/app/(site)/app/profile/[username]/components/user-posts/index.ts b/src/app/(site)/app/profile/[username]/components/user-posts/index.ts new file mode 100644 index 0000000..b5e137b --- /dev/null +++ b/src/app/(site)/app/profile/[username]/components/user-posts/index.ts @@ -0,0 +1 @@ +export * from "./user-posts.component"; diff --git a/src/app/(site)/app/profile/[username]/components/user-posts/user-posts.component.tsx b/src/app/(site)/app/profile/[username]/components/user-posts/user-posts.component.tsx new file mode 100644 index 0000000..d2e0caf --- /dev/null +++ b/src/app/(site)/app/profile/[username]/components/user-posts/user-posts.component.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { PostsGridSkeleton } from "@/components/feature/posts-grid/components/posts-grid-skeleton"; +import { PostsGrid } from "@/components/feature/posts-grid/posts-grid.component"; +import { Heading } from "@/components/ui/typography/heading"; +import { useSupabase } from "@/hooks/use-supabase"; +import { Database } from "@/supabase/types"; +import { useEffect, useState } from "react"; + +export function UserPosts({ profileId }: { profileId: string }) { + const { supabase } = useSupabase(); + + const [posts, setPosts] = useState< + Database["public"]["Tables"]["posts"]["Row"][] + >([]); + + const [error, setError] = useState<boolean>(false); + + const [isLoading, setIsLoading] = useState<boolean>(true); + const [isFetching, setIsFetching] = useState<boolean>(false); + + const [lastPostIndex, setLastPostIndex] = useState<number>(1); + + const [userHasNoMorePosts, setUserHasNoMorePosts] = useState<boolean>(false); + + useEffect(() => { + if (userHasNoMorePosts) return; + + const controller = new AbortController(); + + try { + setIsLoading(true); + setIsFetching(true); + + supabase + .from("posts") + .select("*") + .eq("profile_id", profileId) + .order("id", { ascending: false }) + .limit(32) + .range(lastPostIndex, lastPostIndex + 32) + .abortSignal(controller.signal) + .then(({ data: posts, error }) => { + if (error) return; + + if (!posts) return; + + if (posts.length === 0) setUserHasNoMorePosts(true); + + setPosts((prev) => [...prev, ...posts]); + }); + } catch (error: unknown) { + if ((error as Error).name === "AbortError") return; + + setError(true); + } finally { + setIsLoading(false); + setIsFetching(false); + } + + return () => { + controller.abort(); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastPostIndex]); + + function handleScroll() { + setLastPostIndex((prev) => prev + 32); + } + + return ( + <main className="w-full h-full flex flex-col gap-2"> + {isLoading && <PostsGridSkeleton cuantity={32} />} + {!isLoading && !isFetching && ( + <> + {!error ? ( + <div> + <PostsGrid posts={posts} onFetchNewPosts={handleScroll} /> + </div> + ) : ( + <article className="flex items-center justify-center w-full max-h-96 py-32 text-center border-y border-neutral-300"> + <Heading level={10}> + Something wen wrong, please reload the page + </Heading> + </article> + )} + </> + )} + </main> + ); +} diff --git a/src/app/(site)/app/profile/[username]/page.tsx b/src/app/(site)/app/profile/[username]/page.tsx index 7d549aa..b8ac5a9 100644 --- a/src/app/(site)/app/profile/[username]/page.tsx +++ b/src/app/(site)/app/profile/[username]/page.tsx @@ -1,13 +1,14 @@ +import { CalendarIcon, LinkIcon, MapPinIcon } from "lucide-react"; +import Link from "next/link"; +import { Suspense } from "react"; + import { LazyImage } from "@/components/feature/lazy-image"; import { BackwardsNav } from "@/components/feature/nav/backwards"; -import { PostsGrid } from "@/components/feature/posts-grid"; import { Skeleton } from "@/components/ui/skeleton"; import { Heading } from "@/components/ui/typography/heading"; import { getSuapabaseServerComponent } from "@/supabase/models/index.models"; import { Database } from "@/supabase/types"; -import { CalendarIcon, LinkIcon, MapPinIcon } from "lucide-react"; -import Link from "next/link"; -import { Suspense } from "react"; +import { UserPosts } from "./components/user-posts"; export default function ProfilePage({ params: { username }, @@ -36,122 +37,112 @@ async function UserProfile({ username }: { username: string }) { data: { user: user }, } = await supabase.auth.getUser(); - const { data: userPosts } = await supabase - .from("posts") - .select("*") - .eq("user_id", userProfile?.user_id || "") - .order("created_at", { ascending: false }) - .limit(24); + return ( + <div className="w-full h-full"> + {doesUserProfileExist && userProfile ? ( + <Profile profile={userProfile} currentUserId={user?.id || ""} /> + ) : ( + <UserProfileNotFound /> + )} + </div> + ); +} - function Profile({ - data, - userId, - userPosts, - }: { - data: Database["public"]["Tables"]["profiles"]["Row"]; - userId: Database["public"]["Tables"]["users"]["Row"]["id"]; - userPosts: Database["public"]["Tables"]["posts"]["Row"][]; - }) { - const dateWhenUserJoined = data.created_at - ? new Date(data.created_at).getFullYear() - : null; +function UserProfileNotFound() { + return <>404</>; +} - return ( - <div className="flex flex-col gap-4 pt-4"> - <nav className="w-full flex gap-2 px-2"> - <BackwardsNav catchHref="/app" /> - <div className="w-full flex items-center justify-center px-2"> - <Heading level={9}>{data?.name}</Heading> - </div> - </nav> - <header> - <div className="w-full relative mb-16"> +function Profile({ + profile, + currentUserId, +}: { + profile: Database["public"]["Tables"]["profiles"]["Row"]; + currentUserId: Database["public"]["Tables"]["users"]["Row"]["id"]; +}) { + const dateWhenUserJoined = profile.created_at + ? new Date(profile.created_at).getFullYear() + : null; + + return ( + <div className="flex flex-col gap-4 pt-4"> + <nav className="w-full flex gap-2 px-2"> + <BackwardsNav catchHref="/app" /> + <div className="w-full flex items-center justify-center px-2"> + <Heading level={9}>{profile?.name}</Heading> + </div> + </nav> + <header> + <div className="w-full relative mb-16"> + <div className="h-56 w-full"> <LazyImage - src={data?.banner_url ?? ""} + src={profile?.banner_url ?? ""} className="w-full h-56 object-cover object-center" skeletonClassName="w-full h-56" - alt={data.name || data.username || ""} + alt={profile.name || profile.username || ""} + /> + </div> + <div className="w-32 h-32 rounded-full overflow-hidden absolute -bottom-[25%] left-8 border-[2px] border-neutral-50 dark:border-cm-darker-gray"> + <LazyImage + src={profile?.avatar_url ?? ""} + className="w-full h-full object-cover object-center" + skeletonClassName="w-full h-full" + alt={profile.name || profile.username || ""} /> - <div className="w-32 h-32 rounded-full overflow-hidden absolute -bottom-[25%] left-8 border-[2px] border-neutral-50 dark:border-cm-darker-gray"> - <LazyImage - src={data?.avatar_url ?? ""} - className="w-full h-full object-cover object-center" - skeletonClassName="w-full h-full" - alt={data.name || data.username || ""} - /> - </div> </div> - <article className="flex flex-col gap-2 px-4"> - <section className="flex justify-between items-center"> - <div className="flex flex-col justify-between"> - <Heading level={9}>{data?.name}</Heading> - <span className="text-neutral-500">@{data?.username}</span> + </div> + <article className="flex flex-col gap-2 px-4"> + <section className="flex justify-between items-center flex-wrap"> + <div className="flex flex-col justify-between"> + <Heading level={9}>{profile?.name}</Heading> + <span className="text-neutral-500"> + @{profile?.username ?? "_"} + </span> + </div> + {currentUserId === profile.user_id && ( + <Link + href="/app/settings/section/profile" + className="bg-neutral-700 px-6 py-2 rounded-sm text-neutral-300" + > + Edit Profile + </Link> + )} + </section> + {profile?.description && ( + <p className="w-3/4 text-pretty text-neutral-900 dark:text-neutral-200"> + {profile.description} + </p> + )} + <section className="flex gap-4 flex-wrap"> + {profile?.location && ( + <div className="flex justify-center items-center gap-2 text-neutral-600 dark:text-neutral-400"> + <MapPinIcon className="w-4 h-4" /> + <span>{profile.location}</span> </div> - {userId === data.user_id && ( - <Link - href="/app/settings/section/profile" - className="bg-neutral-700 px-6 py-2 rounded-sm text-neutral-300" + )} + {profile?.website && ( + <div className="flex justify-center items-center gap-2 text-neutral-600 dark:text-neutral-400"> + <LinkIcon className="w-4 h-4" /> + <a + href={profile.website} + className="text-blue-500 hover:underline" + target="_blank" > - Edit Profile - </Link> - )} - </section> - {data?.description && ( - <p className="w-3/4 text-pretty text-neutral-900 dark:text-neutral-200"> - {data.description} - </p> + {profile.website} + </a> + </div> )} - <section className="flex gap-4"> - {data?.location && ( - <div className="flex justify-center items-center gap-2 text-neutral-600 dark:text-neutral-400"> - <MapPinIcon className="w-4 h-4" /> - <span>{data.location}</span> - </div> - )} - {data?.website && ( - <div className="flex justify-center items-center gap-2 text-neutral-600 dark:text-neutral-400"> - <LinkIcon className="w-4 h-4" /> - <a - href={data.website} - className="text-blue-500 hover:underline" - target="_blank" - > - {data.website} - </a> - </div> - )} - {dateWhenUserJoined && ( - <div className="flex justify-center items-center gap-2 text-neutral-600 dark:text-neutral-400"> - <CalendarIcon className="w-4 h-4" /> - <span>joined in {dateWhenUserJoined}</span> - </div> - )} - </section> - </article> - </header> - {userPosts?.length > 0 && ( - <div className="px-4"> - <PostsGrid posts={userPosts} /> - </div> - )} + {dateWhenUserJoined && ( + <div className="flex justify-center items-center gap-2 text-neutral-600 dark:text-neutral-400"> + <CalendarIcon className="w-4 h-4" /> + <span>joined in {dateWhenUserJoined}</span> + </div> + )} + </section> + </article> + </header> + <div className="px-2"> + <UserPosts profileId={profile.id} /> </div> - ); - } - function UserProfileNotFound() { - return <>404</>; - } - - return ( - <div className="w-full h-full"> - {doesUserProfileExist && userProfile ? ( - <Profile - data={userProfile} - userId={user?.id || ""} - userPosts={userPosts || []} - /> - ) : ( - <UserProfileNotFound /> - )} </div> ); } diff --git a/src/app/(site)/app/search/components/SearchForm/SearchForm.tsx b/src/app/(site)/app/search/components/SearchForm/SearchForm.tsx index c361d8d..a9666e4 100644 --- a/src/app/(site)/app/search/components/SearchForm/SearchForm.tsx +++ b/src/app/(site)/app/search/components/SearchForm/SearchForm.tsx @@ -1,46 +1,46 @@ -'use client' -import { Input } from "@/components/ui/input" -import { useForm } from "react-hook-form" -import { useSearchForm } from "./hooks/useSearchForm" +"use client"; +import { Input } from "@/components/ui/input"; +import { useForm } from "react-hook-form"; +import { useSearchForm } from "./hooks/useSearchForm"; const formInputs = { - search: 'search' -} + search: "search", +}; -export type formType = typeof formInputs +export type formType = typeof formInputs; interface SearchFormProps { - defaultSearchValue: string + defaultSearchValue: string; } -export const SearchForm: React.FC<SearchFormProps> = ({ defaultSearchValue }) => { - const { - register - } = useForm<formType>() +export const SearchForm: React.FC<SearchFormProps> = ({ + defaultSearchValue, +}) => { + const { register } = useForm<formType>(); - const { changeSearchValue } = useSearchForm(defaultSearchValue) + const { changeSearchValue } = useSearchForm(defaultSearchValue); - const handleOnChangeToSearch = (event: React.ChangeEvent<HTMLInputElement>) => { - const newValue = event.currentTarget.value - changeSearchValue(newValue) - } + const handleOnChangeToSearch = ( + event: React.ChangeEvent<HTMLInputElement>, + ) => { + const newValue = event.currentTarget.value; + changeSearchValue(newValue); + }; - return ( - <search> - <Input - onInput={handleOnChangeToSearch} - name={formInputs.search} - register={register} - validationScheme={{ - required: { value: true, message: "email is required" }, - }} - type="text" - id="search-input" - label="Search" - placeholder="cats, dogs ..." - error={undefined} - defaultValue={defaultSearchValue} - /> - </search> - ) -} \ No newline at end of file + return ( + <search> + <Input + onInput={handleOnChangeToSearch} + name={formInputs.search} + register={register} + validationScheme={{}} + type="text" + id="search-input" + label="Search" + placeholder="cats, dogs ..." + error={undefined} + defaultValue={defaultSearchValue} + /> + </search> + ); +}; diff --git a/src/app/(site)/app/search/components/SearchForm/hooks/useSearchForm.ts b/src/app/(site)/app/search/components/SearchForm/hooks/useSearchForm.ts index 7adc47d..f9909be 100644 --- a/src/app/(site)/app/search/components/SearchForm/hooks/useSearchForm.ts +++ b/src/app/(site)/app/search/components/SearchForm/hooks/useSearchForm.ts @@ -1,27 +1,25 @@ -'use client' -import { useDebounce } from "@/hooks/use-debounce" -import { useRouting } from "@/hooks/useRouting" -import { useEffect, useState } from "react" +"use client"; +import { useDebounce } from "@/hooks/use-debounce"; +import { useRouting } from "@/hooks/useRouting"; +import { useEffect, useState } from "react"; export const useSearchForm = (defaultSearchValue: string) => { - const [searchValue, setSearchValue] = useState<string>(defaultSearchValue) - const { debouncedValue } = useDebounce(searchValue) - const { goTo } = useRouting() + const [searchValue, setSearchValue] = useState<string>(defaultSearchValue); + const { debouncedValue } = useDebounce(searchValue, 500); + const { goTo } = useRouting(); - useEffect(() => { - const currentURL = new URL(location.origin + location.pathname) + useEffect(() => { + const currentURL = new URL(location.origin + location.pathname); - if (debouncedValue !== '') { - currentURL.searchParams.set('search_query', debouncedValue) - } + currentURL.searchParams.set("search_query", debouncedValue); - goTo(currentURL.toString()) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedValue]) + goTo(currentURL.toString()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedValue]); - const changeSearchValue = (value: string) => { - setSearchValue(value) - } + const changeSearchValue = (value: string) => { + setSearchValue(value); + }; - return { valueToSearch: debouncedValue, changeSearchValue } -} \ No newline at end of file + return { valueToSearch: debouncedValue, changeSearchValue }; +}; diff --git a/src/app/(site)/app/search/components/searched-posts-grid/index.ts b/src/app/(site)/app/search/components/searched-posts-grid/index.ts new file mode 100644 index 0000000..6c37b1c --- /dev/null +++ b/src/app/(site)/app/search/components/searched-posts-grid/index.ts @@ -0,0 +1 @@ +export * from "./searched-posts-grid.component"; diff --git a/src/app/(site)/app/search/components/searched-posts-grid/searched-posts-grid.component.tsx b/src/app/(site)/app/search/components/searched-posts-grid/searched-posts-grid.component.tsx new file mode 100644 index 0000000..b65d39f --- /dev/null +++ b/src/app/(site)/app/search/components/searched-posts-grid/searched-posts-grid.component.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { PostsGridSkeleton } from "@/components/feature/posts-grid/components/posts-grid-skeleton"; +import { PostsGrid } from "@/components/feature/posts-grid/posts-grid.component"; +import { Heading } from "@/components/ui/typography/heading"; +import { useSupabase } from "@/hooks/use-supabase"; +import { Database } from "@/supabase/types"; +import { useEffect, useState } from "react"; + +export function SearchedPostsGrid({ searchValue }: { searchValue: string }) { + const { supabase } = useSupabase(); + + const [posts, setPosts] = useState< + Database["public"]["Tables"]["posts"]["Row"][] + >([]); + + const [error, setError] = useState<boolean>(false); + + const [isLoading, setIsLoading] = useState<boolean>(true); + const [isFetching, setIsFetching] = useState<boolean>(false); + + const [lastPostIndex, setLastPostIndex] = useState<number>(1); + + const [notFound, setNotFound] = useState<boolean>(false); + + function getAndSetPosts({ signal }: { signal: AbortSignal }) { + try { + setIsLoading(true); + setIsFetching(true); + + setNotFound(false); + + supabase + .from("posts") + .select("*") + .like("title", `%${searchValue}%`) + .order("created_at", { ascending: false }) + .range(lastPostIndex, lastPostIndex + 32) + .abortSignal(signal) + .limit(32) + .then(({ data: posts, error }) => { + if (error) return; + + if (!posts) return; + + if (posts.length === 0) { + setNotFound(true); + return; + } + setNotFound(false); + + setPosts((prev) => [...prev, ...posts]); + }); + } catch (error: unknown) { + if ((error as Error).name === "AbortError") return; + + setError(true); + } finally { + setIsLoading(false); + setIsFetching(false); + } + } + + useEffect(() => { + const controller = new AbortController(); + + getAndSetPosts({ signal: controller.signal }); + + return () => { + controller.abort(); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastPostIndex]); + + useEffect(() => { + const controller = new AbortController(); + + if (isLoading || isFetching) return; + + setLastPostIndex(1); + + setPosts([]); + + getAndSetPosts({ signal: controller.signal }); + + return () => { + controller.abort(); + }; + }, [searchValue]); + + function handleScroll() { + setLastPostIndex((prev) => prev + 32); + } + + return ( + <main className="w-full h-full flex flex-col gap-2"> + {isLoading && <PostsGridSkeleton cuantity={32} />} + {!isLoading && !isFetching && ( + <> + {!error ? ( + <div> + <PostsGrid posts={posts} onFetchNewPosts={handleScroll} /> + </div> + ) : ( + <article className="flex items-center justify-center w-full max-h-96 py-32 text-center"> + <Heading level={10}> + Something wen wrong, please reload the page + </Heading> + </article> + )} + </> + )} + {notFound && posts.length === 0 && ( + <article className="flex items-center justify-center w-full max-h-96 py-32 text-center"> + <Heading level={10}>404 not found</Heading> + </article> + )} + </main> + ); +} diff --git a/src/app/(site)/app/search/page.tsx b/src/app/(site)/app/search/page.tsx index 354cf9d..e068359 100644 --- a/src/app/(site)/app/search/page.tsx +++ b/src/app/(site)/app/search/page.tsx @@ -1,8 +1,5 @@ -import { getSuapabaseServerComponent } from "@/supabase/models/index.models"; import { SearchForm } from "./components/SearchForm/SearchForm"; -import { PostsGrid } from "@/components/feature/posts-grid"; -import { Suspense } from "react"; -import { Skeleton } from "@/components/ui/skeleton"; +import { SearchedPostsGrid } from "./components/searched-posts-grid"; interface SearchParams { search_query?: string; @@ -13,37 +10,12 @@ interface Props { } const SearchPage: React.FC<Props> = async ({ searchParams }) => { - const supabase = await getSuapabaseServerComponent(); - const searchValue = searchParams?.search_query ?? ""; - - const { data: posts } = await supabase - .from("posts") - .select("*") - .ilike("title", `%${searchValue}%`) - .order("created_at", { ascending: false }) - .limit(24); - + const searchValue = searchParams.search_query ?? "painting"; return ( <div className="w-full h-full flex min-h-screen"> <div className="flex flex-col gap-2 w-full h-full min-h-screen p-2"> <SearchForm defaultSearchValue={searchValue} /> - <div> - {posts?.length !== undefined && posts?.length > 0 && ( - <Suspense - fallback={ - <ul className="break-inside-avoid gap-2 [column-count:3] md:[column-count:3]"> - {Array(16) - .fill(" ") - .map((_, i) => ( - <Skeleton key={i} className="w-full h-96 mb-2" /> - ))} - </ul> - } - > - <PostsGrid posts={posts} /> - </Suspense> - )} - </div> + <SearchedPostsGrid key={searchValue} searchValue={searchValue} /> </div> </div> ); diff --git a/src/components/feature/lazy-image/index.tsx b/src/components/feature/lazy-image/index.tsx index 7f2657f..5bab7b4 100644 --- a/src/components/feature/lazy-image/index.tsx +++ b/src/components/feature/lazy-image/index.tsx @@ -2,7 +2,18 @@ import { Skeleton } from "@/components/ui/skeleton"; import { ImageOff } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { type HTMLAttributes, useEffect, useRef, useState } from "react"; + +type TProps = { + src: string; + alt: string; + className?: string; + height?: number; + width?: number; + containerClassname?: HTMLAttributes<HTMLDivElement>["className"]; + skeletonClassName?: string; + skeletonBgColor?: string; +}; export function LazyImage({ src, @@ -12,15 +23,8 @@ export function LazyImage({ height, skeletonClassName = "", skeletonBgColor = "", -}: { - src: string; - alt: string; - className?: string; - height?: number; - width?: number; - skeletonClassName?: string; - skeletonBgColor?: string; -}) { + containerClassname = "", +}: TProps) { const [loading, setLoading] = useState<boolean>(true); const [error, setError] = useState<boolean>(false); @@ -46,7 +50,7 @@ export function LazyImage({ }, []); return ( - <div className={`flex flex-col relative`}> + <div className={`relative ${containerClassname}`}> <div className="relative"> {!error && ( <img @@ -60,13 +64,8 @@ export function LazyImage({ )} {loading && !error && ( <Skeleton - className={[ - "absolute top-0 left-0 w-full h-full", - skeletonClassName, - ].join(" ")} + className={["absolute top-0 left-0", skeletonClassName].join(" ")} style={{ - height: "100%", - width: "100%", maxWidth: width, maxHeight: height, backgroundColor: skeletonBgColor, diff --git a/src/components/feature/posts-grid/components/post/index.tsx b/src/components/feature/posts-grid/components/post/index.tsx deleted file mode 100644 index c1a4c34..0000000 --- a/src/components/feature/posts-grid/components/post/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "./post.component"; diff --git a/src/components/feature/posts-grid/components/posts-grid-container/index.ts b/src/components/feature/posts-grid/components/posts-grid-container/index.ts new file mode 100644 index 0000000..99e7d72 --- /dev/null +++ b/src/components/feature/posts-grid/components/posts-grid-container/index.ts @@ -0,0 +1 @@ +export * from "./posts-grid-container.component"; diff --git a/src/components/feature/posts-grid/components/posts-grid-container/posts-grid-container.component.tsx b/src/components/feature/posts-grid/components/posts-grid-container/posts-grid-container.component.tsx new file mode 100644 index 0000000..4276768 --- /dev/null +++ b/src/components/feature/posts-grid/components/posts-grid-container/posts-grid-container.component.tsx @@ -0,0 +1,23 @@ +import { forwardRef } from "react"; + +type TProps = { + children: React.ReactNode; +}; + +type TRef = HTMLUListElement; + +// eslint-disable-next-line react/display-name +const PostsGridContainer = forwardRef<TRef, TProps>(({ children }, ref) => { + return ( + <ul + className="break-inside-avoid gap-2 lg:[column-count:3] [column-count:2] w-full" + ref={ref} + > + {children} + </ul> + ); +}); + +PostsGridContainer.displayName = "PostsGridContainer"; + +export { PostsGridContainer }; diff --git a/src/components/feature/posts-grid/components/posts-grid-row/index.ts b/src/components/feature/posts-grid/components/posts-grid-row/index.ts new file mode 100644 index 0000000..2e870e8 --- /dev/null +++ b/src/components/feature/posts-grid/components/posts-grid-row/index.ts @@ -0,0 +1 @@ +export * from "./posts-grid-row.component"; diff --git a/src/components/feature/posts-grid/components/post/post.component.tsx b/src/components/feature/posts-grid/components/posts-grid-row/posts-grid-row.component.tsx similarity index 88% rename from src/components/feature/posts-grid/components/post/post.component.tsx rename to src/components/feature/posts-grid/components/posts-grid-row/posts-grid-row.component.tsx index a5101e3..a920fea 100644 --- a/src/components/feature/posts-grid/components/post/post.component.tsx +++ b/src/components/feature/posts-grid/components/posts-grid-row/posts-grid-row.component.tsx @@ -1,9 +1,9 @@ "use client"; +import Link from "next/link"; import { LazyImage } from "@/components/feature/lazy-image"; import { ClientRouting } from "@/models/routing/client"; import { Database } from "@/supabase/types"; -import Link from "next/link"; export function PostsGridRow({ post, @@ -14,7 +14,7 @@ export function PostsGridRow({ }) { const imageHeight = (post.asset_height * columnWidth) / post.asset_width; return ( - <li key={post.id} className={`flex w-full h-full pt-2`}> + <li className={`flex w-full h-full pt-2`}> <Link href={ClientRouting.post().page(JSON.stringify(post.id) || "")} className="w-full" @@ -23,7 +23,7 @@ export function PostsGridRow({ src={post.asset_url} alt={post.title} className="flex w-full h-auto rounded-md object-cover object-center" - skeletonClassName="w-full rounded-md" + skeletonClassName="w-full h-full rounded-md" height={imageHeight} width={columnWidth} skeletonBgColor={post.asset_color || undefined} diff --git a/src/components/feature/posts-grid/components/posts-grid-skeleton/index.ts b/src/components/feature/posts-grid/components/posts-grid-skeleton/index.ts new file mode 100644 index 0000000..c034279 --- /dev/null +++ b/src/components/feature/posts-grid/components/posts-grid-skeleton/index.ts @@ -0,0 +1 @@ +export * from "./posts-grid-skeleton.component"; diff --git a/src/components/feature/posts-grid/components/posts-grid-skeleton/posts-grid-skeleton.component.tsx b/src/components/feature/posts-grid/components/posts-grid-skeleton/posts-grid-skeleton.component.tsx new file mode 100644 index 0000000..50ab527 --- /dev/null +++ b/src/components/feature/posts-grid/components/posts-grid-skeleton/posts-grid-skeleton.component.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { PostsGridContainer } from "../posts-grid-container"; + +export function PostsGridSkeleton({ cuantity = 16 }: { cuantity?: number }) { + return ( + <PostsGridContainer> + {Array(cuantity) + .fill(" ") + .map((_, i) => ( + <Skeleton key={i} className="w-full h-96 mb-2 rounded-sm" /> + ))} + </PostsGridContainer> + ); +} diff --git a/src/components/feature/posts-grid/index.ts b/src/components/feature/posts-grid/index.ts new file mode 100644 index 0000000..d670f2d --- /dev/null +++ b/src/components/feature/posts-grid/index.ts @@ -0,0 +1 @@ +export * from "./posts-grid.component"; diff --git a/src/components/feature/posts-grid/index.tsx b/src/components/feature/posts-grid/index.tsx deleted file mode 100644 index 87b5b78..0000000 --- a/src/components/feature/posts-grid/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { PostsGrid } from "./posts-grid.component"; - -export { PostsGrid }; diff --git a/src/components/feature/posts-grid/posts-grid.component.tsx b/src/components/feature/posts-grid/posts-grid.component.tsx index dafd7f7..4ba9fe5 100644 --- a/src/components/feature/posts-grid/posts-grid.component.tsx +++ b/src/components/feature/posts-grid/posts-grid.component.tsx @@ -1,10 +1,15 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { PostsGridRow } from "./components/post"; +import { useCallback, useEffect, useRef, useState } from "react"; import { TPostsGridItem } from "./posts-grid.models"; +import { PostsGridRow } from "./components/posts-grid-row"; +import { PostsGridContainer } from "./components/posts-grid-container"; -export function PostsGrid({ posts }: { posts: TPostsGridItem[] }) { +export function PostsGrid({ + posts, + onFetchNewPosts, +}: { + posts: TPostsGridItem[]; + onFetchNewPosts: () => void; +}) { const [columnWidth, setColumnWidth] = useState<number | null>(null); const containerRef = useRef<HTMLUListElement>(null); @@ -17,7 +22,6 @@ export function PostsGrid({ posts }: { posts: TPostsGridItem[] }) { if (containerWidth <= 0 || !containerWidth) return; const columnCount = window.innerWidth > 1024 ? 3 : 2; - console.log(window.innerWidth); setColumnWidth((containerWidth - 8 * 2) / columnCount); } @@ -27,20 +31,53 @@ export function PostsGrid({ posts }: { posts: TPostsGridItem[] }) { return () => window.removeEventListener("resize", calculateColumnWidth); }, []); + + const [lastElementRef, setLastElementRef] = useState<HTMLDivElement | null>( + null, + ); + + const lastItemRef = useCallback((node: HTMLDivElement) => { + if (!node) return; + + setLastElementRef(node); + }, []); + + useEffect(() => { + if (!lastElementRef || typeof window === "undefined") return; + + const observerOptions: IntersectionObserverInit = { + root: null, + rootMargin: `${window.innerHeight * 2}px`, + threshold: 1.0, + }; + + const observer = new IntersectionObserver(onFetchNewPosts, observerOptions); + + observer.observe(lastElementRef); + + return () => { + observer.unobserve(lastElementRef); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastElementRef]); + return ( - <ul - className="break-inside-avoid gap-2 px-2 lg:[column-count:3] [column-count:2]" - ref={containerRef} - > - {posts.length > 0 && - containerRef.current && - posts.map((post) => ( - <PostsGridRow - columnWidth={columnWidth && !isNaN(columnWidth) ? columnWidth : 400} - key={post.id} - post={post} - /> - ))} - </ul> + <PostsGridContainer ref={containerRef}> + {posts.length > 0 && containerRef.current && ( + <> + {posts.map((post, i) => ( + <PostsGridRow + columnWidth={ + columnWidth && !isNaN(columnWidth) ? columnWidth : 400 + } + key={i} + post={post} + /> + ))} + <div ref={lastItemRef} className="h-96 w-full" /> + </> + )} + </PostsGridContainer> ); }