Skip to content

Commit b94ed1c

Browse files
authored
Merge pull request #454 from CodebilityDev/miguel/feeds-scroll-and-sort
Miguel/feeds scroll and sort
2 parents 996de91 + aa359cc commit b94ed1c

3 files changed

Lines changed: 178 additions & 58 deletions

File tree

‎apps/codebility/app/home/feeds/_components/Feed.tsx‎

Lines changed: 79 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,37 @@
11
"use client";
22

3-
import { useEffect, useMemo, useState } from "react";
4-
import DefaultPagination from "@/components/ui/pagination";
3+
import { useEffect, useMemo, useRef, useState } from "react";
54
import { useFeedsStore } from "@/store/feeds-store";
65

76
import Post from "./PostCard";
87

98
interface FeedProp {
109
isAdmin: boolean;
1110
searchQuery?: string;
11+
sortField: "title" | "date" | "upvotes";
12+
sortOrder: "asc" | "desc";
1213
}
1314

14-
export default function Feed({ isAdmin, searchQuery }: FeedProp) {
15+
export default function Feed({
16+
isAdmin,
17+
searchQuery,
18+
sortField,
19+
sortOrder,
20+
}: FeedProp) {
1521
const posts = useFeedsStore((state) => state.posts);
1622
const fetchPosts = useFeedsStore((state) => state.fetchPosts);
1723

18-
const [currentPage, setCurrentPage] = useState(1);
24+
const [visibleCount, setVisibleCount] = useState(6);
25+
const loaderRef = useRef<HTMLDivElement | null>(null);
1926

2027
useEffect(() => {
2128
fetchPosts();
2229
}, [fetchPosts]);
2330

24-
// Filter posts by title or author's first/last name (case-insensitive)
31+
// Filter posts
2532
const filteredPosts = useMemo(() => {
2633
if (!searchQuery || searchQuery.trim() === "") return posts;
27-
2834
const query = searchQuery.toLowerCase();
29-
3035
return posts.filter((post) => {
3136
const titleMatch = post.title?.toLowerCase().includes(query);
3237
const firstNameMatch = post.author_id?.first_name
@@ -35,42 +40,74 @@ export default function Feed({ isAdmin, searchQuery }: FeedProp) {
3540
const lastNameMatch = post.author_id?.last_name
3641
?.toLowerCase()
3742
.includes(query);
38-
3943
return titleMatch || firstNameMatch || lastNameMatch;
4044
});
4145
}, [posts, searchQuery]);
4246

43-
const postsPerPage = 6;
44-
const totalPages = Math.ceil(filteredPosts.length / postsPerPage);
45-
const startIndex = (currentPage - 1) * postsPerPage;
46-
const currentPosts = filteredPosts.slice(
47-
startIndex,
48-
startIndex + postsPerPage,
49-
);
47+
// Sort posts
48+
const sortedPosts = useMemo(() => {
49+
const sorted = [...filteredPosts];
50+
sorted.sort((a, b) => {
51+
let aVal: string | number = "";
52+
let bVal: string | number = "";
53+
54+
switch (sortField) {
55+
case "title":
56+
aVal = a.title?.toLowerCase() || "";
57+
bVal = b.title?.toLowerCase() || "";
58+
break;
59+
case "date":
60+
aVal = new Date(a.created_at).getTime();
61+
bVal = new Date(b.created_at).getTime();
62+
break;
63+
case "upvotes":
64+
aVal = a.upvote_count ?? 0;
65+
bVal = b.upvote_count ?? 0;
66+
break;
67+
}
68+
69+
if (aVal < bVal) return sortOrder === "asc" ? -1 : 1;
70+
if (aVal > bVal) return sortOrder === "asc" ? 1 : -1;
71+
return 0;
72+
});
73+
return sorted;
74+
}, [filteredPosts, sortField, sortOrder]);
75+
76+
const visiblePosts = sortedPosts.slice(0, visibleCount);
5077

51-
// Reset to first page when search changes
5278
useEffect(() => {
53-
setCurrentPage(1);
79+
setVisibleCount(6);
5480
}, [searchQuery]);
5581

82+
// Infinite scroll
83+
useEffect(() => {
84+
const observer = new IntersectionObserver(
85+
(entries) => {
86+
const entry = entries[0];
87+
if (entry?.isIntersecting) {
88+
setVisibleCount((prev) => Math.min(prev + 6, sortedPosts.length));
89+
}
90+
},
91+
{ threshold: 1 },
92+
);
93+
94+
const currentLoader = loaderRef.current;
95+
if (currentLoader) observer.observe(currentLoader);
96+
return () => {
97+
if (currentLoader) observer.unobserve(currentLoader);
98+
};
99+
}, [sortedPosts.length]);
56100
const handleDeletePost = (deletedPostId: string | number) => {
57101
useFeedsStore.setState((state) => ({
58102
posts: state.posts.filter((post) => post.id !== deletedPostId),
59103
}));
60104
};
61105

62-
const handleNextPage = () => {
63-
if (currentPage < totalPages) setCurrentPage((p) => p + 1);
64-
};
65-
66-
const handlePreviousPage = () => {
67-
if (currentPage > 1) setCurrentPage((p) => p - 1);
68-
};
69-
70106
return (
71107
<>
108+
{/* Posts grid */}
72109
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
73-
{currentPosts.map((post) => (
110+
{visiblePosts.map((post) => (
74111
<Post
75112
key={post.id}
76113
post={post}
@@ -80,14 +117,22 @@ export default function Feed({ isAdmin, searchQuery }: FeedProp) {
80117
))}
81118
</div>
82119

83-
{filteredPosts.length > 0 && (
84-
<DefaultPagination
85-
totalPages={totalPages}
86-
currentPage={currentPage}
87-
handleNextPage={handleNextPage}
88-
handlePreviousPage={handlePreviousPage}
89-
setCurrentPage={setCurrentPage}
90-
/>
120+
{/* Loader */}
121+
{visibleCount < sortedPosts.length && (
122+
<div
123+
ref={loaderRef}
124+
className="mt-6 flex justify-center text-sm text-gray-500"
125+
>
126+
<div className="flex items-center gap-2">
127+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
128+
Loading more posts...
129+
</div>
130+
</div>
131+
)}
132+
133+
{/* Empty state */}
134+
{sortedPosts.length === 0 && (
135+
<div className="mt-8 text-center text-gray-500">No posts found.</div>
91136
)}
92137
</>
93138
);

‎apps/codebility/app/home/feeds/_services/query.ts‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,30 @@ import { createClientServerComponent } from "@/utils/supabase/server";
55
export const getPosts = async () => {
66
try {
77
const supabase = await createClientServerComponent();
8-
8+
99
const { data: posts, error } = await supabase
10-
.from("posts")
11-
.select(`
12-
*,
13-
author_id(
14-
id,
15-
first_name,
16-
last_name,
17-
image_url
18-
)
19-
`).order('created_at', { ascending: false });
10+
.from("posts")
11+
.select(`
12+
*,
13+
author_id (
14+
id,
15+
first_name,
16+
last_name,
17+
image_url
18+
),
19+
post_upvotes (id)
20+
`)
21+
.order("created_at", { ascending: false });
2022

2123
if (error) throw error;
2224

23-
return posts;
25+
// compute upvote count based on the joined array
26+
const postsWithUpvoteCount = posts.map((post) => ({
27+
...post,
28+
upvote_count: post.post_upvotes ? post.post_upvotes.length : 0,
29+
}));
30+
31+
return postsWithUpvoteCount;
2432
} catch (error) {
2533
console.error(error);
2634
throw error;
@@ -39,5 +47,5 @@ export type PostType = {
3947
image_url?: string;
4048
};
4149
image_url?: string;
42-
upvoters_id?: string[];
50+
upvote_count?: number;
4351
};

‎apps/codebility/app/home/feeds/page.tsx‎

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,23 @@ import { useEffect, useState } from "react";
44
import CreatePostModal from "@/components/modals/CreatePostModal";
55
import { Button } from "@/components/ui/button";
66
import { useUserStore } from "@/store/codev-store";
7+
import { ChevronDown } from "lucide-react";
78

89
import Feed from "./_components/Feed";
910
import SocialPointsCard from "./_components/SocialPointsCard";
1011
import { getUserRole } from "./_services/action";
1112

13+
type SortField = "title" | "date" | "upvotes";
14+
type SortOrder = "asc" | "desc";
15+
1216
export default function FeedsPage() {
1317
const [isAdmin, setIsAdmin] = useState(true);
1418
const [isModalOpen, setIsModalOpen] = useState(false);
1519
const [searchQuery, setSearchQuery] = useState("");
1620
const [debouncedQuery, setDebouncedQuery] = useState("");
21+
const [sortField, setSortField] = useState<SortField>("date");
22+
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
23+
const [sortMenuOpen, setSortMenuOpen] = useState(false);
1724
const { user } = useUserStore();
1825

1926
useEffect(() => {
@@ -22,15 +29,11 @@ export default function FeedsPage() {
2229
const role = await getUserRole(user.role_id ?? null);
2330
setIsAdmin(role === "Admin");
2431
};
25-
2632
if (user) fetchRole();
2733
}, [user]);
2834

2935
useEffect(() => {
30-
const handler = setTimeout(() => {
31-
setDebouncedQuery(searchQuery);
32-
}, 500); // triggers after 500ms of inactivity
33-
36+
const handler = setTimeout(() => setDebouncedQuery(searchQuery), 500);
3437
return () => clearTimeout(handler);
3538
}, [searchQuery]);
3639

@@ -48,26 +51,90 @@ export default function FeedsPage() {
4851
</p>
4952
</div>
5053

51-
{/* Social Points Card */}
5254
<SocialPointsCard />
5355

5456
<Button className="mb-4 mt-4 w-auto" onClick={openModal}>
5557
Create Post
5658
</Button>
5759

58-
{/* Search bar */}
59-
<div className="mb-6">
60+
{/* Search bar + sort button row */}
61+
<div className="relative mb-6 flex items-center gap-4">
6062
<input
6163
type="text"
6264
placeholder="Search by title or author name..."
6365
value={searchQuery}
6466
onChange={(e) => setSearchQuery(e.target.value)}
65-
className="focus:border-customBlue-500 focus:ring-customBlue-500 dark:focus:border-customBlue-400 dark:focus:ring-customBlue-400 h-11 w-full rounded-lg border border-gray-300 bg-gray-50 px-4 text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-1 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
67+
className="focus:border-customBlue-500 focus:ring-customBlue-500 h-11 flex-1 rounded-lg border border-gray-300 bg-gray-50 px-4 text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-1 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400"
6668
/>
69+
70+
<div className="relative">
71+
<button
72+
onClick={() => setSortMenuOpen((prev) => !prev)}
73+
className="flex h-11 items-center gap-2 rounded-md border border-gray-300 bg-gray-50 px-3 text-sm text-gray-700 shadow-sm hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
74+
>
75+
Sort:{" "}
76+
<span className="capitalize">
77+
{sortField} ({sortOrder})
78+
</span>
79+
<ChevronDown
80+
className={`h-4 w-4 transition-transform ${sortMenuOpen ? "rotate-180" : ""}`}
81+
/>
82+
</button>
83+
84+
{sortMenuOpen && (
85+
<div className="absolute right-0 z-50 mt-2 w-48 rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800 dark:shadow-black/20">
86+
{["date", "title", "upvotes"].map((field) => (
87+
<button
88+
key={field}
89+
onClick={() => {
90+
setSortField(field as SortField);
91+
setSortMenuOpen(false);
92+
}}
93+
className={`block w-full px-4 py-2 text-left text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700 ${
94+
sortField === field ? "text-gray-900 dark:text-white" : ""
95+
}`}
96+
>
97+
Sort by {field}
98+
</button>
99+
))}
100+
<div className="border-t border-gray-200 px-3 py-2 text-sm text-gray-500 dark:border-gray-600 dark:text-gray-400">
101+
<div className="flex items-center justify-between">
102+
<span>Order:</span>
103+
<div className="flex gap-2">
104+
<button
105+
onClick={() => setSortOrder("asc")}
106+
className={`rounded px-2 py-1 ${
107+
sortOrder === "asc"
108+
? "bg-gray-200 text-gray-900 dark:bg-gray-600 dark:text-white"
109+
: "hover:bg-gray-100 dark:hover:bg-gray-700"
110+
}`}
111+
>
112+
Asc
113+
</button>
114+
<button
115+
onClick={() => setSortOrder("desc")}
116+
className={`rounded px-2 py-1 ${
117+
sortOrder === "desc"
118+
? "bg-gray-200 text-gray-900 dark:bg-gray-600 dark:text-white"
119+
: "hover:bg-gray-100 dark:hover:bg-gray-700"
120+
}`}
121+
>
122+
Desc
123+
</button>
124+
</div>
125+
</div>
126+
</div>
127+
</div>
128+
)}
129+
</div>
67130
</div>
68131

69-
{/* Feed component with live search */}
70-
<Feed isAdmin={isAdmin} searchQuery={debouncedQuery} />
132+
<Feed
133+
isAdmin={isAdmin}
134+
searchQuery={debouncedQuery}
135+
sortField={sortField}
136+
sortOrder={sortOrder}
137+
/>
71138
</div>
72139

73140
<CreatePostModal isOpen={isModalOpen} onClose={closeModal} />

0 commit comments

Comments
 (0)