Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 71 additions & 23 deletions src/app/community/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
orderBy,
getDocs,
limit,
startAfter,
collectionGroup,
type QueryDocumentSnapshot,
type DocumentData,
} from 'firebase/firestore';
import { db } from '@/lib/firebase';
import {
Expand All @@ -28,9 +31,11 @@ import { getEmbedUrl } from '@/lib/utils';
import { useRouter } from 'next/navigation';
import CreateDiscussionModal from '@/components/community/CreateDiscussionModal';
import ProjectCard from '@/components/projects/ProjectCard';
import ProjectCardSkeleton from '@/components/projects/ProjectCardSkeleton';
import Pagination from '@/components/common/Pagination';
import DOMPurify from 'dompurify';

const PROJECTS_PAGE_SIZE = 20;

export default function CommunityPage() {
const { user } = useAuth();
const router = useRouter();
Expand All @@ -44,8 +49,15 @@ export default function CommunityPage() {
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedProject, setSelectedProject] = useState<any>(null);
const [searchQuery, setSearchQuery] = useState('');

const fetchData = async () => {
// Cursor-based pagination state for the Projects showcase
const [pageCursors, setPageCursors] = useState<
QueryDocumentSnapshot<DocumentData>[]
>([]);
const [lastDocInPage, setLastDocInPage] =
useState<QueryDocumentSnapshot<DocumentData> | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [hasNextPage, setHasNextPage] = useState(false);
const fetchData = async (cursor?: QueryDocumentSnapshot<DocumentData>) => {
setLoading(true);
try {
if (activeTab === 'discussions') {
Expand All @@ -60,28 +72,27 @@ export default function CommunityPage() {
snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))
);
} else {
// Fetch Projects for Showcase
let q;
if (sortOption === 'popular') {
// Sort by starCount descending
q = query(
collection(db, 'projects'),
orderBy('starCount', 'desc'),
limit(20)
);
} else {
// Sort by createdAt descending (default)
q = query(
collection(db, 'projects'),
orderBy('createdAt', 'desc'),
limit(20)
);
}
// Fetch Projects for Showcase, one page at a time.
// We ask for PAGE_SIZE + 1 so we can tell whether a next page
// exists without firing a second query.
const orderField = sortOption === 'popular' ? 'starCount' : 'createdAt';
const projectsRef = collection(db, 'projects');
const constraints = cursor
? [
orderBy(orderField, 'desc'),
startAfter(cursor),
limit(PROJECTS_PAGE_SIZE + 1),
]
: [orderBy(orderField, 'desc'), limit(PROJECTS_PAGE_SIZE + 1)];

const q = query(projectsRef, ...constraints);
const snapshot = await getDocs(q);
setProjects(
snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }))
);
const docs = snapshot.docs;
const pageDocs = docs.slice(0, PROJECTS_PAGE_SIZE);

setProjects(pageDocs.map((doc) => ({ id: doc.id, ...doc.data() })));
setHasNextPage(docs.length > PROJECTS_PAGE_SIZE);
setLastDocInPage(pageDocs[pageDocs.length - 1] ?? null);
}
} catch (error: any) {
console.error('Error fetching data:', error);
Expand All @@ -95,8 +106,29 @@ export default function CommunityPage() {
}
};

const handleNextPage = () => {
if (!hasNextPage || !lastDocInPage) return;
setPageCursors((prev) => [...prev, lastDocInPage]);
setCurrentPage((page) => page + 1);
fetchData(lastDocInPage);
};

const handlePreviousPage = () => {
if (pageCursors.length === 0) return;
const remainingCursors = pageCursors.slice(0, -1);
const previousCursor = remainingCursors[remainingCursors.length - 1];
setPageCursors(remainingCursors);
setCurrentPage((page) => Math.max(1, page - 1));
fetchData(previousCursor);
};

useEffect(() => {
// Any time the tab or sort option changes, pagination resets to page 1.
setPageCursors([]);
setLastDocInPage(null);
setCurrentPage(1);
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, sortOption]);

const fuse = useMemo(
Expand Down Expand Up @@ -286,6 +318,22 @@ export default function CommunityPage() {
)}
</div>
)}

{/* Pagination only applies to the showcase tab, and only when
no client-side search is active (search only covers the
currently loaded page of projects). */}
{activeTab === 'showcase' &&
!searchQuery.trim() &&
projects.length > 0 && (
<Pagination
currentPage={currentPage}
hasNextPage={hasNextPage}
hasPreviousPage={currentPage > 1}
loading={loading}
onNext={handleNextPage}
onPrevious={handlePreviousPage}
/>
)}
</div>

{user && (
Expand Down
51 changes: 51 additions & 0 deletions src/components/common/Pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import { ChevronLeft, ChevronRight } from 'lucide-react';

interface PaginationProps {
currentPage: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
loading?: boolean;
onNext: () => void;
onPrevious: () => void;
}

export default function Pagination({
currentPage,
hasNextPage,
hasPreviousPage,
loading = false,
onNext,
onPrevious,
}: PaginationProps) {
return (
<div className="flex items-center justify-center gap-4 mt-8">
<button
type="button"
onClick={onPrevious}
disabled={!hasPreviousPage || loading}
aria-label="Go to previous page"
className="flex items-center gap-1 px-4 py-2 rounded-lg border border-border bg-card text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-card"
>
<ChevronLeft size={16} />
Previous
</button>

<span className="text-sm text-muted-foreground min-w-[80px] text-center">
Page {currentPage}
</span>

<button
type="button"
onClick={onNext}
disabled={!hasNextPage || loading}
aria-label="Go to next page"
className="flex items-center gap-1 px-4 py-2 rounded-lg border border-border bg-card text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-card"
>
Next
<ChevronRight size={16} />
</button>
</div>
);
}
Loading